]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
bootctl: add "link" command
authorLennart Poettering <lennart@amutable.com>
Mon, 9 Feb 2026 22:51:42 +0000 (23:51 +0100)
committerLennart Poettering <lennart@amutable.com>
Fri, 1 May 2026 05:10:31 +0000 (07:10 +0200)
man/bootctl.xml
shell-completion/bash/bootctl
shell-completion/zsh/_bootctl
src/bootctl/bootctl-link.c [new file with mode: 0644]
src/bootctl/bootctl-link.h [new file with mode: 0644]
src/bootctl/bootctl.c
src/bootctl/bootctl.h
src/bootctl/meson.build
src/shared/efi-loader.c
src/shared/efi-loader.h
src/shared/varlink-io.systemd.BootControl.c

index fb5f4b2b2a9eb76b416fb2c20561bcf393fb9759..39ddfdd58c06abb0e976d15f91295350c9565eca 100644 (file)
         <xi:include href="version-info.xml" xpointer="v253"/></listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><option>link</option> <replaceable>KERNEL</replaceable></term>
+
+        <listitem><para>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 <ulink
+        url="https://uapi-group.org/specifications/specs/boot_loader_specification">Boot Loader
+        Specification</ulink> Type #1 entries are generated referring to it (one per UKI profile, if multiple
+        profiles are embedded).</para>
+
+        <para>The title, version, commit number and initial try counter of the generated entries
+        may be overridden with <option>--entry-title=</option>, <option>--entry-version=</option>,
+        <option>--entry-commit=</option> and <option>--tries-left=</option>. Additional sidecar resources
+        (system extension images, configuration extension images, credential files) to pass to the UKI at
+        boot may be specified with <option>--extra=</option>.</para>
+
+        <para>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.</para>
+
+        <para>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
+        <option>--keep-free=</option> to adjust.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><option>cleanup</option></term>
 
         <xi:include href="version-info.xml" xpointer="v261"/></listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><option>--keep-free=<replaceable>BYTES</replaceable></option></term>
+
+        <listitem><para>When used with <command>link</command>, 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.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--entry-title=<replaceable>TITLE</replaceable></option></term>
+
+        <listitem><para>When used with <command>link</command>, specifies the title of the generated boot
+        loader entry (the <literal>title</literal> field of the Type #1 entry). If not specified, a title is
+        derived from the UKI's embedded metadata.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--entry-version=<replaceable>VERSION</replaceable></option></term>
+
+        <listitem><para>When used with <command>link</command>, specifies the version string of the
+        generated boot loader entry (the <literal>version</literal> 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.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--entry-commit=<replaceable>NR</replaceable></option></term>
+
+        <listitem><para>When used with <command>link</command>, specifies the commit number for the generated
+        boot loader entry.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--extra=<replaceable>PATH</replaceable></option></term>
+        <term><option>-X <replaceable>PATH</replaceable></option></term>
+
+        <listitem><para>When used with <command>link</command>, registers an additional sidecar resource
+        file that shall be passed to the UKI at boot. This may be a system extension image
+        (<filename>*.sysext.raw</filename>), configuration extension image
+        (<filename>*.confext.raw</filename>), or credential file
+        (<filename>*.cred</filename>). 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.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--tries-left=<replaceable>NR</replaceable></option></term>
+
+        <listitem><para>When used with <command>link</command>, initializes the boot counting
+        <literal>tries-left</literal> counter for the generated entry. If set, the resulting boot entry file
+        is named according to the boot counting scheme described in the <ulink
+        url="https://systemd.io/AUTOMATIC_BOOT_ASSESSMENT">Automatic Boot Assessment</ulink> 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.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><option>--secure-boot-auto-enroll=yes|no</option></term>
         <term><option>--private-key=<replaceable>PATH/URI</replaceable></option></term>
index 792fc0c0acc83334b77e3848d3257578e6c0d539..3b7290c230c14be044d33ad6a70f52d9736be420 100644 (file)
@@ -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
index c23c1c888dae55438dff24e8f4e849963f781373..423828ba33243cb9a6ce6bc01f492adcbc673c88 100644 (file)
@@ -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 (file)
index 0000000..6358189
--- /dev/null
@@ -0,0 +1,1206 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include <fcntl.h>
+#include <unistd.h>
+
+#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 <token>-commit_<commit>[.<version>][.p<profile>].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 (file)
index 0000000..de64563
--- /dev/null
@@ -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);
index 67a20814daf9e6ec5fcb493abefd76cbb0e26d15..c627a7dd077d5acdab05c127647a280c845df825 100644 (file)
@@ -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");
index ea097ba329753b2fbcedeefbf368b702bfad8f99..b478ae0ec1d1abe1a777899e43897e7876303d30 100644 (file)
@@ -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)
index ff33cde3f615b90e5dc464e8d171df476a3f887b..06137bdae00ced3a48ccf114bf563ef982e26fad 100644 (file)
@@ -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',
index ce10a44d34ccca9fce700e51ae716c1f42bdbab0..20e4719bb067e07df84fc9c70f43145765c21434 100644 (file)
@@ -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);
+}
index abf8bdc49ef043c2ca8b58aeccbb84f11496a4b8..c51c2dbb2f918634ef9328254eff260a80f54bf1 100644 (file)
@@ -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);
index 42f7b53492e45204d4725f0982c43f5432c8cd26..c16a295d8979f15506159dc6c0d35ff34d41c132 100644 (file)
@@ -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);