/* SPDX-License-Identifier: LGPL-2.1-or-later */

#include <unistd.h>

#include "alloc-util.h"
#include "build.h"
#include "devnum-util.h"
#include "efivars.h"
#include "fd-util.h"
#include "find-esp.h"
#include "format-table.h"
#include "fs-util.h"
#include "log.h"
#include "main-func.h"
#include "options.h"
#include "parse-util.h"
#include "path-util.h"
#include "pretty-print.h"
#include "stdio-util.h"
#include "string-util.h"
#include "strv.h"
#include "sync-util.h"
#include "verbs.h"
#include "virt.h"

static char **arg_path = NULL;

STATIC_DESTRUCTOR_REGISTER(arg_path, strv_freep);

typedef enum Status {
        STATUS_GOOD,
        STATUS_BAD,
        STATUS_INDETERMINATE,
} Status;

static int help(void) {
        _cleanup_free_ char *link = NULL;
        _cleanup_(table_unrefp) Table *options = NULL, *verbs = NULL;
        int r;

        r = terminal_urlify_man("systemd-bless-boot.service", "8", &link);
        if (r < 0)
                return log_oom();

        r = option_parser_get_help_table(&options);
        if (r < 0)
                return r;

        r = verbs_get_help_table(&verbs);
        if (r < 0)
                return r;

        (void) table_sync_column_widths(0, options, verbs);

        printf("%s [OPTIONS...] COMMAND\n"
               "\n%sMark the boot process as good or bad.%s\n"
               "\nCommands:\n",
               program_invocation_short_name,
               ansi_highlight(),
               ansi_normal());
        r = table_print_or_warn(verbs);
        if (r < 0)
                return r;

        printf("\nOptions:\n");
        r = table_print_or_warn(options);
        if (r < 0)
                return r;

        printf("\nSee the %s for details.\n", link);
        return 0;
}

VERB_COMMON_HELP_HIDDEN(help);

static int parse_argv(int argc, char *argv[], char ***ret_args) {
        int r;

        assert(argc >= 0);
        assert(argv);

        OptionParser opts = { argc, argv };

        FOREACH_OPTION_OR_RETURN(c, &opts)
                switch (c) {
                OPTION_COMMON_HELP:
                        return help();

                OPTION_COMMON_VERSION:
                        return version();

                OPTION_LONG("path", "PATH", "Path to the $BOOT partition (may be used multiple times)"):
                        r = strv_extend(&arg_path, opts.arg);
                        if (r < 0)
                                return log_oom();
                        break;
                }

        *ret_args = option_parser_get_args(&opts);
        return 1;
}

static int acquire_path(void) {
        _cleanup_free_ char *esp_path = NULL, *xbootldr_path = NULL;
        dev_t esp_devid = 0, xbootldr_devid = 0;
        char **a;
        int r;

        if (!strv_isempty(arg_path))
                return 0;

        r = find_esp_and_warn_full(
                        /* root= */ NULL,
                        /* path= */ NULL,
                        /* unprivileged_mode= */ false,
                        &esp_path,
                        /* ret_fd= */ NULL,
                        /* ret_part= */ NULL,
                        /* ret_pstart= */ NULL,
                        /* ret_psize= */ NULL,
                        /* ret_uuid= */ NULL,
                        &esp_devid);
        if (r < 0 && r != -ENOKEY) /* ENOKEY means not found, and is the only error the function won't log about on its own */
                return r;

        r = find_xbootldr_and_warn_full(
                        /* root= */ NULL,
                        /* path= */ NULL,
                        /* unprivileged_mode= */ false,
                        &xbootldr_path,
                        /* ret_fd= */ NULL,
                        /* ret_uuid= */ NULL,
                        &xbootldr_devid);
        if (r < 0 && r != -ENOKEY)
                return r;

        if (!esp_path && !xbootldr_path)
                return log_error_errno(SYNTHETIC_ERRNO(ENOENT),
                                       "Couldn't find $BOOT partition. It is recommended to mount it to /boot.\n"
                                       "Alternatively, use --path= to specify path to mount point.");

        if (esp_path && xbootldr_path && !devnum_set_and_equal(esp_devid, xbootldr_devid)) /* in case the two paths refer to the same inode, suppress one */
                a = strv_new(esp_path, xbootldr_path);
        else if (esp_path)
                a = strv_new(esp_path);
        else
                a = strv_new(xbootldr_path);
        if (!a)
                return log_oom();

        strv_free_and_replace(arg_path, a);

        if (DEBUG_LOGGING) {
                _cleanup_free_ char *j = NULL;

                j = strv_join(arg_path, ":");
                log_debug("Using %s as boot loader drop-in search path.", strna(j));
        }

        return 0;
}

