]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
prompt-util: add generic prompt loop implementation
authorLennart Poettering <lennart@poettering.net>
Thu, 28 Aug 2025 11:41:24 +0000 (13:41 +0200)
committerLennart Poettering <lennart@poettering.net>
Wed, 24 Sep 2025 13:46:30 +0000 (15:46 +0200)
This is a generalization of the logic in systemd-firstboot. This also
ports over firstboot.c to make use of the new generalization.

src/firstboot/firstboot.c
src/shared/meson.build
src/shared/prompt-util.c [new file with mode: 0644]
src/shared/prompt-util.h [new file with mode: 0644]

index 5ed6d3a9d2d64ac9fa04c3095a8926f2504d5e84..e311cb9fa7d18560ea82d773ce05b72cde716a8b 100644 (file)
@@ -45,6 +45,7 @@
 #include "path-util.h"
 #include "pretty-print.h"
 #include "proc-cmdline.h"
+#include "prompt-util.h"
 #include "runtime-scope.h"
 #include "smack-util.h"
 #include "stat-util.h"
@@ -144,102 +145,6 @@ static void print_welcome(int rfd) {
         done = true;
 }
 
-static int get_completions(
-                const char *key,
-                char ***ret_list,
-                void *userdata) {
-
-        int r;
-
-        if (!userdata) {
-                *ret_list = NULL;
-                return 0;
-        }
-
-        _cleanup_strv_free_ char **copy = strv_copy(userdata);
-        if (!copy)
-                return -ENOMEM;
-
-        r = strv_extend(&copy, "list");
-        if (r < 0)
-                return r;
-
-        *ret_list = TAKE_PTR(copy);
-        return 0;
-}
-
-static int prompt_loop(
-                int rfd,
-                const char *text,
-                char **l,
-                unsigned ellipsize_percentage,
-                bool (*is_valid)(int rfd, const char *name),
-                char **ret) {
-
-        int r;
-
-        assert(text);
-        assert(is_valid);
-        assert(ret);
-
-        for (;;) {
-                _cleanup_free_ char *p = NULL;
-
-                r = ask_string_full(
-                                &p,
-                                get_completions,
-                                l,
-                                strv_isempty(l) ? "%s %s (empty to skip): "
-                                                : "%s %s (empty to skip, \"list\" to list options): ",
-                                glyph(GLYPH_TRIANGULAR_BULLET), text);
-                if (r < 0)
-                        return log_error_errno(r, "Failed to query user: %m");
-
-                if (isempty(p)) {
-                        log_info("No data entered, skipping.");
-                        return 0;
-                }
-
-                if (!strv_isempty(l)) {
-                        if (streq(p, "list")) {
-                                r = show_menu(l,
-                                              /* n_columns= */ 3,
-                                              /* column_width= */ 20,
-                                              ellipsize_percentage,
-                                              /* grey_prefix= */ NULL,
-                                              /* with_numbers= */ true);
-                                if (r < 0)
-                                        return log_error_errno(r, "Failed to show menu: %m");
-
-                                putchar('\n');
-                                continue;
-                        }
-
-                        unsigned u;
-                        r = safe_atou(p, &u);
-                        if (r >= 0) {
-                                if (u <= 0 || u > strv_length(l)) {
-                                        log_error("Specified entry number out of range.");
-                                        continue;
-                                }
-
-                                log_info("Selected '%s'.", l[u-1]);
-                                return free_and_strdup_warn(ret, l[u-1]);
-                        }
-                }
-
-                if (is_valid(rfd, p))
-                        return free_and_replace(*ret, p);
-
-                /* Be more helpful to the user, and give a hint what the user might have wanted to type. */
-                const char *best_match = strv_find_closest(l, p);
-                if (best_match)
-                        log_error("Invalid data '%s', did you mean '%s'?", p, best_match);
-                else
-                        log_error("Invalid data '%s'.", p);
-        }
-}
-
 static int should_configure(int dir_fd, const char *filename) {
         _cleanup_fclose_ FILE *passwd = NULL, *shadow = NULL;
         int r;
@@ -309,20 +214,14 @@ static int should_configure(int dir_fd, const char *filename) {
         return true;
 }
 
-static bool locale_is_installed_bool(const char *name) {
-        return locale_is_installed(name) > 0;
-}
-
-static bool locale_is_ok(int rfd, const char *name) {
-        int r;
-
-        assert(rfd >= 0);
+static int locale_is_ok(const char *name, void *userdata) {
+        int rfd = ASSERT_FD(PTR_TO_FD(userdata)), r;
 
         r = dir_fd_is_root(rfd);
         if (r < 0)
                 log_debug_errno(r, "Unable to determine if operating on host root directory, assuming we are: %m");
 
-        return r != 0 ? locale_is_installed_bool(name) : locale_is_valid(name);
+        return r != 0 ? locale_is_installed(name) > 0 : locale_is_valid(name);
 }
 
 static int prompt_locale(int rfd) {
@@ -379,16 +278,35 @@ static int prompt_locale(int rfd) {
         } else {
                 print_welcome(rfd);
 
-                r = prompt_loop(rfd, "Please enter the new system locale name or number",
-                                locales, 60, locale_is_ok, &arg_locale);
+                r = prompt_loop("Please enter the new system locale name or number",
+                                GLYPH_WORLD,
+                                locales,
+                                /* accepted= */ NULL,
+                                /* ellipsize_percentage= */ 60,
+                                /* n_columns= */ 3,
+                                /* column_width= */ 20,
+                                locale_is_ok,
+                                /* refresh= */ NULL,
+                                FD_TO_PTR(rfd),
+                                PROMPT_MAY_SKIP|PROMPT_SHOW_MENU,
+                                &arg_locale);
                 if (r < 0)
                         return r;
-
                 if (isempty(arg_locale))
                         return 0;
 
-                r = prompt_loop(rfd, "Please enter the new system message locale name or number",
-                                locales, 60, locale_is_ok, &arg_locale_messages);
+                r = prompt_loop("Please enter the new system message locale name or number",
+                                GLYPH_WORLD,
+                                locales,
+                                /* accepted= */ NULL,
+                                /* ellipsize_percentage= */ 60,
+                                /* n_columns= */ 3,
+                                /* column_width= */ 20,
+                                locale_is_ok,
+                                /* refresh= */ NULL,
+                                FD_TO_PTR(rfd),
+                                PROMPT_MAY_SKIP|PROMPT_SHOW_MENU,
+                                &arg_locale_messages);
                 if (r < 0)
                         return r;
 
@@ -463,20 +381,14 @@ static int process_locale(int rfd) {
         return 1;
 }
 
-static bool keymap_exists_bool(const char *name) {
-        return keymap_exists(name) > 0;
-}
-
-static bool keymap_is_ok(int rfd, const char* name) {
-        int r;
-
-        assert(rfd >= 0);
+static int keymap_is_ok(const char* name, void *userdata) {
+        int rfd = ASSERT_FD(PTR_TO_FD(userdata)), r;
 
         r = dir_fd_is_root(rfd);
         if (r < 0)
                 log_debug_errno(r, "Unable to determine if operating on host root directory, assuming we are: %m");
 
-        return r != 0 ? keymap_exists_bool(name) : keymap_is_valid(name);
+        return r != 0 ? keymap_exists(name) > 0 : keymap_is_valid(name);
 }
 
 static int prompt_keymap(int rfd) {
@@ -509,8 +421,19 @@ static int prompt_keymap(int rfd) {
 
         print_welcome(rfd);
 
-        return prompt_loop(rfd, "Please enter the new keymap name or number",
-                           kmaps, 60, keymap_is_ok, &arg_keymap);
+        return prompt_loop(
+                        "Please enter the new keymap name or number",
+                        GLYPH_KEYBOARD,
+                        kmaps,
+                        /* accepted= */ NULL,
+                        /* ellipsize_percentage= */ 60,
+                        /* n_columns= */ 3,
+                        /* column_width= */ 20,
+                        keymap_is_ok,
+                        /* refresh= */ NULL,
+                        FD_TO_PTR(rfd),
+                        PROMPT_MAY_SKIP|PROMPT_SHOW_MENU,
+                        &arg_keymap);
 }
 
 static int process_keymap(int rfd) {
@@ -578,9 +501,7 @@ static int process_keymap(int rfd) {
         return 1;
 }
 
-static bool timezone_is_ok(int rfd, const char *name) {
-        assert(rfd >= 0);
-
+static int timezone_is_ok(const char *name, void *userdata) {
         return timezone_is_valid(name, LOG_DEBUG);
 }
 
@@ -612,8 +533,19 @@ static int prompt_timezone(int rfd) {
 
         print_welcome(rfd);
 
-        return prompt_loop(rfd, "Please enter the new timezone name or number",
-                           zones, 30, timezone_is_ok, &arg_timezone);
+        return prompt_loop(
+                        "Please enter the new timezone name or number",
+                        GLYPH_CLOCK,
+                        zones,
+                        /* accepted= */ NULL,
+                        /* ellipsize_percentage= */ 30,
+                        /* n_columns= */ 3,
+                        /* column_width= */ 20,
+                        timezone_is_ok,
+                        /* refresh= */ NULL,
+                        FD_TO_PTR(rfd),
+                        PROMPT_MAY_SKIP|PROMPT_SHOW_MENU,
+                        &arg_timezone);
 }
 
 static int process_timezone(int rfd) {
@@ -677,9 +609,7 @@ static int process_timezone(int rfd) {
         return 0;
 }
 
-static bool hostname_is_ok(int rfd, const char *name) {
-        assert(rfd >= 0);
-
+static int hostname_is_ok(const char *name, void *userdata) {
         return hostname_is_valid(name, VALID_HOSTNAME_TRAILING_DOT);
 }
 
@@ -698,8 +628,18 @@ static int prompt_hostname(int rfd) {
 
         print_welcome(rfd);
 
-        r = prompt_loop(rfd, "Please enter the new hostname",
-                        NULL, 0, hostname_is_ok, &arg_hostname);
+        r = prompt_loop("Please enter the new hostname",
+                        GLYPH_LABEL,
+                        /* menu= */ NULL,
+                        /* accepted= */ NULL,
+                        /* ellipsize_percentage= */ 100,
+                        /* n_columns= */ 3,
+                        /* column_width= */ 20,
+                        hostname_is_ok,
+                        /* refresh= */ NULL,
+                        FD_TO_PTR(rfd),
+                        PROMPT_MAY_SKIP,
+                        &arg_hostname);
         if (r < 0)
                 return r;
 
@@ -796,8 +736,8 @@ static int prompt_root_password(int rfd) {
 
         print_welcome(rfd);
 
-        msg1 = strjoina(glyph(GLYPH_TRIANGULAR_BULLET), " Please enter the new root password (empty to skip):");
-        msg2 = strjoina(glyph(GLYPH_TRIANGULAR_BULLET), " Please enter the new root password again:");
+        msg1 = strjoina("Please enter the new root password (empty to skip):");
+        msg2 = strjoina("Please enter the new root password again:");
 
         suggest_passwords();
 
@@ -868,8 +808,8 @@ static int find_shell(int rfd, const char *path) {
         return 0;
 }
 
-static bool shell_is_ok(int rfd, const char *path) {
-        assert(rfd >= 0);
+static int shell_is_ok(const char *path, void *userdata) {
+        int rfd = ASSERT_FD(PTR_TO_FD(userdata));
 
         return find_shell(rfd, path) >= 0;
 }
@@ -897,8 +837,19 @@ static int prompt_root_shell(int rfd) {
 
         print_welcome(rfd);
 
-        return prompt_loop(rfd, "Please enter the new root shell",
-                           NULL, 0, shell_is_ok, &arg_root_shell);
+        return prompt_loop(
+                        "Please enter the new root shell",
+                        GLYPH_SHELL,
+                        /* menu= */ NULL,
+                        /* accepted= */ NULL,
+                        /* ellipsize_percentage= */ 0,
+                        /* n_columns= */ 3,
+                        /* column_width= */ 20,
+                        shell_is_ok,
+                        /* refresh= */ NULL,
+                        FD_TO_PTR(rfd),
+                        PROMPT_MAY_SKIP,
+                        &arg_root_shell);
 }
 
 static int write_root_passwd(int rfd, int etc_fd, const char *password, const char *shell) {
@@ -1727,11 +1678,11 @@ static int run(int argc, char *argv[]) {
         /* We check these conditions here instead of in parse_argv() so that we can take the root directory
          * into account. */
 
-        if (arg_keymap && !keymap_is_ok(rfd, arg_keymap))
+        if (arg_keymap && !keymap_is_ok(arg_keymap, FD_TO_PTR(rfd)))
                 return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Keymap %s is not installed.", arg_keymap);
-        if (arg_locale && !locale_is_ok(rfd, arg_locale))
+        if (arg_locale && !locale_is_ok(arg_locale, FD_TO_PTR(rfd)))
                 return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Locale %s is not installed.", arg_locale);
-        if (arg_locale_messages && !locale_is_ok(rfd, arg_locale_messages))
+        if (arg_locale_messages && !locale_is_ok(arg_locale_messages, FD_TO_PTR(rfd)))
                 return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Locale %s is not installed.", arg_locale_messages);
 
         if (arg_root_shell) {
index a734c868913a5644e691823fbf4f2c102aca19c2..9bf9f9b6bf30c0c8f1f024241daf283658a0ba8d 100644 (file)
@@ -154,6 +154,7 @@ shared_sources = files(
         'polkit-agent.c',
         'portable-util.c',
         'pretty-print.c',
+        'prompt-util.c',
         'ptyfwd.c',
         'qrcode-util.c',
         'quota-util.c',
diff --git a/src/shared/prompt-util.c b/src/shared/prompt-util.c
new file mode 100644 (file)
index 0000000..42ddf19
--- /dev/null
@@ -0,0 +1,191 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include "alloc-util.h"
+#include "glyph-util.h"
+#include "log.h"
+#include "macro.h"
+#include "parse-util.h"
+#include "prompt-util.h"
+#include "string-util.h"
+#include "strv.h"
+#include "terminal-util.h"
+
+static int get_completions(
+                const char *key,
+                char ***ret_list,
+                void *userdata) {
+
+        int r;
+
+        assert(ret_list);
+
+        if (!userdata) {
+                *ret_list = NULL;
+                return 0;
+        }
+
+        _cleanup_strv_free_ char **copy = strv_copy(userdata);
+        if (!copy)
+                return -ENOMEM;
+
+        r = strv_extend(&copy, "list");
+        if (r < 0)
+                return r;
+
+        *ret_list = TAKE_PTR(copy);
+        return 0;
+}
+
+int prompt_loop(
+                const char *text,
+                Glyph emoji,
+                char **menu,        /* if non-NULL: choices to suggest */
+                char **accepted,    /* if non-NULL: choices to accept (should be a superset of 'menu') */
+                unsigned ellipsize_percentage,
+                size_t n_columns,
+                size_t column_width,
+                int (*is_valid)(const char *name, void *userdata),
+                int (*refresh)(char ***ret_menu, char ***ret_accepted, void *userdata),
+                void *userdata,
+                PromptFlags flags,
+                char **ret) {
+
+        _cleanup_strv_free_ char **refreshed_menu = NULL, **refreshed_accepted = NULL;
+        int r;
+
+        assert(text);
+        assert(ret);
+
+        if (!emoji_enabled()) /* If emojis aren't available, simpler unicode chars might still be around,
+                               * hence try to downgrade. (Consider the Linux Console!) */
+                emoji = GLYPH_TRIANGULAR_BULLET;
+
+        /* If requested show menu right-away */
+        if (FLAGS_SET(flags, PROMPT_SHOW_MENU_NOW) && !strv_isempty(menu)) {
+                r = show_menu(menu,
+                              n_columns,
+                              column_width,
+                              ellipsize_percentage,
+                              /* grey_prefix= */ NULL,
+                              /* with_numbers= */ true);
+                if (r < 0)
+                        return log_error_errno(r, "Failed to show menu: %m");
+
+                putchar('\n');
+        }
+
+        for (;;) {
+                _cleanup_free_ char *a = NULL;
+
+                if (!FLAGS_SET(flags, PROMPT_HIDE_MENU_HINT) && !strv_isempty(menu))
+                        if (!strextend_with_separator(&a, ", ", "\"list\" to list options"))
+                                return log_oom();
+                if (!FLAGS_SET(flags, PROMPT_HIDE_SKIP_HINT) && FLAGS_SET(flags, PROMPT_MAY_SKIP))
+                        if (!strextend_with_separator(&a, ", ", "empty to skip"))
+                                return log_oom();
+
+                if (a) {
+                        char *b = strjoin(" (", a, ")");
+                        if (!b)
+                                return log_oom();
+
+                        free_and_replace(a, b);
+                }
+
+                _cleanup_free_ char *p = NULL;
+                r = ask_string_full(
+                                &p,
+                                get_completions,
+                                accepted ?: menu,
+                                "%s%s%s%s: ",
+                                emoji >= 0 ? glyph(emoji) : "",
+                                emoji >= 0 ? " " : "",
+                                text,
+                                strempty(a));
+                if (r < 0)
+                        return log_error_errno(r, "Failed to query user: %m");
+
+                if (isempty(p)) {
+                        if (FLAGS_SET(flags, PROMPT_MAY_SKIP)) {
+                                log_info("No data entered, skipping.");
+                                *ret = NULL;
+                                return 0;
+                        }
+
+                        log_info("No data entered, try again.");
+                        continue;
+                }
+
+                /* NB: here we treat non-NULL but empty list different from NULL list. In the former case we
+                 * support the "list" command, in the latter we don't. */
+                if (FLAGS_SET(flags, PROMPT_SHOW_MENU) && streq(p, "list")) {
+                        putchar('\n');
+
+                        if (refresh) {
+                                _cleanup_strv_free_ char **rm = NULL, **ra = NULL;
+
+                                /* If a refresh method is provided, then use it now to refresh the menu
+                                 * before redisplaying it. */
+                                r = refresh(&rm, &ra, userdata);
+                                if (r < 0)
+                                        return r;
+
+                                strv_free_and_replace(refreshed_menu, rm);
+                                strv_free_and_replace(refreshed_accepted, ra);
+
+                                menu = refreshed_menu;
+                                accepted = refreshed_accepted;
+                        }
+
+                        if (strv_isempty(menu)) {
+                                log_warning("No entries known.");
+                                continue;
+                        }
+
+                        r = show_menu(menu,
+                                      n_columns,
+                                      column_width,
+                                      ellipsize_percentage,
+                                      /* grey_prefix= */ NULL,
+                                      /* with_numbers= */ true);
+                        if (r < 0)
+                                return log_error_errno(r, "Failed to show menu: %m");
+
+                        putchar('\n');
+                        continue;
+                }
+
+                unsigned u;
+                if (safe_atou(p, &u) >= 0) {
+                        if (u <= 0 || u > strv_length(menu)) {
+                                log_error("Specified entry number out of range.");
+                                continue;
+                        }
+
+                        log_info("Selected '%s'.", menu[u-1]);
+                        return strdup_to_full(ret, menu[u-1]);
+                }
+
+                bool good = accepted ? strv_contains(accepted, p) : true;
+                if (good && is_valid) {
+                        r = is_valid(p, userdata);
+                        if (r < 0)
+                                return r;
+
+                        good = good && r;
+                }
+                if (good) {
+                        *ret = TAKE_PTR(p);
+                        return 1;
+                }
+
+                if (!FLAGS_SET(flags, PROMPT_SILENT_VALIDATE)) {
+                        /* Be more helpful to the user, and give a hint what the user might have wanted to type. */
+                        const char *best_match = strv_find_closest(accepted ?: menu, p);
+                        if (best_match)
+                                log_error("Invalid input '%s', did you mean '%s'?", p, best_match);
+                        else
+                                log_error("Invalid input '%s'.", p);
+                }
+        }
+}
diff --git a/src/shared/prompt-util.h b/src/shared/prompt-util.h
new file mode 100644 (file)
index 0000000..179f056
--- /dev/null
@@ -0,0 +1,28 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include <stdbool.h>
+
+#include "forward.h"
+
+typedef enum PromptFlags {
+        PROMPT_MAY_SKIP        = 1 << 0, /* Question may be skipped */
+        PROMPT_SHOW_MENU       = 1 << 1, /* Show menu list on "list" */
+        PROMPT_SHOW_MENU_NOW   = 1 << 2, /* Show menu list right away, rather than only on request */
+        PROMPT_HIDE_MENU_HINT  = 1 << 3, /* Don't show hint regarding "list" */
+        PROMPT_HIDE_SKIP_HINT  = 1 << 4, /* Don't show hint regarding skipping */
+        PROMPT_SILENT_VALIDATE = 1 << 5, /* The validation log message logs on its own, don't log again */
+} PromptFlags;
+
+int prompt_loop(const char *text,
+                Glyph emoji,
+                char **menu,
+                char **accepted,
+                unsigned ellipsize_percentage,
+                size_t n_columns,
+                size_t column_width,
+                int (*is_valid)(const char *name, void *userdata),
+                int (*refresh)(char ***ret_menu, char ***ret_accepted, void *userdata),
+                void *userdata,
+                PromptFlags flags,
+                char **ret);