From f77f363c95a8202708ad13822ad79d3c4e2ba1d3 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 5 Feb 2025 10:40:06 +0100 Subject: [PATCH 01/10] string-util: add str_common_prefix() helper that determines length of common prefix of two strings --- src/basic/string-util.c | 16 ++++++++++++++++ src/basic/string-util.h | 2 ++ src/test/test-string-util.c | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/basic/string-util.c b/src/basic/string-util.c index 7f4be13b62..4748d5e2b5 100644 --- a/src/basic/string-util.c +++ b/src/basic/string-util.c @@ -1470,3 +1470,19 @@ char* strrstr(const char *haystack, const char *needle) { } return NULL; } + +size_t str_common_prefix(const char *a, const char *b) { + assert(a); + assert(b); + + /* Returns the length of the common prefix of the two specified strings, or SIZE_MAX in case the + * strings are fully identical. */ + + for (size_t n = 0;; n++) { + char c = a[n]; + if (c != b[n]) + return n; + if (c == 0) + return SIZE_MAX; + } +} diff --git a/src/basic/string-util.h b/src/basic/string-util.h index 85f206c689..d94e52c09d 100644 --- a/src/basic/string-util.h +++ b/src/basic/string-util.h @@ -308,3 +308,5 @@ bool version_is_valid_versionspec(const char *s); ssize_t strlevenshtein(const char *x, const char *y); char* strrstr(const char *haystack, const char *needle); + +size_t str_common_prefix(const char *a, const char *b); diff --git a/src/test/test-string-util.c b/src/test/test-string-util.c index 1b447abda9..8e6862c41d 100644 --- a/src/test/test-string-util.c +++ b/src/test/test-string-util.c @@ -1408,4 +1408,23 @@ TEST(strrstr) { assert_se(!strrstr(p, "xx")); } +TEST(str_common_prefix) { + ASSERT_EQ(str_common_prefix("", ""), SIZE_MAX); + ASSERT_EQ(str_common_prefix("a", "a"), SIZE_MAX); + ASSERT_EQ(str_common_prefix("aa", "aa"), SIZE_MAX); + ASSERT_EQ(str_common_prefix("aa", "bb"), 0U); + ASSERT_EQ(str_common_prefix("bb", "aa"), 0U); + ASSERT_EQ(str_common_prefix("aa", "ab"), 1U); + ASSERT_EQ(str_common_prefix("ab", "aa"), 1U); + ASSERT_EQ(str_common_prefix("systemd-resolved", "systemd-networkd"), 8U); + ASSERT_EQ(str_common_prefix("systemd-", "systemd-networkd"), 8U); + ASSERT_EQ(str_common_prefix("systemd-networkd", "systemd-"), 8U); + ASSERT_EQ(str_common_prefix("syst", "systemd-networkd"), 4U); + ASSERT_EQ(str_common_prefix("systemd-networkd", "syst"), 4U); + ASSERT_EQ(str_common_prefix("s", "systemd-networkd"), 1U); + ASSERT_EQ(str_common_prefix("systemd-networkd", "s"), 1U); + ASSERT_EQ(str_common_prefix("", "systemd-networkd"), 0U); + ASSERT_EQ(str_common_prefix("systemd-networkd", ""), 0U); +} + DEFINE_TEST_MAIN(LOG_DEBUG); From 428146dc89506725f777ae82d288439340926a83 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 6 Feb 2025 12:00:10 +0100 Subject: [PATCH 02/10] strv-util: add strv_filter_prefix() helper --- src/basic/strv.c | 21 +++++++++++++++++++++ src/basic/strv.h | 2 ++ src/test/test-strv.c | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/basic/strv.c b/src/basic/strv.c index 1eea73fa7b..d81c9f127c 100644 --- a/src/basic/strv.c +++ b/src/basic/strv.c @@ -1238,3 +1238,24 @@ int strv_rebreak_lines(char **l, size_t width, char ***ret) { *ret = TAKE_PTR(broken); return 0; } + +char** strv_filter_prefix(char *const*l, const char *prefix) { + _cleanup_strv_free_ char **f = NULL; + + /* Allocates a copy of 'l', but only copies over entries starting with 'prefix' */ + + if (isempty(prefix)) + return strv_copy(l); + + size_t sz = 0; + + STRV_FOREACH(i, l) { + if (!startswith(*i, prefix)) + continue; + + if (strv_extend_with_size(&f, &sz, *i) < 0) + return NULL; + } + + return TAKE_PTR(f); +} diff --git a/src/basic/strv.h b/src/basic/strv.h index 529afc386d..6478723715 100644 --- a/src/basic/strv.h +++ b/src/basic/strv.h @@ -268,3 +268,5 @@ int _string_strv_ordered_hashmap_put(OrderedHashmap **h, const char *key, const #define string_strv_ordered_hashmap_put(h, k, v) _string_strv_ordered_hashmap_put(h, k, v HASHMAP_DEBUG_SRC_ARGS) int strv_rebreak_lines(char **l, size_t width, char ***ret); + +char** strv_filter_prefix(char *const*l, const char *prefix); diff --git a/src/test/test-strv.c b/src/test/test-strv.c index b1d30d73a5..f0e9031b9a 100644 --- a/src/test/test-strv.c +++ b/src/test/test-strv.c @@ -1277,4 +1277,23 @@ TEST(strv_equal_ignore_order) { ASSERT_TRUE(strv_equal_ignore_order(STRV_MAKE("bar", "foo"), STRV_MAKE("bar", "foo", "bar", "foo", "foo"))); } +TEST(strv_filter_prefix) { + char **base = STRV_MAKE("foo", "bar", "baz", "foox", "zzz", "farb", "foerb"); + + _cleanup_strv_free_ char **x = ASSERT_PTR(strv_filter_prefix(base, "fo")); + ASSERT_TRUE(strv_equal(x, STRV_MAKE("foo", "foox", "foerb"))); + x = strv_free(x); + + x = ASSERT_PTR(strv_filter_prefix(base, "")); + ASSERT_TRUE(strv_equal(x, base)); + x = strv_free(x); + + x = ASSERT_PTR(strv_filter_prefix(base, "z")); + ASSERT_TRUE(strv_equal(x, STRV_MAKE("zzz"))); + x = strv_free(x); + + x = ASSERT_PTR(strv_filter_prefix(base, "zzz")); + ASSERT_TRUE(strv_equal(x, STRV_MAKE("zzz"))); +} + DEFINE_TEST_MAIN(LOG_INFO); From 104a6b8c390730f66f5acbcaefbc27898bfef9fe Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 5 Feb 2025 10:44:19 +0100 Subject: [PATCH 03/10] utf8: add helper that determines length in bytes of last UTF-8 character in string --- src/basic/utf8.c | 23 +++++++++++++++++++++++ src/basic/utf8.h | 2 ++ src/test/test-utf8.c | 12 ++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/basic/utf8.c b/src/basic/utf8.c index 2a9da59881..7b47df6f4f 100644 --- a/src/basic/utf8.c +++ b/src/basic/utf8.c @@ -609,3 +609,26 @@ size_t utf8_console_width(const char *str) { return n; } + +size_t utf8_last_length(const char *s, size_t n) { + int r; + + if (n == SIZE_MAX) + n = strlen(s); + + /* Determines length in bytes of last UTF-8 codepoint in string. If the string is empty, returns + * zero. Treats invalid UTF-8 codepoints as 1 sized ones. */ + + for (size_t last = 0;;) { + if (n == 0) + return last; + + r = utf8_encoded_valid_unichar(s, n); + if (r <= 0) + r = 1; /* treat invalid UTF-8 as byte-wide */ + + s += r; + n -= r; + last = r; + } +} diff --git a/src/basic/utf8.h b/src/basic/utf8.h index 221bc46a2d..f6158b2ef1 100644 --- a/src/basic/utf8.h +++ b/src/basic/utf8.h @@ -62,3 +62,5 @@ static inline char32_t utf16_surrogate_pair_to_unichar(char16_t lead, char16_t t size_t utf8_n_codepoints(const char *str); int utf8_char_console_width(const char *str); size_t utf8_console_width(const char *str); + +size_t utf8_last_length(const char *s, size_t n); diff --git a/src/test/test-utf8.c b/src/test/test-utf8.c index d60cf00bf3..18974e7664 100644 --- a/src/test/test-utf8.c +++ b/src/test/test-utf8.c @@ -227,6 +227,18 @@ TEST(utf8_to_utf16) { } } +TEST(utf8_last_length) { + ASSERT_EQ(utf8_last_length("", 0), 0U); + ASSERT_EQ(utf8_last_length("", SIZE_MAX), 0U); + ASSERT_EQ(utf8_last_length("a", 1), 1U); + ASSERT_EQ(utf8_last_length("a", SIZE_MAX), 1U); + ASSERT_EQ(utf8_last_length("ä", SIZE_MAX), strlen("ä")); + ASSERT_EQ(utf8_last_length("👊", SIZE_MAX), strlen("👊")); + ASSERT_EQ(utf8_last_length("koffa", SIZE_MAX), 1U); + ASSERT_EQ(utf8_last_length("koffä", SIZE_MAX), strlen("ä")); + ASSERT_EQ(utf8_last_length("koff👊", SIZE_MAX), strlen("👊")); +} + static int intro(void) { log_show_color(true); return EXIT_SUCCESS; From 4ba044ebd0acb6841a5d6957e4258e921efaaf4d Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 5 Feb 2025 09:39:09 +0100 Subject: [PATCH 04/10] ask-password-api: refuse control characters in passwords Just some extra safety --- src/shared/ask-password-api.c | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/shared/ask-password-api.c b/src/shared/ask-password-api.c index 91d8945fd1..d1a4f24661 100644 --- a/src/shared/ask-password-api.c +++ b/src/shared/ask-password-api.c @@ -745,9 +745,8 @@ int ask_password_tty( if (ttyfd >= 0) (void) loop_write(ttyfd, NO_ECHO, SIZE_MAX); - } else if (p >= sizeof(passphrase)-1) { - - /* Reached the size limit */ + } else if (char_is_cc(c) || p >= sizeof(passphrase)-1) { + /* Don't accept control chars or overly long passphrases */ if (ttyfd >= 0) (void) loop_write(ttyfd, "\a", 1); From 8fcd85768beef5382fb4cb238148889c0b7eee4b Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 5 Feb 2025 09:35:51 +0100 Subject: [PATCH 05/10] terminal-util: tweak any_key_to_proceed() a bit 1. Make the message a bit more visible, by adding ANSI color. This matters in particular during boot, where the message otherwise might be overprinted by other output 2. Let's turn off terminal echo so that whatever key is entered is not made visible on screen, and we can handle newline and other keys reasonably uniformly. --- src/basic/terminal-util.c | 43 +++++++++++++++++++---------------- src/basic/terminal-util.h | 2 +- src/cgtop/cgtop.c | 2 +- src/firstboot/firstboot.c | 2 +- src/home/homectl.c | 7 +++++- src/journal/bsod.c | 4 ++-- src/test/test-terminal-util.c | 8 +++---- 7 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/basic/terminal-util.c b/src/basic/terminal-util.c index 678cd4ed4d..b2cbadb579 100644 --- a/src/basic/terminal-util.c +++ b/src/basic/terminal-util.c @@ -103,22 +103,25 @@ int chvt(int vt) { return RET_NERRNO(ioctl(fd, VT_ACTIVATE, vt)); } -int read_one_char(FILE *f, char *ret, usec_t t, bool *need_nl) { +int read_one_char(FILE *f, char *ret, usec_t t, bool echo, bool *need_nl) { _cleanup_free_ char *line = NULL; struct termios old_termios; int r, fd; - assert(f); assert(ret); + if (!f) + f = stdin; + /* If this is a terminal, then switch canonical mode off, so that we can read a single * character. (Note that fmemopen() streams do not have an fd associated with them, let's handle that - * nicely.) */ + * nicely.) If 'echo' is false we'll also disable ECHO mode so that the pressed key is not made + * visible to the user. */ fd = fileno(f); if (fd >= 0 && tcgetattr(fd, &old_termios) >= 0) { struct termios new_termios = old_termios; - new_termios.c_lflag &= ~ICANON; + new_termios.c_lflag &= ~(ICANON|(echo ? 0 : ECHO)); new_termios.c_cc[VMIN] = 1; new_termios.c_cc[VTIME] = 0; @@ -201,7 +204,7 @@ int ask_char(char *ret, const char *replies, const char *fmt, ...) { fflush(stdout); - r = read_one_char(stdin, &c, DEFAULT_ASK_REFRESH_USEC, &need_nl); + r = read_one_char(stdin, &c, DEFAULT_ASK_REFRESH_USEC, /* echo= */ true, &need_nl); if (r < 0) { if (r == -ETIMEDOUT) @@ -257,20 +260,23 @@ int ask_string(char **ret, const char *text, ...) { } bool any_key_to_proceed(void) { + + /* Insert a new line here as well as to when the user inputs, as this is also used during the boot up + * sequence when status messages may be interleaved with the current program output. This ensures + * that the status messages aren't appended on the same line as this message. */ + + fputc('\n', stdout); + fputs(ansi_highlight_magenta(), stdout); + fputs("-- Press any key to proceed --", stdout); + fputs(ansi_normal(), stdout); + fflush(stdout); + char key = 0; - bool need_nl = true; + (void) read_one_char(stdin, &key, USEC_INFINITY, /* echo= */ false, /* need_nl= */ NULL); - /* - * Insert a new line here as well as to when the user inputs, as this is also used during the - * boot up sequence when status messages may be interleaved with the current program output. - * This ensures that the status messages aren't appended on the same line as this message. - */ - puts("-- Press any key to proceed --"); - - (void) read_one_char(stdin, &key, USEC_INFINITY, &need_nl); - - if (need_nl) - putchar('\n'); + fputc('\n', stdout); + fputc('\n', stdout); + fflush(stdout); return key != 'q'; } @@ -312,10 +318,9 @@ int show_menu(char **x, unsigned n_columns, unsigned width, unsigned percentage) putchar('\n'); /* on the first screen we reserve 2 extra lines for the title */ - if (i % break_lines == break_modulo) { + if (i % break_lines == break_modulo) if (!any_key_to_proceed()) return 0; - } } return 0; diff --git a/src/basic/terminal-util.h b/src/basic/terminal-util.h index d6dd394bcf..d11daefb56 100644 --- a/src/basic/terminal-util.h +++ b/src/basic/terminal-util.h @@ -80,7 +80,7 @@ int proc_cmdline_tty_size(const char *tty, unsigned *ret_rows, unsigned *ret_col int chvt(int vt); -int read_one_char(FILE *f, char *ret, usec_t timeout, bool *need_nl); +int read_one_char(FILE *f, char *ret, usec_t timeout, bool echo, bool *need_nl); int ask_char(char *ret, const char *replies, const char *text, ...) _printf_(3, 4); int ask_string(char **ret, const char *text, ...) _printf_(2, 3); bool any_key_to_proceed(void); diff --git a/src/cgtop/cgtop.c b/src/cgtop/cgtop.c index 08eae5988b..ce73599d90 100644 --- a/src/cgtop/cgtop.c +++ b/src/cgtop/cgtop.c @@ -954,7 +954,7 @@ static int loop(const char *root) { if (arg_batch) (void) usleep_safe(usec_add(usec_sub_unsigned(last_refresh, t), arg_delay)); else { - r = read_one_char(stdin, &key, usec_add(usec_sub_unsigned(last_refresh, t), arg_delay), NULL); + r = read_one_char(stdin, &key, usec_add(usec_sub_unsigned(last_refresh, t), arg_delay), /* echo= */ false, /* need_nl= */ NULL); if (r == -ETIMEDOUT) continue; if (r < 0) diff --git a/src/firstboot/firstboot.c b/src/firstboot/firstboot.c index d00886fb80..aaff8a8e88 100644 --- a/src/firstboot/firstboot.c +++ b/src/firstboot/firstboot.c @@ -128,7 +128,7 @@ static void print_welcome(int rfd) { else printf("\nWelcome to your new installation of %s!\n", pn); - printf("\nPlease configure your system!\n\n"); + printf("\nPlease configure your system!\n"); any_key_to_proceed(); diff --git a/src/home/homectl.c b/src/home/homectl.c index bfd4b3b574..89e9c8b82b 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2485,7 +2485,12 @@ static int create_interactively(void) { return 0; } - any_key_to_proceed(); + printf("\nPlease create your user account!\n"); + + if (!any_key_to_proceed()) { + log_notice("Skipping."); + return 0; + } (void) terminal_reset_defensive_locked(STDOUT_FILENO, /* switch_to_text= */ false); diff --git a/src/journal/bsod.c b/src/journal/bsod.c index 2f06808cd4..68361b084a 100644 --- a/src/journal/bsod.c +++ b/src/journal/bsod.c @@ -228,9 +228,9 @@ static int display_emergency_message_fullscreen(const char *message) { goto cleanup; } - r = read_one_char(f, &read_character_buffer, USEC_INFINITY, NULL); + r = read_one_char(f, &read_character_buffer, USEC_INFINITY, /* echo= */ true, /* need_nl= */ NULL); if (r < 0 && r != -EINTR) - log_error_errno(r, "Failed to read character: %m"); + log_warning_errno(r, "Failed to read character, ignoring: %m"); r = 0; diff --git a/src/test/test-terminal-util.c b/src/test/test-terminal-util.c index ac7eacb01a..87304346e9 100644 --- a/src/test/test-terminal-util.c +++ b/src/test/test-terminal-util.c @@ -55,20 +55,20 @@ TEST(read_one_char) { assert_se(fputs("c\n", file) >= 0); rewind(file); - assert_se(read_one_char(file, &r, 1000000, &need_nl) >= 0); + assert_se(read_one_char(file, &r, 1000000, /* echo= */ true, &need_nl) >= 0); assert_se(!need_nl); assert_se(r == 'c'); - assert_se(read_one_char(file, &r, 1000000, &need_nl) < 0); + assert_se(read_one_char(file, &r, 1000000, /* echo= */ true, &need_nl) < 0); rewind(file); assert_se(fputs("foobar\n", file) >= 0); rewind(file); - assert_se(read_one_char(file, &r, 1000000, &need_nl) < 0); + assert_se(read_one_char(file, &r, 1000000, /* echo= */ true, &need_nl) < 0); rewind(file); assert_se(fputs("\n", file) >= 0); rewind(file); - assert_se(read_one_char(file, &r, 1000000, &need_nl) < 0); + assert_se(read_one_char(file, &r, 1000000, /* echo= */ true, &need_nl) < 0); } TEST(getttyname_malloc) { From b6478aa12f731caac97c984b4cc97dace0bb3e99 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 6 Feb 2025 12:02:24 +0100 Subject: [PATCH 06/10] terminal-util: beef up show_menu() This modernizes the function a bit, and adds some bits: 1. whether to show numbers before entries is now optional, and if they are shown they are displayed in grey. 2. a common prefix can now be grayed out (later useful for completion support) 3. some variables have been named to clarify their purpose 4. the table display dimensions can now be auto-sized (by specifying SIZE_MAX and number of columns and column width) --- src/basic/terminal-util.c | 78 +++++++++++++++++++++++++++++++++------ src/basic/terminal-util.h | 2 +- src/firstboot/firstboot.c | 19 ++++++++-- src/home/homectl.c | 11 ++++-- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/src/basic/terminal-util.c b/src/basic/terminal-util.c index b2cbadb579..53437e690f 100644 --- a/src/basic/terminal-util.c +++ b/src/basic/terminal-util.c @@ -281,38 +281,92 @@ bool any_key_to_proceed(void) { return key != 'q'; } -int show_menu(char **x, unsigned n_columns, unsigned width, unsigned percentage) { - unsigned break_lines, break_modulo; - size_t n, per_column, i, j; +static size_t widest_list_element(char *const*l) { + size_t w = 0; + + /* Returns the largest console width of all elements in 'l' */ + + STRV_FOREACH(i, l) + w = MAX(w, utf8_console_width(*i)); + + return w; +} + +int show_menu(char **x, + size_t n_columns, + size_t column_width, + unsigned ellipsize_percentage, + const char *grey_prefix, + bool with_numbers) { assert(n_columns > 0); - n = strv_length(x); - per_column = DIV_ROUND_UP(n, n_columns); + if (n_columns == SIZE_MAX) + n_columns = 3; - break_lines = lines(); + if (column_width == SIZE_MAX) { + size_t widest = widest_list_element(x); + + /* If not specified, derive column width from screen width */ + size_t column_max = (columns()-1) / n_columns; + + /* Subtract room for numbers */ + if (with_numbers && column_max > 6) + column_max -= 6; + + /* If columns would get too tight let's make this a linear list instead. */ + if (column_max < 10 && widest > 10) { + n_columns = 1; + column_max = columns()-1; + + if (with_numbers && column_max > 6) + column_max -= 6; + } + + column_width = CLAMP(widest+1, 10U, column_max); + } + + size_t n = strv_length(x); + size_t per_column = DIV_ROUND_UP(n, n_columns); + + size_t break_lines = lines(); if (break_lines > 2) break_lines--; /* The first page gets two extra lines, since we want to show * a title */ - break_modulo = break_lines; + size_t break_modulo = break_lines; if (break_modulo > 3) break_modulo -= 3; - for (i = 0; i < per_column; i++) { + for (size_t i = 0; i < per_column; i++) { - for (j = 0; j < n_columns; j++) { + for (size_t j = 0; j < n_columns; j++) { _cleanup_free_ char *e = NULL; if (j * per_column + i >= n) break; - e = ellipsize(x[j * per_column + i], width, percentage); + e = ellipsize(x[j * per_column + i], column_width, ellipsize_percentage); if (!e) - return log_oom(); + return -ENOMEM; - printf("%4zu) %-*s", j * per_column + i + 1, (int) width, e); + if (with_numbers) + printf("%s%4zu)%s ", + ansi_grey(), + j * per_column + i + 1, + ansi_normal()); + + if (grey_prefix && startswith(e, grey_prefix)) { + size_t k = MIN(strlen(grey_prefix), column_width); + printf("%s%.*s%s", + ansi_grey(), + (int) k, e, + ansi_normal()); + printf("%-*s", + (int) (column_width - k), e+k); + } else + printf("%-*s", (int) column_width, e); } putchar('\n'); diff --git a/src/basic/terminal-util.h b/src/basic/terminal-util.h index d11daefb56..c4ee1b3243 100644 --- a/src/basic/terminal-util.h +++ b/src/basic/terminal-util.h @@ -84,7 +84,7 @@ int read_one_char(FILE *f, char *ret, usec_t timeout, bool echo, bool *need_nl); int ask_char(char *ret, const char *replies, const char *text, ...) _printf_(3, 4); int ask_string(char **ret, const char *text, ...) _printf_(2, 3); bool any_key_to_proceed(void); -int show_menu(char **x, unsigned n_columns, unsigned width, unsigned percentage); +int show_menu(char **x, size_t n_columns, size_t column_width, unsigned ellipsize_percentage, const char *grey_prefix, bool with_numbers); int vt_disallocate(const char *name); diff --git a/src/firstboot/firstboot.c b/src/firstboot/firstboot.c index aaff8a8e88..2ee231e2d0 100644 --- a/src/firstboot/firstboot.c +++ b/src/firstboot/firstboot.c @@ -135,7 +135,13 @@ static void print_welcome(int rfd) { done = true; } -static int prompt_loop(int rfd, const char *text, char **l, unsigned percentage, bool (*is_valid)(int rfd, const char *name), char **ret) { +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); @@ -144,7 +150,6 @@ static int prompt_loop(int rfd, const char *text, char **l, unsigned percentage, for (;;) { _cleanup_free_ char *p = NULL; - unsigned u; r = ask_string(&p, strv_isempty(l) ? "%s %s (empty to skip): " : "%s %s (empty to skip, \"list\" to list options): ", @@ -159,14 +164,20 @@ static int prompt_loop(int rfd, const char *text, char **l, unsigned percentage, if (!strv_isempty(l)) { if (streq(p, "list")) { - r = show_menu(l, 3, 20, percentage); + r = show_menu(l, + /* n_columns= */ 3, + /* column_width= */ 20, + ellipsize_percentage, + /* grey_prefix= */ NULL, + /* with_numbers= */ true); if (r < 0) - return r; + 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)) { diff --git a/src/home/homectl.c b/src/home/homectl.c index 89e9c8b82b..e1611b7bfb 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2530,7 +2530,6 @@ static int create_interactively(void) { for (;;) { _cleanup_free_ char *s = NULL; - unsigned u; r = ask_string(&s, "%s Please enter an auxiliary group for user %s (empty to continue, \"list\" to list available groups): ", @@ -2552,15 +2551,21 @@ static int create_interactively(void) { continue; } - r = show_menu(available, /*n_columns=*/ 3, /*width=*/ 20, /*percentage=*/ 60); + r = show_menu(available, + /* n_columns= */ 3, + /* column_width= */ 20, + /* ellipsize_percentage= */ 60, + /* grey_prefix= */ NULL, + /* with_numbers= */ true); if (r < 0) - return r; + return log_error_errno(r, "Failed to show menu: %m"); putchar('\n'); continue; }; if (!strv_isempty(available)) { + unsigned u; r = safe_atou(s, &u); if (r >= 0) { if (u <= 0 || u > strv_length(available)) { From 94a2b1cd25c93870a7a4ac904f6c0f2e4f902038 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 5 Feb 2025 10:55:48 +0100 Subject: [PATCH 07/10] firstboot: add auto-completion to various fields This adds TAB-based auto-completion to various fields we query from the user, such as locale, keymap, timezone, group membership. It makes it a lot easier to quickly iterate through firstboot without typing too much. --- src/basic/terminal-util.c | 230 ++++++++++++++++++++++++++++++++++++-- src/basic/terminal-util.h | 6 +- src/firstboot/firstboot.c | 35 +++++- src/home/homectl.c | 25 ++++- 4 files changed, 284 insertions(+), 12 deletions(-) diff --git a/src/basic/terminal-util.c b/src/basic/terminal-util.c index 53437e690f..f9e2a28024 100644 --- a/src/basic/terminal-util.c +++ b/src/basic/terminal-util.c @@ -26,6 +26,7 @@ #include "constants.h" #include "devnum-util.h" #include "env-util.h" +#include "errno-list.h" #include "fd-util.h" #include "fileio.h" #include "fs-util.h" @@ -231,31 +232,246 @@ int ask_char(char *ret, const char *replies, const char *fmt, ...) { } } -int ask_string(char **ret, const char *text, ...) { - _cleanup_free_ char *line = NULL; +typedef enum CompletionResult{ + COMPLETION_ALREADY, /* the input string is already complete */ + COMPLETION_FULL, /* completed the input string to be complete now */ + COMPLETION_PARTIAL, /* completed the input string so that is still incomplete */ + COMPLETION_NONE, /* found no matching completion */ + _COMPLETION_RESULT_MAX, + _COMPLETION_RESULT_INVALID = -EINVAL, + _COMPLETION_RESULT_ERRNO_MAX = -ERRNO_MAX, +} CompletionResult; + +static CompletionResult pick_completion(const char *string, char *const*completions, char **ret) { + _cleanup_free_ char *found = NULL; + bool partial = false; + + string = strempty(string); + + STRV_FOREACH(c, completions) { + + /* Ignore entries that are not actually completions */ + if (!startswith(*c, string)) + continue; + + /* Store first completion that matches */ + if (!found) { + found = strdup(*c); + if (!found) + return -ENOMEM; + + continue; + } + + /* If there's another completion that works truncate the one we already found by common + * prefix */ + size_t n = str_common_prefix(found, *c); + if (n == SIZE_MAX) + continue; + + found[n] = 0; + partial = true; + } + + *ret = TAKE_PTR(found); + + if (!*ret) + return COMPLETION_NONE; + if (partial) + return COMPLETION_PARTIAL; + + return streq(string, *ret) ? COMPLETION_ALREADY : COMPLETION_FULL; +} + +static void clear_by_backspace(size_t n) { + /* Erase the specified number of character cells backwards on the terminal */ + for (size_t i = 0; i < n; i++) + fputs("\b \b", stdout); +} + +int ask_string_full( + char **ret, + GetCompletionsCallback get_completions, + void *userdata, + const char *text, ...) { + va_list ap; int r; assert(ret); assert(text); + /* Output the prompt */ fputs(ansi_highlight(), stdout); - va_start(ap, text); vprintf(text, ap); va_end(ap); - fputs(ansi_normal(), stdout); - fflush(stdout); - r = read_line(stdin, LONG_LINE_MAX, &line); + _cleanup_free_ char *string = NULL; + size_t n = 0; + + /* Do interactive logic only if stdin + stdout are connected to the same place. And yes, we could use + * STDIN_FILENO and STDOUT_FILENO here, but let's be overly correct for once, after all libc allows + * swapping out stdin/stdout. */ + int fd_input = fileno(stdin); + int fd_output = fileno(stdout); + if (fd_input < 0 || fd_output < 0 || same_fd(fd_input, fd_output) <= 0) + goto fallback; + + /* Try to disable echo, which also tells us if this even is a terminal */ + struct termios old_termios; + if (tcgetattr(fd_input, &old_termios) < 0) + goto fallback; + + struct termios new_termios = old_termios; + termios_disable_echo(&new_termios); + if (tcsetattr(fd_input, TCSADRAIN, &new_termios) < 0) + return -errno; + + for (;;) { + int c = fgetc(stdin); + + /* On EOF or NUL, end the request, don't output anything anymore */ + if (IN_SET(c, EOF, 0)) + break; + + /* On Return also end the request, but make this visible */ + if (IN_SET(c, '\n', '\r')) { + fputc('\n', stdout); + break; + } + + if (c == '\t') { + /* Tab */ + + _cleanup_strv_free_ char **completions = NULL; + if (get_completions) { + r = get_completions(string, &completions, userdata); + if (r < 0) + goto fail; + } + + _cleanup_free_ char *new_string = NULL; + CompletionResult cr = pick_completion(string, completions, &new_string); + if (cr < 0) { + r = cr; + goto fail; + } + if (IN_SET(cr, COMPLETION_PARTIAL, COMPLETION_FULL)) { + /* Output the new suffix we learned */ + fputs(ASSERT_PTR(startswith(new_string, strempty(string))), stdout); + + /* And update the whole string */ + free_and_replace(string, new_string); + n = strlen(string); + } + if (cr == COMPLETION_NONE) + fputc('\a', stdout); /* BEL */ + + if (IN_SET(cr, COMPLETION_PARTIAL, COMPLETION_ALREADY)) { + /* If this worked only partially, or if the user hit TAB even though we were + * complete already, then show the remaining options (in the latter case just + * the one). */ + fputc('\n', stdout); + + _cleanup_strv_free_ char **filtered = strv_filter_prefix(completions, string); + if (!filtered) { + r = -ENOMEM; + goto fail; + } + + r = show_menu(filtered, + /* n_columns= */ SIZE_MAX, + /* column_width= */ SIZE_MAX, + /* ellipsize_percentage= */ 0, + /* grey_prefix=*/ string, + /* with_numbers= */ false); + if (r < 0) + goto fail; + + /* Show the prompt again */ + fputs(ansi_highlight(), stdout); + va_start(ap, text); + vprintf(text, ap); + va_end(ap); + fputs(ansi_normal(), stdout); + fputs(string, stdout); + } + + } else if (IN_SET(c, '\b', 127)) { + /* Backspace */ + + if (n == 0) + fputc('\a', stdout); /* BEL */ + else { + size_t m = utf8_last_length(string, n); + + char *e = string + n - m; + clear_by_backspace(utf8_console_width(e)); + + *e = 0; + n -= m; + } + + } else if (c == 21) { + /* Ctrl-u → erase all input */ + + clear_by_backspace(utf8_console_width(string)); + string[n = 0] = 0; + + } else if (c == 4) { + /* Ctrl-d → cancel this field input */ + + r = -ECANCELED; + goto fail; + + } else if (char_is_cc(c) || n >= LINE_MAX) + /* refuse control characters and too long strings */ + fputc('\a', stdout); /* BEL */ + else { + /* Regular char */ + + if (!GREEDY_REALLOC(string, n+2)) { + r = -ENOMEM; + goto fail; + } + + string[n++] = (char) c; + string[n] = 0; + + fputc(c, stdout); + } + + fflush(stdout); + } + + if (tcsetattr(fd_input, TCSADRAIN, &old_termios) < 0) + return -errno; + + if (!string) { + string = strdup(""); + if (!string) + return -ENOMEM; + } + + *ret = TAKE_PTR(string); + return 0; + +fail: + (void) tcsetattr(fd_input, TCSADRAIN, &old_termios); + return r; + +fallback: + /* A simple fallback without TTY magic */ + r = read_line(stdin, LONG_LINE_MAX, &string); if (r < 0) return r; if (r == 0) return -EIO; - *ret = TAKE_PTR(line); + *ret = TAKE_PTR(string); return 0; } diff --git a/src/basic/terminal-util.h b/src/basic/terminal-util.h index c4ee1b3243..698838e63f 100644 --- a/src/basic/terminal-util.h +++ b/src/basic/terminal-util.h @@ -82,7 +82,11 @@ int chvt(int vt); int read_one_char(FILE *f, char *ret, usec_t timeout, bool echo, bool *need_nl); int ask_char(char *ret, const char *replies, const char *text, ...) _printf_(3, 4); -int ask_string(char **ret, const char *text, ...) _printf_(2, 3); + +typedef int (*GetCompletionsCallback)(const char *key, char ***ret_list, void *userdata); +int ask_string_full(char **ret, GetCompletionsCallback cb, void *userdata, const char *text, ...) _printf_(4, 5); +#define ask_string(ret, text, ...) ask_string_full(ret, NULL, NULL, text, ##__VA_ARGS__) + bool any_key_to_proceed(void); int show_menu(char **x, size_t n_columns, size_t column_width, unsigned ellipsize_percentage, const char *grey_prefix, bool with_numbers); diff --git a/src/firstboot/firstboot.c b/src/firstboot/firstboot.c index 2ee231e2d0..99e75b3422 100644 --- a/src/firstboot/firstboot.c +++ b/src/firstboot/firstboot.c @@ -135,6 +135,30 @@ 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(©, "list"); + if (r < 0) + return r; + + *ret_list = TAKE_PTR(copy); + return 0; +} + static int prompt_loop( int rfd, const char *text, @@ -142,6 +166,7 @@ static int prompt_loop( unsigned ellipsize_percentage, bool (*is_valid)(int rfd, const char *name), char **ret) { + int r; assert(text); @@ -151,9 +176,13 @@ static int prompt_loop( for (;;) { _cleanup_free_ char *p = NULL; - r = ask_string(&p, strv_isempty(l) ? "%s %s (empty to skip): " - : "%s %s (empty to skip, \"list\" to list options): ", - special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), text); + 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): ", + special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), text); if (r < 0) return log_error_errno(r, "Failed to query user: %m"); diff --git a/src/home/homectl.c b/src/home/homectl.c index e1611b7bfb..04457ebeda 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2476,6 +2476,28 @@ static int acquire_group_list(char ***ret) { return !!*ret; } +static int group_completion_callback(const char *key, char ***ret_list, void *userdata) { + char ***available = userdata; + int r; + + if (!*available) { + r = acquire_group_list(available); + if (r < 0) + log_debug_errno(r, "Failed to enumerate available groups, ignoring: %m"); + } + + _cleanup_strv_free_ char **l = strv_copy(*available); + if (!l) + return -ENOMEM; + + r = strv_extend(&l, "list"); + if (r < 0) + return r; + + *ret_list = TAKE_PTR(l); + return 0; +} + static int create_interactively(void) { _cleanup_free_ char *username = NULL; int r; @@ -2531,7 +2553,8 @@ static int create_interactively(void) { for (;;) { _cleanup_free_ char *s = NULL; - r = ask_string(&s, + r = ask_string_full(&s, + group_completion_callback, &available, "%s Please enter an auxiliary group for user %s (empty to continue, \"list\" to list available groups): ", special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), username); if (r < 0) From 9e6fbb5a5163293c67d8355df78564eac45e9453 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 5 Feb 2025 10:58:36 +0100 Subject: [PATCH 08/10] homectl: clarify that we pick the default shell if the prompt is skipped --- src/home/homectl.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/home/homectl.c b/src/home/homectl.c index 04457ebeda..ad372c8420 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2640,13 +2640,13 @@ static int create_interactively(void) { shell = mfree(shell); r = ask_string(&shell, - "%s Please enter the shell to use for user %s (empty to skip): ", + "%s Please enter the shell to use for user %s (empty for default): ", special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), username); if (r < 0) return log_error_errno(r, "Failed to query user for username: %m"); if (isempty(shell)) { - log_info("No data entered, skipping."); + log_info("No data entered, leaving at default."); break; } From cfe16540c8ecf054f7ecdb7aa765589d85d298c1 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 6 Feb 2025 11:59:40 +0100 Subject: [PATCH 09/10] homectl: optionally force interactive firstboot query --- docs/ENVIRONMENT.md | 7 +++++++ src/home/homectl.c | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 30c987f834..b6e59144fb 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -596,6 +596,13 @@ SYSTEMD_HOME_DEBUG_SUFFIX=foo \ parts of the session continue running. Thus, we highly recommend that this variable isn't used unless necessary. Defaults to true. +`homectl`: + +* `$SYSTEMD_HOME_FIRSTBOOT_OVERRIDE` – if set to "1" will make `homectl + firstboot --prompt-new-user` interactively ask for user creation, even if + there already exists at least one regular user on the system. If set to "0" + will make the tool skip any such query. + `kernel-install`: * `$KERNEL_INSTALL_BYPASS` – If set to "1", execution of kernel-install is skipped diff --git a/src/home/homectl.c b/src/home/homectl.c index ad372c8420..cc2f073582 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2697,12 +2697,20 @@ static int verb_firstboot(int argc, char *argv[], void *userdata) { if (r > 0) /* Already created users from credentials */ return 0; - r = has_regular_user(); - if (r < 0) - return r; - if (r > 0) { - log_info("Regular user already present in user database, skipping user creation."); + r = getenv_bool("SYSTEMD_HOME_FIRSTBOOT_OVERRIDE"); + if (r == 0) return 0; + if (r < 0) { + if (r != -ENXIO) + log_warning_errno(r, "Failed to parse $SYSTEMD_HOME_FIRSTBOOT_OVERRIDE, ignoring: %m"); + + r = has_regular_user(); + if (r < 0) + return r; + if (r > 0) { + log_info("Regular user already present in user database, skipping user creation."); + return 0; + } } return create_interactively(); From 0fe3b0e4e2d21d1592836fd37b199f0f156aac9c Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 6 Feb 2025 12:19:52 +0100 Subject: [PATCH 10/10] homectl: show full list of selected groups as they are added --- src/home/homectl.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/home/homectl.c b/src/home/homectl.c index cc2f073582..7acaf59301 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2549,10 +2549,19 @@ static int create_interactively(void) { return log_error_errno(r, "Failed to set userName field: %m"); _cleanup_strv_free_ char **available = NULL, **groups = NULL; - for (;;) { _cleanup_free_ char *s = NULL; + strv_sort_uniq(groups); + + if (!strv_isempty(groups)) { + _cleanup_free_ char *j = strv_join(groups, ", "); + if (!j) + return log_oom(); + + log_info("Currently selected groups: %s", j); + } + r = ask_string_full(&s, group_completion_callback, &available, "%s Please enter an auxiliary group for user %s (empty to continue, \"list\" to list available groups): ",