]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
bootctl: add helpers that format a type1 menu entry filename from a commit nr
authorLennart Poettering <lennart@amutable.com>
Wed, 15 Apr 2026 14:09:48 +0000 (16:09 +0200)
committerLennart Poettering <lennart@amutable.com>
Fri, 1 May 2026 05:10:31 +0000 (07:10 +0200)
src/bootctl/bootspec-util.c
src/bootctl/bootspec-util.h
src/bootctl/meson.build
src/bootctl/test-bootspec-util.c [new file with mode: 0644]

index b96687430ca32316c7eb4a45591d361910b70ec0..5f9842c9d80a385d5bb3ac6815a4bcb4c9d0446f 100644 (file)
@@ -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;
+}
index 51dac12b9f44be5c09a4f66af6fa34b6ed312de4..0824c8040fb6401db2c407897627733b769c5877 100644 (file)
@@ -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);
index f8349df7168e33113d363438af061d1c656ec299..ff33cde3f615b90e5dc464e8d171df476a3f887b 100644 (file)
@@ -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 (file)
index 0000000..1fa8914
--- /dev/null
@@ -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);