From 98dbda7a2c0cf995b346ecd1f49c3ed996f7c993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20J=C4=99drzejewski-Szmek?= Date: Mon, 31 Aug 2026 19:18:44 +0200 Subject: [PATCH 1/6] test-hostname-pick-word: add a simple test to check word selection This is useful to check if how fast the code is (painfully slow) and whether worlds indeed are selected with a flat distribution probability (they are). The code is slow because the hashing and because chaseat is exteremely inefficient :( --- src/shared/hostname-setup.c | 2 +- src/shared/hostname-setup.h | 1 + src/test/meson.build | 4 +++ src/test/test-hostname-pick-word.c | 47 ++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/test/test-hostname-pick-word.c diff --git a/src/shared/hostname-setup.c b/src/shared/hostname-setup.c index 2512292b0c..cd728539f3 100644 --- a/src/shared/hostname-setup.c +++ b/src/shared/hostname-setup.c @@ -369,7 +369,7 @@ static int pick_word_linear_scan(FILE *f, off_t offset, char **ret) { } } -static int hostname_pick_word(sd_id128_t mid, size_t pos, char **ret) { +int hostname_pick_word(sd_id128_t mid, size_t pos, char **ret) { static const sd_id128_t word_key = SD_ID128_MAKE(2d,9f,1c,7a,4b,8e,43,11,9a,6d,5f,02,c8,77,e3,14); _cleanup_fclose_ FILE *f = NULL; struct stat st; diff --git a/src/shared/hostname-setup.h b/src/shared/hostname-setup.h index 6e68b6fa79..c9f1e46eb8 100644 --- a/src/shared/hostname-setup.h +++ b/src/shared/hostname-setup.h @@ -22,6 +22,7 @@ int read_etc_hostname(const char *path, bool substitute_wildcards, char **ret); void hostname_update_source_hint(const char *hostname, HostnameSource source); int hostname_setup(bool really); +int hostname_pick_word(sd_id128_t mid, size_t pos, char **ret); int hostname_substitute_wildcards(const char *name, char **ret); char* get_default_hostname(void); diff --git a/src/test/meson.build b/src/test/meson.build index da3148d955..63535a90d3 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -395,6 +395,10 @@ executables += [ ], 'timeout' : 180, }, + test_template + { + 'sources': files('test-hostname-pick-word.c'), + 'type' : 'manual', + }, test_template + { 'sources' : files('test-ipcrm.c'), 'type' : 'unsafe', diff --git a/src/test/test-hostname-pick-word.c b/src/test/test-hostname-pick-word.c new file mode 100644 index 0000000000..3021c6e6ef --- /dev/null +++ b/src/test/test-hostname-pick-word.c @@ -0,0 +1,47 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-id128.h" + +#include "alloc-util.h" +#include "hostname-setup.h" +#include "log.h" +#include "main-func.h" +#include "parse-util.h" +#include "tests.h" + +static int run(int argc, char **argv) { + int r; + + log_parse_environment(); + + unsigned c = 100, pos = 1; + + if (argc > 1) { + r = safe_atou(argv[1], &pos); + if (r < 0) + return log_error_errno(r, "Cannot parse uint %s: %m", argv[1]); + } + + if (argc > 2) { + r = safe_atou(argv[2], &c); + if (r < 0) + return log_error_errno(r, "Cannot parse uint %s: %m", argv[2]); + } + + sd_id128_t mid = SD_ID128_ARRAY(aa,bb,cc,ee,33,21,42,31,89,ba,a4,91,33,22,11,00); + + while (c-- > 0) { + _cleanup_free_ char *w = NULL; + + ASSERT_OK(hostname_pick_word(mid, pos, &w)); + ASSERT_PTR(w); + + printf("%s\n", w); + + mid.qwords[0] += 3; + } + + return 0; +} + +DEFINE_MAIN_FUNCTION(run); From 6ec548c3366cc094927509536a017d47719ab379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20J=C4=99drzejewski-Szmek?= Date: Tue, 1 Sep 2026 09:50:15 +0200 Subject: [PATCH 2/6] shared/fileio: avoid copying strv twice search_and_fopen called search_and_fopen_internal which made a copy of the search strv and then called search_and_open which made a copy too. --- src/basic/fileio.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/basic/fileio.c b/src/basic/fileio.c index 028372c011..600e7859c4 100644 --- a/src/basic/fileio.c +++ b/src/basic/fileio.c @@ -1295,7 +1295,7 @@ static int search_and_fopen_internal( assert(path); assert(mode || !ret_file); - r = search_and_open( + r = search_and_open_internal( path, mode ? fopen_mode_to_flags(mode) : 0, root, From 96de029ad794fbdb8f8e864691463ffdcb1b5a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20J=C4=99drzejewski-Szmek?= Date: Tue, 1 Sep 2026 12:34:03 +0200 Subject: [PATCH 3/6] basic/path-util: stop ignoring non-absolute paths 112cfb181453e38d3ef4a74fba23abbb53392002 tried to improve handling of paths when root is specified. It said: > To avoid answering what a relative path means when called with a root > prefix path_strv_canonicalize is now path_strv_canonicalize_absolute > and only considers absolute paths. Fortunately all users of already call > path_strv_canonicalize with a list of absolute paths. Of course, other users were added later and for example now SYSTEMD_HOSTNAME_WORDLIST_PATH=build/hostname-wordlist/ ... doesn't work as expected. If root is not specified, there is really no reason to ignore relative paths. They work relative to CWD per the usual convention. It's up to the user to not use relative paths in a context where they should not be used. When root is specified, continue to ignore the relative paths, since there is no clear interpreation what they mean, but emit a debug message. --- src/basic/path-util.c | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/basic/path-util.c b/src/basic/path-util.c index b41029b157..71486cc9e0 100644 --- a/src/basic/path-util.c +++ b/src/basic/path-util.c @@ -264,20 +264,20 @@ char** path_strv_resolve(char **l, const char *root) { if (strv_isempty(l)) return l; - /* Goes through every item in the string list and canonicalize - * the path. This works in place and won't rollback any - * changes on failure. */ + /* Go through every item in the string list and canonicalize the path. + * This works in place and doesn't roll back any changes on failure. */ STRV_FOREACH(s, l) { _cleanup_free_ char *orig = NULL; char *t, *u; - if (!path_is_absolute(*s)) { - free(*s); - continue; - } - if (root) { + if (!path_is_absolute(*s)) { + log_debug("Ignoring non-absolute search path '%s'.", *s); + free(*s); + continue; + } + orig = *s; t = path_join(root, orig); if (!t) { From 00d512bdceb1070cd985ad9765229b05ad569541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20J=C4=99drzejewski-Szmek?= Date: Tue, 1 Sep 2026 14:21:00 +0200 Subject: [PATCH 4/6] basic/fileio: wrap long comments --- src/basic/fileio.c | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/basic/fileio.c b/src/basic/fileio.c index 600e7859c4..892cdefdba 100644 --- a/src/basic/fileio.c +++ b/src/basic/fileio.c @@ -790,7 +790,9 @@ int read_full_stream_full( if (feof(f)) break; - if (size != SIZE_MAX && !FLAGS_SET(flags, READ_FULL_FILE_FAIL_WHEN_LARGER)) { /* If we got asked to read some specific size, we already sized the buffer right, hence leave */ + if (size != SIZE_MAX && !FLAGS_SET(flags, READ_FULL_FILE_FAIL_WHEN_LARGER)) { + /* If we got asked to read some specific size, we already sized the buffer right, + * hence leave. */ assert(l == size); break; } @@ -834,9 +836,9 @@ int read_full_stream_full( } if (!ret_size) { - /* Safety check: if the caller doesn't want to know the size of what we just read it will rely on the - * trailing NUL byte. But if there's an embedded NUL byte, then we should refuse operation as otherwise - * there'd be ambiguity about what we just read. */ + /* Safety check: if the caller doesn't want to know the size of what we just read it will + * rely on the trailing NUL byte. But if there's an embedded NUL byte, then we should refuse + * operation as otherwise there'd be ambiguity about what we just read. */ if (memchr(buf, 0, l)) { r = -EBADMSG; From c737b297a3881c81e3f552ba4f4bdcf042a55b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20J=C4=99drzejewski-Szmek?= Date: Tue, 1 Sep 2026 14:23:23 +0200 Subject: [PATCH 5/6] hostname-setup: read the whole wordlist into memory when picking words hostname_pick_word() used to seek around in the wordlist file and read individual lines with read_line()/read_stripped_line(). Instead, slurp the whole file into memory use offset arithmetic. The wordlists are small, so reading them entirely is faster and there is really no reason to do the complicated thing. The new code is 15% faster (the slow parts are the hashing and chaseat, they actually dominate over the reading of the data). The "raced with truncation" case is gone, since the buffer is a consistent snapshot of the file. The random offsets derived from the machine ID are unchanged, so the picked words stay the same. --- src/shared/hostname-setup.c | 127 +++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 54 deletions(-) diff --git a/src/shared/hostname-setup.c b/src/shared/hostname-setup.c index cd728539f3..194ce01080 100644 --- a/src/shared/hostname-setup.c +++ b/src/shared/hostname-setup.c @@ -331,41 +331,68 @@ static bool normalize_and_validate_word(char *word) { return hostname_is_valid(word, /* flags= */ 0); } -static int pick_word_linear_scan(FILE *f, off_t offset, char **ret) { - int r; - - assert(f); +static int pick_word_at(const char *words, size_t size, size_t offset, char **ret) { + assert(words); + assert(offset <= size); assert(ret); - if (fseeko(f, offset, SEEK_SET) < 0) - return -errno; + /* Extracts the line starting at 'offset', strips and validates it. Returns 1 and the word on + * success, 0 if the line is a comment, empty, or not a valid hostname word. */ - bool wrapped = false; - r = read_line(f, LONG_LINE_MAX, NULL); /* discard the partial line we landed in */ - if (r < 0) - return r; - if (r == 0) { - wrapped = true; - rewind(f); + const char *p = words + offset; + size_t len = MIN(size - offset, LONG_LINE_MAX + 1); + + const char *e = memchr(p, '\n', len); + if (e) + len = e - p; + if (len > LONG_LINE_MAX) + return -EUCLEAN; + + /* Strip leading and trailing whitespace */ + while (len > 0 && strchr(WHITESPACE, p[0])) { + p++; + len--; } + while (len > 0 && strchr(WHITESPACE, p[len - 1])) + len--; + + _cleanup_free_ char *word = strndup(p, len); + if (!word) + return -ENOMEM; + + bool good = normalize_and_validate_word(word); + *ret = good ? TAKE_PTR(word) : NULL; + return good; +} + +static int pick_word_linear_scan(const char *words, size_t size, size_t offset, char **ret) { + int r; + + assert(words); + assert(size > 0); + assert(offset <= size); + assert(ret); + + /* Skip the partial line we landed in */ + bool wrapped = false; + const char *e = memchr(words + offset, '\n', size - offset); + offset = e ? (size_t) (e - words) + 1 : size; for (;;) { - _cleanup_free_ char *line = NULL; - - r = read_stripped_line(f, LONG_LINE_MAX, &line); - if (r < 0) - return r; - if (r == 0) { /* hit EOF: we started at a random offset, wrap around to the beginning */ + if (offset >= size) { /* hit EOF: we started at a random offset, wrap around to the beginning */ if (wrapped) /* already wrapped once, the file contains no usable word at all */ return -ENOENT; wrapped = true; - rewind(f); - continue; - } - if (normalize_and_validate_word(line)) { - *ret = TAKE_PTR(line); - return 0; + offset = 0; } + + r = pick_word_at(words, size, offset, ret); + if (r != 0) + return r < 0 ? r : 0; + + /* Advance to the next line */ + e = memchr(words + offset, '\n', size - offset); + offset = e ? (size_t) (e - words) + 1 : size; } } @@ -391,55 +418,47 @@ int hostname_pick_word(sd_id128_t mid, size_t pos, char **ret) { r = stat_verify_regular(&st); if (r < 0) return r; - if (st.st_size == 0) + + _cleanup_free_ char *words = NULL; + size_t size; + r = read_full_stream(f, &words, &size); + if (r < 0) + return r; + if (size == 0) return -ENOENT; - /* Pick a word without reading the whole list into memory: - * 1. pick a random offset in the file [0 … st.st_size-1] - * 2. if offset is zero, read a full line from the beginning of the file, use that. - * 3. otherwise, seek to offset minus 1 and read one character. - * 4. if that character is newline, then read a full line after it, and use that as result - * 5. otherwise, goto 1 + /* Pick a word: + * 1. pick a random offset in the buffer [0 … size-1] + * 2. if the offset is zero or the preceding byte is a newline, it starts a line: use that line + * 3. otherwise, goto 1 * * As a safety net terminate after a fixed number iterations (for pathological wordlists) - * This stream is independent of the '?' nibble stream so pure-'?' * templates keep producing + * This stream is independent of the '?' nibble stream so pure-'?' templates keep producing * byte-identical output. Stable as long as the wordlist is stable. */ - off_t offset = 0; + size_t offset = 0; const unsigned int MAX_ITERATIONS = 64; for (unsigned i = 0; i < MAX_ITERATIONS; i++) { - _cleanup_free_ char *line = NULL; - struct siphash state; siphash24_init(&state, word_key.bytes); siphash24_compress_typesafe(mid, &state); siphash24_compress_typesafe(pos, &state); siphash24_compress_typesafe(i, &state); /* counter mode */ - offset = (off_t) (siphash24_finalize(&state) % (uint64_t) st.st_size); + offset = (size_t) (siphash24_finalize(&state) % (uint64_t) size); - if (offset > 0) { - if (fseeko(f, offset - 1, SEEK_SET) < 0) - return -errno; - if (fgetc(f) != '\n') - continue; /* not a line start */ - } else if (fseeko(f, 0, SEEK_SET) < 0) /* offset 0 always begins the first line */ - return -errno; + if (offset > 0 && words[offset - 1] != '\n') + continue; /* not a line start */ + + r = pick_word_at(words, size, offset, ret); + if (r != 0) + return r < 0 ? r : 0; - r = read_stripped_line(f, LONG_LINE_MAX, &line); - if (r < 0) - return r; - if (r == 0) /* raced with truncation */ - continue; - if (normalize_and_validate_word(line)) { - *ret = TAKE_PTR(line); - return 0; - } /* Comment/empty/invalid line: resample rather than advancing, to keep the pick uniform. */ } /* We exhausted the uniform attempts, this should never happen but if it does fallback to picking the * next word after our last attempt. */ log_warning("hostname_pick_word did not find a usable word after %u in wordlist %zu", MAX_ITERATIONS, pos); - return pick_word_linear_scan(f, offset, ret); + return pick_word_linear_scan(words, size, offset, ret); } int hostname_substitute_wildcards(const char *name, char **ret) { From c47a5fa25bcc1032bbc51ccb005b8f1ea3bf8fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20J=C4=99drzejewski-Szmek?= Date: Tue, 1 Sep 2026 14:39:43 +0200 Subject: [PATCH 6/6] hostname-wordlist: fix comment and downgrade warning The comment was wrong. The average word is about 6 characters, so in 64 iterations, the probability of never landing on a word boundary is ~(1 - 1/7)**64, about 0.01%. --- src/shared/hostname-setup.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/shared/hostname-setup.c b/src/shared/hostname-setup.c index 194ce01080..4abf662002 100644 --- a/src/shared/hostname-setup.c +++ b/src/shared/hostname-setup.c @@ -455,9 +455,10 @@ int hostname_pick_word(sd_id128_t mid, size_t pos, char **ret) { /* Comment/empty/invalid line: resample rather than advancing, to keep the pick uniform. */ } - /* We exhausted the uniform attempts, this should never happen but if it does fallback to picking the - * next word after our last attempt. */ - log_warning("hostname_pick_word did not find a usable word after %u in wordlist %zu", MAX_ITERATIONS, pos); + /* We exhausted the uniform attempts, this occasionally happens, so fall back to picking the next + * word after our last offset. */ + log_debug("Falling back to linear scan for a word in wordlist %zu after trying %u random offsets.", + pos, MAX_ITERATIONS); return pick_word_linear_scan(words, size, offset, ret); }