static int parse_counter(
                const char *path,
                const char **p,
                uint64_t *ret_left,
                uint64_t *ret_done) {

        uint64_t left, done;
        const char *z, *e;
        size_t k;
        int r;

        assert(path);
        assert(p);

        e = *p;
        assert(e);
        assert(*e == '+');

        e++;

        k = strspn(e, DIGITS);
        if (k == 0)
                return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
                                       "Can't parse empty 'tries left' counter from LoaderBootCountPath: %s",
                                       path);

        z = strndupa_safe(e, k);
        r = safe_atou64(z, &left);
        if (r < 0)
                return log_error_errno(r, "Failed to parse 'tries left' counter from LoaderBootCountPath: %s", path);

        e += k;

        if (*e == '-') {
                e++;

                k = strspn(e, DIGITS);
                if (k == 0) /* If there's a "-" there also needs to be at least one digit */
                        return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
                                               "Can't parse empty 'tries done' counter from LoaderBootCountPath: %s",
                                               path);

                z = strndupa_safe(e, k);
                r = safe_atou64(z, &done);
                if (r < 0)
                        return log_error_errno(r, "Failed to parse 'tries done' counter from LoaderBootCountPath: %s", path);

                e += k;
        } else
                done = 0;

        if (done == 0)
                log_warning("The 'tries done' counter is currently at zero. This can't really be, after all we are running, and this boot must hence count as one. Proceeding anyway.");

        *p = e;

        if (ret_left)
                *ret_left = left;

        if (ret_done)
                *ret_done = done;

        return 0;
}

static int acquire_boot_count_path(
                char **ret_path,
                char **ret_prefix,
                uint64_t *ret_left,
                uint64_t *ret_done,
                char **ret_suffix) {

        int r;

        _cleanup_free_ char *path = NULL;
        r = efi_get_variable_path(EFI_LOADER_VARIABLE_STR("LoaderBootCountPath"), &path);
        if (r == -ENOENT)
                return -EUNATCH; /* in this case, let the caller print a message */
        if (r < 0)
                return log_error_errno(r, "Failed to read LoaderBootCountPath EFI variable: %m");

        if (!path_is_normalized(path))
                return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
                                       "Path read from LoaderBootCountPath is not normalized, refusing: %s",
                                       path);

        if (!path_is_absolute(path))
                return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
                                       "Path read from LoaderBootCountPath is not absolute, refusing: %s",
                                       path);

        const char *last = NULL;
        r = path_find_last_component(path, /* accept_dot_dot= */ false, /* next= */ NULL, &last);
        if (r < 0)
                return log_error_errno(r, "Failed to extract filename from LoaderBootCountPath '%s': %m", path);
        if (r == 0)
                return log_error_errno(SYNTHETIC_ERRNO(EADDRNOTAVAIL), "LoaderBootCountPath '%s' refers to the root directory.", path);
        if (strlen(last) > (size_t) r)
                return log_error_errno(SYNTHETIC_ERRNO(EISDIR), "LoaderBootCountPath '%s' refers to directory path, refusing.", path);

        const char *e = strrchr(last, '+');
        if (!e)
                return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
                                       "Path read from LoaderBootCountPath does not contain a counter, refusing: %s",
                                       path);

        _cleanup_free_ char *prefix = NULL;
        if (ret_prefix) {
                prefix = strndup(path, e - path);
                if (!prefix)
                        return log_oom();
        }

        uint64_t left, done;
        r = parse_counter(path, &e, &left, &done);
        if (r < 0)
                return r;

        _cleanup_free_ char *suffix = NULL;
        if (ret_suffix) {
                suffix = strdup(e);
                if (!suffix)
                        return log_oom();

                *ret_suffix = TAKE_PTR(suffix);
        }

        if (ret_path)
                *ret_path = TAKE_PTR(path);
        if (ret_prefix)
                *ret_prefix = TAKE_PTR(prefix);
        if (ret_left)
                *ret_left = left;
        if (ret_done)
                *ret_done = done;

        return 0;
}

static int make_good(const char *prefix, const char *suffix, char **ret) {
        _cleanup_free_ char *good = NULL;

        assert(prefix);
        assert(suffix);
        assert(ret);

        /* Generate the path we'd use on good boots. This one is easy. If we are successful, we simple drop the counter
         * pair entirely from the name. After all, we know all is good, and the logs will contain information about the
         * tries we needed to come here, hence it's safe to drop the counters from the name. */

        good = strjoin(prefix, suffix);
        if (!good)
                return -ENOMEM;

        *ret = TAKE_PTR(good);
        return 0;
}

