diff --git a/man/bootctl.xml b/man/bootctl.xml index 0d796bedc19..ada8c03332c 100644 --- a/man/bootctl.xml +++ b/man/bootctl.xml @@ -153,6 +153,21 @@ disables the timeout while always showing the menu. When an empty string ("") is specified the bootloader will revert to its default menu timeout. + + + 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 files from the ESP and XBOOTLDR partitions that belong to the entry token but + are not referenced in any boot loader entries. + @@ -409,6 +424,14 @@ the firmware's boot option menu. + + + Dry run for and . + + In dry run mode, the unlink and cleanup operations only print the files that would get deleted + without actually deleting them. + + diff --git a/meson.build b/meson.build index d1f3a00691b..965b237d23a 100644 --- a/meson.build +++ b/meson.build @@ -4051,6 +4051,7 @@ public_programs += exe if want_tests != 'false' and want_kernel_install test('test-kernel-install', test_kernel_install_sh, + env : test_env, args : [exe.full_path(), loaderentry_install]) endif diff --git a/shell-completion/bash/bootctl b/shell-completion/bash/bootctl index 0b7cef7871b..8d8b507ea95 100644 --- a/shell-completion/bash/bootctl +++ b/shell-completion/bash/bootctl @@ -31,7 +31,7 @@ _bootctl() { local i verb comps local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]} local -A OPTS=( - [STANDALONE]='-h --help -p --print-esp-path -x --print-boot-path --version --no-variables --no-pager --graceful' + [STANDALONE]='-h --help -p --print-esp-path -x --print-boot-path --version --no-variables --no-pager --graceful --dry-run' [ARG]='--esp-path --boot-path --make-machine-id-directory --root --image --install-source' ) @@ -67,8 +67,8 @@ _bootctl() { local -A VERBS=( # systemd-efi-options takes an argument, but it is free-form, so we cannot complete it - [STANDALONE]='help status install update remove is-installed random-seed systemd-efi-options list set-timeout set-timeout-oneshot' - [BOOTENTRY]='set-default set-oneshot' + [STANDALONE]='help status install update remove is-installed random-seed systemd-efi-options list set-timeout set-timeout-oneshot cleanup' + [BOOTENTRY]='set-default set-oneshot unlink' [BOOLEAN]='reboot-to-firmware' ) diff --git a/shell-completion/zsh/_bootctl b/shell-completion/zsh/_bootctl index 8634e8b9bc0..83d910cda15 100644 --- a/shell-completion/zsh/_bootctl +++ b/shell-completion/zsh/_bootctl @@ -24,6 +24,10 @@ _bootctl_set-oneshot() { _bootctl_comp_ids } +_bootctl_unlink() { + _bootctl_comp_ids +} + _bootctl_reboot-to-firmware() { local -a _completions _completions=( yes no ) @@ -48,6 +52,8 @@ _bootctl_reboot-to-firmware() { "set-oneshot:Set the default boot loader entry only for the next boot" "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" ) if (( CURRENT == 1 )); then _describe -t commands 'bootctl command' _bootctl_cmds || compadd "$@" @@ -73,6 +79,7 @@ _arguments \ '--no-variables[Do not touch EFI variables]' \ '--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)]' \ '--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/boot/bootctl-status.c b/src/boot/bootctl-status.c index 0adb354bdb8..8077a8a0055 100644 --- a/src/boot/bootctl-status.c +++ b/src/boot/bootctl-status.c @@ -18,6 +18,7 @@ #include "find-esp.h" #include "path-util.h" #include "pretty-print.h" +#include "recurse-dir.h" #include "terminal-util.h" #include "tpm2-util.h" @@ -503,6 +504,250 @@ int verb_status(int argc, char *argv[], void *userdata) { return r; } +static int ref_file(Hashmap *known_files, const char *fn, int increment) { + char *k = NULL; + int n, 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) + return 0; + + n = PTR_TO_INT(hashmap_get2(known_files, fn, (void**)&k)); + n += increment; + + assert(n >= 0); + + if (n == 0) { + (void) hashmap_remove(known_files, fn); + free(k); + } else if (!k) { + _cleanup_free_ char *t = NULL; + + t = strdup(fn); + if (!t) + return -ENOMEM; + r = hashmap_put(known_files, t, INT_TO_PTR(n)); + if (r < 0) + return r; + TAKE_PTR(t); + } else { + r = hashmap_update(known_files, fn, INT_TO_PTR(n)); + if (r < 0) + return r; + } + + return n; +} + +static void deref_unlink_file(Hashmap *known_files, const char *fn, const char *root) { + _cleanup_free_ char *path = NULL; + int r; + + /* just gracefully ignore this. This way the caller doesn't + have to verify whether the bootloader entry is relevant */ + if (!fn || !root) + return; + + r = ref_file(known_files, fn, -1); + if (r < 0) + return (void) log_warning_errno(r, "Failed to deref \"%s\", ignoring: %m", fn); + if (r > 0) + return; + + if (arg_dry_run) { + r = chase_symlinks_and_access(fn, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, F_OK, &path, NULL); + if (r < 0) + log_info("Unable to determine whether \"%s\" exists, ignoring: %m", fn); + else + log_info("Would remove %s", path); + return; + } + + r = chase_symlinks_and_unlink(fn, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, 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", path ?: fn); + + _cleanup_free_ char *d = NULL; + if (path_extract_directory(fn, &d) >= 0 && !path_equal(d, "/")) { + r = chase_symlinks_and_unlink(d, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, AT_REMOVEDIR, NULL); + if (r < 0 && !IN_SET(r, -ENOTEMPTY, -ENOENT)) + log_warning_errno(r, "Failed to remove directoy \"%s\", ignoring: %m", d); + } +} + +static int count_known_files(const BootConfig *config, const char* root, Hashmap **ret_known_files) { + _cleanup_(hashmap_free_free_keyp) Hashmap *known_files = NULL; + int r = 0; + + assert(config); + assert(ret_known_files); + + known_files = hashmap_new(&path_hash_ops); + if (!known_files) + return -ENOMEM; + + for (size_t i = 0; i < config->n_entries; i++) { + const BootEntry *e = config->entries + i; + + if (!path_equal(e->root, root)) + continue; + + r = ref_file(known_files, e->kernel, +1); + if (r < 0) + return r; + r = ref_file(known_files, e->efi, +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 int boot_config_find_in(const BootConfig *config, const char *root, const char *id) { + assert(config); + + if (!root || !id) + return -1; + + for (size_t i = 0; i < config->n_entries; i++) + if (path_equal(config->entries[i].root, root) + && fnmatch(id, config->entries[i].id, FNM_CASEFOLD) == 0) + return i; + + return -1; +} + +static int unlink_entry(const BootConfig *config, const char *root, const char *id) { + _cleanup_(hashmap_free_free_keyp) Hashmap *known_files = NULL; + const BootEntry *e = NULL; + int r; + + assert(config); + + r = 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 -ENOENT; + + 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); + STRV_FOREACH(s, e->initrd) + deref_unlink_file(known_files, *s, e->root); + deref_unlink_file(known_files, e->device_tree, e->root); + STRV_FOREACH(s, e->device_tree_overlay) + deref_unlink_file(known_files, *s, e->root); + + if (arg_dry_run) + log_info("Would remove %s", e->path); + else { + r = chase_symlinks_and_unlink(e->path, root, CHASE_PROHIBIT_SYMLINKS, 0, NULL); + if (r < 0) + return log_error_errno(r, "Failed to remove \"%s\": %m", e->path); + + log_info("Removed %s", e->path); + } + + return 0; +} + +static int list_remove_orphaned_file( + RecurseDirEvent event, + const char *path, + int dir_fd, + int inode_fd, + const struct dirent *de, + const struct statx *sx, + void *userdata) { + Hashmap *known_files = userdata; + + assert(path); + assert(known_files); + + if (event != RECURSE_DIR_ENTRY) + return RECURSE_DIR_CONTINUE; + + if (hashmap_get(known_files, path)) + return RECURSE_DIR_CONTINUE; /* keep! */ + + if (arg_dry_run) + log_info("Would remove %s", path); + else if (unlinkat(dir_fd, de->d_name, 0) < 0) + log_warning_errno(errno, "Failed to remove \"%s\", ignoring: %m", path); + else + log_info("Removed %s", path); + + return RECURSE_DIR_CONTINUE; +} + +static int cleanup_orphaned_files( + const BootConfig *config, + const char *root) { + _cleanup_(hashmap_free_free_keyp) Hashmap *known_files = NULL; + _cleanup_free_ char *full = NULL, *p = NULL; + _cleanup_close_ int dir_fd = -1; + int r = -1; + + assert(config); + assert(root); + + log_info("Cleaning %s", root); + + r = settle_entry_token(); + if (r < 0) + return r; + + r = count_known_files(config, root, &known_files); + if (r < 0) + return log_error_errno(r, "Failed to count files in %s: %m", root); + + dir_fd = chase_symlinks_and_open(arg_entry_token, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, + O_DIRECTORY|O_CLOEXEC, &full); + if (dir_fd == -ENOENT) + return 0; + if (dir_fd < 0) + return log_error_errno(dir_fd, "Failed to open '%s/%s': %m", root, arg_entry_token); + + p = path_join("/", arg_entry_token); + if (!p) + return log_oom(); + + r = recurse_dir(dir_fd, p, 0, UINT_MAX, RECURSE_DIR_SORT, list_remove_orphaned_file, known_files); + if (r < 0) + return log_error_errno(r, "Failed to cleanup %s: %m", full); + + return r; +} + int verb_list(int argc, char *argv[], void *userdata) { _cleanup_(boot_config_free) BootConfig config = BOOT_CONFIG_NULL; dev_t esp_devid = 0, xbootldr_devid = 0; @@ -534,6 +779,24 @@ int verb_list(int argc, char *argv[], void *userdata) { return 0; } - pager_open(arg_pager_flags); - return show_boot_entries(&config, arg_json_format_flags); + if (streq(argv[0], "list")) { + pager_open(arg_pager_flags); + return show_boot_entries(&config, arg_json_format_flags); + } else if (streq(argv[0], "cleanup")) { + if (arg_xbootldr_path && xbootldr_devid != esp_devid) + cleanup_orphaned_files(&config, arg_xbootldr_path); + return cleanup_orphaned_files(&config, arg_esp_path); + } else { + assert(streq(argv[0], "unlink")); + if (arg_xbootldr_path && xbootldr_devid != esp_devid) { + r = unlink_entry(&config, arg_xbootldr_path, argv[1]); + if (r == 0 || r != -ENOENT) + return r; + } + return unlink_entry(&config, arg_esp_path, argv[1]); + } +} + +int verb_unlink(int argc, char *argv[], void *userdata) { + return verb_list(argc, argv, userdata); } diff --git a/src/boot/bootctl-status.h b/src/boot/bootctl-status.h index 0b57c86f917..f7998a3303e 100644 --- a/src/boot/bootctl-status.h +++ b/src/boot/bootctl-status.h @@ -2,3 +2,4 @@ int verb_status(int argc, char *argv[], void *userdata); int verb_list(int argc, char *argv[], void *userdata); +int verb_unlink(int argc, char *argv[], void *userdata); diff --git a/src/boot/bootctl.c b/src/boot/bootctl.c index 7dd1a2f5f29..e910a72042b 100644 --- a/src/boot/bootctl.c +++ b/src/boot/bootctl.c @@ -48,6 +48,7 @@ char *arg_root = NULL; char *arg_image = NULL; InstallSource arg_install_source = ARG_INSTALL_SOURCE_AUTO; char *arg_efi_boot_option_description = NULL; +bool arg_dry_run = false; STATIC_DESTRUCTOR_REGISTER(arg_esp_path, freep); STATIC_DESTRUCTOR_REGISTER(arg_xbootldr_path, freep); @@ -145,6 +146,8 @@ static int help(int argc, char *argv[], void *userdata) { " set-timeout SECONDS Set the menu timeout\n" " set-timeout-oneshot SECONDS\n" " Set the menu timeout for the next boot only\n" + " unlink ID Remove boot loader entry\n" + " cleanup Remove files in ESP not referenced in any boot entry\n" "\n%3$ssystemd-boot Commands:%4$s\n" " install Install systemd-boot to the ESP and EFI variables\n" " update Update systemd-boot in the ESP and EFI variables\n" @@ -179,6 +182,7 @@ static int help(int argc, char *argv[], void *userdata) { " Install all supported EFI architectures\n" " --efi-boot-option-description=DESCRIPTION\n" " Description of the entry in the boot option list\n" + " --dry-run Dry run (unlink and cleanup)\n" "\nSee the %2$s for details.\n", program_invocation_short_name, link, @@ -206,6 +210,7 @@ static int parse_argv(int argc, char *argv[]) { ARG_JSON, ARG_ARCH_ALL, ARG_EFI_BOOT_OPTION_DESCRIPTION, + ARG_DRY_RUN, }; static const struct option options[] = { @@ -230,6 +235,7 @@ static int parse_argv(int argc, char *argv[]) { { "json", required_argument, NULL, ARG_JSON }, { "all-architectures", no_argument, NULL, ARG_ARCH_ALL }, { "efi-boot-option-description", required_argument, NULL, ARG_EFI_BOOT_OPTION_DESCRIPTION }, + { "dry-run", no_argument, NULL, ARG_DRY_RUN }, {} }; @@ -379,6 +385,10 @@ static int parse_argv(int argc, char *argv[]) { return r; break; + case ARG_DRY_RUN: + arg_dry_run = true; + break; + case '?': return -EINVAL; @@ -387,7 +397,7 @@ static int parse_argv(int argc, char *argv[]) { } if ((arg_root || arg_image) && argv[optind] && !STR_IN_SET(argv[optind], "status", "list", - "install", "update", "remove", "is-installed", "random-seed")) + "install", "update", "remove", "is-installed", "random-seed", "unlink", "cleanup")) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Options --root= and --image= are not supported with verb %s.", argv[optind]); @@ -398,6 +408,9 @@ static int parse_argv(int argc, char *argv[]) { if (arg_install_source != ARG_INSTALL_SOURCE_AUTO && !arg_root && !arg_image) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--install-from-host is only supported with --root= or --image=."); + if (arg_dry_run && argv[optind] && !STR_IN_SET(argv[optind], "unlink", "cleanup")) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--dry is only supported with --unlink or --cleanup"); + return 1; } @@ -412,6 +425,8 @@ static int bootctl_main(int argc, char *argv[]) { { "kernel-identify", 2, 2, 0, verb_kernel_identify }, { "kernel-inspect", 2, 2, 0, verb_kernel_inspect }, { "list", VERB_ANY, 1, 0, verb_list }, + { "unlink", 2, 2, 0, verb_unlink }, + { "cleanup", VERB_ANY, 1, 0, verb_list }, { "set-default", 2, 2, 0, verb_set_efivar }, { "set-oneshot", 2, 2, 0, verb_set_efivar }, { "set-timeout", 2, 2, 0, verb_set_efivar }, diff --git a/src/boot/bootctl.h b/src/boot/bootctl.h index 5a14faf1a47..311b954c2c8 100644 --- a/src/boot/bootctl.h +++ b/src/boot/bootctl.h @@ -39,6 +39,7 @@ extern char *arg_root; extern char *arg_image; extern InstallSource arg_install_source; extern char *arg_efi_boot_option_description; +extern bool arg_dry_run; 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/kernel-install/90-loaderentry.install.in b/src/kernel-install/90-loaderentry.install.in index 0992c641f03..e8e8cf37c36 100755 --- a/src/kernel-install/90-loaderentry.install.in +++ b/src/kernel-install/90-loaderentry.install.in @@ -32,7 +32,7 @@ MACHINE_ID="$KERNEL_INSTALL_MACHINE_ID" ENTRY_TOKEN="$KERNEL_INSTALL_ENTRY_TOKEN" BOOT_ROOT="$KERNEL_INSTALL_BOOT_ROOT" -BOOT_MNT="$(stat -c %m "$BOOT_ROOT")" +[ -n "$BOOT_MNT" ] || BOOT_MNT="$(stat -c %m "$BOOT_ROOT")" if [ "$BOOT_MNT" = '/' ]; then ENTRY_DIR="$ENTRY_DIR_ABS" else diff --git a/src/kernel-install/test-kernel-install.sh b/src/kernel-install/test-kernel-install.sh index 2e440636680..f16bb9f50f9 100755 --- a/src/kernel-install/test-kernel-install.sh +++ b/src/kernel-install/test-kernel-install.sh @@ -9,6 +9,8 @@ plugin="${2:?}" D="$(mktemp --tmpdir --directory "test-kernel-install.XXXXXXXXXX")" +export _KERNEL_INSTALL_BOOTCTL="$PROJECT_BUILD_ROOT/bootctl" + # shellcheck disable=SC2064 trap "rm -rf '$D'" EXIT INT QUIT PIPE mkdir -p "$D/boot" @@ -31,6 +33,7 @@ EOF export KERNEL_INSTALL_CONF_ROOT="$D/sources" export KERNEL_INSTALL_PLUGINS="$plugin" export BOOT_ROOT="$D/boot" +export BOOT_MNT="$D/boot" export MACHINE_ID='3e0484f3634a418b8e6a39e8828b03e3' "$kernel_install" -v add 1.1.1 "$D/sources/linux" "$D/sources/initrd" @@ -82,3 +85,32 @@ grep -qE '^initrd .*/the-token/1.1.1/initrd' "$entry" grep -qE 'image' "$BOOT_ROOT/the-token/1.1.1/linux" grep -qE 'initrd' "$BOOT_ROOT/the-token/1.1.1/initrd" + +if test -x "$_KERNEL_INSTALL_BOOTCTL"; then + echo "Testing bootctl" + e2="${entry%+*}_2.conf" + cp "$entry" "$e2" + export SYSTEMD_ESP_PATH=/ + + # create file that is not referenced. Check if cleanup removes + # it but leaves the rest alone + :> "$BOOT_ROOT/the-token/1.1.2/initrd" + "$_KERNEL_INSTALL_BOOTCTL" --root="$BOOT_ROOT" cleanup + test ! -e "$BOOT_ROOT/the-token/1.1.2/initrd" + test -e "$BOOT_ROOT/the-token/1.1.2/linux" + test -e "$BOOT_ROOT/the-token/1.1.1/linux" + test -e "$BOOT_ROOT/the-token/1.1.1/initrd" + # now remove duplicated entry and make sure files are left over + "$_KERNEL_INSTALL_BOOTCTL" --root="$BOOT_ROOT" unlink "${e2##*/}" + test -e "$BOOT_ROOT/the-token/1.1.1/linux" + test -e "$BOOT_ROOT/the-token/1.1.1/initrd" + test -e "$entry" + test ! -e "$e2" + # remove last entry referencing those files + entry_id="${entry##*/}" + entry_id="${entry_id%+*}.conf" + "$_KERNEL_INSTALL_BOOTCTL" --root="$BOOT_ROOT" unlink "$entry_id" + test ! -e "$entry" + test ! -e "$BOOT_ROOT/the-token/1.1.1/linux" + test ! -e "$BOOT_ROOT/the-token/1.1.1/initrd" +fi diff --git a/src/test/meson.build b/src/test/meson.build index 3aa98e1ffdf..8dfc97c9e8a 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -15,6 +15,7 @@ path = run_command(sh, '-c', 'echo "$PATH"', check: true).stdout().strip() test_env = environment() test_env.set('SYSTEMD_LANGUAGE_FALLBACK_MAP', language_fallback_map) test_env.set('PATH', project_build_root + ':' + path) +test_env.set('PROJECT_BUILD_ROOT', project_build_root) ############################################################