From acdaececf08fa7653d3d04885ae9eea18127d206 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 15 Apr 2026 16:19:55 +0200 Subject: [PATCH 01/10] bootspec: add boot_config_selected_entry() helper --- src/shared/bootspec.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/shared/bootspec.h b/src/shared/bootspec.h index 951a81f08c..afc5a576c9 100644 --- a/src/shared/bootspec.h +++ b/src/shared/bootspec.h @@ -116,6 +116,16 @@ static inline const BootEntry* boot_config_default_entry(const BootConfig *confi return config->entries + config->default_entry; } +static inline const BootEntry* boot_config_selected_entry(const BootConfig *config) { + assert(config); + + if (config->selected_entry < 0) + return NULL; + + assert((size_t) config->selected_entry < config->n_entries); + return config->entries + config->selected_entry; +} + void boot_config_free(BootConfig *config); int boot_loader_read_conf(BootConfig *config, FILE *file, const char *path); From 0a46727ce7bdb3a99e43bcb321aa7df500a8f24a Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 13 Mar 2026 22:52:18 +0100 Subject: [PATCH 02/10] bootspec: make pe_find_uki_sections() non-static --- src/shared/bootspec.c | 22 ++++++++++++++-------- src/shared/bootspec.h | 2 ++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/shared/bootspec.c b/src/shared/bootspec.c index 3338d75f66..ebd63788c0 100644 --- a/src/shared/bootspec.c +++ b/src/shared/bootspec.c @@ -963,7 +963,7 @@ static int trim_cmdline(char **cmdline) { * the ones we do care about and we are willing to load into memory have this size limit.) */ #define PE_SECTION_SIZE_MAX (4U*1024U*1024U) -static int pe_find_uki_sections( +int pe_find_uki_sections( int fd, const char *path, unsigned profile, @@ -979,9 +979,6 @@ static int pe_find_uki_sections( assert(fd >= 0); assert(path); assert(profile != UINT_MAX); - assert(ret_osrelease); - assert(ret_profile); - assert(ret_cmdline); r = pe_load_headers_and_sections(fd, path, §ions, &pe_header); if (r < 0) @@ -1038,13 +1035,22 @@ static int pe_find_uki_sections( if (trim_cmdline(&cmdline_text) < 0) return log_oom(); - *ret_osrelease = TAKE_PTR(osrelease_text); - *ret_profile = TAKE_PTR(profile_text); - *ret_cmdline = TAKE_PTR(cmdline_text); + if (ret_osrelease) + *ret_osrelease = TAKE_PTR(osrelease_text); + if (ret_profile) + *ret_profile = TAKE_PTR(profile_text); + if (ret_cmdline) + *ret_cmdline = TAKE_PTR(cmdline_text); return 1; nothing: - *ret_osrelease = *ret_profile = *ret_cmdline = NULL; + if (ret_osrelease) + *ret_osrelease = NULL; + if (ret_profile) + *ret_profile = NULL; + if (ret_cmdline) + *ret_cmdline = NULL; + return 0; } diff --git a/src/shared/bootspec.h b/src/shared/bootspec.h index afc5a576c9..01abe05e72 100644 --- a/src/shared/bootspec.h +++ b/src/shared/bootspec.h @@ -162,3 +162,5 @@ int show_boot_entries( int boot_filename_extract_tries(const char *fname, char **ret_stripped, unsigned *ret_tries_left, unsigned *ret_tries_done); int boot_entry_to_json(const BootConfig *c, size_t i, sd_json_variant **ret); + +int pe_find_uki_sections(int fd, const char *path, unsigned profile, char **ret_osrelease, char **ret_profile, char **ret_cmdline); From d292874bf6845605b733ad8e6926a5bee23ef5d3 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 20 Mar 2026 10:21:11 +0100 Subject: [PATCH 03/10] bootspec: add bootspec_extract_osrelease() helper --- src/shared/bootspec.c | 147 +++++++++++++++++++++++++++++++++--------- src/shared/bootspec.h | 2 + 2 files changed, 120 insertions(+), 29 deletions(-) diff --git a/src/shared/bootspec.c b/src/shared/bootspec.c index ebd63788c0..c0111d1f9c 100644 --- a/src/shared/bootspec.c +++ b/src/shared/bootspec.c @@ -31,6 +31,7 @@ #include "string-util.h" #include "strv.h" #include "uki.h" +#include "utf8.h" static const char* const boot_entry_type_description_table[_BOOT_ENTRY_TYPE_MAX] = { [BOOT_ENTRY_TYPE1] = "Boot Loader Specification Type #1 (.conf)", @@ -711,6 +712,110 @@ static int boot_entries_find_type1( return 0; } +static void mangle_osrelease_string(char **s, const char *field) { + assert(s); + assert(field); + + if (!isempty(*s) && !string_has_cc(*s, /* ok= */ NULL) && utf8_is_valid(*s)) + return; + + if (*s) { + log_debug("OS release field '%s' is not clean, suppressing.", field); + *s = mfree(*s); + } +} + +int bootspec_extract_osrelease( + const char *text, + char **ret_good_name, + char **ret_good_version, + char **ret_good_sort_key, + char **ret_os_id, + char **ret_os_version_id, + char **ret_image_id, + char **ret_image_version) { + + int r; + + assert(text); + + _cleanup_free_ char *os_pretty_name = NULL, *image_id = NULL, *os_name = NULL, *os_id = NULL, + *image_version = NULL, *os_version = NULL, *os_version_id = NULL, *os_build_id = NULL; + r = parse_env_data(text, /* size= */ SIZE_MAX, + "os-release", + "PRETTY_NAME", &os_pretty_name, + "IMAGE_ID", &image_id, + "NAME", &os_name, + "ID", &os_id, + "IMAGE_VERSION", &image_version, + "VERSION", &os_version, + "VERSION_ID", &os_version_id, + "BUILD_ID", &os_build_id); + if (r < 0) + return r; + + mangle_osrelease_string(&os_pretty_name, "PRETTY_NAME"); + mangle_osrelease_string(&image_id, "IMAGE_ID"); + mangle_osrelease_string(&os_name, "NAME"); + mangle_osrelease_string(&os_id, "ID"); + mangle_osrelease_string(&image_version, "IMAGE_VERSION"); + mangle_osrelease_string(&os_version, "VERSION"); + mangle_osrelease_string(&os_version_id, "VERSION_ID"); + mangle_osrelease_string(&os_build_id, "BUILD_ID"); + + const char *good_name, *good_version, *good_sort_key; + if (!bootspec_pick_name_version_sort_key( + os_pretty_name, + image_id, + os_name, + os_id, + image_version, + os_version, + os_version_id, + os_build_id, + &good_name, + &good_version, + &good_sort_key)) + return -EBADMSG; + + _cleanup_free_ char *copy_good_name = NULL, *copy_good_version = NULL, *copy_good_sort_key = NULL; + if (ret_good_name) { + copy_good_name = strdup(good_name); + if (!copy_good_name) + return -ENOMEM; + } + + if (ret_good_version && good_version) { + copy_good_version = strdup(good_version); + if (!copy_good_version) + return -ENOMEM; + } + + if (ret_good_sort_key && good_sort_key) { + copy_good_sort_key = strdup(good_sort_key); + if (!copy_good_sort_key) + return -ENOMEM; + } + + if (ret_good_name) + *ret_good_name = TAKE_PTR(copy_good_name); + if (ret_good_version) + *ret_good_version = TAKE_PTR(copy_good_version); + if (ret_good_sort_key) + *ret_good_sort_key = TAKE_PTR(copy_good_sort_key); + + if (ret_os_id) + *ret_os_id = TAKE_PTR(os_id); + if (ret_os_version_id) + *ret_os_version_id = TAKE_PTR(os_version_id); + if (ret_image_id) + *ret_image_id = TAKE_PTR(image_id); + if (ret_image_version) + *ret_image_version = TAKE_PTR(image_version); + + return 0; +} + static int boot_entry_load_unified( const char *root, const BootEntrySource source, @@ -721,9 +826,6 @@ static int boot_entry_load_unified( const char *cmdline_text, BootEntry *ret) { - _cleanup_free_ char *fname = NULL, *os_pretty_name = NULL, *os_image_id = NULL, *os_name = NULL, *os_id = NULL, - *os_image_version = NULL, *os_version = NULL, *os_version_id = NULL, *os_build_id = NULL; - const char *k, *good_name, *good_version, *good_sort_key; int r; assert(root); @@ -731,36 +833,22 @@ static int boot_entry_load_unified( assert(osrelease_text); assert(ret); - k = path_startswith(path, root); + const char *k = path_startswith(path, root); if (!k) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Path is not below root: %s", path); - r = parse_env_data(osrelease_text, /* size= */ SIZE_MAX, - ".osrel", - "PRETTY_NAME", &os_pretty_name, - "IMAGE_ID", &os_image_id, - "NAME", &os_name, - "ID", &os_id, - "IMAGE_VERSION", &os_image_version, - "VERSION", &os_version, - "VERSION_ID", &os_version_id, - "BUILD_ID", &os_build_id); + _cleanup_free_ char *good_name = NULL, *good_version = NULL, *good_sort_key = NULL, *os_id = NULL, *os_version_id = NULL; + r = bootspec_extract_osrelease( + osrelease_text, + &good_name, + &good_version, + &good_sort_key, + &os_id, + &os_version_id, + /* ret_image_id= */ NULL, + /* ret_image_version= */ NULL); if (r < 0) - return log_error_errno(r, "Failed to parse os-release data from unified kernel image %s: %m", path); - - if (!bootspec_pick_name_version_sort_key( - os_pretty_name, - os_image_id, - os_name, - os_id, - os_image_version, - os_version, - os_version_id, - os_build_id, - &good_name, - &good_version, - &good_sort_key)) - return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Missing fields in os-release data from unified kernel image %s, refusing.", path); + return log_error_errno(r, "Failed to extract name/version/sort-key from os-release data from unified kernel image %s, refusing: %m", path); _cleanup_free_ char *profile_id = NULL, *profile_title = NULL; if (profile_text) { @@ -773,6 +861,7 @@ static int boot_entry_load_unified( return log_error_errno(r, "Failed to parse profile data from unified kernel image '%s': %m", path); } + _cleanup_free_ char *fname = NULL; r = path_extract_filename(path, &fname); if (r < 0) return log_error_errno(r, "Failed to extract file name from '%s': %m", path); diff --git a/src/shared/bootspec.h b/src/shared/bootspec.h index 01abe05e72..d5f6930be9 100644 --- a/src/shared/bootspec.h +++ b/src/shared/bootspec.h @@ -164,3 +164,5 @@ int boot_filename_extract_tries(const char *fname, char **ret_stripped, unsigned int boot_entry_to_json(const BootConfig *c, size_t i, sd_json_variant **ret); int pe_find_uki_sections(int fd, const char *path, unsigned profile, char **ret_osrelease, char **ret_profile, char **ret_cmdline); + +int bootspec_extract_osrelease(const char *text, char **ret_good_name, char **ret_good_version, char **ret_good_sort_key, char **ret_os_id, char **ret_os_version_id, char **ret_image_id, char **ret_image_version); From 4d0f6ac5931c051871e46e98a2ff7eb37136ea57 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 15 Apr 2026 16:09:48 +0200 Subject: [PATCH 04/10] bootctl: add helpers that format a type1 menu entry filename from a commit nr --- src/bootctl/bootspec-util.c | 214 +++++++++++++++++++++++++++++++ src/bootctl/bootspec-util.h | 12 ++ src/bootctl/meson.build | 6 + src/bootctl/test-bootspec-util.c | 51 ++++++++ 4 files changed, 283 insertions(+) create mode 100644 src/bootctl/test-bootspec-util.c diff --git a/src/bootctl/bootspec-util.c b/src/bootctl/bootspec-util.c index b96687430c..5f9842c9d8 100644 --- a/src/bootctl/bootspec-util.c +++ b/src/bootctl/bootspec-util.c @@ -1,11 +1,18 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include "alloc-util.h" +#include "boot-entry.h" #include "bootspec-util.h" #include "devnum-util.h" #include "efi-loader.h" #include "errno-util.h" #include "log.h" +#include "parse-util.h" +#include "path-util.h" +#include "stdio-util.h" +#include "string-util.h" #include "strv.h" +#include "utf8.h" int boot_config_load_and_select( BootConfig *config, @@ -39,3 +46,210 @@ int boot_config_load_and_select( return boot_config_select_special_entries(config, /* skip_efivars= */ !!root); } + +int boot_entry_make_commit_filename( + const char *entry_token, + uint64_t entry_commit, + const char *version, + unsigned profile_nr, + unsigned tries_left, + char **ret) { + + assert(entry_token); + assert(ret); + + /* Generate a new entry filename from the entry token, the commit number, and (optionally) the + * image/OS version, (if non-zero) the profile number, and (unless UINT_MAX) the number of tries + * left. */ + + if (!boot_entry_token_valid(entry_token)) + return -EINVAL; + if (!entry_commit_valid(entry_commit)) + return -EINVAL; + + _cleanup_free_ char *filename = asprintf_safe("%s-commit_%" PRIu64, entry_token, entry_commit); + if (!filename) + return -ENOMEM; + if (version && !strextend(&filename, ".", version)) + return -ENOMEM; + if (profile_nr > 0 && strextendf(&filename, "@%u", profile_nr) < 0) + return -ENOMEM; + if (tries_left != UINT_MAX && strextendf(&filename, "+%u", tries_left) < 0) + return -ENOMEM; + if (!strextend(&filename, ".conf")) + return -ENOMEM; + + if (!filename_is_valid(filename) || string_has_cc(filename, /* ok= */ NULL) || !utf8_is_valid(filename)) + return -EINVAL; + + *ret = TAKE_PTR(filename); + return 0; +} + +int boot_entry_parse_commit_filename( + const char *filename, + char **ret_entry_token, + uint64_t *ret_entry_commit) { + + int r; + + assert(filename); + + if (!filename_is_valid(filename)) + return -EINVAL; + + _cleanup_free_ char *stripped = NULL; + r = boot_filename_extract_tries(filename, &stripped, /* ret_tries_left= */ NULL, /* ret_tries_done= */ NULL); + if (r < 0) + return r; + + const char *a = strrstr_no_case(stripped, "-commit_"); + if (!a) + return -EBADMSG; + + const char *c = endswith_no_case(stripped, ".conf"); + if (!c) + return -EBADMSG; + + assert(a < c); + + _cleanup_free_ char *entry_token = strndup(stripped, a - stripped); + if (!entry_token) + return -ENOMEM; + + if (!boot_entry_token_valid(entry_token)) + return -EBADMSG; + + const char *b = a + STRLEN("-commit_"); + size_t n = strspn(b, DIGITS); + if (n <= 0 || !IN_SET(b[n], '+', '.', '@')) + return -EBADMSG; + + _cleanup_free_ char *entry_commit_string = strndup(b, n); + if (!entry_commit_string) + return -ENOMEM; + + uint64_t entry_commit; + r = safe_atou64_full(entry_commit_string, 10, &entry_commit); + if (r < 0) + return r; + if (!entry_commit_valid(entry_commit)) + return -EBADMSG; + + if (ret_entry_token) + *ret_entry_token = TAKE_PTR(entry_token); + if (ret_entry_commit) + *ret_entry_commit = entry_commit; + + return 0; +} + +int boot_entry_parse_commit( + BootEntry *entry, + char **ret_entry_token, + uint64_t *ret_entry_commit) { + + int r; + + assert(entry); + + if (entry->type != BOOT_ENTRY_TYPE1) + return -EADDRNOTAVAIL; + + _cleanup_free_ char *fn = NULL; + r = path_extract_filename(entry->path, &fn); + if (r < 0) + return r; + + return boot_entry_parse_commit_filename(fn, ret_entry_token, ret_entry_commit); +} + +int boot_config_find_oldest_commit( + BootConfig *config, + const char *entry_token, + char ***ret_ids) { + + int r; + + assert(config); + assert(entry_token); + assert(ret_ids); + + uint64_t commit_oldest = UINT64_MAX, commit_2nd_oldest = UINT64_MAX, commit_blocked = UINT64_MAX; + + /* First, determine which commit is the oldest (that isn't the current one), and hence the candidate + * to be removed */ + FOREACH_ARRAY(b, config->entries, config->n_entries) { + _cleanup_free_ char *et = NULL; + uint64_t ec; + + r = boot_entry_parse_commit(b, &et, &ec); + if (r == -EADDRNOTAVAIL) + continue; + if (r < 0) { + log_debug_errno(r, "Failed to parse entry filename of '%s', ignoring: %m", strna(b->id)); + continue; + } + + if (!streq(et, entry_token)) /* Not ours? */ + continue; + + if (ec < commit_oldest) { + commit_2nd_oldest = commit_oldest; + commit_oldest = ec; + } else if (ec > commit_oldest && ec < commit_2nd_oldest) + commit_2nd_oldest = ec; + + if (boot_config_selected_entry(config) == b) { + assert(commit_blocked == UINT64_MAX); + commit_blocked = ec; + } + } + + uint64_t commit_picked; + if (commit_oldest == UINT64_MAX) + return log_debug_errno(SYNTHETIC_ERRNO(ENXIO), "No matching entry found while determining oldest entry."); + if (commit_oldest != commit_blocked) + commit_picked = commit_oldest; + else { + if (commit_2nd_oldest == UINT64_MAX) + return log_debug_errno(SYNTHETIC_ERRNO(EBUSY), "Only matching entry found while determining oldest entry is current one, skipping it."); + + assert(commit_2nd_oldest != commit_blocked); + commit_picked = commit_2nd_oldest; + } + + log_debug("Determined commit %" PRIu64 " to be oldest.", commit_picked); + + /* Second loop: actually remove all entries matching this commit (which can be multiple, since UKIs + * have profiles) */ + _cleanup_(strv_freep) char **l = NULL; + FOREACH_ARRAY(b, config->entries, config->n_entries) { + _cleanup_free_ char *et = NULL; + uint64_t ec; + + r = boot_entry_parse_commit(b, &et, &ec); + if (r == -EADDRNOTAVAIL) + continue; + if (r < 0) { + log_debug_errno(r, "Failed to parse entry filename of '%s', ignoring: %m", strna(b->id)); + continue; + } + + if (!streq(et, entry_token)) /* Not ours? */ + continue; + + if (ec != commit_picked) + continue; + + r = strv_extend(&l, b->id); + if (r < 0) + return r; + } + + /* The list cannot be empty, the first loop above and the 2nd loop must have found the same matching + * entries, and if the first loop didn't find any we'd not come this far. */ + assert(!strv_isempty(l)); + *ret_ids = TAKE_PTR(l); + return 0; +} diff --git a/src/bootctl/bootspec-util.h b/src/bootctl/bootspec-util.h index 51dac12b9f..0824c8040f 100644 --- a/src/bootctl/bootspec-util.h +++ b/src/bootctl/bootspec-util.h @@ -4,3 +4,15 @@ #include "bootspec.h" int boot_config_load_and_select(BootConfig *config, const char *root, const char *esp_path, dev_t esp_devid, const char *xbootldr_path, dev_t xbootldr_devid); + +static inline bool entry_commit_valid(uint64_t commit) { + return commit > 0 && commit < UINT64_MAX; +} + +int boot_entry_make_commit_filename(const char *entry_token, uint64_t entry_commit, const char *version, unsigned profile_nr, unsigned tries_left, char **ret); + +int boot_entry_parse_commit_filename(const char *filename, char **ret_entry_token, uint64_t *ret_entry_commit); + +int boot_entry_parse_commit(BootEntry *entry, char **ret_entry_token, uint64_t *ret_entry_commit); + +int boot_config_find_oldest_commit(BootConfig *config, const char *entry_token, char ***ret_ids); diff --git a/src/bootctl/meson.build b/src/bootctl/meson.build index f8349df716..ff33cde3f6 100644 --- a/src/bootctl/meson.build +++ b/src/bootctl/meson.build @@ -25,4 +25,10 @@ executables += [ 'link_with' : boot_link_with, 'dependencies' : [libopenssl_cflags], }, + test_template + { + 'sources' : files( + 'test-bootspec-util.c', + 'bootspec-util.c', + ), + }, ] diff --git a/src/bootctl/test-bootspec-util.c b/src/bootctl/test-bootspec-util.c new file mode 100644 index 0000000000..1fa891469f --- /dev/null +++ b/src/bootctl/test-bootspec-util.c @@ -0,0 +1,51 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "alloc-util.h" +#include "bootspec-util.h" +#include "tests.h" + +static void test_one( + const char *entry_token, + uint64_t entry_commit, + const char *version, + unsigned profile_nr, + unsigned tries_left, + const char *expected) { + + _cleanup_free_ char *fn = NULL; + ASSERT_OK(boot_entry_make_commit_filename(entry_token, entry_commit, version, profile_nr, tries_left, &fn)); + ASSERT_STREQ(fn, expected); + + _cleanup_free_ char *token = NULL; + uint64_t commit = 0; + ASSERT_OK(boot_entry_parse_commit_filename(fn, &token, &commit)); + ASSERT_STREQ(token, entry_token); + ASSERT_EQ(commit, entry_commit); +} + +TEST(boot_entry_commit_filename) { + test_one("foo", 1, NULL, 0, UINT_MAX, "foo-commit_1.conf"); + test_one("foo", 42, "1.0", 0, UINT_MAX, "foo-commit_42.1.0.conf"); + test_one("foo", 42, "1.0", 3, UINT_MAX, "foo-commit_42.1.0@3.conf"); + test_one("foo", 42, "1.0", 3, 5, "foo-commit_42.1.0@3+5.conf"); + test_one("foo", 42, NULL, 3, UINT_MAX, "foo-commit_42@3.conf"); + test_one("foo", 42, NULL, 3, 7, "foo-commit_42@3+7.conf"); + test_one("foo", 42, NULL, 0, 9, "foo-commit_42+9.conf"); + test_one("my-token", 123456, "v2", 0, UINT_MAX, "my-token-commit_123456.v2.conf"); + + /* Invalid inputs for make */ + _cleanup_free_ char *fn = NULL; + ASSERT_ERROR(boot_entry_make_commit_filename("foo/bar", 1, NULL, 0, UINT_MAX, &fn), EINVAL); + ASSERT_ERROR(boot_entry_make_commit_filename("foo", 0, NULL, 0, UINT_MAX, &fn), EINVAL); + ASSERT_ERROR(boot_entry_make_commit_filename("foo", UINT64_MAX, NULL, 0, UINT_MAX, &fn), EINVAL); + + /* Invalid inputs for parse */ + _cleanup_free_ char *token = NULL; + uint64_t commit = 0; + ASSERT_ERROR(boot_entry_parse_commit_filename("foo.conf", &token, &commit), EBADMSG); + ASSERT_ERROR(boot_entry_parse_commit_filename("foo-commit_.conf", &token, &commit), EBADMSG); + ASSERT_ERROR(boot_entry_parse_commit_filename("foo-commit_abc.conf", &token, &commit), EBADMSG); + ASSERT_ERROR(boot_entry_parse_commit_filename("foo-commit_0.conf", &token, &commit), EBADMSG); +} + +DEFINE_TEST_MAIN(LOG_INFO); From e68bf712be1b348ae8c13fe0e04cc1f42d57ca9e Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 10 Apr 2026 14:48:25 +0200 Subject: [PATCH 05/10] bootctl: rework/modernize "unlink" and add Varlink API for it Among other things this changes tracking of the location of resources during GC from using the BootEntrySource enum rather than a path, since we have that and it is more efficient and easier to grok. --- man/bootctl.xml | 25 +- shell-completion/bash/bootctl | 10 +- shell-completion/zsh/_bootctl | 21 + src/bootctl/bootctl-cleanup.c | 7 +- src/bootctl/bootctl-unlink.c | 627 ++++++++++++++++---- src/bootctl/bootctl-unlink.h | 6 +- src/bootctl/bootctl.c | 15 +- src/bootctl/bootctl.h | 1 + src/shared/shared-forward.h | 2 + src/shared/varlink-io.systemd.BootControl.c | 22 +- 10 files changed, 611 insertions(+), 125 deletions(-) diff --git a/man/bootctl.xml b/man/bootctl.xml index 558891eaa1..fb5f4b2b2a 100644 --- a/man/bootctl.xml +++ b/man/bootctl.xml @@ -102,11 +102,17 @@ - ID + ID - Removes a boot loader entry including the files it refers to. Takes a single boot - loader entry ID string or a glob pattern as argument. Referenced files such as kernel or initrd are - only removed if no other entry refers to them. + Removes a boot loader entry including the files it refers to. Takes an optional boot + loader entry ID string or a glob pattern as argument. Referenced files such as the kernel, initrds, + system extensions (sysexts), configuration extensions (confexts) or credential files are only removed + if no other entry refers to them. + + If no ID argument is specified, the option + must be specified, in which case the boot loader entry with the lowest version is removed (for + robustness reasons the currently booted menu entry is never removed, nor is the last existing boot + loader entry). @@ -560,6 +566,17 @@ + + + + When used with unlink, selects the oldest installed boot loader + entry matching the boot entry token for removal (rather than passing an explicit entry ID). This is + useful for pruning older installed boot loader entries. Note that the currently booted entry is never + removed, nor is the last remaining one. + + + + diff --git a/shell-completion/bash/bootctl b/shell-completion/bash/bootctl index d7714731c2..792fc0c0ac 100644 --- a/shell-completion/bash/bootctl +++ b/shell-completion/bash/bootctl @@ -40,8 +40,10 @@ _bootctl() { --dry-run' [ARG]='--esp-path --boot-path --root --image --image-policy --install-source --variables --random-seed --make-entry-directory --entry-token --json - --efi-boot-option-description --secure-boot-auto-enroll --private-key - --private-key-source --certificate --certificate-source' + --efi-boot-option-description --efi-boot-option-description-with-device + --secure-boot-auto-enroll --private-key + --private-key-source --certificate --certificate-source + --oldest' ) if __contains_word "$prev" ${OPTS[ARG]}; then @@ -67,7 +69,7 @@ _bootctl() { --install-source) comps="image host auto" ;; - --random-seed|--variables|--secure-boot-auto-enroll) + --random-seed|--variables|--secure-boot-auto-enroll|--oldest|--efi-boot-option-description-with-device) comps="yes no" ;; --json) @@ -85,7 +87,7 @@ _bootctl() { local -A VERBS=( [STANDALONE]='help status install update remove is-installed random-seed list set-timeout set-timeout-oneshot cleanup' - [BOOTENTRY]='set-default set-oneshot set-sysfail unlink' + [BOOTENTRY]='set-default set-oneshot set-sysfail set-preferred unlink' [BOOLEAN]='reboot-to-firmware' [FILE]='kernel-identify kernel-inspect' ) diff --git a/shell-completion/zsh/_bootctl b/shell-completion/zsh/_bootctl index f7ed2a8e41..c23c1c888d 100644 --- a/shell-completion/zsh/_bootctl +++ b/shell-completion/zsh/_bootctl @@ -24,10 +24,26 @@ _bootctl_set-oneshot() { _bootctl_comp_ids } +_bootctl_set-sysfail() { + _bootctl_comp_ids +} + +_bootctl_set-preferred() { + _bootctl_comp_ids +} + _bootctl_unlink() { _bootctl_comp_ids } +_bootctl_kernel-identify() { + _files +} + +_bootctl_kernel-inspect() { + _files +} + _bootctl_reboot-to-firmware() { local -a _completions _completions=( yes no ) @@ -49,10 +65,14 @@ _bootctl_reboot-to-firmware() { "list:List boot loader entries" "set-default:Set the default boot loader entry" "set-oneshot:Set the default boot loader entry only for the next boot" + "set-sysfail:Set boot loader entry used in case of a system failure" + "set-preferred:Set the preferred boot loader entry" "set-timeout:Set the menu timeout" "set-timeout-oneshot:Set the menu timeout for the next boot only" "unlink:Remove boot loader entry" "cleanup:Remove files in ESP not referenced in any boot entry" + "kernel-identify:Identify kernel image type" + "kernel-inspect:Print details about the kernel image" ) if (( CURRENT == 1 )); then _describe -t commands 'bootctl command' _bootctl_cmds || compadd "$@" @@ -79,6 +99,7 @@ _arguments \ '--no-pager[Do not pipe output into a pager]' \ '--graceful[Do not fail when locating ESP or writing fails]' \ '--dry-run[Dry run (unlink and cleanup)]' \ + '--oldest=[Delete oldest boot menu entry]:options:(yes no)' \ '--root=[Operate under the specified directory]:PATH' \ '--image=[Operate on the specified image]:PATH' \ '--install-source[Where to pick files when using --root=/--image=]:options:(image host auto)' \ diff --git a/src/bootctl/bootctl-cleanup.c b/src/bootctl/bootctl-cleanup.c index 1e8819bea1..011567d187 100644 --- a/src/bootctl/bootctl-cleanup.c +++ b/src/bootctl/bootctl-cleanup.c @@ -49,6 +49,7 @@ static int list_remove_orphaned_file( static int cleanup_orphaned_files( const BootConfig *config, + BootEntrySource source, const char *root) { _cleanup_hashmap_free_ Hashmap *known_files = NULL; @@ -65,7 +66,7 @@ static int cleanup_orphaned_files( if (r < 0) return r; - r = boot_config_count_known_files(config, root, &known_files); + r = boot_config_count_known_files(config, source, &known_files); if (r < 0) return log_error_errno(r, "Failed to count files in %s: %m", root); @@ -116,10 +117,10 @@ int verb_cleanup(int argc, char *argv[], uintptr_t _data, void *userdata) { return r; r = 0; - RET_GATHER(r, cleanup_orphaned_files(&config, arg_esp_path)); + RET_GATHER(r, cleanup_orphaned_files(&config, BOOT_ENTRY_ESP, arg_esp_path)); if (arg_xbootldr_path && xbootldr_devid != esp_devid) - RET_GATHER(r, cleanup_orphaned_files(&config, arg_xbootldr_path)); + RET_GATHER(r, cleanup_orphaned_files(&config, BOOT_ENTRY_XBOOTLDR, arg_xbootldr_path)); return r; } diff --git a/src/bootctl/bootctl-unlink.c b/src/bootctl/bootctl-unlink.c index 0d0e7ad076..80e74926c6 100644 --- a/src/bootctl/bootctl-unlink.c +++ b/src/bootctl/bootctl-unlink.c @@ -3,20 +3,70 @@ #include #include +#include "sd-id128.h" +#include "sd-json.h" +#include "sd-varlink.h" + #include "alloc-util.h" +#include "boot-entry.h" #include "bootctl.h" #include "bootctl-unlink.h" #include "bootspec.h" #include "bootspec-util.h" #include "chase.h" +#include "efi-loader.h" #include "errno-util.h" +#include "fd-util.h" +#include "find-esp.h" #include "hashmap.h" +#include "id128-util.h" +#include "json-util.h" #include "log.h" #include "path-util.h" +#include "stat-util.h" +#include "string-util.h" #include "strv.h" +typedef struct UnlinkContext { + char *root; + int root_fd; + + sd_id128_t machine_id; + BootEntryTokenType entry_token_type; + char *entry_token; + + char *esp_path; + dev_t esp_devid; + int esp_fd; + + char *xbootldr_path; + dev_t xbootldr_devid; + int xbootldr_fd; +} UnlinkContext; + +#define UNLINK_CONTEXT_NULL \ + (UnlinkContext) { \ + .root_fd = -EBADF, \ + .entry_token_type = _BOOT_ENTRY_TOKEN_TYPE_INVALID, \ + .esp_fd = -EBADF, \ + .xbootldr_fd = -EBADF, \ + } + +static void unlink_context_done(UnlinkContext *c) { + assert(c); + + c->root = mfree(c->root); + c->root_fd = safe_close(c->root_fd); + + c->entry_token = mfree(c->entry_token); + + c->esp_path = mfree(c->esp_path); + c->esp_fd = safe_close(c->esp_fd); + c->xbootldr_path = mfree(c->xbootldr_path); + c->xbootldr_fd = safe_close(c->xbootldr_fd); +} + static int ref_file(Hashmap **known_files, const char *fn, int increment) { - char *k = NULL; int n, r; assert(known_files); @@ -26,13 +76,15 @@ static int ref_file(Hashmap **known_files, const char *fn, int increment) { if (!fn) return 0; + char *k = NULL; n = PTR_TO_INT(hashmap_get2(*known_files, fn, (void**)&k)); - n += increment; + if (!INC_SAFE(&n, increment)) + return -EOVERFLOW; assert(n >= 0); if (n == 0) { - (void) hashmap_remove(*known_files, fn); + (void) hashmap_remove(*known_files, k); free(k); } else if (!k) { _cleanup_free_ char *t = NULL; @@ -40,12 +92,14 @@ static int ref_file(Hashmap **known_files, const char *fn, int increment) { t = strdup(fn); if (!t) return -ENOMEM; + r = hashmap_ensure_put(known_files, &path_hash_ops_free, t, INT_TO_PTR(n)); if (r < 0) return r; + TAKE_PTR(t); } else { - r = hashmap_update(*known_files, fn, INT_TO_PTR(n)); + r = hashmap_update(*known_files, k, INT_TO_PTR(n)); if (r < 0) return r; } @@ -53,195 +107,548 @@ static int ref_file(Hashmap **known_files, const char *fn, int increment) { return n; } +static int boot_entry_ref_files( + const BootEntry *e, + Hashmap **known_files, + int increment) { + + int r; + + assert(e); + assert(known_files); + assert(increment != 0); + + r = ref_file(known_files, e->kernel, increment); + if (r < 0) + return r; + + r = ref_file(known_files, e->efi, increment); + if (r < 0) + return r; + + r = ref_file(known_files, e->uki, increment); + if (r < 0) + return r; + + STRV_FOREACH(s, e->initrd) { + r = ref_file(known_files, *s, increment); + if (r < 0) + return r; + } + + r = ref_file(known_files, e->device_tree, increment); + if (r < 0) + return r; + + STRV_FOREACH(s, e->device_tree_overlay) { + r = ref_file(known_files, *s, increment); + if (r < 0) + return r; + } + + return 0; +} + int boot_config_count_known_files( const BootConfig *config, - const char* root, + BootEntrySource source, Hashmap **ret_known_files) { - _cleanup_hashmap_free_ Hashmap *known_files = NULL; int r; assert(config); assert(ret_known_files); - for (size_t i = 0; i < config->n_entries; i++) { - const BootEntry *e = config->entries + i; + _cleanup_hashmap_free_ Hashmap *known_files = NULL; + FOREACH_ARRAY(e, config->entries, config->n_entries) { - if (!path_equal(e->root, root)) + if (e->source != source) continue; - r = ref_file(&known_files, e->kernel, +1); + r = boot_entry_ref_files(e, &known_files, +1); if (r < 0) return r; - r = ref_file(&known_files, e->efi, +1); - if (r < 0) - return r; - r = ref_file(&known_files, e->uki, +1); - if (r < 0) - return r; - STRV_FOREACH(s, e->initrd) { - r = ref_file(&known_files, *s, +1); - if (r < 0) - return r; - } - r = ref_file(&known_files, e->device_tree, +1); - if (r < 0) - return r; - STRV_FOREACH(s, e->device_tree_overlay) { - r = ref_file(&known_files, *s, +1); - if (r < 0) - return r; - } } *ret_known_files = TAKE_PTR(known_files); - return 0; } -static void deref_unlink_file(Hashmap **known_files, const char *fn, const char *root) { - _cleanup_free_ char *path = NULL; +static int unref_unlink_file( + Hashmap **known_files, + const char *root, + int root_fd, + const char *path, + bool dry_run) { + int r; assert(known_files); /* just gracefully ignore this. This way the caller doesn't have to verify whether the bootloader entry is relevant */ - if (!fn || !root) - return; + if (root_fd < 0 || !root || !path) + return 0; - r = ref_file(known_files, fn, -1); + r = ref_file(known_files, path, -1); if (r < 0) - return (void) log_warning_errno(r, "Failed to deref \"%s\", ignoring: %m", fn); + return log_error_errno(r, "Failed to unref '%s': %m", path); if (r > 0) - return; + return 0; - if (arg_dry_run) { - r = chase_and_access(fn, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS, F_OK, &path); - if (r < 0) - log_info_errno(r, "Unable to determine whether \"%s\" exists, ignoring: %m", fn); + if (dry_run) { + _cleanup_free_ char *resolved = NULL; + r = chase_and_accessat( + /* root_fd= */ root_fd, + /* dir_fd= */ root_fd, + path, + CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS|CHASE_MUST_BE_REGULAR, + F_OK, + &resolved); + if (r < 0) { + log_warning_errno(r, "Unable to determine whether '%s' exists, ignoring: %m", path); + return 0; + } + + log_info("Would remove '%s'", resolved); + return 1; + } + + _cleanup_free_ char *resolved = NULL; + r = chase_and_unlinkat( + /* root_fd= */ root_fd, + /* dir_fd= */ root_fd, + path, + CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS, + /* unlink_flags= */ 0, + &resolved); + if (r == -ENOENT) + log_debug("Resource '%s' is already removed, skipping.", path); + else if (r < 0) { + log_warning_errno(r, "Failed to remove '%s', ignoring: %m", path); + return 0; + } else + log_info("Removed '%s'", resolved); + + _cleanup_free_ char *parent = NULL; + r = path_extract_directory(path, &parent); + if (r < 0) + log_debug_errno(r, "Failed to extract parent directory of '%s', ignoring.", path); + else { + _cleanup_free_ char *resolved_parent = NULL; + r = chase_and_unlinkat( + /* root_fd= */ root_fd, + /* dir_fd= */ root_fd, + parent, + CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS, + AT_REMOVEDIR, + &resolved_parent); + if (IN_SET(r, -ENOTEMPTY, -ENOENT)) + log_debug_errno(r, "Failed to remove directory '%s', ignoring: %m", parent); + else if (r < 0) + log_warning_errno(r, "Failed to remove directory '%s', ignoring: %m", parent); else - log_info("Would remove \"%s\"", path); - return; + log_info("Removed '%s'.", resolved_parent); } - r = chase_and_unlink(fn, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS, 0, &path); - if (r >= 0) - log_info("Removed \"%s\"", path); - else if (r != -ENOENT) - return (void) log_warning_errno(r, "Failed to remove \"%s\", ignoring: %m", fn); - - _cleanup_free_ char *d = NULL; - if (path_extract_directory(fn, &d) >= 0 && !path_equal(d, "/")) { - r = chase_and_unlink(d, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS, AT_REMOVEDIR, NULL); - if (r < 0 && !IN_SET(r, -ENOTEMPTY, -ENOENT)) - log_warning_errno(r, "Failed to remove directory \"%s\", ignoring: %m", d); - } + return 1; } -static int boot_config_find_in(const BootConfig *config, const char *root, const char *id) { - assert(config); +static ssize_t boot_config_find_in( + const BootConfig *config, + BootEntrySource source, + const char *id) { - if (!root || !id) + assert(config); + assert(source >= 0); + assert(source < _BOOT_ENTRY_SOURCE_MAX); + + if (!id) return -ENOENT; for (size_t i = 0; i < config->n_entries; i++) - if (path_equal(config->entries[i].root, root) && + if (config->entries[i].source == source && fnmatch(id, config->entries[i].id, FNM_CASEFOLD) == 0) - return i; + return (ssize_t) i; return -ENOENT; } -static int unlink_entry(const BootConfig *config, const char *root, const char *id) { - _cleanup_hashmap_free_ Hashmap *known_files = NULL; - const BootEntry *e = NULL; +int boot_entry_unlink( + const BootEntry *e, + const char *root, + int root_fd, + Hashmap *known_files, + bool dry_run) { + int r; - assert(config); + assert(e); + assert(root_fd >= 0); - r = boot_config_count_known_files(config, root, &known_files); - if (r < 0) - return log_error_errno(r, "Failed to count files in %s: %m", root); - - r = boot_config_find_in(config, root, id); - if (r < 0) - return 0; /* There is nothing to remove. */ - - if (r == config->default_entry) - log_warning("%s is the default boot entry", id); - if (r == config->selected_entry) - log_warning("%s is the selected boot entry", id); - - e = &config->entries[r]; - - deref_unlink_file(&known_files, e->kernel, e->root); - deref_unlink_file(&known_files, e->efi, e->root); - deref_unlink_file(&known_files, e->uki, e->root); + (void) unref_unlink_file(&known_files, root, root_fd, e->kernel, dry_run); + (void) unref_unlink_file(&known_files, root, root_fd, e->efi, dry_run); + (void) unref_unlink_file(&known_files, root, root_fd, e->uki, dry_run); STRV_FOREACH(s, e->initrd) - deref_unlink_file(&known_files, *s, e->root); - deref_unlink_file(&known_files, e->device_tree, e->root); + (void) unref_unlink_file(&known_files, root, root_fd, *s, dry_run); + (void) unref_unlink_file(&known_files, root, root_fd, e->device_tree, dry_run); STRV_FOREACH(s, e->device_tree_overlay) - deref_unlink_file(&known_files, *s, e->root); + (void) unref_unlink_file(&known_files, root, root_fd, *s, dry_run); - if (arg_dry_run) + if (dry_run) log_info("Would remove \"%s\"", e->path); else { - r = chase_and_unlink(e->path, root, CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS, 0, NULL); + const char *p = path_startswith(e->path, root); + if (!p) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "File '%s' is not inside root '%s', refusing.", e->path, root); + + _cleanup_free_ char *resolved = NULL; + r = chase_and_unlinkat( + /* root_fd= */ root_fd, + /* dir_fd= */ root_fd, + p, + CHASE_PROHIBIT_SYMLINKS|CHASE_TRIGGER_AUTOFS, + /* unlink_flags= */ 0, + &resolved); if (r == -ENOENT) return 0; /* Already removed? */ if (r < 0) return log_error_errno(r, "Failed to remove \"%s\": %m", e->path); - log_info("Removed %s", e->path); + log_info("Removed '%s'.", resolved); } return 0; } -int verb_unlink(int argc, char *argv[], uintptr_t _data, void *userdata) { - dev_t esp_devid = 0, xbootldr_devid = 0; +static int unlink_entry( + const BootConfig *config, + const char *root, + int root_fd, + BootEntrySource source, + char **ids, + bool dry_run) { + + size_t n_removed = 0; int r; + assert(config); + + _cleanup_hashmap_free_ Hashmap *known_files = NULL; + r = boot_config_count_known_files(config, source, &known_files); + if (r < 0) + return log_error_errno(r, "Failed to count files in %s: %m", root); + + int ret = 0; + STRV_FOREACH(id, ids) { + log_debug("Unlinking '%s'", *id); + ssize_t idx = boot_config_find_in(config, source, *id); + if (idx < 0) + continue; /* There is nothing to remove. */ + + log_debug("Index %zi", idx); + + if (idx == config->default_entry) + log_warning("%s is the default boot entry", *id); + if (idx == config->selected_entry) + log_warning("%s is the selected boot entry", *id); + + r = boot_entry_unlink(config->entries + idx, root, root_fd, known_files, dry_run); + if (r < 0) + RET_GATHER(ret, r); + else + n_removed++; + } + + if (n_removed == 0) + log_info("No matching entries found or removed."); + + return ret; +} + +static int unlink_context_from_cmdline(UnlinkContext *ret) { + int r; + + assert(ret); + + _cleanup_(unlink_context_done) UnlinkContext b = UNLINK_CONTEXT_NULL; + b.entry_token_type = arg_entry_token_type; + + if (strdup_to(&b.entry_token, arg_entry_token) < 0) + return log_oom(); + + if (arg_root) { + b.root_fd = open(arg_root, O_CLOEXEC|O_DIRECTORY|O_PATH); + if (b.root_fd < 0) + return log_error_errno(errno, "Failed to open root directory '%s': %m", arg_root); + + if (strdup_to(&b.root, arg_root) < 0) + return log_oom(); + } else + b.root_fd = XAT_FDROOT; + r = acquire_esp(/* unprivileged_mode= */ false, /* graceful= */ false, - /* ret_fd= */ NULL, + &b.esp_fd, /* ret_part= */ NULL, /* ret_pstart= */ NULL, /* ret_psize= */ NULL, /* ret_uuid= */ NULL, - &esp_devid); - if (r == -EACCES) /* We really need the ESP path for this call, hence also log about access errors */ - return log_error_errno(r, "Failed to determine ESP location: %m"); - if (r < 0) - return r; + &b.esp_devid); + if (r < 0 && r != -ENOKEY) + return r; /* About all other errors acquire_esp() logs on its own */ + if (r > 0) { + if (arg_root) { + const char *e = path_startswith(arg_esp_path, arg_root); + if (!e) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "ESP path '%s' not below specified root '%s', refusing.", arg_esp_path, arg_root); + + r = strdup_to(&b.esp_path, e); + } else + r = strdup_to(&b.esp_path, arg_esp_path); + if (r < 0) + return log_oom(); + } r = acquire_xbootldr( /* unprivileged_mode= */ false, - /* ret_fd= */ NULL, + &b.xbootldr_fd, /* ret_uuid= */ NULL, - &xbootldr_devid); - if (r == -EACCES) - return log_error_errno(r, "Failed to determine XBOOTLDR partition: %m"); - if (r < 0) + &b.xbootldr_devid); + if (r < 0 && r != -ENOKEY) return r; + if (r > 0) { + if (arg_root) { + const char *e = path_startswith(arg_xbootldr_path, arg_root); + if (!e) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "XBOOTLDR path '%s' not below specified root '%s', refusing.", arg_xbootldr_path, arg_root); + + r = strdup_to(&b.xbootldr_path, e); + } else + r = strdup_to(&b.xbootldr_path, arg_xbootldr_path); + if (r < 0) + return log_oom(); + } + + /* Only if we found neither ESP nor XBOOTLDR let's fail. */ + if (!b.xbootldr_path && !b.esp_path) + return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), "Neither ESP nor XBOOTLDR found, refusing."); + + *ret = TAKE_GENERIC(b, UnlinkContext, UNLINK_CONTEXT_NULL); + return 0; +} + +static int run_unlink( + UnlinkContext *c, + char **_ids, + bool dry_run) { + + int r; + assert(c); + + _cleanup_free_ char *x = NULL, *y = NULL; + if (c->root && c->esp_path) { + x = path_join(c->root, c->esp_path); + if (!x) + return log_oom(); + } + + if (c->root && c->xbootldr_path) { + y = path_join(c->root, c->xbootldr_path); + if (!y) + return log_oom(); + } _cleanup_(boot_config_free) BootConfig config = BOOT_CONFIG_NULL; r = boot_config_load_and_select( &config, - arg_root, - arg_esp_path, - esp_devid, - arg_xbootldr_path, - xbootldr_devid); + c->root, + x ?: c->esp_path, + c->esp_devid, + y ?: c->xbootldr_path, + c->xbootldr_devid); if (r < 0) return r; - r = 0; - RET_GATHER(r, unlink_entry(&config, arg_esp_path, argv[1])); + _cleanup_(strv_freep) char **ids = NULL; + if (strv_isempty(_ids)) { + r = id128_get_machine_at(c->root_fd, &c->machine_id); + if (r < 0 && !ERRNO_IS_NEG_MACHINE_ID_UNSET(r)) + return log_error_errno(r, "Failed to get machine-id: %m"); - if (arg_xbootldr_path && xbootldr_devid != esp_devid) - RET_GATHER(r, unlink_entry(&config, arg_xbootldr_path, argv[1])); + const char *e = secure_getenv("KERNEL_INSTALL_CONF_ROOT"); + r = boot_entry_token_ensure_at( + e ? XAT_FDROOT : c->root_fd, + e, + c->machine_id, + /* machine_id_is_random= */ false, + &c->entry_token_type, + &c->entry_token); + if (r < 0) + return r; + + r = boot_config_find_oldest_commit( + &config, + c->entry_token, + &ids); + if (r == -ENXIO) + return log_error_errno(r, "No suitable boot menu entry to delete found."); + if (r == -EBUSY) + return log_error_errno(r, "Refusing to remove currently booted boot menu entry."); + if (r < 0) + return log_error_errno(r, "Failed to find suitable oldest boot menu entry: %m"); + + STRV_FOREACH(id, ids) + log_info("Will unlink '%s'.", *id); + } else { + ids = strv_copy(_ids); + if (!ids) + return log_oom(); + } + + strv_sort_uniq(ids); + + r = 0; + if (c->esp_path) + RET_GATHER(r, unlink_entry(&config, x ?: c->esp_path, c->esp_fd, BOOT_ENTRY_ESP, ids, dry_run)); + + if (c->xbootldr_path && c->xbootldr_devid != c->esp_devid) + RET_GATHER(r, unlink_entry(&config, y ?: c->xbootldr_path, c->xbootldr_fd, BOOT_ENTRY_XBOOTLDR, ids, dry_run)); return r; } + +int verb_unlink(int argc, char *argv[], uintptr_t _data, void *userdata) { + int r; + + assert(argc < 3); + + if (arg_oldest != isempty(argv[1])) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Exactly one of an entry ID or --oldest= must be specified."); + + const char *id = empty_to_null(argv[1]); + + _cleanup_(unlink_context_done) UnlinkContext c = UNLINK_CONTEXT_NULL; + r = unlink_context_from_cmdline(&c); + if (r < 0) + return r; + + return run_unlink(&c, STRV_MAKE(id), arg_dry_run); +} + +static JSON_DISPATCH_ENUM_DEFINE(json_dispatch_boot_entry_token_type, BootEntryTokenType, boot_entry_token_type_from_string); + +typedef struct UnlinkParameters { + UnlinkContext context; + unsigned root_fd_index; + const char *id; + bool oldest; +} UnlinkParameters; + +static void unlink_parameters_done(UnlinkParameters *p) { + assert(p); + + unlink_context_done(&p->context); +} + +int vl_method_unlink( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + int r; + + assert(link); + + _cleanup_(unlink_parameters_done) UnlinkParameters p = { + .context = UNLINK_CONTEXT_NULL, + .root_fd_index = UINT_MAX, + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "rootFileDescriptor", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, voffsetof(p, root_fd_index), 0 }, + { "rootDirectory", SD_JSON_VARIANT_STRING, json_dispatch_path, voffsetof(p, context.root), 0 }, + { "bootEntryTokenType", SD_JSON_VARIANT_STRING, json_dispatch_boot_entry_token_type, voffsetof(p, context.entry_token_type), 0 }, + { "id", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, id), 0 }, + { "oldest", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, voffsetof(p, oldest), 0 }, + {}, + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + /* Only allow oldest *or* id to be set */ + if (p.oldest == !!p.id) + return sd_varlink_error_invalid_parameter_name(link, "id"); + if (p.id && !efi_loader_entry_name_valid(p.id)) + return sd_varlink_error_invalid_parameter_name(link, "id"); + + if (p.root_fd_index != UINT_MAX) { + p.context.root_fd = sd_varlink_peek_dup_fd(link, p.root_fd_index); + if (p.context.root_fd < 0) + return log_debug_errno(p.context.root_fd, "Failed to acquire root fd from Varlink: %m"); + + r = fd_verify_safe_flags_full(p.context.root_fd, O_DIRECTORY); + if (r < 0) + return sd_varlink_error_invalid_parameter_name(link, "rootFileDescriptor"); + + r = fd_verify_directory(p.context.root_fd); + if (r < 0) + return log_debug_errno(r, "Specified file descriptor does not refer to a directory: %m"); + + if (!p.context.root) { + r = fd_get_path(p.context.root_fd, &p.context.root); + if (r < 0) + return log_debug_errno(r, "Failed to get path of file descriptor: %m"); + + if (empty_or_root(p.context.root)) + p.context.root = mfree(p.context.root); + } + } else if (p.context.root) { + p.context.root_fd = open(p.context.root, O_RDONLY|O_CLOEXEC|O_DIRECTORY); + if (p.context.root_fd < 0) + return log_debug_errno(errno, "Failed to open '%s': %m", p.context.root); + } else + p.context.root_fd = XAT_FDROOT; + + if (p.context.entry_token_type < 0) + p.context.entry_token_type = BOOT_ENTRY_TOKEN_AUTO; + + r = find_esp_and_warn_at_full( + p.context.root_fd, + /* path= */ NULL, + /* unprivileged_mode= */ false, + &p.context.esp_path, + &p.context.esp_fd, + /* ret_part= */ NULL, + /* ret_pstart= */ NULL, + /* ret_psize= */ NULL, + /* ret_uuid= */ NULL, + &p.context.esp_devid); + if (r < 0 && r != -ENOKEY) + return r; + r = find_xbootldr_and_warn_at_full( + p.context.root_fd, + /* path= */ NULL, + /* unprivileged_mode= */ false, + &p.context.xbootldr_path, + &p.context.xbootldr_fd, + /* ret_uuid= */ NULL, + &p.context.xbootldr_devid); + if (r < 0 && r != -ENOKEY) + return r; + + /* Only if we found neither ESP nor XBOOTLDR let's fail. */ + if (!p.context.xbootldr_path && !p.context.esp_path) + return sd_varlink_error(link, "io.systemd.BootControl.NoDollarBootFound", NULL); + + r = run_unlink(&p.context, STRV_MAKE(p.id), /* dry_run= */ false); + if (r == -EUNATCH) /* no boot entry token is set */ + return sd_varlink_error(link, "io.systemd.BootControl.BootEntryTokenUnavailable", NULL); + if (r < 0) + return r; + + return sd_varlink_reply(link, NULL); +} diff --git a/src/bootctl/bootctl-unlink.h b/src/bootctl/bootctl-unlink.h index 5c33088859..728c775d26 100644 --- a/src/bootctl/bootctl-unlink.h +++ b/src/bootctl/bootctl-unlink.h @@ -5,4 +5,8 @@ int verb_unlink(int argc, char *argv[], uintptr_t _data, void *userdata); -int boot_config_count_known_files(const BootConfig *config, const char* root, Hashmap **ret_known_files); +int vl_method_unlink(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); + +int boot_config_count_known_files(const BootConfig *config, BootEntrySource source, Hashmap **ret_known_files); + +int boot_entry_unlink(const BootEntry *e, const char *root, int root_fd, Hashmap *known_files, bool dry_run); diff --git a/src/bootctl/bootctl.c b/src/bootctl/bootctl.c index 6869e838cf..67a20814da 100644 --- a/src/bootctl/bootctl.c +++ b/src/bootctl/bootctl.c @@ -34,6 +34,7 @@ #include "options.h" #include "pager.h" #include "parse-argument.h" +#include "parse-util.h" #include "path-util.h" #include "pretty-print.h" #include "string-table.h" @@ -80,6 +81,7 @@ char *arg_certificate_source = NULL; char *arg_private_key = NULL; KeySourceType arg_private_key_source_type = OPENSSL_KEY_SOURCE_FILE; char *arg_private_key_source = NULL; +bool arg_oldest = false; STATIC_DESTRUCTOR_REGISTER(arg_esp_path, freep); STATIC_DESTRUCTOR_REGISTER(arg_xbootldr_path, freep); @@ -361,7 +363,7 @@ VERB_GROUP("Boot Loader Specification Commands"); VERB_SCOPE_NOARG(, verb_list, "list", "List boot loader entries"); -VERB_SCOPE(, verb_unlink, "unlink", "ID", 2, 2, 0, +VERB_SCOPE(, verb_unlink, "unlink", "ID", VERB_ANY, 2, 0, "Remove boot loader entry"); VERB_SCOPE_NOARG(, verb_cleanup, "cleanup", @@ -631,6 +633,14 @@ static int parse_argv(int argc, char *argv[], char ***ret_args) { if (r < 0) return r; break; + + OPTION_LONG("oldest", "BOOL", + "Delete oldest boot menu entry"): + r = parse_boolean_argument("--oldest=", opts.arg, &arg_oldest); + if (r < 0) + return r; + + break; } char **args = option_parser_get_args(&opts); @@ -700,7 +710,8 @@ static int vl_server(void) { "io.systemd.BootControl.ListBootEntries", vl_method_list_boot_entries, "io.systemd.BootControl.SetRebootToFirmware", vl_method_set_reboot_to_firmware, "io.systemd.BootControl.GetRebootToFirmware", vl_method_get_reboot_to_firmware, - "io.systemd.BootControl.Install", vl_method_install); + "io.systemd.BootControl.Install", vl_method_install, + "io.systemd.BootControl.Unlink", vl_method_unlink); if (r < 0) return log_error_errno(r, "Failed to bind Varlink methods: %m"); diff --git a/src/bootctl/bootctl.h b/src/bootctl/bootctl.h index d0daab9dd1..ea097ba329 100644 --- a/src/bootctl/bootctl.h +++ b/src/bootctl/bootctl.h @@ -51,6 +51,7 @@ extern char *arg_certificate_source; extern char *arg_private_key; extern KeySourceType arg_private_key_source_type; extern char *arg_private_key_source; +extern bool arg_oldest; static inline const char* arg_dollar_boot_path(void) { /* $BOOT shall be the XBOOTLDR partition if it exists, and otherwise the ESP */ diff --git a/src/shared/shared-forward.h b/src/shared/shared-forward.h index 1207fe8a25..e850d8982b 100644 --- a/src/shared/shared-forward.h +++ b/src/shared/shared-forward.h @@ -14,6 +14,7 @@ struct in_addr_full; typedef enum AskPasswordFlags AskPasswordFlags; typedef enum BootEntryTokenType BootEntryTokenType; +typedef enum BootEntrySource BootEntrySource; typedef enum BusPrintPropertyFlags BusPrintPropertyFlags; typedef enum BusTransport BusTransport; typedef enum CatFlags CatFlags; @@ -48,6 +49,7 @@ typedef enum UserStorage UserStorage; typedef struct AskPasswordRequest AskPasswordRequest; typedef struct Bitmap Bitmap; typedef struct BootConfig BootConfig; +typedef struct BootEntry BootEntry; typedef struct BPFProgram BPFProgram; typedef struct BusObjectImplementation BusObjectImplementation; typedef struct CalendarSpec CalendarSpec; diff --git a/src/shared/varlink-io.systemd.BootControl.c b/src/shared/varlink-io.systemd.BootControl.c index 70203300ff..42f7b53492 100644 --- a/src/shared/varlink-io.systemd.BootControl.c +++ b/src/shared/varlink-io.systemd.BootControl.c @@ -134,6 +134,19 @@ static SD_VARLINK_DEFINE_METHOD( SD_VARLINK_FIELD_COMMENT("If true the boot loader will be registered in an EFI boot entry via EFI variables, otherwise this is omitted"), SD_VARLINK_DEFINE_INPUT(touchVariables, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE)); +static SD_VARLINK_DEFINE_METHOD( + Unlink, + SD_VARLINK_FIELD_COMMENT("Index into array of file descriptors passed along with this message, pointing to file descriptor to root file system to operate on"), + SD_VARLINK_DEFINE_INPUT(rootFileDescriptor, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Root directory to operate relative to. If both this and rootFileDescriptor is specified, this is purely informational. If only this is specified, it is what will be used."), + SD_VARLINK_DEFINE_INPUT(rootDirectory, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Selects how to identify boot entries"), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(bootEntryTokenType, BootEntryTokenType, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The ID of the boot loader entry to remove."), + SD_VARLINK_DEFINE_INPUT(id, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("If true, remove the oldest entry."), + SD_VARLINK_DEFINE_INPUT(oldest, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE)); + static SD_VARLINK_DEFINE_ERROR( RebootToFirmwareNotSupported); @@ -143,6 +156,9 @@ static SD_VARLINK_DEFINE_ERROR( static SD_VARLINK_DEFINE_ERROR( NoESPFound); +static SD_VARLINK_DEFINE_ERROR( + NoDollarBootFound); + static SD_VARLINK_DEFINE_ERROR( BootEntryTokenUnavailable); @@ -170,11 +186,15 @@ SD_VARLINK_DEFINE_INTERFACE( &vl_type_BootEntryTokenType, SD_VARLINK_SYMBOL_COMMENT("Install the boot loader on the ESP."), &vl_method_Install, + SD_VARLINK_SYMBOL_COMMENT("Unlink a boot menu item"), + &vl_method_Unlink, SD_VARLINK_SYMBOL_COMMENT("SetRebootToFirmware() and GetRebootToFirmware() return this if the firmware does not actually support the reboot-to-firmware-UI concept."), &vl_error_RebootToFirmwareNotSupported, SD_VARLINK_SYMBOL_COMMENT("No boot entry defined."), &vl_error_NoSuchBootEntry, SD_VARLINK_SYMBOL_COMMENT("No EFI System Partition (ESP) found."), &vl_error_NoESPFound, - SD_VARLINK_SYMBOL_COMMENT("The select boot entry token could not be determined."), + SD_VARLINK_SYMBOL_COMMENT("Neither ESP nor XBOOTLDR found, hence no $BOOT location identified."), + &vl_error_NoDollarBootFound, + SD_VARLINK_SYMBOL_COMMENT("The selected boot entry token could not be determined."), &vl_error_BootEntryTokenUnavailable); From e48d8a1ea0d3a37939fc2f008a68fe173e219a4b Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 9 Feb 2026 23:51:42 +0100 Subject: [PATCH 06/10] bootctl: add "link" command --- man/bootctl.xml | 101 ++ shell-completion/bash/bootctl | 7 +- shell-completion/zsh/_bootctl | 5 + src/bootctl/bootctl-link.c | 1206 +++++++++++++++++++ src/bootctl/bootctl-link.h | 8 + src/bootctl/bootctl.c | 122 ++ src/bootctl/bootctl.h | 10 + src/bootctl/meson.build | 1 + src/shared/efi-loader.c | 11 + src/shared/efi-loader.h | 2 + src/shared/varlink-io.systemd.BootControl.c | 47 +- 11 files changed, 1515 insertions(+), 5 deletions(-) create mode 100644 src/bootctl/bootctl-link.c create mode 100644 src/bootctl/bootctl-link.h diff --git a/man/bootctl.xml b/man/bootctl.xml index fb5f4b2b2a..39ddfdd58c 100644 --- a/man/bootctl.xml +++ b/man/bootctl.xml @@ -117,6 +117,36 @@ + + KERNEL + + Creates one or more Type #1 boot loader entries pointing to the specified UKI. Takes + the path to a Unified Kernel Image (UKI) as argument. The UKI is copied into the ESP (or XBOOTLDR + partition if present) below the configured entry token directory, and one or more Boot Loader + Specification Type #1 entries are generated referring to it (one per UKI profile, if multiple + profiles are embedded). + + The title, version, commit number and initial try counter of the generated entries + may be overridden with , , + and . Additional sidecar resources + (system extension images, configuration extension images, credential files) to pass to the UKI at + boot may be specified with . + + If the ESP/XBOOTLDR do not have enough free space for the new boot loader entry and its + referenced resources the oldest existing boot loader entry matching the selected entry token is + removed (along with any resources referenced by it that are no longer referenced by any other + entry). This step is repeated until the new boot loader entry fits. For robustness reasons the + currently booted boot loader entry is never removed, nor is the last existing boot loader + entry. + + By default, the operation refuses to proceed if the resulting ESP/XBOOTLDR free space would + drop below a safety threshold after automatic removal of older entries completes; use + to adjust. + + + + @@ -577,6 +607,77 @@ + + + + When used with link, controls the minimum amount of free space + (in bytes) that must remain on the target partition (ESP or XBOOTLDR) after the new entry has been + materialized. The operation fails if installing the entry would drop the free space below this + threshold. Accepts the usual size suffixes (K, M, G, …). If empty, the built-in default is + restored. If set to zero no minimum amount of free space is kept. + + + + + + + + When used with link, specifies the title of the generated boot + loader entry (the title field of the Type #1 entry). If not specified, a title is + derived from the UKI's embedded metadata. + + + + + + + + When used with link, specifies the version string of the + generated boot loader entry (the version field of the Type #1 entry). If not + specified, the version is derived from the UKI's embedded metadata. Used by the boot loader to sort + and select entries. + + + + + + + + When used with link, specifies the commit number for the generated + boot loader entry. + + + + + + + + + When used with link, registers an additional sidecar resource + file that shall be passed to the UKI at boot. This may be a system extension image + (*.sysext.raw), configuration extension image + (*.confext.raw), or credential file + (*.cred). The file is copied into the ESP/XBOOTLDR alongside the UKI and the + boot loader will load and pass it to the kernel via initrd. This option may be used multiple times + to register more than one extra resource. If passed an empty argument, all previously specified + extras are cleared. + + + + + + + + When used with link, initializes the boot counting + tries-left counter for the generated entry. If set, the resulting boot entry file + is named according to the boot counting scheme described in the Automatic Boot Assessment documentation, + so that the boot loader decreases the counter on each attempted boot and eventually marks the entry + as bad. If not specified, boot counting is not enabled for the generated entry. + + + + diff --git a/shell-completion/bash/bootctl b/shell-completion/bash/bootctl index 792fc0c0ac..3b7290c230 100644 --- a/shell-completion/bash/bootctl +++ b/shell-completion/bash/bootctl @@ -43,7 +43,8 @@ _bootctl() { --efi-boot-option-description --efi-boot-option-description-with-device --secure-boot-auto-enroll --private-key --private-key-source --certificate --certificate-source - --oldest' + --oldest --keep-free --entry-title --entry-version --entry-commit + -X --extra --tries-left' ) if __contains_word "$prev" ${OPTS[ARG]}; then @@ -62,7 +63,7 @@ _bootctl() { --entry-token) comps="machine-id os-id os-image-id auto literal:" ;; - --image|--root) + --image|--root|-X|--extra) compopt -o nospace comps=$( compgen -A file -- "$cur" ) ;; @@ -89,7 +90,7 @@ _bootctl() { [STANDALONE]='help status install update remove is-installed random-seed list set-timeout set-timeout-oneshot cleanup' [BOOTENTRY]='set-default set-oneshot set-sysfail set-preferred unlink' [BOOLEAN]='reboot-to-firmware' - [FILE]='kernel-identify kernel-inspect' + [FILE]='kernel-identify kernel-inspect link' ) for ((i=0; i < COMP_CWORD; i++)); do diff --git a/shell-completion/zsh/_bootctl b/shell-completion/zsh/_bootctl index c23c1c888d..423828ba33 100644 --- a/shell-completion/zsh/_bootctl +++ b/shell-completion/zsh/_bootctl @@ -36,6 +36,10 @@ _bootctl_unlink() { _bootctl_comp_ids } +_bootctl_link() { + _files +} + _bootctl_kernel-identify() { _files } @@ -70,6 +74,7 @@ _bootctl_reboot-to-firmware() { "set-timeout:Set the menu timeout" "set-timeout-oneshot:Set the menu timeout for the next boot only" "unlink:Remove boot loader entry" + "link:Add boot loader entry" "cleanup:Remove files in ESP not referenced in any boot entry" "kernel-identify:Identify kernel image type" "kernel-inspect:Print details about the kernel image" diff --git a/src/bootctl/bootctl-link.c b/src/bootctl/bootctl-link.c new file mode 100644 index 0000000000..6358189e7d --- /dev/null +++ b/src/bootctl/bootctl-link.c @@ -0,0 +1,1206 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include + +#include "sd-json.h" +#include "sd-varlink.h" + +#include "boot-entry.h" +#include "bootctl.h" +#include "bootctl-link.h" +#include "bootctl-unlink.h" +#include "bootspec.h" +#include "bootspec-util.h" +#include "chase.h" +#include "copy.h" +#include "dirent-util.h" +#include "efi-loader.h" +#include "env-file.h" +#include "errno-util.h" +#include "fd-util.h" +#include "find-esp.h" +#include "format-util.h" +#include "fs-util.h" +#include "hashmap.h" +#include "id128-util.h" +#include "io-util.h" +#include "json-util.h" +#include "kernel-image.h" +#include "log.h" +#include "parse-argument.h" +#include "path-util.h" +#include "recurse-dir.h" +#include "stat-util.h" +#include "stdio-util.h" +#include "string-util.h" +#include "strv.h" +#include "tmpfile-util.h" +#include "uki.h" +#include "utf8.h" + +/* Keeps track of an "extra" file to associate with the type 1 entries to generate */ +typedef struct ExtraFile { + /* The source and the temporary file we copy it into */ + int source_fd, temp_fd; + char *filename, *temp_filename; +} ExtraFile; + +#define EXTRA_FILE_NULL \ + (const ExtraFile) { \ + .source_fd = -EBADF, \ + .temp_fd = -EBADF, \ + } + +/* Keeps track of a specific UKI profile we need to generate a type entry for */ +typedef struct Profile { + /* The final and the temporary file for the .conf entry file, while we write it */ + char *entry_filename, *entry_temp_filename; + int entry_temp_fd; +} Profile; + +typedef struct LinkContext { + char *root; + int root_fd; + + sd_id128_t machine_id; + BootEntryTokenType entry_token_type; + char *entry_token; + + char *entry_title; + char *entry_version; + uint64_t entry_commit; + + BootEntrySource dollar_boot_source; + char *dollar_boot_path; + int dollar_boot_fd; + int entry_token_dir_fd; + int loader_entries_dir_fd; + + /* The UKI source and temporary target while we write it. Note that for now we exclusively support + * UKIs, but let's keep things somewhat generic to keep options open for the future. */ + char *kernel_filename, *kernel_temp_filename; + int kernel_fd, kernel_temp_fd; + + ExtraFile *extra; + size_t n_extra; + + Profile *profiles; + size_t n_profiles; + + unsigned tries_left; + + uint64_t keep_free; + + char **linked_ids; +} LinkContext; + +#define LINK_CONTEXT_NULL \ + (LinkContext) { \ + .root_fd = -EBADF, \ + .entry_token_type = _BOOT_ENTRY_TOKEN_TYPE_INVALID, \ + .dollar_boot_fd = -EBADF, \ + .loader_entries_dir_fd = -EBADF, \ + .entry_token_dir_fd = -EBADF, \ + .kernel_fd = -EBADF, \ + .kernel_temp_fd = -EBADF, \ + .tries_left = UINT_MAX, \ + .keep_free = UINT64_MAX, \ + } + +static void extra_file_done(ExtraFile *x) { + assert(x); + + x->source_fd = safe_close(x->source_fd); + x->temp_fd = safe_close(x->temp_fd); + x->filename = mfree(x->filename); + x->temp_filename = mfree(x->temp_filename); +} + +static void profile_done(Profile *p) { + assert(p); + + p->entry_filename = mfree(p->entry_filename); + p->entry_temp_filename = mfree(p->entry_temp_filename); + p->entry_temp_fd = safe_close(p->entry_temp_fd); +} + +static void link_context_unlink_temporary(LinkContext *c) { + assert(c); + + if (c->kernel_temp_filename) { + if (c->entry_token_dir_fd >= 0) + (void) unlinkat(c->entry_token_dir_fd, c->kernel_temp_filename, /* flags= */ 0); + + c->kernel_temp_fd = safe_close(c->kernel_temp_fd); + c->kernel_temp_filename = mfree(c->kernel_temp_filename); + } + + FOREACH_ARRAY(x, c->extra, c->n_extra) { + if (!x->temp_filename) + continue; + + if (c->entry_token_dir_fd >= 0) + (void) unlinkat(c->entry_token_dir_fd, x->temp_filename, /* flags= */ 0); + + x->temp_fd = safe_close(x->temp_fd); + x->temp_filename = mfree(x->temp_filename); + } + + FOREACH_ARRAY(p, c->profiles, c->n_profiles) { + if (!p->entry_temp_filename) + continue; + + if (c->loader_entries_dir_fd >= 0) + (void) unlinkat(c->loader_entries_dir_fd, p->entry_temp_filename, /* flags= */ 0); + + p->entry_temp_fd = safe_close(p->entry_temp_fd); + p->entry_temp_filename = mfree(p->entry_temp_filename); + } +} + +static void link_context_clear_profiles(LinkContext *c) { + assert(c); + + FOREACH_ARRAY(p, c->profiles, c->n_profiles) + profile_done(p); + + c->profiles = mfree(c->profiles); + c->n_profiles = 0; +} + +static void link_context_done(LinkContext *c) { + assert(c); + + link_context_unlink_temporary(c); + + FOREACH_ARRAY(x, c->extra, c->n_extra) + extra_file_done(x); + + c->extra = mfree(c->extra); + c->n_extra = 0; + + link_context_clear_profiles(c); + + c->kernel_filename = mfree(c->kernel_filename); + c->kernel_fd = safe_close(c->kernel_fd); + c->kernel_temp_filename = mfree(c->kernel_temp_filename); + c->kernel_temp_fd = safe_close(c->kernel_temp_fd); + + c->root = mfree(c->root); + c->root_fd = safe_close(c->root_fd); + + c->entry_token = mfree(c->entry_token); + c->entry_title = mfree(c->entry_title); + c->entry_version = mfree(c->entry_version); + + c->dollar_boot_path = mfree(c->dollar_boot_path); + c->dollar_boot_fd = safe_close(c->dollar_boot_fd); + c->entry_token_dir_fd = safe_close(c->entry_token_dir_fd); + c->loader_entries_dir_fd = safe_close(c->loader_entries_dir_fd); + + c->linked_ids = strv_free(c->linked_ids); +} + +static int link_context_from_cmdline(LinkContext *ret, const char *kernel) { + int r; + + assert(ret); + assert(kernel); + + _cleanup_(link_context_done) LinkContext b = LINK_CONTEXT_NULL; + b.entry_token_type = arg_entry_token_type; + b.tries_left = arg_tries_left; + b.entry_commit = arg_entry_commit; + b.keep_free = arg_keep_free; + + if (strdup_to(&b.entry_token, arg_entry_token) < 0 || + strdup_to(&b.entry_title, arg_entry_title) < 0 || + strdup_to(&b.entry_version, arg_entry_version) < 0) + return log_oom(); + + if (arg_root) { + b.root_fd = open(arg_root, O_CLOEXEC|O_DIRECTORY|O_PATH); + if (b.root_fd < 0) + return log_error_errno(errno, "Failed to open root directory '%s': %m", arg_root); + + if (strdup_to(&b.root, arg_root) < 0) + return log_oom(); + } else + b.root_fd = XAT_FDROOT; + + r = path_extract_filename(kernel, &b.kernel_filename); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from kernel path '%s': %m", kernel); + if (!efi_loader_entry_resource_filename_valid(b.kernel_filename)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Kernel '%s' is not suitable for reference in a boot menu entry.", kernel); + b.kernel_fd = xopenat_full(AT_FDCWD, kernel, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY, XO_REGULAR, /* mode= */ MODE_INVALID); + if (b.kernel_fd < 0) + return log_error_errno(b.kernel_fd, "Failed to open kernel path '%s': %m", kernel); + + KernelImageType kit = _KERNEL_IMAGE_TYPE_INVALID; + r = inspect_kernel(b.kernel_fd, /* filename= */ NULL, &kit); + if (r == -EBADMSG) + return log_error_errno(r, "Kernel image '%s' is not valid.", kernel); + if (r < 0) + return log_error_errno(r, "Failed to determine kernel image type of '%s': %m", kernel); + if (kit != KERNEL_IMAGE_TYPE_UKI) + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "Kernel image '%s' is not a UKI.", kernel); + + STRV_FOREACH(x, arg_extras) { + _cleanup_free_ char *fn = NULL; + r = path_extract_filename(*x, &fn); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from path '%s': %m", *x); + if (r == O_DIRECTORY) + return log_error_errno(SYNTHETIC_ERRNO(EISDIR), "Extra file path '%s' does not refer to regular file.", *x); + + _cleanup_close_ int fd = -EBADF; + fd = xopenat_full(AT_FDCWD, *x, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY, XO_REGULAR, /* mode= */ MODE_INVALID); + if (fd < 0) + return log_error_errno(fd, "Failed to open '%s': %m", *x); + + if (!GREEDY_REALLOC(b.extra, b.n_extra+1)) + return log_oom(); + + b.extra[b.n_extra++] = (ExtraFile) { + .source_fd = TAKE_FD(fd), + .filename = TAKE_PTR(fn), + .temp_fd = -EBADF, + }; + } + + r = acquire_xbootldr( + /* unprivileged_mode= */ false, + &b.dollar_boot_fd, + /* ret_uuid= */ NULL, + /* ret_devid= */ NULL); + if (r < 0) + return r; + if (r > 0) { /* XBOOTLDR has been found */ + assert(arg_xbootldr_path); + + if (arg_root) { + const char *e = path_startswith(arg_xbootldr_path, arg_root); + if (!e) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "XBOOTLDR path '%s' not below specified root '%s', refusing.", arg_xbootldr_path, arg_root); + + r = strdup_to(&b.dollar_boot_path, e); + } else + r = strdup_to(&b.dollar_boot_path, arg_xbootldr_path); + if (r < 0) + return log_oom(); + + b.dollar_boot_source = BOOT_ENTRY_XBOOTLDR; + } else { + /* No XBOOTLDR has been found, look for ESP */ + + r = acquire_esp(/* unprivileged_mode= */ false, + /* graceful= */ false, + &b.dollar_boot_fd, + /* ret_part= */ NULL, + /* ret_pstart= */ NULL, + /* ret_psize= */ NULL, + /* ret_uuid= */ NULL, + /* ret_devid= */ NULL); + if (r < 0) + return r; + + assert(arg_esp_path); + + if (arg_root) { + const char *e = path_startswith(arg_esp_path, arg_root); + if (!e) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "ESP path '%s' not below specified root '%s', refusing.", arg_esp_path, arg_root); + + r = strdup_to(&b.dollar_boot_path, e); + } else + r = strdup_to(&b.dollar_boot_path, arg_esp_path); + if (r < 0) + return log_oom(); + + b.dollar_boot_source = BOOT_ENTRY_ESP; + } + + *ret = TAKE_GENERIC(b, LinkContext, LINK_CONTEXT_NULL); + return 0; +} + +static int link_context_load_etc_machine_id(LinkContext *c) { + int r; + + assert(c); + + r = id128_get_machine_at(c->root_fd, &c->machine_id); + if (ERRNO_IS_NEG_MACHINE_ID_UNSET(r)) /* Not set or empty */ + return 0; + if (r < 0) + return log_error_errno(r, "Failed to get machine-id: %m"); + + log_debug("Loaded machine ID %s from '%s/etc/machine-id'.", SD_ID128_TO_STRING(c->machine_id), strempty(c->root)); + return 0; +} + +static int link_context_pick_entry_token(LinkContext *c) { + int r; + + assert(c); + + r = link_context_load_etc_machine_id(c); + if (r < 0) + return r; + + const char *e = secure_getenv("KERNEL_INSTALL_CONF_ROOT"); + r = boot_entry_token_ensure_at( + e ? XAT_FDROOT : c->root_fd, + e, + c->machine_id, + /* machine_id_is_random= */ false, + &c->entry_token_type, + &c->entry_token); + if (r < 0) + return r; + + log_debug("Using entry token: %s", c->entry_token); + return 0; +} + +static int begin_copy_file( + int source_fd, + const char *filename, + int target_dir_fd, + int *ret_tmpfile_fd, + char **ret_tmpfile_filename) { + + int r; + + assert(source_fd >= 0); + assert(filename); + assert(target_dir_fd >= 0); + assert(ret_tmpfile_fd); + assert(ret_tmpfile_filename); + + if (faccessat(target_dir_fd, filename, F_OK, AT_SYMLINK_NOFOLLOW) < 0) { + if (errno != ENOENT) + return log_error_errno(errno, "Failed to check if '%s' exists already: %m", filename); + } else { + log_info("'%s' already in place, not copying.", filename); + + *ret_tmpfile_fd = -EBADF; + *ret_tmpfile_filename = NULL; + return 0; + } + + _cleanup_free_ char *t = NULL; + _cleanup_close_ int write_fd = open_tmpfile_linkable_at(target_dir_fd, filename, O_WRONLY|O_CLOEXEC, &t); + if (write_fd < 0) + return log_error_errno(write_fd, "Failed to create '%s': %m", filename); + + CLEANUP_TMPFILE_AT(target_dir_fd, t); + + r = copy_bytes(source_fd, write_fd, UINT64_MAX, COPY_REFLINK|COPY_SEEK0_SOURCE); + if (r < 0) + return log_error_errno(r, "Failed to copy data into '%s': %m", filename); + + (void) copy_times(source_fd, write_fd, /* flags= */ 0); + (void) fchmod(write_fd, 0644); + + *ret_tmpfile_fd = TAKE_FD(write_fd); + *ret_tmpfile_filename = TAKE_PTR(t); + + return 1; +} + +static int begin_write_entry_file( + LinkContext *c, + unsigned profile_nr, + const char *osrelease_text, + const char *profile_text, + Profile *ret) { + + int r; + + assert(c); + assert(osrelease_text); + assert(ret); + + assert(c->entry_token); + assert(c->kernel_filename); + assert(c->loader_entries_dir_fd >= 0); + + _cleanup_free_ char *good_name = NULL, *good_sort_key = NULL, *os_version_id = NULL, *image_version = NULL; + r = bootspec_extract_osrelease( + osrelease_text, + /* These three fields are used by systemd-stub for showing entries + sorting them */ + &good_name, /* human readable */ + /* ret_good_version= */ NULL, + &good_sort_key, + /* These four fields are the raw fields provided in os-release */ + /* ret_os_id= */ NULL, + &os_version_id, + /* ret_image_id= */ NULL, + &image_version); + if (r < 0) + return log_error_errno(r, "Failed to extract name/version/sort-key from os-release data from unified kernel image, refusing."); + + assert(good_name); /* This one is the only field guaranteed to be defined once the above succeeds */ + + _cleanup_free_ char *profile_id = NULL, *profile_title = NULL; + if (profile_text) { + r = parse_env_data( + profile_text, /* size= */ SIZE_MAX, + ".profile", + "ID", &profile_id, + "TITLE", &profile_title); + if (r < 0) + return log_error_errno(r, "Failed to parse profile data from unified kernel image: %m"); + } + + const char *version = c->entry_version ?: image_version ?: os_version_id; + + _cleanup_free_ char *filename = NULL; + r = boot_entry_make_commit_filename( + c->entry_token, + c->entry_commit, + version, + profile_nr, + c->tries_left, + &filename); + if (r < 0) + return log_error_errno(r, "Failed to generate filename for entry file: %m"); + + if (faccessat(c->loader_entries_dir_fd, filename, F_OK, AT_SYMLINK_NOFOLLOW) < 0) { + if (errno != ENOENT) + return log_error_errno(errno, "Failed to check if '%s' exists: %m", filename); + } else + return log_error_errno(SYNTHETIC_ERRNO(EEXIST), "Boot menu entry '%s' exists already, refusing.", filename); + + log_info("Writing new boot menu entry '%s/loader/entries/%s' for profile %u.", c->dollar_boot_path, filename, profile_nr); + + _cleanup_free_ char *t = NULL; + _cleanup_close_ int write_fd = open_tmpfile_linkable_at(c->loader_entries_dir_fd, filename, O_WRONLY|O_CLOEXEC, &t); + if (write_fd < 0) + return log_error_errno(write_fd, "Failed to create '%s': %m", filename); + + CLEANUP_TMPFILE_AT(c->loader_entries_dir_fd, t); + + _cleanup_free_ char *_title = NULL; + const char *title; + if (profile_title || profile_id) { + _title = strjoin(c->entry_title ?: good_name, " (", profile_title ?: profile_id, ")"); + if (!_title) + return log_oom(); + + title = _title; + } else if (profile_nr > 0) { + _title = asprintf_safe("%s (Profile #%u)", c->entry_title ?: good_name, profile_nr); + if (!_title) + return log_oom(); + + title = _title; + } else + title = c->entry_title ?: good_name; + + /* Do some validation that this will result in a valid type #1 entry before we write this out */ + if (string_has_cc(title, /* ok= */ NULL) || !utf8_is_valid(title)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Failed to generate valid title for new commit: %s", title); + if (string_has_cc(c->kernel_filename, /* ok= */ NULL) || !utf8_is_valid(c->kernel_filename)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "UKI filename is not suitable for inclusion in new commit: %s", c->kernel_filename); + + _cleanup_free_ char *text = NULL; + if (asprintf(&text, + "title %s\n" + "uki /%s/%s\n" + "version %" PRIu64 "%s%s\n", + title, + c->entry_token, c->kernel_filename, + c->entry_commit, isempty(version) ? "" : ".", strempty(version)) < 0) + return log_oom(); + + if (good_sort_key && strextendf(&text, "sort-key %s\n", good_sort_key) < 0) + return log_oom(); + + if (profile_nr > 0 && strextendf(&text, "profile %u\n", profile_nr) < 0) + return log_oom(); + + if (!sd_id128_is_null(c->machine_id) && strextendf(&text, "machine-id " SD_ID128_FORMAT_STR "\n", SD_ID128_FORMAT_VAL(c->machine_id)) < 0) + return log_oom(); + + FOREACH_ARRAY(x, c->extra, c->n_extra) { + if (string_has_cc(x->filename, /* ok= */ NULL) || !utf8_is_valid(x->filename)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Extra filename is not suitable for inclusion in new commit: %s", x->filename); + + if (strextendf(&text, + "extra /%s/%s\n", + c->entry_token, + x->filename) < 0) + return log_oom(); + } + + r = loop_write(write_fd, text, /* nbytes= */ SIZE_MAX); + if (r < 0) + return log_error_errno(r, "Failed to write entry file: %m"); + + *ret = (Profile) { + .entry_filename = TAKE_PTR(filename), + .entry_temp_filename = TAKE_PTR(t), + .entry_temp_fd = TAKE_FD(write_fd), + }; + + return 0; +} + +static int finalize_file( + const char *filename, + int target_dir_fd, + int tmpfile_fd, + const char *tmpfile_filename) { + + int r; + + assert(filename); + assert(target_dir_fd >= 0); + + if (tmpfile_fd < 0) /* If the file already existed, we don't move anything into place. */ + return 0; + + r = link_tmpfile_at(tmpfile_fd, target_dir_fd, tmpfile_filename, filename, LINK_TMPFILE_REPLACE|LINK_TMPFILE_SYNC); + if (r < 0) + return log_error_errno(r, "Failed to move from '%s' into place: %m", filename); + + log_info("Installed '%s' into place.", filename); + return 1; +} + +static int link_context_pick_entry_commit(LinkContext *c) { + int r; + + assert(c); + assert(c->loader_entries_dir_fd >= 0); + assert(c->entry_token); + + /* Already have a commit nr? */ + if (c->entry_commit != 0) + return 0; + + _cleanup_close_ int opened_fd = fd_reopen(c->loader_entries_dir_fd, O_DIRECTORY|O_CLOEXEC); + if (opened_fd < 0) + return log_error_errno(opened_fd, "Failed to reopen loader entries dir: %m"); + + _cleanup_free_ DirectoryEntries *dentries = NULL; + r = readdir_all(opened_fd, RECURSE_DIR_IGNORE_DOT, &dentries); + if (r < 0) + return log_error_errno(r, "Failed to read loader entries directory: %m"); + + uint64_t m = 0; /* largest commit number seen */ + FOREACH_ARRAY(i, dentries->entries, dentries->n_entries) { + const struct dirent *de = *i; + + /* We look for files named -commit_[.][.p].conf */ + + if (!dirent_is_file(de)) + continue; + + if (!efi_loader_entry_name_valid(de->d_name)) + continue; + + _cleanup_free_ char *et = NULL; + uint64_t ec; + r = boot_entry_parse_commit_filename(de->d_name, &et, &ec); + if (r < 0) { + log_debug_errno(r, "Cannot extract entry token/commit number from '%s', ignoring.", de->d_name); + continue; + } + + if (!streq(c->entry_token, et)) + continue; + + log_debug("Found existing commit %" PRIu64 ".", ec); + if (ec > m) + m = ec; + } + + assert(m < UINT64_MAX); + uint64_t next = m + 1; + + if (!entry_commit_valid(next)) + return log_error_errno(SYNTHETIC_ERRNO(E2BIG), "Too many commits already in place, refusing."); + + log_debug("Picking commit %" PRIu64 " for new commit.", next); + c->entry_commit = next; + return 0; +} + +static int clean_temporary_files(int fd) { + int r; + + assert(fd >= 0); + + /* Before we create any new files let's clear any possible left-overs from a previous run. We look + * specifically for all temporary files whose name starts with .# because that's what we create, via + * open_tmpfile_linkable_at(). + * + * Ideally, this would not be necessary because O_TMPFILE would ensure that files are not + * materialized before they are fully written. However, vfat currently does not support O_TMPFILE, + * hence we need to clean things up manually. */ + + _cleanup_close_ int dfd = fd_reopen(fd, O_CLOEXEC|O_DIRECTORY); + if (dfd < 0) + return log_error_errno(dfd, "Failed to open directory: %m"); + + _cleanup_free_ DirectoryEntries *de = NULL; + r = readdir_all(dfd, RECURSE_DIR_ENSURE_TYPE, &de); + if (r < 0) + return log_error_errno(r, "Failed to enumerate contents of directory: %m"); + + FOREACH_ARRAY(i, de->entries, de->n_entries) { + struct dirent *e = *i; + + if (e->d_type != DT_REG) + continue; + + if (!startswith_no_case(e->d_name, ".#")) + continue; + + if (unlinkat(dfd, e->d_name, /* flags= */ 0) < 0 && errno != ENOENT) + log_warning_errno(errno, "Failed to remove temporary file '%s', ignoring: %m", e->d_name); + } + + return 0; +} + +static int link_context_unlink_oldest(LinkContext *c) { + int r; + + assert(c); + + /* We only load the entries from the partition we want to make space on (!) */ + _cleanup_(boot_config_free) BootConfig config = BOOT_CONFIG_NULL; + r = boot_config_load_and_select( + &config, + c->root, + c->dollar_boot_source == BOOT_ENTRY_ESP ? c->dollar_boot_path : NULL, + /* esp_devid= */ 0, + c->dollar_boot_source == BOOT_ENTRY_XBOOTLDR ? c->dollar_boot_path : NULL, + /* xbootldr_devid= */ 0); + if (r < 0) + return r; + + _cleanup_(strv_freep) char **ids = NULL; + r = boot_config_find_oldest_commit( + &config, + c->entry_token, + &ids); + if (r == -ENXIO) + return log_error_errno(r, "No suitable boot menu entry to delete found."); + if (r == -EBUSY) + return log_error_errno(r, "Refusing to remove currently booted boot menu entry."); + if (r < 0) + return log_error_errno(r, "Failed to find suitable oldest boot menu entry: %m"); + + _cleanup_(hashmap_freep) Hashmap *known_files = NULL; + r = boot_config_count_known_files(&config, c->dollar_boot_source, &known_files); + if (r < 0) + return r; + + int ret = 0; + STRV_FOREACH(id, ids) { + const BootEntry *entry = boot_config_find_entry(&config, *id); + if (!entry) + continue; + + RET_GATHER(ret, boot_entry_unlink(entry, c->dollar_boot_path, c->dollar_boot_fd, known_files, /* dry_run= */ false)); + } + + if (ret < 0) + return ret; + + return 1; +} + +static int verify_keep_free(LinkContext *c) { + int r; + + assert(c); + + if (c->keep_free == 0) + return 0; + + uint64_t f; + r = vfs_free_bytes(ASSERT_FD(c->dollar_boot_fd), &f); + if (r < 0) + return log_error_errno(r, "Failed to statvfs() the $BOOT partition: %m"); + + if (f < c->keep_free) + return log_error_errno( + SYNTHETIC_ERRNO(EDQUOT), + "Not installing boot menu entry, free space after installation of %s would be below configured keep free size %s.", + FORMAT_BYTES(f), FORMAT_BYTES(c->keep_free)); + + return 0; +} + +static int run_link_now(LinkContext *c) { + int r; + + assert(c); + assert(c->dollar_boot_fd >= 0); + + _cleanup_free_ char *j = path_join(empty_to_root(c->root), c->dollar_boot_path); + if (!j) + return log_oom(); + + if (c->loader_entries_dir_fd < 0) { + r = chaseat(/* root_fd= */ c->dollar_boot_fd, + /* dir_fd= */ c->dollar_boot_fd, + "loader/entries", + CHASE_PROHIBIT_SYMLINKS|CHASE_MKDIR_0755|CHASE_MUST_BE_DIRECTORY, + /* ret_path= */ NULL, + &c->loader_entries_dir_fd); + if (r < 0) + return log_error_errno(r, "Failed to pin '/loader/entries' directory below '%s': %m", j); + } + + /* Remove any left-overs from an earlier run before we write new stuff */ + (void) clean_temporary_files(c->loader_entries_dir_fd); + + r = link_context_pick_entry_commit(c); + if (r < 0) + return r; + + log_info("Will create commit %" PRIu64 ".", c->entry_commit); + + if (c->entry_token_dir_fd < 0) { + r = chaseat(/* root_fd= */ c->dollar_boot_fd, + /* dir_fd= */ c->dollar_boot_fd, + c->entry_token, + CHASE_PROHIBIT_SYMLINKS|CHASE_MKDIR_0755|CHASE_MUST_BE_DIRECTORY, + /* ret_path= */ NULL, + &c->entry_token_dir_fd); + if (r < 0) + return log_error_errno(r, "Failed to pin '/%s' directory below '%s': %m", c->entry_token, j); + } + + /* As above */ + (void) clean_temporary_files(c->entry_token_dir_fd); + + /* Synchronize everything to disk before we verify the disk space, to ensure the counters are + * accurate (some file systems delay accurate counters) */ + (void) syncfs(c->dollar_boot_fd); + + /* Before we start copying things, let's see if there's even a remote chance to get this copied + * in. Note that we do not try to be overly smart here, i.e. we do not try to calculate how much + * extra space we'll need here. Doing that is not trivial since after all the same resources can be + * referenced by multiple entries, which makes copying them multiple times unnecessary. */ + r = verify_keep_free(c); + if (r < 0) + return r; + + for (unsigned p = 0; p < UNIFIED_PROFILES_MAX; p++) { + _cleanup_free_ char *osrelease = NULL, *profile = NULL; + r = pe_find_uki_sections(c->kernel_fd, j, p, &osrelease, &profile, /* ret_cmdline= */ NULL); + if (r < 0) + return r; + if (r == 0) /* this profile does not exist, we are done */ + break; + + if (!GREEDY_REALLOC(c->profiles, c->n_profiles+1)) + return log_oom(); + + r = begin_write_entry_file( + c, + p, + osrelease, + profile, + c->profiles + c->n_profiles); + if (r < 0) + return r; + + c->n_profiles++; + } + + if (c->n_profiles == 0) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "UKI with no valid profile, refusing."); + + r = begin_copy_file( + c->kernel_fd, + c->kernel_filename, + c->entry_token_dir_fd, + &c->kernel_temp_fd, + &c->kernel_temp_filename); + if (r < 0) + return r; + + FOREACH_ARRAY(x, c->extra, c->n_extra) { + r = begin_copy_file( + x->source_fd, + x->filename, + c->entry_token_dir_fd, + &x->temp_fd, + &x->temp_filename); + if (r < 0) + return r; + } + + /* We copied all files into place, but they are not materialized yet. Let's ensure the data hits the + * disk before we proceed */ + (void) syncfs(c->dollar_boot_fd); + + /* Before we materialize things, let's ensure the space to keep free is not taken */ + r = verify_keep_free(c); + if (r < 0) + return r; + + /* We successfully managed to put all resources we need into the $BOOT partition. Now, let's + * "materialize" them by linking them into the file system. Before this point we'd get rid of every + * file we created on error again. But from now on we switch modes: what we manage to move into place + * we leave in place even on error. These are not lost resources after all, the GC logic implemented + * by "bootctl cleanup" will take care of removing things again if necessary. */ + + r = finalize_file( + c->kernel_filename, + c->entry_token_dir_fd, + c->kernel_temp_fd, + c->kernel_temp_filename); + if (r < 0) + return r; + + c->kernel_temp_fd = safe_close(c->kernel_temp_fd); + c->kernel_temp_filename = mfree(c->kernel_temp_filename); + + FOREACH_ARRAY(x, c->extra, c->n_extra) { + r = finalize_file( + x->filename, + c->entry_token_dir_fd, + x->temp_fd, + x->temp_filename); + if (r < 0) + return r; + + x->temp_fd = safe_close(x->temp_fd); + x->temp_filename = mfree(x->temp_filename); + } + + /* Finally, after all our resources are in place, also materialize the menu entry files themselves */ + FOREACH_ARRAY(profile, c->profiles, c->n_profiles) { + r = finalize_file( + profile->entry_filename, + c->loader_entries_dir_fd, + profile->entry_temp_fd, + profile->entry_temp_filename); + if (r < 0) + return r; + + profile->entry_temp_fd = safe_close(profile->entry_temp_fd); + profile->entry_temp_filename = mfree(profile->entry_temp_filename); + + _cleanup_free_ char *stripped = NULL; + r = boot_filename_extract_tries( + profile->entry_filename, + &stripped, + /* ret_tries_left= */ NULL, + /* ret_tries_done= */ NULL); + if (r < 0) + return log_warning_errno(r, "Failed to extract tries counters from id '%s'", profile->entry_filename); + + if (strv_consume(&c->linked_ids, TAKE_PTR(stripped)) < 0) + return log_oom(); + } + + (void) syncfs(c->dollar_boot_fd); + return 0; +} + +static int run_link(LinkContext *c) { + int r; + + assert(c); + assert(c->dollar_boot_path); + assert(c->dollar_boot_fd >= 0); + + if (c->keep_free == UINT64_MAX) + c->keep_free = KEEP_FREE_BYTES_DEFAULT; + + r = link_context_pick_entry_token(c); + if (r < 0) + return r; + + unsigned n_removals = 0; + for (;;) { + r = run_link_now(c); + if (r < 0) { + if (!ERRNO_IS_NEG_DISK_SPACE(r)) + return r; + } else + break; + + log_notice("Attempt to link entry failed due to exhausted disk space, trying to remove oldest boot menu entry."); + + link_context_unlink_temporary(c); + link_context_clear_profiles(c); + + if (link_context_unlink_oldest(c) <= 0) { + log_warning("Attempted to make space on $BOOT, but this failed, attempt to link entry failed."); + return r; /* propagate original error */ + } + + /* Close entry token dir here, quite possible the unlinking above might have removed it too, in case it was empty */ + c->entry_token_dir_fd = safe_close(c->entry_token_dir_fd); + + log_info("Removing oldest boot menu entry succeeded, will retry to create boot loader menu entry."); + n_removals++; + } + + _cleanup_free_ char *j = strv_join(c->linked_ids, "', '"); + if (!j) + return log_oom(); + + if (n_removals > 0) + log_info("Successfully installed boot loader entries '%s', after removing %u old entries.", j, n_removals); + else + log_info("Successfully installed boot loader entries '%s'.", j); + + return 0; +} + +int verb_link(int argc, char *argv[], uintptr_t data, void *userdata) { + int r; + + assert(argc == 2); + + _cleanup_free_ char *x = NULL; + r = parse_path_argument(argv[1], /* suppress_root= */ false, &x); + if (r < 0) + return r; + + _cleanup_(link_context_done) LinkContext c = LINK_CONTEXT_NULL; + r = link_context_from_cmdline(&c, x); + if (r < 0) + return r; + + return run_link(&c); +} + +static JSON_DISPATCH_ENUM_DEFINE(json_dispatch_boot_entry_token_type, BootEntryTokenType, boot_entry_token_type_from_string); + +typedef struct LinkParameters { + LinkContext context; + unsigned root_fd_index; + unsigned kernel_fd_index; + sd_varlink *link; +} LinkParameters; + +static void link_parameters_done(LinkParameters *p) { + assert(p); + + link_context_done(&p->context); +} + +typedef struct ExtraParameters { + ExtraFile extra_file; + unsigned fd_index; +} ExtraParameters; + +static void extra_parameters_done(ExtraParameters *p) { + assert(p); + + extra_file_done(&p->extra_file); +} + +static int json_dispatch_loader_entry_resource_filename(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + char **n = ASSERT_PTR(userdata); + const char *filename; + int r; + + assert(variant); + + r = json_dispatch_const_filename(name, variant, flags, &filename); + if (r < 0) + return r; + + if (filename && !efi_loader_entry_resource_filename_valid(filename)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not a valid boot entry resource filename.", strna(name)); + + if (free_and_strdup(n, filename) < 0) + return json_log_oom(variant, flags); + + return 0; +} + +static int dispatch_extras(const char *name, sd_json_variant *v, sd_json_dispatch_flags_t flags, void *userdata) { + LinkParameters *c = ASSERT_PTR(userdata); + int r; + + if (!sd_json_variant_is_array(v)) + return json_log(v, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not an array.", strna(name)); + + sd_json_variant *i; + JSON_VARIANT_ARRAY_FOREACH(i, v) { + _cleanup_(extra_parameters_done) ExtraParameters xp = { + .extra_file = EXTRA_FILE_NULL, + .fd_index = UINT_MAX, + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "filename", SD_JSON_VARIANT_STRING, json_dispatch_loader_entry_resource_filename, offsetof(ExtraParameters, extra_file.filename), SD_JSON_MANDATORY }, + { "fileDescriptor", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, offsetof(ExtraParameters, fd_index), SD_JSON_MANDATORY }, + {}, + }; + + r = sd_json_dispatch(i, dispatch_table, /* flags= */ 0, &xp); + if (r < 0) + return r; + + xp.extra_file.source_fd = sd_varlink_peek_dup_fd(c->link, xp.fd_index); + if (xp.extra_file.source_fd < 0) + return log_debug_errno(xp.extra_file.source_fd, "Failed to acquire extra fd from Varlink: %m"); + + r = fd_verify_safe_flags(xp.extra_file.source_fd); + if (r < 0) + return sd_varlink_error_invalid_parameter_name(c->link, name); + + r = fd_verify_regular(xp.extra_file.source_fd); + if (r < 0) + return log_debug_errno(r, "Failed to validate that the extra file is a regular file descriptor: %m"); + + if (!GREEDY_REALLOC(c->context.extra, c->context.n_extra+1)) + return log_oom(); + + c->context.extra[c->context.n_extra++] = TAKE_GENERIC(xp.extra_file, ExtraFile, EXTRA_FILE_NULL); + } + + return 0; +} + +int vl_method_link( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + int r; + + assert(link); + + _cleanup_(link_parameters_done) LinkParameters p = { + .context = LINK_CONTEXT_NULL, + .root_fd_index = UINT_MAX, + .kernel_fd_index = UINT_MAX, + .link = link, + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "rootFileDescriptor", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, voffsetof(p, root_fd_index), 0 }, + { "rootDirectory", SD_JSON_VARIANT_STRING, json_dispatch_path, voffsetof(p, context.root), 0 }, + { "bootEntryTokenType", SD_JSON_VARIANT_STRING, json_dispatch_boot_entry_token_type, voffsetof(p, context.entry_token_type), 0 }, + { "entryTitle", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, voffsetof(p, context.entry_title), 0 }, + { "entryVersion", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, voffsetof(p, context.entry_version), 0 }, + { "entryCommit", SD_JSON_VARIANT_INTEGER, sd_json_dispatch_uint64, voffsetof(p, context.entry_commit), 0 }, + { "kernelFilename", SD_JSON_VARIANT_STRING, json_dispatch_loader_entry_resource_filename, voffsetof(p, context.kernel_filename), SD_JSON_MANDATORY }, + { "kernelFileDescriptor", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, voffsetof(p, kernel_fd_index), SD_JSON_MANDATORY }, + { "extraFiles", SD_JSON_VARIANT_ARRAY, dispatch_extras, 0, 0 }, + { "triesLeft", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, voffsetof(p, context.tries_left), 0 }, + { "keepFree", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, context.keep_free), 0 }, + {}, + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + if (p.root_fd_index != UINT_MAX) { + p.context.root_fd = sd_varlink_peek_dup_fd(link, p.root_fd_index); + if (p.context.root_fd < 0) + return log_debug_errno(p.context.root_fd, "Failed to acquire root fd from Varlink: %m"); + + r = fd_verify_safe_flags_full(p.context.root_fd, O_DIRECTORY); + if (r < 0) + return sd_varlink_error_invalid_parameter_name(link, "rootFileDescriptor"); + + r = fd_verify_directory(p.context.root_fd); + if (r < 0) + return log_debug_errno(r, "Specified file descriptor does not refer to a directory: %m"); + + if (!p.context.root) { + r = fd_get_path(p.context.root_fd, &p.context.root); + if (r < 0) + return log_debug_errno(r, "Failed to get path of file descriptor: %m"); + + if (empty_or_root(p.context.root)) + p.context.root = mfree(p.context.root); + } + } else if (p.context.root) { + p.context.root_fd = open(p.context.root, O_RDONLY|O_CLOEXEC|O_DIRECTORY); + if (p.context.root_fd < 0) + return log_debug_errno(errno, "Failed to open '%s': %m", p.context.root); + } else + p.context.root_fd = XAT_FDROOT; + + if (p.context.entry_token_type < 0) + p.context.entry_token_type = BOOT_ENTRY_TOKEN_AUTO; + + if (p.context.entry_title && !efi_loader_entry_title_valid(p.context.entry_title)) + return sd_varlink_error_invalid_parameter_name(link, "entryTitle"); + + if (p.context.entry_version && !version_is_valid_versionspec(p.context.entry_version)) + return sd_varlink_error_invalid_parameter_name(link, "entryVersion"); + + if (p.context.entry_commit != 0 && !entry_commit_valid(p.context.entry_commit)) + return sd_varlink_error_invalid_parameter_name(link, "entryCommit"); + + p.context.kernel_fd = sd_varlink_peek_dup_fd(link, p.kernel_fd_index); + if (p.context.kernel_fd < 0) + return log_debug_errno(p.context.kernel_fd, "Failed to acquire kernel fd from Varlink: %m"); + + r = fd_verify_safe_flags(p.context.kernel_fd); + if (r < 0) + return sd_varlink_error_invalid_parameter_name(link, "kernelFileDescriptor"); + r = fd_verify_regular(p.context.kernel_fd); + if (r < 0) + return log_debug_errno(r, "Failed to validate that kernel image file is a regular file descriptor: %m"); + + /* Refuse non-UKIs for now. */ + KernelImageType kit = _KERNEL_IMAGE_TYPE_INVALID; + r = inspect_kernel(p.context.kernel_fd, /* filename= */ NULL, &kit); + if (r == -EBADMSG) + return sd_varlink_error(link, "io.systemd.BootControl.InvalidKernelImage", NULL); + if (r < 0) + return r; + if (kit != KERNEL_IMAGE_TYPE_UKI) + return sd_varlink_error(link, "io.systemd.BootControl.InvalidKernelImage", NULL); + + r = find_xbootldr_and_warn_at( + p.context.root_fd, + /* path= */ NULL, + /* unprivileged_mode= */ false, + &p.context.dollar_boot_path, + &p.context.dollar_boot_fd); + if (r < 0) { + if (r != -ENOKEY) + return r; + + /* No XBOOTLDR found, let's look for ESP then. */ + + r = find_esp_and_warn_at( + p.context.root_fd, + /* path= */ NULL, + /* unprivileged_mode= */ false, + &p.context.dollar_boot_path, + &p.context.dollar_boot_fd); + if (r == -ENOKEY) + return sd_varlink_error(link, "io.systemd.BootControl.NoDollarBootFound", NULL); + if (r < 0) + return r; + + p.context.dollar_boot_source = BOOT_ENTRY_ESP; + } else + p.context.dollar_boot_source = BOOT_ENTRY_XBOOTLDR; + + r = run_link(&p.context); + if (r == -EUNATCH) /* no boot entry token is set */ + return sd_varlink_error(link, "io.systemd.BootControl.BootEntryTokenUnavailable", NULL); + if (r < 0) + return r; + + return sd_varlink_replybo(link, SD_JSON_BUILD_PAIR_STRV("ids", p.context.linked_ids)); +} diff --git a/src/bootctl/bootctl-link.h b/src/bootctl/bootctl-link.h new file mode 100644 index 0000000000..de64563b87 --- /dev/null +++ b/src/bootctl/bootctl-link.h @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "shared-forward.h" + +int verb_link(int argc, char *argv[], uintptr_t data, void *userdata); + +int vl_method_link(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); diff --git a/src/bootctl/bootctl.c b/src/bootctl/bootctl.c index 67a20814da..c627a7dd07 100644 --- a/src/bootctl/bootctl.c +++ b/src/bootctl/bootctl.c @@ -9,6 +9,7 @@ #include "bootctl.h" #include "bootctl-cleanup.h" #include "bootctl-install.h" +#include "bootctl-link.h" #include "bootctl-random-seed.h" #include "bootctl-reboot-to-firmware.h" #include "bootctl-set-efivar.h" @@ -16,6 +17,7 @@ #include "bootctl-uki.h" #include "bootctl-unlink.h" #include "bootctl-util.h" +#include "bootspec-util.h" #include "build.h" #include "crypto-util.h" #include "devnum-util.h" @@ -82,6 +84,12 @@ char *arg_private_key = NULL; KeySourceType arg_private_key_source_type = OPENSSL_KEY_SOURCE_FILE; char *arg_private_key_source = NULL; bool arg_oldest = false; +uint64_t arg_keep_free = KEEP_FREE_BYTES_DEFAULT; +char *arg_entry_title = NULL; +char *arg_entry_version = NULL; +uint64_t arg_entry_commit = 0; +char **arg_extras = NULL; +unsigned arg_tries_left = UINT_MAX; STATIC_DESTRUCTOR_REGISTER(arg_esp_path, freep); STATIC_DESTRUCTOR_REGISTER(arg_xbootldr_path, freep); @@ -95,6 +103,9 @@ STATIC_DESTRUCTOR_REGISTER(arg_certificate, freep); STATIC_DESTRUCTOR_REGISTER(arg_certificate_source, freep); STATIC_DESTRUCTOR_REGISTER(arg_private_key, freep); STATIC_DESTRUCTOR_REGISTER(arg_private_key_source, freep); +STATIC_DESTRUCTOR_REGISTER(arg_entry_title, freep); +STATIC_DESTRUCTOR_REGISTER(arg_entry_version, freep); +STATIC_DESTRUCTOR_REGISTER(arg_extras, strv_freep); static const char* const install_source_table[_INSTALL_SOURCE_MAX] = { [INSTALL_SOURCE_IMAGE] = "image", @@ -366,6 +377,9 @@ VERB_SCOPE_NOARG(, verb_list, "list", VERB_SCOPE(, verb_unlink, "unlink", "ID", VERB_ANY, 2, 0, "Remove boot loader entry"); +VERB_SCOPE(, verb_link, "link", "KERNEL", 2, 2, 0, + "Create boot loader entry for specified kernel"); + VERB_SCOPE_NOARG(, verb_cleanup, "cleanup", "Remove files in ESP not referenced in any boot entry"); @@ -641,8 +655,115 @@ static int parse_argv(int argc, char *argv[], char ***ret_args) { return r; break; + + OPTION_LONG("keep-free", "BYTES", + "How much space to keep free on ESP/XBOOTLDR"): + + if (isempty(opts.arg)) + arg_keep_free = KEEP_FREE_BYTES_DEFAULT; + else { + r = parse_size(opts.arg, 1024, &arg_keep_free); + if (r < 0) + return log_error_errno(r, "Failed to parse --keep-free=: %s", opts.arg); + } + + break; + + OPTION_LONG("entry-title", "TITLE", + "Selects the entry title for the new boot menu entry"): + + if (isempty(opts.arg)) { + arg_entry_title = mfree(arg_entry_title); + break; + } + + if (!efi_loader_entry_title_valid(opts.arg)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Not a valid boot menu entry title: %s", opts.arg); + + r = free_and_strdup_warn(&arg_entry_title, opts.arg); + if (r < 0) + return r; + break; + + OPTION_LONG("entry-version", "VERSION", + "Selects the entry version for the new boot menu entry"): + if (isempty(opts.arg)) { + arg_entry_version = mfree(arg_entry_version); + break; + } + + if (!version_is_valid_versionspec(opts.arg)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Not a valid boot menu entry version: %s", opts.arg); + + r = free_and_strdup_warn(&arg_entry_version, opts.arg); + if (r < 0) + return r; + break; + + OPTION_LONG("entry-commit", "NR", + "Selects the entry commit version for the new boot menu entry"): { + if (isempty(opts.arg)) { + arg_entry_commit = 0; + break; + } + + uint64_t n; + r = safe_atou64(opts.arg, &n); + if (r < 0) + return log_error_errno(r, "Failed to parse --entry-commit= parameter: %s", opts.arg); + if (!entry_commit_valid(n)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Not a valid entry commit number."); + + arg_entry_commit = n; + break; } + OPTION('X', "extra", "PATH", + "Pass extra resource (confext, sysext, credential) to the invoked UKI of the boot menu entry"): { + + if (isempty(opts.arg)) { + arg_extras = strv_free(arg_extras); + break; + } + + _cleanup_free_ char *x = NULL; + r = parse_path_argument(opts.arg, /* suppress_root= */ false, &x); + if (r < 0) + return r; + + _cleanup_free_ char *fn = NULL; + r = path_extract_filename(x, &fn); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from '%s': %m", x); + if (!efi_loader_entry_resource_filename_valid(fn)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Extra filename '%s' is not suitable for reference in a boot menu entry.", fn); + + r = strv_consume(&arg_extras, TAKE_PTR(x)); + if (r < 0) + return log_oom(); + + strv_uniq(arg_extras); + break; + } + + OPTION_LONG("tries-left", "NR", + "Set boot menu entries tries-left counter to the specified value"): { + if (isempty(opts.arg)) { + arg_tries_left = UINT_MAX; + break; + } + + unsigned u; + r = safe_atou(opts.arg, &u); + if (r < 0) + return log_error_errno(r, "Failed to parse tries left counter: %s", opts.arg); + if (u >= UINT_MAX) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Tries left counter too large, refusing: %u", u); + + arg_tries_left = u; + break; + }} + char **args = option_parser_get_args(&opts); if (!!arg_print_esp_path + !!arg_print_dollar_boot_path + (arg_print_root_device > 0) + arg_print_loader_path + arg_print_stub_path + arg_print_efi_architecture > 1) @@ -711,6 +832,7 @@ static int vl_server(void) { "io.systemd.BootControl.SetRebootToFirmware", vl_method_set_reboot_to_firmware, "io.systemd.BootControl.GetRebootToFirmware", vl_method_get_reboot_to_firmware, "io.systemd.BootControl.Install", vl_method_install, + "io.systemd.BootControl.Link", vl_method_link, "io.systemd.BootControl.Unlink", vl_method_unlink); if (r < 0) return log_error_errno(r, "Failed to bind Varlink methods: %m"); diff --git a/src/bootctl/bootctl.h b/src/bootctl/bootctl.h index ea097ba329..b478ae0ec1 100644 --- a/src/bootctl/bootctl.h +++ b/src/bootctl/bootctl.h @@ -52,6 +52,12 @@ extern char *arg_private_key; extern KeySourceType arg_private_key_source_type; extern char *arg_private_key_source; extern bool arg_oldest; +extern uint64_t arg_keep_free; +extern char *arg_entry_title; +extern char *arg_entry_version; +extern uint64_t arg_entry_commit; +extern char **arg_extras; +extern unsigned arg_tries_left; static inline const char* arg_dollar_boot_path(void) { /* $BOOT shall be the XBOOTLDR partition if it exists, and otherwise the ESP */ @@ -68,3 +74,7 @@ int acquire_xbootldr(int unprivileged_mode, int *ret_fd, sd_id128_t *ret_uuid, d * string, but we limit the length to something reasonable to prevent from the firmware * having to deal with a potentially too long string. */ #define EFI_BOOT_OPTION_DESCRIPTION_MAX ((size_t) 255) + +/* Before we "materialize" a new entry, let's ensure we have this much space free still on the partition, by + * default */ +#define KEEP_FREE_BYTES_DEFAULT (5U * U64_MB) diff --git a/src/bootctl/meson.build b/src/bootctl/meson.build index ff33cde3f6..06137bdae0 100644 --- a/src/bootctl/meson.build +++ b/src/bootctl/meson.build @@ -3,6 +3,7 @@ bootctl_sources = files( 'bootctl.c', 'bootctl-install.c', + 'bootctl-link.c', 'bootctl-random-seed.c', 'bootctl-reboot-to-firmware.c', 'bootctl-set-efivar.c', diff --git a/src/shared/efi-loader.c b/src/shared/efi-loader.c index ce10a44d34..20e4719bb0 100644 --- a/src/shared/efi-loader.c +++ b/src/shared/efi-loader.c @@ -433,3 +433,14 @@ bool efi_loader_entry_name_valid(const char *s) { return in_charset(s, ALPHANUMERICAL "+-_.@"); } + +bool efi_loader_entry_title_valid(const char *s) { + return string_is_safe(s, /* flags= */ 0); +} + +bool efi_loader_entry_resource_filename_valid(const char *s) { + /* Validates file names so that they are safe for their inclusion in boot loader type #1 + * entries. i.e. may not contain CCs, and should be ASCII */ + + return string_is_safe(s, STRING_ASCII|STRING_FILENAME); +} diff --git a/src/shared/efi-loader.h b/src/shared/efi-loader.h index abf8bdc49e..c51c2dbb2f 100644 --- a/src/shared/efi-loader.h +++ b/src/shared/efi-loader.h @@ -23,3 +23,5 @@ int efi_loader_update_entry_one_shot_cache(char **cache, struct stat *cache_stat int efi_get_variable_id128(const char *variable, sd_id128_t *ret); bool efi_loader_entry_name_valid(const char *s); +bool efi_loader_entry_title_valid(const char *s); +bool efi_loader_entry_resource_filename_valid(const char *s); diff --git a/src/shared/varlink-io.systemd.BootControl.c b/src/shared/varlink-io.systemd.BootControl.c index 42f7b53492..c16a295d89 100644 --- a/src/shared/varlink-io.systemd.BootControl.c +++ b/src/shared/varlink-io.systemd.BootControl.c @@ -27,7 +27,7 @@ static SD_VARLINK_DEFINE_STRUCT_TYPE( SD_VARLINK_FIELD_COMMENT("The location of the local addon."), SD_VARLINK_DEFINE_FIELD(localAddon, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("The command line options by the addon."), - SD_VARLINK_DEFINE_FIELD(options, SD_VARLINK_STRING, 0)); + SD_VARLINK_DEFINE_FIELD(options, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); static SD_VARLINK_DEFINE_STRUCT_TYPE( BootEntry, @@ -147,6 +147,40 @@ static SD_VARLINK_DEFINE_METHOD( SD_VARLINK_FIELD_COMMENT("If true, remove the oldest entry."), SD_VARLINK_DEFINE_INPUT(oldest, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE)); +static SD_VARLINK_DEFINE_STRUCT_TYPE( + BootEntryExtraFile, + SD_VARLINK_FIELD_COMMENT("The name of the extra file"), + SD_VARLINK_DEFINE_FIELD(filename, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("Index into array of file descriptors, pointing to a file descriptor referencing the extra file."), + SD_VARLINK_DEFINE_FIELD(fileDescriptor, SD_VARLINK_INT, 0)); + +static SD_VARLINK_DEFINE_METHOD( + Link, + SD_VARLINK_FIELD_COMMENT("Index into array of file descriptors passed along with this message, pointing to file descriptor to root file system to operate on"), + SD_VARLINK_DEFINE_INPUT(rootFileDescriptor, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Root directory to operate relative to. If both this and rootFileDescriptor is specified, this is purely informational. If only this is specified, it is what will be used."), + SD_VARLINK_DEFINE_INPUT(rootDirectory, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Selects how to identify boot entries"), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(bootEntryTokenType, BootEntryTokenType, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The entry title for the newly created boot menu entry"), + SD_VARLINK_DEFINE_INPUT(entryTitle, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The entry version for the newly created boot menu entry"), + SD_VARLINK_DEFINE_INPUT(entryVersion, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The commit number for the newly created boot menu entry"), + SD_VARLINK_DEFINE_INPUT(entryCommit, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Target filename for the kernel image (UKI) in the $BOOT partition"), + SD_VARLINK_DEFINE_INPUT(kernelFilename, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("Index into array of file descriptors passed along with this message, pointing to file descriptor to the kernel image to copy"), + SD_VARLINK_DEFINE_INPUT(kernelFileDescriptor, SD_VARLINK_INT, 0), + SD_VARLINK_FIELD_COMMENT("An array of 'extra' files for this entry, i.e. credentials, confexts, sysexts, addons."), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(extraFiles, BootEntryExtraFile, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY), + SD_VARLINK_FIELD_COMMENT("What to set the triesLeft counter of the boot menu entry to initially."), + SD_VARLINK_DEFINE_INPUT(triesLeft, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("How much space to always keep free on ESP/XBOOTLDR. Defaults to 1 MiB"), + SD_VARLINK_DEFINE_INPUT(keepFree, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The IDs of the created boot loader entries."), + SD_VARLINK_DEFINE_OUTPUT(ids, SD_VARLINK_STRING, SD_VARLINK_ARRAY)); + static SD_VARLINK_DEFINE_ERROR( RebootToFirmwareNotSupported); @@ -162,6 +196,9 @@ static SD_VARLINK_DEFINE_ERROR( static SD_VARLINK_DEFINE_ERROR( BootEntryTokenUnavailable); +static SD_VARLINK_DEFINE_ERROR( + InvalidKernelImage); + SD_VARLINK_DEFINE_INTERFACE( io_systemd_BootControl, "io.systemd.BootControl", @@ -172,6 +209,8 @@ SD_VARLINK_DEFINE_INTERFACE( &vl_type_BootEntrySource, SD_VARLINK_SYMBOL_COMMENT("A structure encapsulating an addon of a boot entry"), &vl_type_BootEntryAddon, + SD_VARLINK_SYMBOL_COMMENT("An additional file to install"), + &vl_type_BootEntryExtraFile, SD_VARLINK_SYMBOL_COMMENT("A structure encapsulating a boot entry"), &vl_type_BootEntry, SD_VARLINK_SYMBOL_COMMENT("The operation to execute"), @@ -188,6 +227,8 @@ SD_VARLINK_DEFINE_INTERFACE( &vl_method_Install, SD_VARLINK_SYMBOL_COMMENT("Unlink a boot menu item"), &vl_method_Unlink, + SD_VARLINK_SYMBOL_COMMENT("Install a kernel as boot menu item"), + &vl_method_Link, SD_VARLINK_SYMBOL_COMMENT("SetRebootToFirmware() and GetRebootToFirmware() return this if the firmware does not actually support the reboot-to-firmware-UI concept."), &vl_error_RebootToFirmwareNotSupported, SD_VARLINK_SYMBOL_COMMENT("No boot entry defined."), @@ -197,4 +238,6 @@ SD_VARLINK_DEFINE_INTERFACE( SD_VARLINK_SYMBOL_COMMENT("Neither ESP nor XBOOTLDR found, hence no $BOOT location identified."), &vl_error_NoDollarBootFound, SD_VARLINK_SYMBOL_COMMENT("The selected boot entry token could not be determined."), - &vl_error_BootEntryTokenUnavailable); + &vl_error_BootEntryTokenUnavailable, + SD_VARLINK_SYMBOL_COMMENT("The specified kernel image is not valid."), + &vl_error_InvalidKernelImage); From e7128bb795949da6b785f2c78f75c8442ffb0b77 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Tue, 10 Feb 2026 15:13:02 +0100 Subject: [PATCH 07/10] bootspec: generalize "addon" logic for all kinds of extra sidecars Let's pick up all kinds of sidecars and show them, not just addons This also fixes some issues regarding "root" directory handling. In one context we'd resolve a directory claiming it was a "root", but it wasn't. Implements: https://github.com/uapi-group/specifications/pull/212 --- src/shared/bootspec.c | 475 ++++++++++++++------ src/shared/bootspec.h | 30 +- src/shared/varlink-io.systemd.BootControl.c | 2 + test/units/TEST-87-AUX-UTILS-VM.bootctl.sh | 2 +- 4 files changed, 349 insertions(+), 160 deletions(-) diff --git a/src/shared/bootspec.c b/src/shared/bootspec.c index c0111d1f9c..a167e19a90 100644 --- a/src/shared/bootspec.c +++ b/src/shared/bootspec.c @@ -14,10 +14,12 @@ #include "efi-loader.h" #include "efivars.h" #include "env-file.h" +#include "errno-util.h" #include "extract-word.h" #include "fd-util.h" #include "fileio.h" #include "find-esp.h" +#include "json-util.h" #include "log.h" #include "parse-util.h" #include "path-util.h" @@ -65,15 +67,65 @@ static const char* const boot_entry_source_table[_BOOT_ENTRY_SOURCE_MAX] = { DEFINE_STRING_TABLE_LOOKUP_TO_STRING(boot_entry_source, BootEntrySource); -static void boot_entry_addons_done(BootEntryAddons *addons) { - assert(addons); +static BootEntryExtraType boot_entry_extra_type_from_filename(const char *path) { + if (!path) + return _BOOT_ENTRY_EXTRA_TYPE_INVALID; - FOREACH_ARRAY(addon, addons->items, addons->n_items) { - free(addon->cmdline); - free(addon->location); + if (endswith_no_case(path, ".addon.efi")) + return BOOT_ENTRY_ADDON; + if (endswith_no_case(path, ".confext.raw")) + return BOOT_ENTRY_CONFEXT; + if (endswith_no_case(path, ".sysext.raw")) + return BOOT_ENTRY_SYSEXT; + if (endswith_no_case(path, ".cred")) + return BOOT_ENTRY_CREDENTIAL; + + return _BOOT_ENTRY_EXTRA_TYPE_INVALID; +} + +static void boot_entry_extras_done(BootEntryExtras *extras) { + assert(extras); + + FOREACH_ARRAY(extra, extras->items, extras->n_items) { + free(extra->location); + free(extra->cmdline); } - addons->items = mfree(addons->items); - addons->n_items = 0; + extras->items = mfree(extras->items); + extras->n_items = 0; +} + +static int boot_entry_extras_add( + BootEntryExtras *extras, + BootEntryExtraType type, + const char *path, + const char *cmdline) { + + assert(extras); + assert(type >= 0); + assert(type < _BOOT_ENTRY_EXTRA_TYPE_MAX); + assert(path); + + _cleanup_free_ char *p = strdup(path); + if (!p) + return -ENOMEM; + + _cleanup_free_ char *c = NULL; + if (cmdline) { + c = strdup(cmdline); + if (!c) + return -ENOMEM; + } + + if (!GREEDY_REALLOC(extras->items, extras->n_items + 1)) + return -ENOMEM; + + extras->items[extras->n_items++] = (BootEntryExtra) { + .type = type, + .location = TAKE_PTR(p), + .cmdline = TAKE_PTR(c), + }; + + return 0; } static void boot_entry_free(BootEntry *entry) { @@ -91,7 +143,7 @@ static void boot_entry_free(BootEntry *entry) { free(entry->machine_id); free(entry->architecture); strv_free(entry->options); - boot_entry_addons_done(&entry->local_addons); + boot_entry_extras_done(&entry->local_extras); free(entry->kernel); free(entry->efi); free(entry->uki); @@ -213,6 +265,50 @@ static int parse_path_many( return strv_extend_strv_consume(s, TAKE_PTR(f), /* filter_duplicates= */ false); } +static int parse_extra( + const char *fname, + unsigned line, + const char *field, + BootEntryExtras *extras, + const char *p) { + + int r; + + assert(extras); + + _cleanup_strv_free_ char **l = strv_split(p, NULL); + if (!l) + return -ENOMEM; + + STRV_FOREACH(i, l) { + _cleanup_free_ char *c = NULL; + r = mangle_path(fname, line, field, *i, &c); + if (r < 0) + return r; + if (r == 0) + continue; + + BootEntryExtraType type = boot_entry_extra_type_from_filename(c); + if (type < 0) { + log_debug_errno(type, "Failed to determine boot entry extra type of '%s', skipping: %m", c); + continue; + } + + /* Let's filter out EFI addons for now. We have no protocol for passing them from sd-boot to + * sd-stub, hence supporting them would require major plumbing first. */ + if (type == BOOT_ENTRY_ADDON) { + log_debug("EFI addons are currently not supported for Type #1 entries, skipping '%s'.", c); + continue; + } + + r = boot_entry_extras_add(extras, type, c, /* cmdline= */ NULL); + if (r < 0) + return r; + } + + return 0; +} + static int parse_tries(const char *fname, const char **p, unsigned *ret) { _cleanup_free_ char *d = NULL; unsigned tries; @@ -421,6 +517,8 @@ static int boot_entry_load_type1( r = parse_path_one(tmp.path, line, field, &tmp.device_tree, p); else if (streq(field, "devicetree-overlay")) r = parse_path_many(tmp.path, line, field, &tmp.device_tree_overlay, p); + else if (streq(field, "extra")) + r = parse_extra(tmp.path, line, field, &tmp.local_extras, p); else { log_syntax(NULL, LOG_WARNING, tmp.path, line, 0, "Unknown line '%s', ignoring.", field); continue; @@ -458,7 +556,7 @@ int boot_config_load_type1( return r; config->n_entries++; - entry->global_addons = &config->global_addons[source]; + entry->global_extras = &config->global_extras[source]; return 0; } @@ -479,8 +577,8 @@ void boot_config_free(BootConfig *config) { boot_entry_free(i); free(config->entries); - FOREACH_ELEMENT(i, config->global_addons) - boot_entry_addons_done(i); + FOREACH_ELEMENT(i, config->global_extras) + boot_entry_extras_done(i); set_free(config->inodes_seen); } @@ -1188,126 +1286,140 @@ nothing: return 0; } -static int insert_boot_entry_addon( - BootEntryAddons *addons, - char *location, - char *cmdline) { - - assert(addons); - - if (!GREEDY_REALLOC(addons->items, addons->n_items + 1)) - return log_oom(); - - addons->items[addons->n_items++] = (BootEntryAddon) { - .location = location, - .cmdline = cmdline, - }; - - return 0; -} - -static int boot_entries_find_unified_addons( +static int boot_entries_find_unified_extras( BootConfig *config, int d_fd, - const char *addon_dir, - const char *root, - BootEntryAddons *ret_addons) { + const char *extra_dir, + BootEntryExtraType only_type, + const char *where, + bool suppress_seen, + BootEntryExtras *extras) { - _cleanup_closedir_ DIR *d = NULL; - _cleanup_free_ char *full = NULL; - _cleanup_(boot_entry_addons_done) BootEntryAddons addons = {}; int r; - assert(ret_addons); assert(config); + assert(extras); - r = chase_and_opendirat(d_fd, d_fd, addon_dir, /* chase_flags= */ 0, &full, &d); + _cleanup_closedir_ DIR *d = NULL; + r = chase_and_opendirat( + /* root_fd= */ d_fd, + /* dir_fd= */ d_fd, + extra_dir, + /* chase_flags= */ 0, + /* ret_path= */ NULL, + &d); if (r == -ENOENT) return 0; if (r < 0) - return log_error_errno(r, "Failed to open '%s/%s': %m", root, skip_leading_slash(addon_dir)); - - FOREACH_DIRENT(de, d, return log_error_errno(errno, "Failed to read %s: %m", full)) { - _cleanup_free_ char *j = NULL, *cmdline = NULL, *location = NULL; - _cleanup_close_ int fd = -EBADF; + return log_error_errno(r, "Failed to open '%s/%s': %m", where, skip_leading_slash(extra_dir)); + FOREACH_DIRENT(de, d, return log_error_errno(errno, "Failed to read '%s': %m", extra_dir)) { if (!dirent_is_file(de)) continue; - if (!endswith_no_case(de->d_name, ".addon.efi")) + BootEntryExtraType type = boot_entry_extra_type_from_filename(de->d_name); + if (type < 0) { + log_debug_errno(type, "Unrecognized extra file '%s', skipping.", de->d_name); continue; - - fd = openat(dirfd(d), de->d_name, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOFOLLOW|O_NOCTTY); - if (fd < 0) { - log_warning_errno(errno, "Failed to open %s/%s, ignoring: %m", full, de->d_name); + } + if (only_type >= 0 && type != only_type) { + log_debug("Extra file '%s' type not permitted in '%s', skipping.", de->d_name, extra_dir); continue; } - r = config_check_inode_relevant_and_unseen(config, fd, de->d_name); - if (r < 0) - return r; - if (r == 0) /* inode already seen or otherwise not relevant */ - continue; - - j = path_join(full, de->d_name); - if (!j) - return log_oom(); - - if (pe_find_addon_sections(fd, j, &cmdline) <= 0) - continue; - - location = strdup(j); + _cleanup_free_ char *location = path_join(extra_dir, de->d_name); if (!location) return log_oom(); - r = insert_boot_entry_addon(&addons, location, cmdline); + _cleanup_close_ int pin_fd = openat(dirfd(d), de->d_name, O_PATH|O_CLOEXEC|O_NOFOLLOW); + if (pin_fd < 0) { + log_debug_errno(errno, "Failed to pin '%s', ignoring: %m", location); + continue; + } + + r = fd_verify_regular(pin_fd); + if (r < 0) { + log_debug_errno(r, "Unrecognized inode type of '%s', skipping.", location); + continue; + } + + if (suppress_seen) { + r = config_check_inode_relevant_and_unseen(config, pin_fd, location); + if (r < 0) + return r; + if (r == 0) /* inode already seen or otherwise not relevant */ + continue; + } + + _cleanup_free_ char *cmdline = NULL; + if (type == BOOT_ENTRY_ADDON) { + _cleanup_close_ int fd = fd_reopen(pin_fd, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY); + if (fd < 0) { + log_debug_errno(fd, "Failed to open '%s', ignoring: %m", location); + continue; + } + + /* Try to extract the command line, but let's handle any failures gracefully, but + * still mention the extra file exists. */ + (void) pe_find_addon_sections(fd, location, &cmdline); + } + + r = boot_entry_extras_add(extras, type, location, cmdline); if (r < 0) return r; - - TAKE_PTR(location); - TAKE_PTR(cmdline); } - *ret_addons = TAKE_STRUCT(addons); return 0; } -static int boot_entries_find_unified_global_addons( +static int boot_entries_find_unified_global_extras( BootConfig *config, - const char *root, - const char *d_name, - BootEntryAddons *ret_addons) { + const char *where, + const char *extra_dir, + BootEntryExtraType only_type, + BootEntryExtras *extras) { - int r; - _cleanup_closedir_ DIR *d = NULL; + assert(extras); - assert(ret_addons); - - r = chase_and_opendir(root, NULL, CHASE_PROHIBIT_SYMLINKS, NULL, &d); - if (r == -ENOENT) + _cleanup_close_ int where_fd = RET_NERRNO(open(where, O_DIRECTORY|O_CLOEXEC)); + if (where_fd == -ENOENT) return 0; - if (r < 0) - return log_error_errno(r, "Failed to open '%s/%s': %m", root, skip_leading_slash(d_name)); + if (where_fd < 0) + return log_error_errno(where_fd, "Failed to open '%s': %m", where); - return boot_entries_find_unified_addons(config, dirfd(d), d_name, root, ret_addons); + return boot_entries_find_unified_extras( + config, + where_fd, + extra_dir, + only_type, + where, + /* suppress_seen= */ true, + extras); } -static int boot_entries_find_unified_local_addons( +static int boot_entries_find_unified_local_extras( BootConfig *config, int d_fd, - const char *d_name, - const char *root, + const char *uki, + const char *where, BootEntry *ret) { - _cleanup_free_ char *addon_dir = NULL; + _cleanup_free_ char *extra_dir = NULL; assert(ret); - addon_dir = strjoin(d_name, ".extra.d"); - if (!addon_dir) + extra_dir = strjoin(uki, ".extra.d"); + if (!extra_dir) return log_oom(); - return boot_entries_find_unified_addons(config, d_fd, addon_dir, root, &ret->local_addons); + return boot_entries_find_unified_extras( + config, + d_fd, + extra_dir, + /* only_type= */ _BOOT_ENTRY_EXTRA_TYPE_INVALID, + where, + /* suppress_seen= */ false, + &ret->local_extras); } static int boot_entries_find_unified( @@ -1369,11 +1481,11 @@ static int boot_entries_find_unified( if (boot_entry_load_unified(root, source, j, p, osrelease, profile, cmdline, entry) < 0) continue; - /* look for .efi.extra.d */ - (void) boot_entries_find_unified_local_addons(config, dirfd(d), de->d_name, full, entry); + /* Look for .efi.extra.d/ */ + (void) boot_entries_find_unified_local_extras(config, dirfd(d), de->d_name, full, entry); - /* Set up the backpointer, so that we can find the global addons */ - entry->global_addons = &config->global_addons[source]; + /* Set up the backpointer, so that we can find the global extras */ + entry->global_extras = &config->global_extras[source]; config->n_entries++; } @@ -1614,6 +1726,52 @@ int boot_config_finalize(BootConfig *config) { return 0; } +static int boot_entries_load( + BootConfig *config, + BootEntrySource source, + const char *where) { /* Mount point of ESP/XBOOTLDR */ + + int r; + + assert(config); + assert(source >= 0); + assert(source < _BOOT_ENTRY_SOURCE_MAX); + + if (!where) + return 0; + + r = boot_entries_find_type1(config, where, source, "/loader/entries"); + if (r < 0) + return r; + + r = boot_entries_find_unified(config, where, source, "/EFI/Linux/"); + if (r < 0) + return r; + + static const struct { + BootEntryExtraType extra_type; + const char *directory; + } table[] = { + { BOOT_ENTRY_ADDON, "/loader/addons/" }, + { BOOT_ENTRY_CONFEXT, "/loader/extensions/" }, + { BOOT_ENTRY_SYSEXT, "/loader/extensions/" }, + { BOOT_ENTRY_CREDENTIAL, "/loader/credentials/" }, + }; + + FOREACH_ELEMENT(i, table) { + r = boot_entries_find_unified_global_extras( + config, + where, + i->directory, + i->extra_type, + &config->global_extras[source]); + if (r < 0) + return r; + } + + return 0; +} + int boot_config_load( BootConfig *config, const char *esp_path, @@ -1628,31 +1786,13 @@ int boot_config_load( if (r < 0) return r; - r = boot_entries_find_type1(config, esp_path, BOOT_ENTRY_ESP, "/loader/entries"); - if (r < 0) - return r; - - r = boot_entries_find_unified(config, esp_path, BOOT_ENTRY_ESP, "/EFI/Linux/"); - if (r < 0) - return r; - - r = boot_entries_find_unified_global_addons(config, esp_path, "/loader/addons/", - &config->global_addons[BOOT_ENTRY_ESP]); + r = boot_entries_load(config, BOOT_ENTRY_ESP, esp_path); if (r < 0) return r; } if (xbootldr_path) { - r = boot_entries_find_type1(config, xbootldr_path, BOOT_ENTRY_XBOOTLDR, "/loader/entries"); - if (r < 0) - return r; - - r = boot_entries_find_unified(config, xbootldr_path, BOOT_ENTRY_XBOOTLDR, "/EFI/Linux/"); - if (r < 0) - return r; - - r = boot_entries_find_unified_global_addons(config, xbootldr_path, "/loader/addons/", - &config->global_addons[BOOT_ENTRY_XBOOTLDR]); + r = boot_entries_load(config, BOOT_ENTRY_XBOOTLDR, xbootldr_path); if (r < 0) return r; } @@ -1724,7 +1864,7 @@ int boot_config_augment_from_loader( char **found_by_loader, bool auto_only) { - static const BootEntryAddons no_addons = (BootEntryAddons) {}; + static const BootEntryExtras no_extras = (BootEntryExtras) {}; static const char *const title_table[] = { /* Pretty names for a few well-known automatically discovered entries. */ "auto-osx", "macOS", @@ -1783,7 +1923,7 @@ int boot_config_augment_from_loader( .tries_left = UINT_MAX, .tries_done = UINT_MAX, .profile = UINT_MAX, - .global_addons = &no_addons, + .global_extras = &no_extras, }; } @@ -1806,10 +1946,10 @@ static void boot_entry_file_list( const char *field, const char *root, const char *p, - int *ret_status) { + int *pstatus) { assert(p); - assert(ret_status); + assert(pstatus); int status = chase_and_access(p, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, F_OK, NULL); @@ -1824,16 +1964,23 @@ static void boot_entry_file_list( } else printf("%s\n", p); - if (*ret_status == 0 && status < 0) - *ret_status = status; + if (*pstatus == 0 && status < 0) + *pstatus = status; } -static void print_addon( - BootEntryAddon *addon, - const char *addon_str) { +static void print_extra( + const BootEntry *e, + const BootEntryExtra *extra, + const char *field, + int *status) { - printf(" %s: %s\n", addon_str, addon->location); - printf(" options: %s%s\n", glyph(GLYPH_TREE_RIGHT), addon->cmdline); + assert(e); + assert(extra); + + boot_entry_file_list(field, e->root, extra->location, status); + + if (extra->cmdline) + printf(" options: %s%s\n", glyph(GLYPH_TREE_RIGHT), extra->cmdline); } static int indent_embedded_newlines(char *cmdline, char **ret_cmdline) { @@ -1851,12 +1998,10 @@ static int indent_embedded_newlines(char *cmdline, char **ret_cmdline) { return -ENOMEM; *ret_cmdline = TAKE_PTR(t); - return 0; } -static int print_cmdline(const BootEntry *e) { - +static int print_cmdline(const BootEntry *e, int *status) { _cleanup_free_ char *options = NULL, *combined_cmdline = NULL, *t2 = NULL; assert(e); @@ -1877,17 +2022,20 @@ static int print_cmdline(const BootEntry *e) { return log_oom(); } - FOREACH_ARRAY(addon, e->global_addons->items, e->global_addons->n_items) { - print_addon(addon, "global-addon"); - if (!strextend(&t2, " ", addon->cmdline)) - return log_oom(); + FOREACH_ARRAY(extra, e->global_extras->items, e->global_extras->n_items) { + print_extra(e, extra, "extra", status); + + if (extra->cmdline) + if (!strextend(&t2, " ", extra->cmdline)) + return log_oom(); } - FOREACH_ARRAY(addon, e->local_addons.items, e->local_addons.n_items) { - /* Add space at the beginning of addon_str to align it correctly */ - print_addon(addon, " local-addon"); - if (!strextend(&t2, " ", addon->cmdline)) - return log_oom(); + FOREACH_ARRAY(extra, e->local_extras.items, e->local_extras.n_items) { + print_extra(e, extra, "extra", status); + + if (extra->cmdline) + if (!strextend(&t2, " ", extra->cmdline)) + return log_oom(); } /* Don't print the combined cmdline if it's same as options. */ @@ -1904,19 +2052,19 @@ static int print_cmdline(const BootEntry *e) { } static int json_addon( - BootEntryAddon *addon, - const char *addon_str, + const BootEntryExtra *extra, + const char *extra_str, sd_json_variant **array) { int r; - assert(addon); - assert(addon_str); + assert(extra); + assert(extra_str); r = sd_json_variant_append_arraybo( array, - SD_JSON_BUILD_PAIR_STRING(addon_str, addon->location), - SD_JSON_BUILD_PAIR_STRING("options", addon->cmdline)); + SD_JSON_BUILD_PAIR_STRING(extra_str, extra->location), + JSON_BUILD_PAIR_STRING_NON_EMPTY("options", extra->cmdline)); if (r < 0) return log_oom(); @@ -1940,20 +2088,31 @@ static int json_cmdline( return log_oom(); } - FOREACH_ARRAY(addon, e->global_addons->items, e->global_addons->n_items) { - r = json_addon(addon, "globalAddon", &addons_array); + /* NB: these JSON fields are kinda obsolete, we want the more generic 'extra' ones to be used. */ + FOREACH_ARRAY(extra, e->global_extras->items, e->global_extras->n_items) { + if (extra->type != BOOT_ENTRY_ADDON) + continue; + + r = json_addon(extra, "globalAddon", &addons_array); if (r < 0) return r; - if (!strextend(&combined_cmdline, " ", addon->cmdline)) - return log_oom(); + + if (extra->cmdline) + if (!strextend(&combined_cmdline, " ", extra->cmdline)) + return log_oom(); } - FOREACH_ARRAY(addon, e->local_addons.items, e->local_addons.n_items) { - r = json_addon(addon, "localAddon", &addons_array); + FOREACH_ARRAY(extra, e->local_extras.items, e->local_extras.n_items) { + if (extra->type != BOOT_ENTRY_ADDON) + continue; + + r = json_addon(extra, "localAddon", &addons_array); if (r < 0) return r; - if (!strextend(&combined_cmdline, " ", addon->cmdline)) - return log_oom(); + + if (extra->cmdline) + if (!strextend(&combined_cmdline, " ", extra->cmdline)) + return log_oom(); } r = sd_json_variant_merge_objectbo( @@ -2064,7 +2223,7 @@ int show_boot_entry( *s, &status); - r = print_cmdline(e); + r = print_cmdline(e, &status); if (r < 0) return r; @@ -2144,6 +2303,24 @@ int boot_entry_to_json(const BootConfig *c, size_t i, sd_json_variant **ret) { if (r < 0) return log_oom(); + _cleanup_(sd_json_variant_unrefp) sd_json_variant *jextras = NULL; + FOREACH_ARRAY(extra, e->global_extras->items, e->global_extras->n_items) { + r = sd_json_variant_append_arrayb(&jextras, SD_JSON_BUILD_STRING(extra->location)); + if (r < 0) + return log_oom(); + } + FOREACH_ARRAY(extra, e->local_extras.items, e->local_extras.n_items) { + r = sd_json_variant_append_arrayb(&jextras, SD_JSON_BUILD_STRING(extra->location)); + if (r < 0) + return log_oom(); + } + + r = sd_json_variant_merge_objectbo( + &v, + SD_JSON_BUILD_PAIR_CONDITION(!!jextras, "extras", SD_JSON_BUILD_VARIANT(jextras))); + if (r < 0) + return log_oom(); + *ret = TAKE_PTR(v); return 1; } diff --git a/src/shared/bootspec.h b/src/shared/bootspec.h index d5f6930be9..677667383c 100644 --- a/src/shared/bootspec.h +++ b/src/shared/bootspec.h @@ -20,15 +20,25 @@ typedef enum BootEntrySource { _BOOT_ENTRY_SOURCE_INVALID = -EINVAL, } BootEntrySource; -typedef struct BootEntryAddon { - char *location; - char *cmdline; -} BootEntryAddon; +typedef enum BootEntryExtraType { + BOOT_ENTRY_ADDON, + BOOT_ENTRY_CONFEXT, + BOOT_ENTRY_SYSEXT, + BOOT_ENTRY_CREDENTIAL, + _BOOT_ENTRY_EXTRA_TYPE_MAX, + _BOOT_ENTRY_EXTRA_TYPE_INVALID = -EINVAL, +} BootEntryExtraType; -typedef struct BootEntryAddons { - BootEntryAddon *items; +typedef struct BootEntryExtra { + BootEntryExtraType type; + char *location; + char *cmdline; /* only for BOOT_ENTRY_ADDON */ +} BootEntryExtra; + +typedef struct BootEntryExtras { + BootEntryExtra *items; size_t n_items; -} BootEntryAddons; +} BootEntryExtras; typedef struct BootEntry { BootEntryType type; @@ -46,8 +56,8 @@ typedef struct BootEntry { char *machine_id; char *architecture; char **options; - BootEntryAddons local_addons; - const BootEntryAddons *global_addons; /* Backpointer into the BootConfig; we don't own this here */ + BootEntryExtras local_extras; + const BootEntryExtras *global_extras; /* Backpointer into the BootConfig; we don't own this here */ char *kernel; /* linux is #defined to 1, yikes! */ char *efi; char *uki; @@ -84,7 +94,7 @@ typedef struct BootConfig { BootEntry *entries; size_t n_entries; - BootEntryAddons global_addons[_BOOT_ENTRY_SOURCE_MAX]; + BootEntryExtras global_extras[_BOOT_ENTRY_SOURCE_MAX]; ssize_t default_entry; ssize_t selected_entry; diff --git a/src/shared/varlink-io.systemd.BootControl.c b/src/shared/varlink-io.systemd.BootControl.c index c16a295d89..920b9479db 100644 --- a/src/shared/varlink-io.systemd.BootControl.c +++ b/src/shared/varlink-io.systemd.BootControl.c @@ -80,6 +80,8 @@ static SD_VARLINK_DEFINE_STRUCT_TYPE( SD_VARLINK_DEFINE_FIELD(isSelected, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("Addon images of the entry."), SD_VARLINK_DEFINE_FIELD_BY_TYPE(addons, BootEntryAddon, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY), + SD_VARLINK_FIELD_COMMENT("Extra files associated with the entry."), + SD_VARLINK_DEFINE_FIELD(extras, SD_VARLINK_STRING, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY), SD_VARLINK_FIELD_COMMENT("Command line options of the entry."), SD_VARLINK_DEFINE_FIELD(cmdline, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); diff --git a/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh b/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh index 440c3e5edf..668c0cfac4 100755 --- a/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh +++ b/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh @@ -374,7 +374,7 @@ testcase_00_secureboot() { bootctl status | grep "Secure Boot: enabled" >/dev/null # Ensure the addon is fully loaded and parsed - bootctl status | grep "global-addon: loader/addons/test.addon.efi" >/dev/null + bootctl status | grep "extra: /boot//loader/addons/test.addon.efi" >/dev/null bootctl status | grep "cmdline" | grep addonfoobar >/dev/null grep -q addonfoobar /proc/cmdline } From e7ab31b4d1c90ffe41e29b60731742c3cdeed1bd Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 20 Mar 2026 17:33:07 +0100 Subject: [PATCH 08/10] bootctl: make sure "unlink" properly tracks "extra" files --- src/bootctl/bootctl-unlink.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/bootctl/bootctl-unlink.c b/src/bootctl/bootctl-unlink.c index 80e74926c6..6e8e5c4bb1 100644 --- a/src/bootctl/bootctl-unlink.c +++ b/src/bootctl/bootctl-unlink.c @@ -146,6 +146,12 @@ static int boot_entry_ref_files( return r; } + FOREACH_ARRAY(x, e->local_extras.items, e->local_extras.n_items) { + r = ref_file(known_files, x->location, increment); + if (r < 0) + return r; + } + return 0; } @@ -294,6 +300,8 @@ int boot_entry_unlink( (void) unref_unlink_file(&known_files, root, root_fd, e->device_tree, dry_run); STRV_FOREACH(s, e->device_tree_overlay) (void) unref_unlink_file(&known_files, root, root_fd, *s, dry_run); + FOREACH_ARRAY(x, e->local_extras.items, e->local_extras.n_items) + (void) unref_unlink_file(&known_files, root, root_fd, x->location, dry_run); if (dry_run) log_info("Would remove \"%s\"", e->path); From c68b8ff4d192b47d1b99a52b6217c1dc3f7a39ea Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 17 Apr 2026 15:01:00 +0200 Subject: [PATCH 09/10] ci: add integration test for new bootctl functionality --- test/units/TEST-87-AUX-UTILS-VM.bootctl.sh | 271 +++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh b/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh index 668c0cfac4..90daaf52ea 100755 --- a/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh +++ b/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh @@ -397,4 +397,275 @@ testcase_install_varlink() { bootctl is-installed } +cleanup_link() { + if [[ -n "${LINK_WORKDIR:-}" ]]; then + rm -rf "$LINK_WORKDIR" + unset LINK_WORKDIR + fi + restore_esp +} + +testcase_bootctl_link() { + if ! command -v ukify >/dev/null; then + echo "ukify not found, skipping." + return 0 + fi + + backup_esp + LINK_WORKDIR="$(mktemp --directory /tmp/test-bootctl-link.XXXXXXXXXX)" + trap cleanup_link RETURN ERR + + # Ensure loader/entries directory is present + bootctl install --make-entry-directory=yes + + local ESP + ESP="$(bootctl --print-esp-path)" + + # Build a minimal UKI via ukify. The .linux content does not need to be a + # real kernel — bootctl link only requires a valid PE with .osrel (and the + # systemd-stub SBAT marker that pe_is_uki() checks for). + cat >"$LINK_WORKDIR/os-release" <<'EOF' +ID=testos +NAME="Test OS" +PRETTY_NAME="Test OS" +EOF + echo "fake-kernel" >"$LINK_WORKDIR/vmlinuz" + echo "fake-initrd" >"$LINK_WORKDIR/initrd" + echo "fake-sysext-data" >"$LINK_WORKDIR/hello.sysext.raw" + echo "fake-confext-data" >"$LINK_WORKDIR/hello.confext.raw" + echo "fake-credential" >"$LINK_WORKDIR/hello.cred" + + ukify build \ + --linux "$LINK_WORKDIR/vmlinuz" \ + --initrd "$LINK_WORKDIR/initrd" \ + --os-release "@$LINK_WORKDIR/os-release" \ + --uname "1.2.3-testkernel" \ + --cmdline "quiet" \ + --output "$LINK_WORKDIR/testuki.efi" + + # Pin an explicit entry token so the resulting filenames are deterministic + local TOKEN="systemdtest" + local BOOTCTL=(bootctl "--entry-token=literal:$TOKEN") + + # --- Test 1: basic link/unlink --- + "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" + + # Exactly one entry file should exist, named "${TOKEN}-commit_1.conf" + local ENTRY="$ESP/loader/entries/${TOKEN}-commit_1.conf" + test -f "$ENTRY" + test -f "$ESP/$TOKEN/testuki.efi" + + # Verify the entry file contents + grep "^title " "$ENTRY" >/dev/null + grep "^uki /${TOKEN}/testuki.efi\$" "$ENTRY" >/dev/null + grep "^version 1\$" "$ENTRY" >/dev/null + + # Make sure bootctl list sees it + bootctl list --json=short | grep -F "${TOKEN}-commit_1.conf" >/dev/null + + # Remove it again using the ID (entry IDs include the .conf suffix) + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_1.conf" + test ! -e "$ENTRY" + test ! -e "$ESP/$TOKEN/testuki.efi" + + # --- Test 2: link with --entry-title/--entry-version/--entry-commit/--tries-left --- + "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" \ + --entry-title="My Funky Entry" \ + --entry-version="9.8.7" \ + --entry-commit=42 \ + --tries-left=3 + + ENTRY="$ESP/loader/entries/${TOKEN}-commit_42.9.8.7+3.conf" + test -f "$ENTRY" + test -f "$ESP/$TOKEN/testuki.efi" + + grep "^title My Funky Entry\$" "$ENTRY" >/dev/null + grep "^version 42.9.8.7\$" "$ENTRY" >/dev/null + grep "^uki /${TOKEN}/testuki.efi\$" "$ENTRY" >/dev/null + + # Unlink using the ID (the tries counter "+3" is stripped from the canonical ID) + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_42.9.8.7.conf" + test ! -e "$ENTRY" + test ! -e "$ESP/$TOKEN/testuki.efi" + + # --- Test 3: link with extras (-X and --extra=) --- + "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" \ + --entry-commit=50 \ + -X "$LINK_WORKDIR/hello.sysext.raw" \ + --extra="$LINK_WORKDIR/hello.confext.raw" \ + -X "$LINK_WORKDIR/hello.cred" + + ENTRY="$ESP/loader/entries/${TOKEN}-commit_50.conf" + test -f "$ENTRY" + test -f "$ESP/$TOKEN/testuki.efi" + test -f "$ESP/$TOKEN/hello.sysext.raw" + test -f "$ESP/$TOKEN/hello.confext.raw" + test -f "$ESP/$TOKEN/hello.cred" + + grep "^extra /${TOKEN}/hello.sysext.raw\$" "$ENTRY" >/dev/null + grep "^extra /${TOKEN}/hello.confext.raw\$" "$ENTRY" >/dev/null + grep "^extra /${TOKEN}/hello.cred\$" "$ENTRY" >/dev/null + + # Unlink must also clean up the extra resources + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_50.conf" + test ! -e "$ENTRY" + test ! -e "$ESP/$TOKEN/testuki.efi" + test ! -e "$ESP/$TOKEN/hello.sysext.raw" + test ! -e "$ESP/$TOKEN/hello.confext.raw" + test ! -e "$ESP/$TOKEN/hello.cred" + + # --- Test 4: --oldest drops the lowest commit first --- + "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" --entry-commit=10 + "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" --entry-commit=20 + "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" --entry-commit=30 + + test -f "$ESP/loader/entries/${TOKEN}-commit_10.conf" + test -f "$ESP/loader/entries/${TOKEN}-commit_20.conf" + test -f "$ESP/loader/entries/${TOKEN}-commit_30.conf" + test -f "$ESP/$TOKEN/testuki.efi" + + "${BOOTCTL[@]}" unlink --oldest=yes + test ! -e "$ESP/loader/entries/${TOKEN}-commit_10.conf" + test -f "$ESP/loader/entries/${TOKEN}-commit_20.conf" + test -f "$ESP/loader/entries/${TOKEN}-commit_30.conf" + test -f "$ESP/$TOKEN/testuki.efi" + + "${BOOTCTL[@]}" unlink --oldest=yes + test ! -e "$ESP/loader/entries/${TOKEN}-commit_20.conf" + test -f "$ESP/loader/entries/${TOKEN}-commit_30.conf" + test -f "$ESP/$TOKEN/testuki.efi" + + # --- Test 5: --dry-run leaves everything in place --- + "${BOOTCTL[@]}" --dry-run unlink "${TOKEN}-commit_30.conf" + test -f "$ESP/loader/entries/${TOKEN}-commit_30.conf" + test -f "$ESP/$TOKEN/testuki.efi" + + # Actually remove it now + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_30.conf" + test ! -e "$ESP/loader/entries/${TOKEN}-commit_30.conf" + test ! -e "$ESP/$TOKEN/testuki.efi" + + # --- Test 6: invalid combinations are rejected --- + # Neither an ID nor --oldest + (! "${BOOTCTL[@]}" unlink) + # Both an ID and --oldest + (! "${BOOTCTL[@]}" unlink --oldest=yes "${TOKEN}-commit_1.conf") + + # --- Test 7: refusing to link when --keep-free cannot be satisfied --- + (! "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" --entry-commit=99 --keep-free=1T) + test ! -e "$ESP/loader/entries/${TOKEN}-commit_99.conf" + + # --- Test 8: refusing to re-link the same commit number --- + "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" --entry-commit=77 + (! "${BOOTCTL[@]}" link "$LINK_WORKDIR/testuki.efi" --entry-commit=77) + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_77.conf" + + # --- Test 9: passing a non-UKI is rejected --- + (! "${BOOTCTL[@]}" link "$LINK_WORKDIR/vmlinuz") + + # === Varlink coverage === + # + # Exercise io.systemd.BootControl.Link/Unlink by forking bootctl as a + # varlink server via 'varlinkctl call '. Note the Varlink schema + # has no way to supply a literal entry token (unlike --entry-token= on + # the command line), so the token is chosen by bootctl from + # machine-id/os-release — we recover it from the returned id. + local BOOTCTL_BIN vreply vid vtoken + BOOTCTL_BIN="$(type -p bootctl)" + + # --- Test 10: Link + Unlink via varlink --- + vreply="$(varlinkctl call --json=short \ + --push-fd="$LINK_WORKDIR/testuki.efi" \ + "$BOOTCTL_BIN" io.systemd.BootControl.Link \ + '{"kernelFilename":"vluki.efi","kernelFileDescriptor":0}')" + vid="$(echo "$vreply" | jq -r '.ids[0]')" + test -n "$vid" + test "$vid" != "null" + vtoken="${vid%%-commit_*}" + test -n "$vtoken" + + test -f "$ESP/loader/entries/$vid" + test -f "$ESP/$vtoken/vluki.efi" + grep "^uki /$vtoken/vluki.efi\$" "$ESP/loader/entries/$vid" >/dev/null + + varlinkctl call --quiet "$BOOTCTL_BIN" io.systemd.BootControl.Unlink \ + "{\"id\":\"$vid\"}" + test ! -e "$ESP/loader/entries/$vid" + test ! -e "$ESP/$vtoken/vluki.efi" + + # --- Test 11: Link with entryTitle/entryVersion/entryCommit/triesLeft + extraFiles via varlink --- + vreply="$(varlinkctl call --json=short \ + --push-fd="$LINK_WORKDIR/testuki.efi" \ + --push-fd="$LINK_WORKDIR/hello.sysext.raw" \ + --push-fd="$LINK_WORKDIR/hello.cred" \ + "$BOOTCTL_BIN" io.systemd.BootControl.Link \ + '{"kernelFilename":"vluki2.efi","kernelFileDescriptor":0,"entryTitle":"Varlink Title","entryVersion":"2.3.4","entryCommit":111,"triesLeft":2,"extraFiles":[{"filename":"hello.sysext.raw","fileDescriptor":1},{"filename":"hello.cred","fileDescriptor":2}]}')" + vid="$(echo "$vreply" | jq -r '.ids[0]')" + # The returned id has the tries counter ("+2") stripped + assert_eq "$vid" "$vtoken-commit_111.2.3.4.conf" + # The on-disk entry filename includes the tries counter + local VENTRY="$ESP/loader/entries/$vtoken-commit_111.2.3.4+2.conf" + test -f "$VENTRY" + test -f "$ESP/$vtoken/vluki2.efi" + test -f "$ESP/$vtoken/hello.sysext.raw" + test -f "$ESP/$vtoken/hello.cred" + + grep "^title Varlink Title\$" "$VENTRY" >/dev/null + grep "^version 111.2.3.4\$" "$VENTRY" >/dev/null + grep "^extra /$vtoken/hello.sysext.raw\$" "$VENTRY" >/dev/null + grep "^extra /$vtoken/hello.cred\$" "$VENTRY" >/dev/null + + varlinkctl call --quiet "$BOOTCTL_BIN" io.systemd.BootControl.Unlink \ + "{\"id\":\"$vid\"}" + test ! -e "$VENTRY" + test ! -e "$ESP/$vtoken/vluki2.efi" + test ! -e "$ESP/$vtoken/hello.sysext.raw" + test ! -e "$ESP/$vtoken/hello.cred" + + # --- Test 12: Unlink oldest via varlink --- + local c + for c in 210 220 230; do + varlinkctl call --quiet \ + --push-fd="$LINK_WORKDIR/testuki.efi" \ + "$BOOTCTL_BIN" io.systemd.BootControl.Link \ + "{\"kernelFilename\":\"vluki3.efi\",\"kernelFileDescriptor\":0,\"entryCommit\":$c}" + done + test -f "$ESP/loader/entries/$vtoken-commit_210.conf" + test -f "$ESP/loader/entries/$vtoken-commit_220.conf" + test -f "$ESP/loader/entries/$vtoken-commit_230.conf" + + varlinkctl call --quiet "$BOOTCTL_BIN" io.systemd.BootControl.Unlink \ + '{"oldest":true}' + test ! -e "$ESP/loader/entries/$vtoken-commit_210.conf" + test -f "$ESP/loader/entries/$vtoken-commit_220.conf" + test -f "$ESP/loader/entries/$vtoken-commit_230.conf" + test -f "$ESP/$vtoken/vluki3.efi" + + # Clean up remaining entries + varlinkctl call --quiet "$BOOTCTL_BIN" io.systemd.BootControl.Unlink \ + "{\"id\":\"$vtoken-commit_220.conf\"}" + varlinkctl call --quiet "$BOOTCTL_BIN" io.systemd.BootControl.Unlink \ + "{\"id\":\"$vtoken-commit_230.conf\"}" + test ! -e "$ESP/loader/entries/$vtoken-commit_220.conf" + test ! -e "$ESP/loader/entries/$vtoken-commit_230.conf" + test ! -e "$ESP/$vtoken/vluki3.efi" + + # --- Test 13: Link with a non-UKI via varlink returns InvalidKernelImage --- + varlinkctl call --quiet \ + --push-fd="$LINK_WORKDIR/vmlinuz" \ + --graceful=io.systemd.BootControl.InvalidKernelImage \ + "$BOOTCTL_BIN" io.systemd.BootControl.Link \ + '{"kernelFilename":"notauki.efi","kernelFileDescriptor":0}' + + # --- Test 14: Unlink with invalid argument combinations is rejected --- + # Both id and oldest=true + (! varlinkctl call "$BOOTCTL_BIN" io.systemd.BootControl.Unlink \ + '{"id":"foo.conf","oldest":true}') + # Neither id nor oldest + (! varlinkctl call "$BOOTCTL_BIN" io.systemd.BootControl.Unlink '{}') + # Invalid id characters (e.g. a glob) + (! varlinkctl call "$BOOTCTL_BIN" io.systemd.BootControl.Unlink \ + '{"id":"foo*.conf"}') +} + run_testcases From 5e74fd06ba92ac1956b29daeb2a2ecef21d01dc6 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Tue, 10 Feb 2026 12:39:02 +0100 Subject: [PATCH 10/10] update TODO --- TODO.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 60a11dcee3..b5777556e9 100644 --- a/TODO.md +++ b/TODO.md @@ -143,6 +143,12 @@ SPDX-License-Identifier: LGPL-2.1-or-later volume is auto-destroyed. Would be exposed via a new flag on the Acquire call, similar to the locking logic above. +- clean up credential naming a bit: let's say encrypted creds always should + carry .cred suffix, and unencrypted should not. + +- clean up naming of sidecar files in sd-stub: let's put global ones strictly + into /loader/extras/ + - a small tool that can do basic btrfs raid policy mgmt. i.e. gets started as part of the initial transaction for some btrfs raid fs, waits for some time, then puts message on screen (plymouth, console) that some devices apparently @@ -2163,10 +2169,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later - run0: maybe enable utmp for run0 sessions, so that they are easily visible. -- sd-boot/sd-stub: install a uefi "handle" to a sidecar dir of bls type #1 - entries with an "uki" or "uki-url" stanza, and make sd-stub look for - that. That way we can parameterize type #1 entries nicely. - - **sd-boot:** - do something useful if we find exactly zero entries (ignoring items such as reboot/poweroff/factory reset). Show a help text or so.