static int make_bad(const char *prefix, uint64_t done, const char *suffix, char **ret) {
        char *bad;

        assert(prefix);
        assert(suffix);
        assert(ret);

        /* Generate the path we'd use on bad boots. Let's simply set the 'left' counter to zero, and keep the 'done'
         * counter. The information might be interesting to boot loaders, after all. */

        if (done == 0)
                bad = strjoin(prefix, "+0", suffix);
        else
                bad = asprintf_safe("%s+0-%" PRIu64 "%s", prefix, done, suffix);
        if (!bad)
                return -ENOMEM;

        *ret = bad;
        return 0;
}

VERB_DEFAULT_NOARG(verb_status, "status", "Show status of current boot loader entry");
static int verb_status(int argc, char *argv[], uintptr_t _data, void *userdata) {
        _cleanup_free_ char *path = NULL, *prefix = NULL, *suffix = NULL, *good = NULL, *bad = NULL;
        uint64_t left, done;
        int r;

        r = acquire_boot_count_path(&path, &prefix, &left, &done, &suffix);
        if (r == -EUNATCH) { /* No boot count in place, then let's consider this a "clean" boot,
                              * since "good", "bad", or "indeterminate" don't apply. */
                puts("clean");
                return 0;
        }
        if (r < 0)
                return r;

        r = acquire_path();
        if (r < 0)
                return r;

        r = make_good(prefix, suffix, &good);
        if (r < 0)
                return log_oom();

        r = make_bad(prefix, done, suffix, &bad);
        if (r < 0)
                return log_oom();

        log_debug("Booted file: %s\n"
                  "The same modified for 'good': %s\n"
                  "The same modified for 'bad':  %s\n",
                  path,
                  good,
                  bad);

        log_debug("Tries left: %" PRIu64"\n"
                  "Tries done: %" PRIu64"\n",
                  left, done);

        STRV_FOREACH(p, arg_path) {
                _cleanup_close_ int fd = -EBADF;

                fd = open(*p, O_DIRECTORY|O_CLOEXEC|O_RDONLY);
                if (fd < 0) {
                        if (errno == ENOENT)
                                continue;

                        return log_error_errno(errno, "Failed to open $BOOT partition '%s': %m", *p);
                }

                if (faccessat(fd, skip_leading_slash(path), F_OK, 0) >= 0) {
                        /* If the item we booted with still exists under its name, it means we have not
                         * change the current boot's marking so far. This may have two reasons: because we
                         * simply didn't do that yet but still plan to, or because the left tries counter is
                         * already at zero, hence we cannot further decrease it to mark it even
                         * "worse"... Here we check the current counter to detect the latter case and return
                         * "dirty", since the item is already marked bad from a previous boot, but otherwise
                         * report "indeterminate" since we just didn't make a decision yet. */
                        puts(left == 0 ? "dirty" : "indeterminate");
                        return 0;
                }
                if (errno != ENOENT)
                        return log_error_errno(errno, "Failed to check if '%s' exists: %m", path);

                if (faccessat(fd, skip_leading_slash(good), F_OK, 0) >= 0) {
                        puts("good");
                        return 0;
                }

                if (errno != ENOENT)
                        return log_error_errno(errno, "Failed to check if '%s' exists: %m", good);

                if (faccessat(fd, skip_leading_slash(bad), F_OK, 0) >= 0) {
                        puts("bad");
                        return 0;
                }
                if (errno != ENOENT)
                        return log_error_errno(errno, "Failed to check if '%s' exists: %m", bad);

                /* We didn't find any of the three? If so, let's try the next directory, before we give up. */
        }

        return log_error_errno(SYNTHETIC_ERRNO(EBUSY), "Couldn't determine boot state.");
}

static int rename_in_dir_idempotent(int fd, const char *from, const char *to) {
        int r;

        assert(fd >= 0);
        assert(from);
        assert(to);

        /* A wrapper around rename_noreplace() which executes no operation if the source and target are the
         * same. */

        if (streq(from, to)) {
                if (faccessat(fd, from, F_OK, AT_SYMLINK_NOFOLLOW) < 0)
                        return -errno;

                return 0;
        }

         r = rename_noreplace(fd, from, fd, to);
         if (r < 0)
                 return r;

         return 1;
}

VERB_FULL(verb_set, "good", NULL, VERB_ANY, 1, 0, STATUS_GOOD,
          "Mark this boot as good");
VERB_FULL(verb_set, "bad", NULL, VERB_ANY, 1, 0, STATUS_BAD,
          "Mark this boot as bad");
VERB_FULL(verb_set, "indeterminate", NULL, VERB_ANY, 1, 0, STATUS_INDETERMINATE,
          "Undo any marking as good or bad");
static int verb_set(int argc, char *argv[], uintptr_t data, void *userdata) {
        _cleanup_free_ char *path = NULL, *prefix = NULL, *suffix = NULL, *good = NULL, *bad = NULL;
        const char *target, *source1, *source2;
        uint64_t left, done;
        Status status = data;
        int r;

        assert(IN_SET(status, STATUS_GOOD, STATUS_BAD, STATUS_INDETERMINATE));

        r = acquire_boot_count_path(&path, &prefix, &left, &done, &suffix);
        if (r == -EUNATCH) /* acquire_boot_count_path() won't log on its own for this specific error */
                return log_error_errno(r, "Not booted with boot counting in effect.");
        if (r < 0)
                return r;

        r = acquire_path();
        if (r < 0)
                return r;

        r = make_good(prefix, suffix, &good);
        if (r < 0)
                return log_oom();

        r = make_bad(prefix, done, suffix, &bad);
        if (r < 0)
                return log_oom();

        /* Figure out what rename to what */
        switch (status) {
        case STATUS_GOOD:
                target = good;
                source1 = path;
                source2 = bad;      /* Maybe this boot was previously marked as 'bad'? */
                break;
        case STATUS_BAD:
                target = bad;
                source1 = path;
                source2 = good;     /* Maybe this boot was previously marked as 'good'? */
                break;
        case STATUS_INDETERMINATE:
                if (left == 0)
                        return log_error_errno(r, "Current boot entry was already marked bad in a previous boot, cannot reset to indeterminate.");

                target = path;
                source1 = good;
                source2 = bad;
                break;
        }

        STRV_FOREACH(p, arg_path) {
                _cleanup_close_ int fd = -EBADF;

                fd = open(*p, O_DIRECTORY|O_CLOEXEC|O_RDONLY);
                if (fd < 0)
                        return log_error_errno(errno, "Failed to open $BOOT partition '%s': %m", *p);

                r = rename_in_dir_idempotent(fd, skip_leading_slash(source1), skip_leading_slash(target));
                if (r == -EEXIST)
                        goto exists;
                if (r == -ENOENT) {

                        r = rename_in_dir_idempotent(fd, skip_leading_slash(source2), skip_leading_slash(target));
                        if (r == -EEXIST)
                                goto exists;
                        if (r == -ENOENT) {

                                if (faccessat(fd, skip_leading_slash(target), F_OK, 0) >= 0) /* Hmm, if we can't find either source file, maybe the destination already exists? */
                                        goto exists;

                                if (errno != ENOENT)
                                        return log_error_errno(errno, "Failed to determine if %s already exists: %m", target);

                                /* We found none of the snippets here, try the next directory */
                                continue;
                        }
                        if (r < 0)
                                return log_error_errno(r, "Failed to rename '%s' to '%s': %m", source2, target);

                        if (r > 0)
                                log_debug("Successfully renamed '%s' to '%s'.", source2, target);
                        else
                                log_debug("Not renaming, as '%s' already matches target name.", source2);
                } else if (r < 0)
                        return log_error_errno(r, "Failed to rename '%s' to '%s': %m", source1, target);
                else if (r > 0)
                        log_debug("Successfully renamed '%s' to '%s'.", source1, target);
                else
                        log_debug("Not renaming, as '%s' already matches target name.", source1);

                /* First, fsync() the directory these files are located in */
                r = fsync_parent_at(fd, skip_leading_slash(target));
                if (r < 0)
                        log_debug_errno(r, "Failed to synchronize image directory, ignoring: %m");

                /* Secondly, syncfs() the whole file system these files are located in */
                if (syncfs(fd) < 0)
                        log_debug_errno(errno, "Failed to synchronize $BOOT partition, ignoring: %m");

                log_info("Marked boot as '%s'. (Boot attempt counter is at %" PRIu64".)", argv[0], done);
                return 0;
        }

        return log_error_errno(SYNTHETIC_ERRNO(EBUSY), "Can't find boot counter source file for '%s'.", target);

exists:
        log_debug("Operation already executed before, not doing anything.");
        return 0;
}

static int run(int argc, char *argv[]) {
        char **args = NULL;
        int r;

        log_setup();

        r = parse_argv(argc, argv, &args);
        if (r <= 0)
                return r;

        if (detect_container() > 0)
                return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP),
                                       "Marking a boot is not supported in containers.");

        if (!is_efi_boot())
                return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP),
                                       "Marking a boot is only supported on EFI systems.");

        return dispatch_verb(args, NULL);
}

DEFINE_MAIN_FUNCTION(run);
