From 95a372a514dfe0e03d9e03b4171f87fd98ddcb14 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 12:37:39 +0200 Subject: [PATCH 1/9] vpick: take separate root_fd and dir_fd arguments Mirror how chaseat() works these days: instead of a single toplevel_fd that serves as both the root (chroot) boundary and the directory that resolution starts from, path_pick() now takes a separate root_fd and dir_fd. This lets callers resolve a path relative to a specific directory fd while confining symlink and absolute-path resolution to a root directory fd. All existing callers are updated to pass the same fd for both, preserving their current behaviour. --- src/core/exec-invoke.c | 15 ++++--- src/core/namespace.c | 10 +++-- src/portable/portable.c | 15 ++++--- src/shared/discover-image.c | 2 + src/shared/mstack.c | 2 +- src/shared/vpick.c | 90 +++++++++++++++++++++---------------- src/shared/vpick.h | 5 ++- src/test/test-vpick.c | 48 ++++++++++---------- src/vpick/vpick-tool.c | 5 ++- 9 files changed, 109 insertions(+), 83 deletions(-) diff --git a/src/core/exec-invoke.c b/src/core/exec-invoke.c index a06852a6b5..c45c807198 100644 --- a/src/core/exec-invoke.c +++ b/src/core/exec-invoke.c @@ -3719,8 +3719,9 @@ static int pin_rootfs( if (context->root_image) { _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, context->root_image, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), @@ -3760,8 +3761,9 @@ static int pin_rootfs( if (context->root_directory) { _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, context->root_directory, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), @@ -3789,8 +3791,9 @@ static int pin_rootfs( if (context->root_mstack) { _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, context->root_mstack, pick_filter_image_mstack, /* n_filters= */ 1, diff --git a/src/core/namespace.c b/src/core/namespace.c index a6ba2659e4..3fc0a8652d 100644 --- a/src/core/namespace.c +++ b/src/core/namespace.c @@ -564,8 +564,9 @@ static int append_extensions( _cleanup_free_ char *mount_point = NULL; const MountImage *m = mount_images + i; - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, m->source, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), @@ -636,8 +637,9 @@ static int append_extensions( if (startswith(e, "+")) e++; - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, e, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), diff --git a/src/portable/portable.c b/src/portable/portable.c index a98c1162d9..9d510118be 100644 --- a/src/portable/portable.c +++ b/src/portable/portable.c @@ -821,8 +821,9 @@ static int extract_image_and_extensions( /* If we get a path, then check if it can be resolved with vpick. We need this as we might just * get a simple image name, which would make vpick error out. */ if (path_is_absolute(name_or_path)) { - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, name_or_path, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), @@ -860,8 +861,9 @@ static int extract_image_and_extensions( const char *path = *p; if (path_is_absolute(*p)) { - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, *p, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), @@ -2201,8 +2203,9 @@ static bool marker_matches_images(const char *marker, const char *name_or_path, if (r < 0) return log_debug_errno(r, "Failed to extract image name from %s, ignoring: %m", image); - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, *image_name_or_path, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), diff --git a/src/shared/discover-image.c b/src/shared/discover-image.c index 0410b523ba..9994ff5da4 100644 --- a/src/shared/discover-image.c +++ b/src/shared/discover-image.c @@ -956,6 +956,7 @@ int image_find(RuntimeScope scope, _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; r = path_pick(root, + rfd, rfd, fname_path, /* This has to be the unresolved entry with the .v suffix */ &filter, @@ -1177,6 +1178,7 @@ int image_discover( _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; r = path_pick(root, + rfd, rfd, fname_path, /* This has to be the unresolved entry with the .v suffix */ &filter, diff --git a/src/shared/mstack.c b/src/shared/mstack.c index cc3cb9a75f..65f38ba3b3 100644 --- a/src/shared/mstack.c +++ b/src/shared/mstack.c @@ -139,7 +139,7 @@ static int mstack_load_one(MStack *mstack, const char *dir, int dir_fd, const ch }; _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; - r = path_pick(dir, dir_fd, fname, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE, &result); + r = path_pick(dir, dir_fd, dir_fd, fname, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE, &result); if (r < 0) return log_debug_errno(r, "Failed to resolve '%s' directory: %m", fname); if (r == 0) diff --git a/src/shared/vpick.c b/src/shared/vpick.c index 7890fb1708..c661f92faf 100644 --- a/src/shared/vpick.c +++ b/src/shared/vpick.c @@ -176,8 +176,9 @@ static int errno_from_mode(uint32_t type_mask, mode_t found) { } static int pin_choice( - const char *toplevel_path, - int toplevel_fd, + const char *root_path, + int root_fd, + int dir_fd, const char *inode_path, int _inode_fd, /* we always take ownership of the fd, even on failure */ unsigned tries_left, @@ -190,14 +191,15 @@ static int pin_choice( _cleanup_free_ char *resolved_path = NULL; int r; - assert(wildcard_fd_is_valid(toplevel_fd)); + assert(wildcard_fd_is_valid(root_fd)); + assert(wildcard_fd_is_valid(dir_fd)); assert(inode_path); assert(filter); assert(ret); if (inode_fd < 0 || FLAGS_SET(flags, PICK_RESOLVE)) { - r = chaseat(toplevel_fd, - toplevel_fd, + r = chaseat(root_fd, + dir_fd, inode_path, /* flags= */ 0, FLAGS_SET(flags, PICK_RESOLVE) ? &resolved_path : NULL, @@ -212,11 +214,11 @@ static int pin_choice( struct stat st; if (fstat(inode_fd, &st) < 0) return log_debug_errno(errno, "Failed to stat discovered inode '%s%s': %m", - empty_to_root(toplevel_path), skip_leading_slash(inode_path)); + empty_to_root(root_path), skip_leading_slash(inode_path)); if (filter->type_mask != 0 && !BIT_SET(filter->type_mask, IFTODT(st.st_mode))) { log_debug("Inode '%s/%s' has wrong type, found '%s'.", - empty_to_root(toplevel_path), skip_leading_slash(inode_path), + empty_to_root(root_path), skip_leading_slash(inode_path), inode_type_to_string(st.st_mode)); *ret = PICK_RESULT_NULL; return 0; @@ -311,8 +313,9 @@ static bool architecture_matches(const PickFilter *filter, Architecture a) { } static int make_choice( - const char *toplevel_path, - int toplevel_fd, + const char *root_path, + int root_fd, + int dir_fd, const char *inode_path, int _inode_fd, /* we always take ownership of the fd, even on failure */ const PickFilter *filter, @@ -322,13 +325,14 @@ static int make_choice( _cleanup_close_ int inode_fd = TAKE_FD(_inode_fd); int r; - assert(wildcard_fd_is_valid(toplevel_fd)); + assert(wildcard_fd_is_valid(root_fd)); + assert(wildcard_fd_is_valid(dir_fd)); assert(inode_path); assert(filter); assert(ret); if (inode_fd < 0) { - r = chaseat(toplevel_fd, toplevel_fd, inode_path, /* flags= */ 0, NULL, &inode_fd); + r = chaseat(root_fd, dir_fd, inode_path, /* flags= */ 0, NULL, &inode_fd); if (r < 0) return r; } @@ -345,18 +349,19 @@ static int make_choice( return log_oom_debug(); _cleanup_close_ int object_fd = -EBADF; - r = chaseat(toplevel_fd, toplevel_fd, p, /* flags= */ 0, &object_path, &object_fd); + r = chaseat(root_fd, dir_fd, p, /* flags= */ 0, &object_path, &object_fd); if (r == -ENOENT) { *ret = PICK_RESULT_NULL; return 0; } if (r < 0) return log_debug_errno(r, "Failed to open '%s/%s': %m", - empty_to_root(toplevel_path), skip_leading_slash(p)); + empty_to_root(root_path), skip_leading_slash(p)); return pin_choice( - toplevel_path, - toplevel_fd, + root_path, + root_fd, + dir_fd, FLAGS_SET(flags, PICK_RESOLVE) ? object_path : p, TAKE_FD(object_fd), /* unconditionally pass ownership of the fd */ /* tries_left= */ UINT_MAX, @@ -371,16 +376,16 @@ static int make_choice( /* Underspecified, so we do our enumeration dance */ /* Convert O_PATH to a regular directory fd */ - _cleanup_close_ int dir_fd = fd_reopen(inode_fd, O_DIRECTORY|O_RDONLY|O_CLOEXEC); - if (dir_fd < 0) - return log_debug_errno(dir_fd, "Failed to reopen '%s/%s' as directory: %m", - empty_to_root(toplevel_path), skip_leading_slash(inode_path)); + _cleanup_close_ int enumerate_fd = fd_reopen(inode_fd, O_DIRECTORY|O_RDONLY|O_CLOEXEC); + if (enumerate_fd < 0) + return log_debug_errno(enumerate_fd, "Failed to reopen '%s/%s' as directory: %m", + empty_to_root(root_path), skip_leading_slash(inode_path)); _cleanup_free_ DirectoryEntries *de = NULL; - r = readdir_all(dir_fd, 0, &de); + r = readdir_all(enumerate_fd, 0, &de); if (r < 0) return log_debug_errno(r, "Failed to read directory '%s/%s': %m", - empty_to_root(toplevel_path), skip_leading_slash(inode_path)); + empty_to_root(root_path), skip_leading_slash(inode_path)); _cleanup_(pick_result_done) PickResult best = PICK_RESULT_NULL; @@ -457,8 +462,9 @@ static int make_choice( return log_oom_debug(); _cleanup_(pick_result_done) PickResult found = PICK_RESULT_NULL; - r = pin_choice(toplevel_path, - toplevel_fd, + r = pin_choice(root_path, + root_fd, + dir_fd, p, /* _inode_fd= */ -EBADF, found_tries_left, @@ -501,8 +507,9 @@ static int make_choice( } static int path_pick_one( - const char *toplevel_path, - int toplevel_fd, + const char *root_path, + int root_fd, + int dir_fd, const char *path, const PickFilter *filter, PickFlags flags, @@ -513,7 +520,8 @@ static int path_pick_one( uint32_t filter_type_mask; int r; - assert(wildcard_fd_is_valid(toplevel_fd)); + assert(wildcard_fd_is_valid(root_fd)); + assert(wildcard_fd_is_valid(dir_fd)); assert(path); assert(filter); assert(ret); @@ -537,8 +545,9 @@ static int path_pick_one( /* Explicit basename specified, then shortcut things and do .v mode regardless of the path name. */ if (filter->basename) return make_choice( - toplevel_path, - toplevel_fd, + root_path, + root_fd, + dir_fd, path, /* inode_fd= */ -EBADF, filter, @@ -623,8 +632,9 @@ static int path_pick_one( } return make_choice( - toplevel_path, - toplevel_fd, + root_path, + root_fd, + dir_fd, enumeration_path, /* inode_fd= */ -EBADF, &(const PickFilter) { @@ -640,8 +650,9 @@ static int path_pick_one( bypass: /* Don't make any choice, but just use the passed path literally */ return pin_choice( - toplevel_path, - toplevel_fd, + root_path, + root_fd, + dir_fd, path, /* inode_fd= */ -EBADF, /* tries_left= */ UINT_MAX, @@ -651,8 +662,9 @@ bypass: ret); } -int path_pick(const char *toplevel_path, - int toplevel_fd, +int path_pick(const char *root_path, + int root_fd, + int dir_fd, const char *path, const PickFilter filters[], size_t n_filters, @@ -662,7 +674,8 @@ int path_pick(const char *toplevel_path, _cleanup_(pick_result_done) PickResult best = PICK_RESULT_NULL; int r; - assert(wildcard_fd_is_valid(toplevel_fd)); + assert(wildcard_fd_is_valid(root_fd)); + assert(wildcard_fd_is_valid(dir_fd)); assert(path); assert(filters || n_filters == 0); assert(ret); @@ -671,7 +684,7 @@ int path_pick(const char *toplevel_path, for (size_t i = 0; i < n_filters; i++) { _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; - r = path_pick_one(toplevel_path, toplevel_fd, path, &filters[i], flags, &result); + r = path_pick_one(root_path, root_fd, dir_fd, path, &filters[i], flags, &result); if (r < 0) return r; if (r == 0) @@ -717,8 +730,9 @@ int path_pick_update_warn( /* This updates the first argument if needed! */ - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, *path, filters, n_filters, diff --git a/src/shared/vpick.h b/src/shared/vpick.h index 55596d24df..de67cbe353 100644 --- a/src/shared/vpick.h +++ b/src/shared/vpick.h @@ -45,8 +45,9 @@ void pick_result_done(PickResult *p); int pick_result_compare(const PickResult *a, const PickResult *b, PickFlags flags); -int path_pick(const char *toplevel_path, - int toplevel_fd, +int path_pick(const char *root_path, + int root_fd, + int dir_fd, const char *path, const PickFilter filters[], size_t n_filters, diff --git a/src/test/test-vpick.c b/src/test/test-vpick.c index 400380b575..6fea29e594 100644 --- a/src/test/test-vpick.c +++ b/src/test/test-vpick.c @@ -48,7 +48,7 @@ TEST(path_pick) { }; if (IN_SET(native_architecture(), ARCHITECTURE_X86, ARCHITECTURE_X86_64)) { - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "99"); ASSERT_EQ(result.architecture, ARCHITECTURE_X86); @@ -58,7 +58,7 @@ TEST(path_pick) { } filter.architecture = ARCHITECTURE_X86_64; - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "55"); ASSERT_EQ(result.architecture, ARCHITECTURE_X86_64); @@ -66,7 +66,7 @@ TEST(path_pick) { pick_result_done(&result); filter.architecture = ARCHITECTURE_IA64; - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "5"); ASSERT_EQ(result.architecture, ARCHITECTURE_IA64); @@ -75,7 +75,7 @@ TEST(path_pick) { filter.architecture = _ARCHITECTURE_INVALID; filter.version = "5"; - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "5"); if (native_architecture() != ARCHITECTURE_IA64) { @@ -85,7 +85,7 @@ TEST(path_pick) { pick_result_done(&result); filter.architecture = ARCHITECTURE_IA64; - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "5"); ASSERT_EQ(result.architecture, ARCHITECTURE_IA64); @@ -93,7 +93,7 @@ TEST(path_pick) { pick_result_done(&result); filter.architecture = ARCHITECTURE_CRIS; - ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_EQ(result.st.st_mode, MODE_INVALID); ASSERT_NULL(result.version); ASSERT_LT(result.architecture, 0); @@ -104,7 +104,7 @@ TEST(path_pick) { filter.architecture = _ARCHITECTURE_INVALID; filter.version = NULL; if (IN_SET(native_architecture(), ARCHITECTURE_X86_64, ARCHITECTURE_X86)) { - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "55"); @@ -123,7 +123,7 @@ TEST(path_pick) { pp = ASSERT_NOT_NULL(path_join(p, "foo.v/foo___.raw")); if (IN_SET(native_architecture(), ARCHITECTURE_X86, ARCHITECTURE_X86_64)) { - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "55"); ASSERT_EQ(result.architecture, native_architecture()); @@ -137,10 +137,10 @@ TEST(path_pick) { pp = ASSERT_NOT_NULL(path_join(p, "foo.v/foo_5.raw")); filter.type_mask = UINT32_C(1) << DT_DIR; - ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); filter.type_mask = UINT32_C(1) << DT_REG; - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_NULL(result.version); ASSERT_EQ(result.architecture, _ARCHITECTURE_INVALID); @@ -153,7 +153,7 @@ TEST(path_pick) { filter.architecture = ARCHITECTURE_S390; filter.basename = "quux"; - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, &filter, /* n_filters= */ 1, PICK_ARCHITECTURE|PICK_TRIES, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "2"); ASSERT_EQ(result.tries_left, 4U); @@ -209,7 +209,7 @@ TEST(pick_filter_image_any) { _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; /* Test pick_filter_image_any: should pick the highest version, which is the directory test_5 */ - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); ASSERT_TRUE(S_ISDIR(result.st.st_mode)); ASSERT_STREQ(result.version, "5"); ASSERT_TRUE(endswith(result.path, "/test_5")); @@ -219,14 +219,14 @@ TEST(pick_filter_image_any) { ASSERT_OK(unlinkat(sub_dfd, "test_4", AT_REMOVEDIR)); ASSERT_OK(unlinkat(sub_dfd, "test_5", AT_REMOVEDIR)); - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "3"); ASSERT_TRUE(endswith(result.path, "/test_3.raw")); pick_result_done(&result); /* Verify that pick_filter_image_raw only matches .raw files */ - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), PICK_ARCHITECTURE, &result)); ASSERT_TRUE(S_ISREG(result.st.st_mode)); ASSERT_STREQ(result.version, "3"); ASSERT_TRUE(endswith(result.path, "/test_3.raw")); @@ -239,12 +239,12 @@ TEST(pick_filter_image_any) { ASSERT_OK(unlinkat(sub_dfd, "test_3.raw", 0)); /* Now only test_10.txt, test_11.img, and test_12 remain - none should match */ - ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); + ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); /* But if we add a directory, it should be picked */ ASSERT_OK(mkdirat(sub_dfd, "test_6", 0755)); - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); ASSERT_TRUE(S_ISDIR(result.st.st_mode)); ASSERT_STREQ(result.version, "6"); ASSERT_TRUE(endswith(result.path, "/test_6")); @@ -263,7 +263,7 @@ TEST(pick_filter_image_any) { pick_result_done(&result); - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE, &result)); ASSERT_TRUE(S_ISDIR(result.st.st_mode)); ASSERT_STREQ(result.version, "2"); ASSERT_TRUE(endswith(result.path, "/myimage_2")); @@ -273,7 +273,7 @@ TEST(pick_filter_image_any) { ASSERT_OK(unlinkat(sub_dfd, "myimage_1", AT_REMOVEDIR)); ASSERT_OK(unlinkat(sub_dfd, "myimage_2", AT_REMOVEDIR)); - ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE, &result)); + ASSERT_OK_ZERO(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE, &result)); } TEST(path_pick_resolve) { @@ -294,26 +294,26 @@ TEST(path_pick_resolve) { _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; /* Test without PICK_RESOLVE - should return the symlink path */ - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE, &result)); ASSERT_STREQ(result.version, "2"); ASSERT_TRUE(endswith(result.path, "/resolve_2.raw")); pick_result_done(&result); /* Test with PICK_RESOLVE - should return the resolved (target) path */ - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE|PICK_RESOLVE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_any, ELEMENTSOF(pick_filter_image_any), PICK_ARCHITECTURE|PICK_RESOLVE, &result)); ASSERT_STREQ(result.version, "2"); ASSERT_TRUE(endswith(result.path, "/target_file.raw")); pick_result_done(&result); /* Test pick_filter_image_dir without PICK_RESOLVE */ - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE, &result)); ASSERT_TRUE(S_ISDIR(result.st.st_mode)); ASSERT_STREQ(result.version, "1"); ASSERT_TRUE(endswith(result.path, "/resolve_1")); pick_result_done(&result); /* Test pick_filter_image_dir with PICK_RESOLVE */ - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE|PICK_RESOLVE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_dir, ELEMENTSOF(pick_filter_image_dir), PICK_ARCHITECTURE|PICK_RESOLVE, &result)); ASSERT_TRUE(S_ISDIR(result.st.st_mode)); ASSERT_STREQ(result.version, "1"); ASSERT_TRUE(endswith(result.path, "/target_dir")); @@ -323,12 +323,12 @@ TEST(path_pick_resolve) { ASSERT_OK(symlinkat("target_file.raw", dfd, "intermediate_link.raw")); ASSERT_OK(symlinkat("../intermediate_link.raw", sub_dfd, "resolve_3.raw")); - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), PICK_ARCHITECTURE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), PICK_ARCHITECTURE, &result)); ASSERT_STREQ(result.version, "3"); ASSERT_TRUE(endswith(result.path, "/resolve_3.raw")); pick_result_done(&result); - ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, pp, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), PICK_ARCHITECTURE|PICK_RESOLVE, &result)); + ASSERT_OK_POSITIVE(path_pick(NULL, AT_FDCWD, AT_FDCWD, pp, pick_filter_image_raw, ELEMENTSOF(pick_filter_image_raw), PICK_ARCHITECTURE|PICK_RESOLVE, &result)); ASSERT_STREQ(result.version, "3"); /* The chain should be fully resolved to target_file.raw */ ASSERT_TRUE(endswith(result.path, "/target_file.raw")); diff --git a/src/vpick/vpick-tool.c b/src/vpick/vpick-tool.c index f0b5ef44df..85f2b5c1cb 100644 --- a/src/vpick/vpick-tool.c +++ b/src/vpick/vpick-tool.c @@ -237,8 +237,9 @@ static int run(int argc, char *argv[]) { return log_error_errno(r, "Failed to make path '%s' absolute: %m", *i); _cleanup_(pick_result_done) PickResult result = PICK_RESULT_NULL; - r = path_pick(/* toplevel_path= */ NULL, - /* toplevel_fd= */ AT_FDCWD, + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ AT_FDCWD, + /* dir_fd= */ AT_FDCWD, p, &(PickFilter) { .basename = arg_filter_basename, From d36bdc946738d0ac3bf059f9f3ef604820f2d461 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 11:40:42 +0200 Subject: [PATCH 2/9] sysupdate: notify hook subscribers after a successful update Define a new io.systemd.SysUpdate.Notify Varlink interface with a single OnCompletedUpdate() method, and after sysupdate successfully installs an update, invoke that method on every socket linked into /run/systemd/sysupdate/notify/ via varlink_execute_directory(). This gives other components a hook to react to applied updates (e.g. recompute a TPM policy, link a freshly downloaded kernel, refresh extensions). The notification carries the component name, the installed version and the list of updated resources (transfer id + on-disk path). Subscribers are free to ignore the parameters and just treat the call as a trigger. Setting SYSTEMD_SYSUPDATE_FORCE_NOTIFY=1 forces the notification to be sent even when no update was applied (in which case no resource list is included), so follow-up work can be triggered unconditionally. Fixes: #35988 --- docs/ENVIRONMENT.md | 7 ++ src/basic/constants.h | 2 + src/libsystemd/sd-varlink/test-varlink-idl.c | 2 + src/shared/meson.build | 1 + .../varlink-io.systemd.SysUpdate.Notify.c | 20 ++++ .../varlink-io.systemd.SysUpdate.Notify.h | 6 ++ src/sysupdate/sysupdate.c | 94 +++++++++++++++++++ 7 files changed, 132 insertions(+) create mode 100644 src/shared/varlink-io.systemd.SysUpdate.Notify.c create mode 100644 src/shared/varlink-io.systemd.SysUpdate.Notify.h diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index b528fce37d..31f180beea 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -870,3 +870,10 @@ Tools using the Varlink protocol (such as `varlinkctl`) or sd-bus (such as 'freshness' check via `BEST-BEFORE-YYYY-MM-DD` files in `SHA256SUMS` manifest files is disabled, and updating from outdated manifests will not result in an error. + +* `$SYSTEMD_SYSUPDATE_FORCE_NOTIFY` – takes a boolean. If true the notification + callouts in `/run/systemd/sysupdate/notify/` are invoked even when no update + was applied (i.e. the system was already up-to-date). In this case the + notification carries no list of updated resources. This is useful to + unconditionally trigger follow-up work such as relinking a kernel or + recomputing a TPM policy. diff --git a/src/basic/constants.h b/src/basic/constants.h index 3dbffdee26..475d8bf79a 100644 --- a/src/basic/constants.h +++ b/src/basic/constants.h @@ -68,6 +68,8 @@ #define VARLINK_PATH_MACHINED_RESOLVE_HOOK "/run/systemd/resolve.hook/io.systemd.Machine" /* Path where to connect to send varlink prekill events */ #define VARLINK_DIR_OOMD_PREKILL_HOOK "/run/systemd/oomd.prekill.hook/" +/* Directory whose sockets receive io.systemd.SysUpdate.Notify() after a successful sysupdate run */ +#define VARLINK_DIR_SYSUPDATE_NOTIFY_HOOK "/run/systemd/sysupdate/notify/" /* Recommended baseline - see README for details */ #define KERNEL_BASELINE_VERSION "5.14" diff --git a/src/libsystemd/sd-varlink/test-varlink-idl.c b/src/libsystemd/sd-varlink/test-varlink-idl.c index e026d4b07e..0b3304a43c 100644 --- a/src/libsystemd/sd-varlink/test-varlink-idl.c +++ b/src/libsystemd/sd-varlink/test-varlink-idl.c @@ -51,6 +51,7 @@ #include "varlink-io.systemd.Resolve.Monitor.h" #include "varlink-io.systemd.Shutdown.h" #include "varlink-io.systemd.StorageProvider.h" +#include "varlink-io.systemd.SysUpdate.Notify.h" #include "varlink-io.systemd.Udev.h" #include "varlink-io.systemd.Unit.h" #include "varlink-io.systemd.UserDatabase.h" @@ -227,6 +228,7 @@ TEST(parse_format) { &vl_interface_io_systemd_Resolve_Monitor, &vl_interface_io_systemd_Shutdown, &vl_interface_io_systemd_StorageProvider, + &vl_interface_io_systemd_SysUpdate_Notify, &vl_interface_io_systemd_Udev, &vl_interface_io_systemd_Unit, &vl_interface_io_systemd_UserDatabase, diff --git a/src/shared/meson.build b/src/shared/meson.build index 8e874cb99d..52df34bb0f 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -250,6 +250,7 @@ shared_sources = files( 'varlink-io.systemd.Resolve.Monitor.c', 'varlink-io.systemd.Shutdown.c', 'varlink-io.systemd.StorageProvider.c', + 'varlink-io.systemd.SysUpdate.Notify.c', 'varlink-io.systemd.Udev.c', 'varlink-io.systemd.Unit.c', 'varlink-io.systemd.UserDatabase.c', diff --git a/src/shared/varlink-io.systemd.SysUpdate.Notify.c b/src/shared/varlink-io.systemd.SysUpdate.Notify.c new file mode 100644 index 0000000000..2fecc6005f --- /dev/null +++ b/src/shared/varlink-io.systemd.SysUpdate.Notify.c @@ -0,0 +1,20 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.SysUpdate.Notify.h" + +static SD_VARLINK_DEFINE_STRUCT_TYPE( + Resource, + SD_VARLINK_DEFINE_FIELD(transfer, SD_VARLINK_STRING, 0), + SD_VARLINK_DEFINE_FIELD(path, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); + +static SD_VARLINK_DEFINE_METHOD( + OnCompletedUpdate, + SD_VARLINK_DEFINE_INPUT(component, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_DEFINE_INPUT(version, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(resources, Resource, SD_VARLINK_ARRAY|SD_VARLINK_NULLABLE)); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_SysUpdate_Notify, + "io.systemd.SysUpdate.Notify", + &vl_type_Resource, + &vl_method_OnCompletedUpdate); diff --git a/src/shared/varlink-io.systemd.SysUpdate.Notify.h b/src/shared/varlink-io.systemd.SysUpdate.Notify.h new file mode 100644 index 0000000000..72deccb380 --- /dev/null +++ b/src/shared/varlink-io.systemd.SysUpdate.Notify.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_SysUpdate_Notify; diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 81ed4861e0..9f78ee78bf 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -3,11 +3,14 @@ #include #include "sd-daemon.h" +#include "sd-json.h" +#include "sd-varlink.h" #include "build.h" #include "conf-files.h" #include "constants.h" #include "dissect-image.h" +#include "env-util.h" #include "errno-util.h" #include "fd-util.h" #include "format-table.h" @@ -36,6 +39,7 @@ #include "sysupdate-transfer.h" #include "sysupdate-update-set.h" #include "sysupdate-util.h" +#include "varlink-util.h" #include "verbs.h" static char *arg_definitions = NULL; @@ -1192,6 +1196,82 @@ static int context_process_partial_and_pending( return 1; } +static int notify_subscribers_reply( + sd_varlink *link, + sd_json_variant *reply, + const char *error_id, + sd_varlink_reply_flags_t flags, + void *userdata) { + + assert(link); + + if (error_id) + log_warning("Notification subscriber '%s' returned error, ignoring: %s", + strna(sd_varlink_get_description(link)), error_id); + + return 0; +} + +static int context_notify_subscribers(Context *c, UpdateSet *us) { + int r; + + assert(c); + + /* 'us' is NULL when we are forced to notify even though no update was applied (via + * SYSTEMD_SYSUPDATE_FORCE_NOTIFY=1). In that case we send neither a version nor a resource list. */ + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *resources = NULL; + if (us) + for (size_t i = 0; i < c->n_transfers; i++) { + Instance *inst = us->instances[i]; + Transfer *t = c->transfers[i]; + + if (inst->resource == &t->target && + !inst->is_pending) + continue; + + /* Report where the resource was installed *to* (not the source it came from): the + * final on-disk path for filesystem targets, the partition device node for partition + * targets. */ + const char *target_path = + RESOURCE_IS_FILESYSTEM(t->target.type) ? t->final_path : + t->target.type == RESOURCE_PARTITION ? t->partition_info.device : + NULL; + + r = sd_json_variant_append_arraybo( + &resources, + SD_JSON_BUILD_PAIR_STRING("transfer", t->id), + SD_JSON_BUILD_PAIR_CONDITION(!!target_path, "path", SD_JSON_BUILD_STRING(target_path))); + if (r < 0) + return log_warning_errno(r, "Failed to build sysupdate notify resources list, skipping notification: %m"); + } + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *params = NULL; + r = sd_json_buildo( + ¶ms, + SD_JSON_BUILD_PAIR_CONDITION(!!arg_component, "component", SD_JSON_BUILD_STRING(arg_component)), + SD_JSON_BUILD_PAIR_CONDITION(!!us, "version", SD_JSON_BUILD_STRING(us ? us->version : NULL)), + SD_JSON_BUILD_PAIR_CONDITION(!!resources, "resources", SD_JSON_BUILD_VARIANT(resources))); + if (r < 0) + return log_warning_errno(r, "Failed to build sysupdate notify parameters, skipping notification: %m"); + + ssize_t n = varlink_execute_directory( + VARLINK_DIR_SYSUPDATE_NOTIFY_HOOK, + "io.systemd.SysUpdate.Notify.OnCompletedUpdate", + params, + /* more= */ false, + /* timeout_usec= */ 5 * USEC_PER_MINUTE, + notify_subscribers_reply, + /* userdata= */ NULL); + if (n < 0) + log_debug_errno(n, "Failed to dispatch sysupdate notification to %s, ignoring: %m", + VARLINK_DIR_SYSUPDATE_NOTIFY_HOOK); + else if (n > 0) + log_debug("Dispatched sysupdate notification to %zi subscribers in %s.", n, VARLINK_DIR_SYSUPDATE_NOTIFY_HOOK); + + return 0; +} + static int context_install( Context *c, const char *version, @@ -1236,6 +1316,9 @@ static int context_install( log_info("%s Successfully installed update '%s'.", glyph(GLYPH_SPARKLES), us->version); + if (!arg_root) + (void) context_notify_subscribers(c, us); + (void) sd_notifyf(/* unset_environment= */ false, "STATUS=Installed '%s'.", us->version); @@ -1625,6 +1708,17 @@ static int verb_update_impl(int argc, char **argv, UpdateActionFlags action_flag installed = r > 0; } + + /* context_install() returns > 0 (and emits a notification) only if it actually applied an update. If + * nothing was applied but SYSTEMD_SYSUPDATE_FORCE_NOTIFY=1 is set, still notify subscribers (without a + * resource list), so e.g. a kernel/policy refresh can be triggered unconditionally. */ + if ((action_flags & UPDATE_ACTION_INSTALL) && !installed) { + int f = secure_getenv_bool("SYSTEMD_SYSUPDATE_FORCE_NOTIFY"); + if (f < 0 && f != -ENXIO) + log_debug_errno(f, "Failed to parse $SYSTEMD_SYSUPDATE_FORCE_NOTIFY, ignoring: %m"); + if (f > 0) + (void) context_notify_subscribers(context, /* us= */ NULL); + } } if (arg_cleanup > 0) From 27b7fb9b2065b4da56f532aca103a04811d70450 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 11:41:45 +0200 Subject: [PATCH 3/9] pcrlock: recompute PCR policy on completed system update Bind the io.systemd.SysUpdate.Notify.OnCompletedUpdate() method in the pcrlock Varlink server and hook a socket into /run/systemd/sysupdate/notify/ via systemd-sysupdate-notify-pcrlock.socket, enabled by default via the preset. When sysupdate signals a completed update, we unconditionally re-run make-policy, since the set of measured components may have changed. --- presets/90-systemd.preset | 1 + src/pcrlock/pcrlock.c | 36 ++++++++++++++++--- units/meson.build | 8 +++++ units/systemd-sysupdate-notify-pcrlock.socket | 29 +++++++++++++++ ...stemd-sysupdate-notify-pcrlock@.service.in | 20 +++++++++++ 5 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 units/systemd-sysupdate-notify-pcrlock.socket create mode 100644 units/systemd-sysupdate-notify-pcrlock@.service.in diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset index 85108dcdd5..ace5d6d980 100644 --- a/presets/90-systemd.preset +++ b/presets/90-systemd.preset @@ -40,6 +40,7 @@ enable systemd-report-sign-tsm.socket enable systemd-report.socket enable systemd-resolved.service enable systemd-sysext.service +enable systemd-sysupdate-notify-pcrlock.socket enable systemd-timesyncd.service enable systemd-tpm2-clear.service enable systemd-userdbd.socket diff --git a/src/pcrlock/pcrlock.c b/src/pcrlock/pcrlock.c index 5aba4e966b..53b9b8a155 100644 --- a/src/pcrlock/pcrlock.c +++ b/src/pcrlock/pcrlock.c @@ -61,6 +61,7 @@ #include "unit-name.h" #include "utf8.h" #include "varlink-io.systemd.PCRLock.h" +#include "varlink-io.systemd.SysUpdate.Notify.h" #include "varlink-util.h" #include "verbs.h" @@ -5467,6 +5468,27 @@ static int vl_method_remove_policy(sd_varlink *link, sd_json_variant *parameters return sd_varlink_reply(link, NULL); } +static int vl_method_on_completed_update(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + int r; + + assert(link); + + /* Triggered by systemd-sysupdate after an update completed. We deliberately ignore all parameters + * (we don't even dispatch them) and simply recompute the PCR policy unconditionally, since the set + * of measured components might have changed. */ + + /* Only honour update notifications if they come from root */ + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + + r = make_policy(/* force= */ false, /* recovery_pin_mode= */ RECOVERY_PIN_HIDE); + if (r < 0) + return r; + + return sd_varlink_reply(link, NULL); +} + static int run(int argc, char *argv[]) { int r; @@ -5496,15 +5518,19 @@ static int run(int argc, char *argv[]) { if (r < 0) return log_error_errno(r, "Failed to allocate Varlink server: %m"); - r = sd_varlink_server_add_interface(varlink_server, &vl_interface_io_systemd_PCRLock); + r = sd_varlink_server_add_interface_many( + varlink_server, + &vl_interface_io_systemd_PCRLock, + &vl_interface_io_systemd_SysUpdate_Notify); if (r < 0) - return log_error_errno(r, "Failed to add Varlink interface: %m"); + return log_error_errno(r, "Failed to add Varlink interfaces: %m"); r = sd_varlink_server_bind_method_many( varlink_server, - "io.systemd.PCRLock.ReadEventLog", vl_method_read_event_log, - "io.systemd.PCRLock.MakePolicy", vl_method_make_policy, - "io.systemd.PCRLock.RemovePolicy", vl_method_remove_policy); + "io.systemd.PCRLock.ReadEventLog", vl_method_read_event_log, + "io.systemd.PCRLock.MakePolicy", vl_method_make_policy, + "io.systemd.PCRLock.RemovePolicy", vl_method_remove_policy, + "io.systemd.SysUpdate.Notify.OnCompletedUpdate", vl_method_on_completed_update); if (r < 0) return log_error_errno(r, "Failed to bind Varlink methods: %m"); diff --git a/units/meson.build b/units/meson.build index f261fded6f..f2e1006bad 100644 --- a/units/meson.build +++ b/units/meson.build @@ -706,6 +706,14 @@ units = [ 'conditions' : ['ENABLE_BOOTLOADER', 'HAVE_OPENSSL', 'HAVE_TPM2'], 'symlinks' : ['sockets.target.wants/'], }, + { + 'file' : 'systemd-sysupdate-notify-pcrlock@.service.in', + 'conditions' : ['ENABLE_BOOTLOADER', 'HAVE_OPENSSL', 'HAVE_TPM2'], + }, + { + 'file' : 'systemd-sysupdate-notify-pcrlock.socket', + 'conditions' : ['ENABLE_BOOTLOADER', 'HAVE_OPENSSL', 'HAVE_TPM2'], + }, { 'file' : 'systemd-portabled.service.in', 'conditions' : ['ENABLE_PORTABLED'], diff --git a/units/systemd-sysupdate-notify-pcrlock.socket b/units/systemd-sysupdate-notify-pcrlock.socket new file mode 100644 index 0000000000..6b26911bcc --- /dev/null +++ b/units/systemd-sysupdate-notify-pcrlock.socket @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Recompute TPM PCR Policy on Completed System Update +Documentation=man:systemd-pcrlock(8) +DefaultDependencies=no +After=tpm2.target +Before=sockets.target +ConditionSecurity=measured-uki + +[Socket] +ListenStream=/run/systemd/sysupdate/notify/io.systemd.PCRLock +FileDescriptorName=varlink +SocketMode=0644 +Accept=yes +MaxConnectionsPerSource=16 +XAttrEntryPoint=user.varlink=entrypoint +XAttrListen=user.varlink=listen +XAttrAccept=user.varlink=server + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-sysupdate-notify-pcrlock@.service.in b/units/systemd-sysupdate-notify-pcrlock@.service.in new file mode 100644 index 0000000000..50108b6f07 --- /dev/null +++ b/units/systemd-sysupdate-notify-pcrlock@.service.in @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Recompute TPM PCR Policy on Completed System Update +Documentation=man:systemd-pcrlock(8) +DefaultDependencies=no +Conflicts=shutdown.target +After=systemd-tpm2-setup.service +After=systemd-remount-fs.service var.mount +Before=shutdown.target + +[Service] +ExecStart={{LIBEXECDIR}}/systemd-pcrlock --location=770 From 1421e6c5f49e3e54b54109fa2668f79cf451e14b Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 11:42:21 +0200 Subject: [PATCH 4/9] bootctl: add link-auto/LinkAuto and auto-link on completed system update Add a "bootctl link-auto" verb and a matching io.systemd.BootControl.LinkAuto() Varlink method that behave exactly like "bootctl link" / Link(), except that the UKI and extra resources are discovered automatically instead of being passed in. The following directories are searched, in decreasing priority: /etc/systemd/uki/, /run/systemd/uki/, /var/lib/systemd/uki/ (where systemd-sysupdate stages downloaded resources), /usr/local/lib/systemd/uki/ and /usr/lib/systemd/uki/. - the UKI is taken from kernel.efi, or the best version in kernel.efi.v/ (resolved via vpick, without honouring boot-counting suffixes), from the highest-priority directory that has one; - extra resources are picked up from extras.d/, matching *.sysext.raw, *.confext.raw and *.cred, each either as a plain file or as a versioned *.v/ directory resolved via vpick, combined across all directories with higher-priority directories winning on conflicts. Everything is resolved relative to the pinned root directory fd. Files passed via --extra= on the command line are linked in addition to the auto-discovered ones. Also bind io.systemd.SysUpdate.Notify.OnCompletedUpdate() in the boot control Varlink server, which simply does the same as LinkAuto(), and hook a socket into /run/systemd/sysupdate/notify/ via systemd-sysupdate-notify-bootctl.socket (enabled by default via the preset) so a freshly downloaded kernel is linked into $BOOT automatically after a sysupdate run. --- man/bootctl.xml | 54 ++ presets/90-systemd.preset | 1 + shell-completion/bash/bootctl | 2 +- shell-completion/zsh/_bootctl | 1 + src/bootctl/bootctl-install.c | 5 + src/bootctl/bootctl-link.c | 758 ++++++++++++++---- src/bootctl/bootctl-link.h | 3 + src/bootctl/bootctl-reboot-to-firmware.c | 5 + src/bootctl/bootctl-unlink.c | 5 + src/bootctl/bootctl.c | 25 +- src/shared/varlink-io.systemd.BootControl.c | 23 + units/meson.build | 8 + units/systemd-sysupdate-notify-bootctl.socket | 29 + .../systemd-sysupdate-notify-bootctl@.service | 19 + 14 files changed, 791 insertions(+), 147 deletions(-) create mode 100644 units/systemd-sysupdate-notify-bootctl.socket create mode 100644 units/systemd-sysupdate-notify-bootctl@.service diff --git a/man/bootctl.xml b/man/bootctl.xml index 39ddfdd58c..bf5d2bf230 100644 --- a/man/bootctl.xml +++ b/man/bootctl.xml @@ -147,6 +147,34 @@ + + + + Like link, but instead of taking the kernel and sidecar resources + as arguments, discovers them automatically. The following directories are searched, in decreasing + order of priority: /etc/systemd/uki/, /run/systemd/uki/, + /var/lib/systemd/uki/ (where + systemd-sysupdate8 + stages downloaded resources), /usr/local/lib/systemd/uki/ and + /usr/lib/systemd/uki/. In each directory the UKI is taken from + kernel.efi, or — if absent — the newest version in the versioned directory + kernel.efi.v/ (see + systemd.v7); the + UKI from the highest-priority directory that has one is used. Sidecar resources are picked up from + the extras.d/ subdirectory of each, matching the suffixes + .sysext.raw, .confext.raw and .cred, each + either as a plain file or as a versioned .v/ directory; resources are combined + across all directories, with the same resource in a higher-priority directory taking precedence. Any + files specified with are linked in addition to the discovered ones. + + This is used by + systemd-sysupdate8 + when it completes an update, so a freshly downloaded kernel and its sidecar resources are linked into + place without specifying any paths. + + + + @@ -845,6 +873,32 @@ Boot Loader Entries: + + Files + + + + /etc/systemd/uki/ + /run/systemd/uki/ + /var/lib/systemd/uki/ + /usr/local/lib/systemd/uki/ + /usr/lib/systemd/uki/ + + The link-auto command discovers the UKI to install from these + directories (listed in decreasing order of priority). It uses kernel.efi or the + versioned kernel.efi.v/ directory from the highest-priority directory that + contains one. Additional sidecar resources (system extension images, configuration extension images, + credential files) are picked up from the extras.d/ subdirectory of each, combined + across all directories. /var/lib/systemd/uki/ is the directory where + systemd-sysupdate8 + is supposed to place downloaded kernels, system extensions, configuration extensions and + credentials. + + + + + + See Also diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset index ace5d6d980..9ecebf0e08 100644 --- a/presets/90-systemd.preset +++ b/presets/90-systemd.preset @@ -40,6 +40,7 @@ enable systemd-report-sign-tsm.socket enable systemd-report.socket enable systemd-resolved.service enable systemd-sysext.service +enable systemd-sysupdate-notify-bootctl.socket enable systemd-sysupdate-notify-pcrlock.socket enable systemd-timesyncd.service enable systemd-tpm2-clear.service diff --git a/shell-completion/bash/bootctl b/shell-completion/bash/bootctl index 3b7290c230..16aafbe4ce 100644 --- a/shell-completion/bash/bootctl +++ b/shell-completion/bash/bootctl @@ -87,7 +87,7 @@ _bootctl() { fi local -A VERBS=( - [STANDALONE]='help status install update remove is-installed random-seed list set-timeout set-timeout-oneshot cleanup' + [STANDALONE]='help status install update remove is-installed random-seed list set-timeout set-timeout-oneshot cleanup link-auto' [BOOTENTRY]='set-default set-oneshot set-sysfail set-preferred unlink' [BOOLEAN]='reboot-to-firmware' [FILE]='kernel-identify kernel-inspect link' diff --git a/shell-completion/zsh/_bootctl b/shell-completion/zsh/_bootctl index 423828ba33..46956e55e3 100644 --- a/shell-completion/zsh/_bootctl +++ b/shell-completion/zsh/_bootctl @@ -75,6 +75,7 @@ _bootctl_reboot-to-firmware() { "set-timeout-oneshot:Set the menu timeout for the next boot only" "unlink:Remove boot loader entry" "link:Add boot loader entry" + "link-auto:Add boot loader entry for kernel and resources staged in /var/lib/systemd/uki/" "cleanup:Remove files in ESP not referenced in any boot entry" "kernel-identify:Identify kernel image type" "kernel-inspect:Print details about the kernel image" diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index 834d46faca..f8ba48a485 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -45,6 +45,7 @@ #include "tmpfile-util.h" #include "umask-util.h" #include "utf8.h" +#include "varlink-util.h" typedef enum InstallOperation { INSTALL_NEW, @@ -2121,6 +2122,10 @@ int vl_method_install( if (r != 0) return r; + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + if (!IN_SET(p.context.operation, INSTALL_NEW, INSTALL_UPDATE)) return sd_varlink_error_invalid_parameter_name(link, "operation"); diff --git a/src/bootctl/bootctl-link.c b/src/bootctl/bootctl-link.c index b8372752b5..af0955b64a 100644 --- a/src/bootctl/bootctl-link.c +++ b/src/bootctl/bootctl-link.c @@ -32,6 +32,7 @@ #include "parse-argument.h" #include "path-util.h" #include "recurse-dir.h" +#include "set.h" #include "stat-util.h" #include "stdio-util.h" #include "string-util.h" @@ -39,6 +40,8 @@ #include "tmpfile-util.h" #include "uki.h" #include "utf8.h" +#include "varlink-util.h" +#include "vpick.h" /* Keeps track of an "extra" file to associate with the type 1 entries to generate */ typedef struct ExtraFile { @@ -205,77 +208,14 @@ static void link_context_done(LinkContext *c) { c->linked_ids = strv_free(c->linked_ids); } -static int link_context_from_cmdline(LinkContext *ret, const char *kernel) { +static int link_context_acquire_dollar_boot(LinkContext *b) { int r; - assert(ret); - assert(kernel); - - _cleanup_(link_context_done) LinkContext b = LINK_CONTEXT_NULL; - b.entry_token_type = arg_entry_token_type; - b.tries_left = arg_tries_left; - b.entry_commit = arg_entry_commit; - b.keep_free = arg_keep_free; - - if (strdup_to(&b.entry_token, arg_entry_token) < 0 || - strdup_to(&b.entry_title, arg_entry_title) < 0 || - strdup_to(&b.entry_version, arg_entry_version) < 0) - return log_oom(); - - if (arg_root) { - b.root_fd = open(arg_root, O_CLOEXEC|O_DIRECTORY|O_PATH); - if (b.root_fd < 0) - return log_error_errno(errno, "Failed to open root directory '%s': %m", arg_root); - - if (strdup_to(&b.root, arg_root) < 0) - return log_oom(); - } else - b.root_fd = XAT_FDROOT; - - r = path_extract_filename(kernel, &b.kernel_filename); - if (r < 0) - return log_error_errno(r, "Failed to extract filename from kernel path '%s': %m", kernel); - if (!efi_loader_entry_resource_filename_valid(b.kernel_filename)) - return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Kernel '%s' is not suitable for reference in a boot menu entry.", kernel); - b.kernel_fd = xopenat_full(AT_FDCWD, kernel, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY, XO_REGULAR, /* mode= */ MODE_INVALID); - if (b.kernel_fd < 0) - return log_error_errno(b.kernel_fd, "Failed to open kernel path '%s': %m", kernel); - - KernelImageType kit = _KERNEL_IMAGE_TYPE_INVALID; - r = inspect_kernel(b.kernel_fd, /* filename= */ NULL, &kit); - if (r == -EBADMSG) - return log_error_errno(r, "Kernel image '%s' is not valid.", kernel); - if (r < 0) - return log_error_errno(r, "Failed to determine kernel image type of '%s': %m", kernel); - if (kit != KERNEL_IMAGE_TYPE_UKI) - return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "Kernel image '%s' is not a UKI.", kernel); - - STRV_FOREACH(x, arg_extras) { - _cleanup_free_ char *fn = NULL; - r = path_extract_filename(*x, &fn); - if (r < 0) - return log_error_errno(r, "Failed to extract filename from path '%s': %m", *x); - if (r == O_DIRECTORY) - return log_error_errno(SYNTHETIC_ERRNO(EISDIR), "Extra file path '%s' does not refer to regular file.", *x); - - _cleanup_close_ int fd = -EBADF; - fd = xopenat_full(AT_FDCWD, *x, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY, XO_REGULAR, /* mode= */ MODE_INVALID); - if (fd < 0) - return log_error_errno(fd, "Failed to open '%s': %m", *x); - - if (!GREEDY_REALLOC(b.extra, b.n_extra+1)) - return log_oom(); - - b.extra[b.n_extra++] = (ExtraFile) { - .source_fd = TAKE_FD(fd), - .filename = TAKE_PTR(fn), - .temp_fd = -EBADF, - }; - } + assert(b); r = acquire_xbootldr( /* unprivileged_mode= */ false, - &b.dollar_boot_fd, + &b->dollar_boot_fd, /* ret_uuid= */ NULL, /* ret_devid= */ NULL); if (r < 0) @@ -288,19 +228,19 @@ static int link_context_from_cmdline(LinkContext *ret, const char *kernel) { if (!e) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "XBOOTLDR path '%s' not below specified root '%s', refusing.", arg_xbootldr_path, arg_root); - r = strdup_to(&b.dollar_boot_path, e); + r = strdup_to(&b->dollar_boot_path, e); } else - r = strdup_to(&b.dollar_boot_path, arg_xbootldr_path); + r = strdup_to(&b->dollar_boot_path, arg_xbootldr_path); if (r < 0) return log_oom(); - b.dollar_boot_source = BOOT_ENTRY_XBOOTLDR; + b->dollar_boot_source = BOOT_ENTRY_XBOOTLDR; } else { /* No XBOOTLDR has been found, look for ESP */ r = acquire_esp(/* unprivileged_mode= */ false, /* graceful= */ false, - &b.dollar_boot_fd, + &b->dollar_boot_fd, /* ret_part= */ NULL, /* ret_pstart= */ NULL, /* ret_psize= */ NULL, @@ -316,15 +256,126 @@ static int link_context_from_cmdline(LinkContext *ret, const char *kernel) { if (!e) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "ESP path '%s' not below specified root '%s', refusing.", arg_esp_path, arg_root); - r = strdup_to(&b.dollar_boot_path, e); + r = strdup_to(&b->dollar_boot_path, e); } else - r = strdup_to(&b.dollar_boot_path, arg_esp_path); + r = strdup_to(&b->dollar_boot_path, arg_esp_path); if (r < 0) return log_oom(); - b.dollar_boot_source = BOOT_ENTRY_ESP; + b->dollar_boot_source = BOOT_ENTRY_ESP; } + return 0; +} + +/* Fills the bits of a LinkContext that both "bootctl link" and "bootctl link-auto" derive from the global + * command line options (everything except the kernel image and extra files themselves). */ +static int link_context_from_cmdline_common(LinkContext *b) { + assert(b); + + b->entry_token_type = arg_entry_token_type; + b->tries_left = arg_tries_left; + b->entry_commit = arg_entry_commit; + b->keep_free = arg_keep_free; + + if (strdup_to(&b->entry_token, arg_entry_token) < 0 || + strdup_to(&b->entry_title, arg_entry_title) < 0 || + strdup_to(&b->entry_version, arg_entry_version) < 0) + return log_oom(); + + if (arg_root) { + b->root_fd = open(arg_root, O_CLOEXEC|O_DIRECTORY|O_PATH); + if (b->root_fd < 0) + return log_error_errno(errno, "Failed to open root directory '%s': %m", arg_root); + + if (strdup_to(&b->root, arg_root) < 0) + return log_oom(); + } else + b->root_fd = XAT_FDROOT; + + return 0; +} + +/* Appends the files passed via --extra= on the command line as extra resources to the link context. */ +static int link_context_add_cmdline_extras(LinkContext *b) { + int r; + + assert(b); + + STRV_FOREACH(x, arg_extras) { + _cleanup_free_ char *fn = NULL; + r = path_extract_filename(*x, &fn); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from path '%s': %m", *x); + if (r == O_DIRECTORY) + return log_error_errno(SYNTHETIC_ERRNO(EISDIR), "Extra file path '%s' does not refer to regular file.", *x); + + _cleanup_close_ int fd = xopenat_full(AT_FDCWD, *x, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY, XO_REGULAR, /* mode= */ MODE_INVALID); + if (fd < 0) + return log_error_errno(fd, "Failed to open '%s': %m", *x); + + if (!GREEDY_REALLOC(b->extra, b->n_extra+1)) + return log_oom(); + + b->extra[b->n_extra++] = (ExtraFile) { + .source_fd = TAKE_FD(fd), + .filename = TAKE_PTR(fn), + .temp_fd = -EBADF, + }; + } + + return 0; +} + +static int validate_kernel(int kernel_fd, const char *filename) { + int r; + + assert(kernel_fd >= 0); + + KernelImageType kit = _KERNEL_IMAGE_TYPE_INVALID; + r = inspect_kernel(kernel_fd, /* filename= */ NULL, &kit); + if (r == -EBADMSG) + return log_error_errno(r, "UKI '%s' is not valid.", filename); + if (r < 0) + return log_error_errno(r, "Failed to determine kernel image type of '%s': %m", filename); + if (kit != KERNEL_IMAGE_TYPE_UKI) + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "Image '%s' is not a UKI.", filename); + + return 0; +} + +static int link_context_from_cmdline(LinkContext *ret, const char *kernel) { + int r; + + assert(ret); + assert(kernel); + + _cleanup_(link_context_done) LinkContext b = LINK_CONTEXT_NULL; + r = link_context_from_cmdline_common(&b); + if (r < 0) + return r; + + r = path_extract_filename(kernel, &b.kernel_filename); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from kernel path '%s': %m", kernel); + if (!efi_loader_entry_resource_filename_valid(b.kernel_filename)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Kernel '%s' is not suitable for reference in a boot menu entry.", kernel); + b.kernel_fd = xopenat_full(AT_FDCWD, kernel, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY, XO_REGULAR, /* mode= */ MODE_INVALID); + if (b.kernel_fd < 0) + return log_error_errno(b.kernel_fd, "Failed to open kernel path '%s': %m", kernel); + + r = validate_kernel(b.kernel_fd, kernel); + if (r < 0) + return r; + + r = link_context_add_cmdline_extras(&b); + if (r < 0) + return r; + + r = link_context_acquire_dollar_boot(&b); + if (r < 0) + return r; + *ret = TAKE_GENERIC(b, LinkContext, LINK_CONTEXT_NULL); return 0; } @@ -993,6 +1044,311 @@ int verb_link(int argc, char *argv[], uintptr_t data, void *userdata) { return run_link(&c); } +/* Directories (relative to the operative root) below which "bootctl link-auto" looks for a staged UKI and + * extra resources, in decreasing priority order. /var/lib/systemd/uki/ is where systemd-sysupdate stages + * downloaded resources; the others follow the usual configuration search path precedence, with + * /var/lib/ slotted in just below the volatile locations and above the vendor trees. */ +static const char* const uki_auto_dirs[] = { + "etc/systemd/uki", + "run/systemd/uki", + "var/lib/systemd/uki", + "usr/local/lib/systemd/uki", + "usr/lib/systemd/uki", +}; + +static const PickFilter pick_filter_uki = { + .type_mask = UINT32_C(1) << DT_REG, + .architecture = _ARCHITECTURE_INVALID, + .suffix = ".efi", +}; + +/* The recognized suffixes of extra resources we pick up alongside the UKI. Each may appear as a plain file + * (e.g. foo.sysext.raw) or as a versioned directory (e.g. foo.sysext.raw.v/) resolved via the vpick + * mechanism. */ +static const char* const auto_link_extra_suffixes[] = { + ".sysext.raw", + ".confext.raw", + ".cred", +}; + +static int link_context_add_extra(LinkContext *c, int dir_fd, const char *path, const char *filename) { + assert(c); + assert(dir_fd >= 0); + assert(path); + assert(filename); + + if (!efi_loader_entry_resource_filename_valid(filename)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Extra file '%s' is not suitable for reference in a boot menu entry, refusing.", filename); + + _cleanup_close_ int fd = xopenat_full(dir_fd, path, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY, XO_REGULAR, /* mode= */ MODE_INVALID); + if (fd < 0) + return log_error_errno(fd, "Failed to open extra file '%s': %m", path); + + _cleanup_free_ char *fn = strdup(filename); + if (!fn) + return log_oom(); + + if (!GREEDY_REALLOC(c->extra, c->n_extra + 1)) + return log_oom(); + + c->extra[c->n_extra++] = (ExtraFile) { + .source_fd = TAKE_FD(fd), + .filename = TAKE_PTR(fn), + .temp_fd = -EBADF, + }; + + return 0; +} + +/* Adds the extra resources found in /extras.d/ to the link context. The 'seen' set tracks the + * logical resource names already added by a higher-priority directory, so the same resource appearing in + * multiple directories is taken from the highest-priority one only. */ +static int link_context_add_extras(LinkContext *c, int uki_dir_fd, Set **seen) { + int r; + + assert(c); + assert(uki_dir_fd >= 0); + assert(seen); + + _cleanup_close_ int extras_dir_fd = chase_and_openat( + c->root_fd, + uki_dir_fd, + "extras.d", + /* chase_flags= */ 0, + O_DIRECTORY|O_CLOEXEC, + /* ret_path= */ NULL); + if (extras_dir_fd == -ENOENT) + return 0; + if (extras_dir_fd < 0) + return log_error_errno(extras_dir_fd, "Failed to open extras.d/ directory: %m"); + + _cleanup_free_ DirectoryEntries *des = NULL; + r = readdir_all(extras_dir_fd, RECURSE_DIR_IGNORE_DOT|RECURSE_DIR_ENSURE_TYPE|RECURSE_DIR_SORT, &des); + if (r < 0) + return log_error_errno(r, "Failed to enumerate extras.d/ directory: %m"); + + FOREACH_ARRAY(i, des->entries, des->n_entries) { + struct dirent *de = *i; + + if (!IN_SET(de->d_type, DT_DIR, DT_REG)) + continue; + + FOREACH_ELEMENT(suffix, auto_link_extra_suffixes) { + _cleanup_free_ char *dot_v = strjoin(*suffix, ".v"); + if (!dot_v) + return log_oom(); + + bool versioned; + _cleanup_free_ char *key = NULL; + if (endswith(de->d_name, *suffix)) { + if (de->d_type != DT_REG) + continue; + + versioned = false; + key = strdup(de->d_name); + + } else if (endswith(de->d_name, dot_v)) { + if (de->d_type != DT_DIR) + continue; + + /* The dedup key is the directory entry name with any trailing ".v" stripped, so that + * a plain file and a versioned directory of the same resource collapse, and a + * higher-priority directory wins. */ + versioned = true; + key = strndup(de->d_name, strlen(de->d_name) - strlen(".v")); + } else + continue; + if (!key) + return log_oom(); + + if (set_contains(*seen, key)) + break; + + if (versioned) { + /* A versioned directory: resolve the best version via vpick, relative to the + * extras directory fd. We deliberately do not honour boot-counting suffixes + * here. */ + _cleanup_(pick_result_done) PickResult pick = PICK_RESULT_NULL; + const PickFilter filter = { + .type_mask = UINT32_C(1) << DT_REG, + .architecture = _ARCHITECTURE_INVALID, + .suffix = *suffix, + }; + + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ c->root_fd, + /* dir_fd= */ extras_dir_fd, + de->d_name, + &filter, + /* n_filters= */ 1, + /* flags= */ 0, + &pick); + if (r == -ENOENT || r == 0) { + log_debug("No matching entry in versioned directory '%s', skipping.", de->d_name); + break; + } + if (r < 0) + return log_error_errno(r, "Failed to pick version in '%s': %m", de->d_name); + + _cleanup_free_ char *fn = NULL; + r = path_extract_filename(pick.path, &fn); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from '%s': %m", pick.path); + + r = link_context_add_extra(c, extras_dir_fd, pick.path, fn); + } else + /* A plain file. */ + r = link_context_add_extra(c, extras_dir_fd, de->d_name, de->d_name); + if (r < 0) + return r; + + r = set_ensure_consume(seen, &string_hash_ops_free, TAKE_PTR(key)); + if (r < 0) + return log_oom(); + + break; + } + } + + return 0; +} + +/* Looks for a UKI (kernel.efi, or the best version in kernel.efi.v/) in the directory referenced by + * uki_dir_fd and, if found, fills c->kernel_fd and c->kernel_filename. Returns 1 if found, 0 if not, and a + * negative errno on error. We deliberately do not honour boot-counting suffixes here. */ +static int link_context_find_kernel(LinkContext *c, int uki_dir_fd) { + int r; + + assert(c); + assert(uki_dir_fd >= 0); + + _cleanup_free_ char *filename = NULL; + _cleanup_close_ int kernel_fd = xopenat_full( + uki_dir_fd, "kernel.efi", + O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY|O_NOFOLLOW, + XO_REGULAR, + /* mode= */ MODE_INVALID); + if (kernel_fd == -ENOENT) { + _cleanup_(pick_result_done) PickResult pick = PICK_RESULT_NULL; + r = path_pick(/* root_path= */ NULL, + /* root_fd= */ c->root_fd, + /* dir_fd= */ uki_dir_fd, + "kernel.efi.v", + &pick_filter_uki, + /* n_filters= */ 1, + /* flags= */ 0, + &pick); + if (r == -ENOENT || r == 0) + return 0; + if (r < 0) + return log_error_errno(r, "Failed to pick UKI version in 'kernel.efi.v': %m"); + + r = path_extract_filename(pick.path, &filename); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from '%s': %m", pick.path); + + kernel_fd = fd_reopen(pick.fd, O_RDONLY|O_CLOEXEC|O_NONBLOCK|O_NOCTTY); + if (kernel_fd < 0) + return log_error_errno(kernel_fd, "Failed to open UKI '%s': %m", pick.path); + + r = fd_verify_regular(kernel_fd); + if (r < 0) + return log_error_errno(r, "UKI '%s' is not a regular file: %m", pick.path); + + } else if (kernel_fd < 0) + return log_error_errno(kernel_fd, "Failed to open 'kernel.efi': %m"); + else { + filename = strdup("kernel.efi"); + if (!filename) + return log_oom(); + } + + if (!efi_loader_entry_resource_filename_valid(filename)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "UKI '%s' is not suitable for reference in a boot menu entry.", filename); + + r = validate_kernel(kernel_fd, filename); + if (r < 0) + return r; + + c->kernel_fd = TAKE_FD(kernel_fd); + c->kernel_filename = TAKE_PTR(filename); + return 1; +} + +/* Discovers the staged UKI and extra resources below the uki_auto_dirs[] (relative to the already-pinned + * c->root_fd) and fills c->kernel_fd, c->kernel_filename and c->extra[] accordingly. The directories are + * searched in priority order: the UKI is taken from the first directory that has one, and extra resources + * are combined across all directories with higher-priority directories winning on conflicts. Returns 1 if a + * UKI was found, 0 if there is nothing to link, and a negative errno on error. */ +static int link_context_discover_resources(LinkContext *c) { + _cleanup_set_free_ Set *seen = NULL; + int r; + + assert(c); + + FOREACH_ELEMENT(dir, uki_auto_dirs) { + _cleanup_close_ int uki_dir_fd = chase_and_openat( + /* root_fd= */ c->root_fd, + /* dir_fd= */ c->root_fd, + *dir, + CHASE_MUST_BE_DIRECTORY, + O_RDONLY|O_DIRECTORY|O_CLOEXEC, + /* ret_path= */ NULL); + if (uki_dir_fd == -ENOENT) + continue; + if (uki_dir_fd < 0) + return log_error_errno(uki_dir_fd, "Failed to open '%s': %m", *dir); + + /* Take the UKI from the first (highest-priority) directory that has one. */ + if (c->kernel_fd < 0) { + r = link_context_find_kernel(c, uki_dir_fd); + if (r < 0) + return r; + } + + r = link_context_add_extras(c, uki_dir_fd, &seen); + if (r < 0) + return r; + } + + if (c->kernel_fd < 0) { + log_debug("No UKI found in any of the search directories, nothing to link."); + return 0; + } + + return 1; +} + +int verb_link_auto(int argc, char *argv[], uintptr_t data, void *userdata) { + int r; + + assert(argc == 1); + + _cleanup_(link_context_done) LinkContext c = LINK_CONTEXT_NULL; + r = link_context_from_cmdline_common(&c); + if (r < 0) + return r; + + r = link_context_discover_resources(&c); + if (r < 0) + return r; + if (r == 0) { + log_info("No UKI staged for linking, nothing to do."); + return 0; + } + + /* In addition to the auto-discovered resources, also honour any files passed via --extra=. */ + r = link_context_add_cmdline_extras(&c); + if (r < 0) + return r; + + r = link_context_acquire_dollar_boot(&c); + if (r < 0) + return r; + + return run_link(&c); +} + static JSON_DISPATCH_ENUM_DEFINE(json_dispatch_boot_entry_token_type, BootEntryTokenType, boot_entry_token_type_from_string); typedef struct LinkParameters { @@ -1089,6 +1445,107 @@ static int dispatch_extras(const char *name, sd_json_variant *v, sd_json_dispatc return 0; } +/* Resolves the root_fd of the LinkContext from the dispatched Link/LinkAuto parameters, fills in the + * boot entry token type default, and validates the entry title/version/commit fields. Returns 0 on success, + * non-zero (Varlink error or negative errno) otherwise. */ +static int vl_link_prepare(sd_varlink *link, LinkParameters *p) { + int r; + + assert(link); + assert(p); + + if (p->root_fd_index != UINT_MAX) { + p->context.root_fd = sd_varlink_peek_dup_fd(link, p->root_fd_index); + if (p->context.root_fd < 0) + return log_debug_errno(p->context.root_fd, "Failed to acquire root fd from Varlink: %m"); + + r = fd_verify_safe_flags_full(p->context.root_fd, O_DIRECTORY); + if (r < 0) + return sd_varlink_error_invalid_parameter_name(link, "rootFileDescriptor"); + + r = fd_verify_directory(p->context.root_fd); + if (r < 0) + return log_debug_errno(r, "Specified file descriptor does not refer to a directory: %m"); + + if (!p->context.root) { + r = fd_get_path(p->context.root_fd, &p->context.root); + if (r < 0) + return log_debug_errno(r, "Failed to get path of file descriptor: %m"); + + if (empty_or_root(p->context.root)) + p->context.root = mfree(p->context.root); + } + } else if (p->context.root) { + p->context.root_fd = open(p->context.root, O_RDONLY|O_CLOEXEC|O_DIRECTORY); + if (p->context.root_fd < 0) + return log_debug_errno(errno, "Failed to open '%s': %m", p->context.root); + } else + p->context.root_fd = XAT_FDROOT; + + if (p->context.entry_token_type < 0) + p->context.entry_token_type = BOOT_ENTRY_TOKEN_AUTO; + + if (p->context.entry_title && !efi_loader_entry_title_valid(p->context.entry_title)) + return sd_varlink_error_invalid_parameter_name(link, "entryTitle"); + + if (p->context.entry_version && !version_is_valid_versionspec(p->context.entry_version)) + return sd_varlink_error_invalid_parameter_name(link, "entryVersion"); + + if (p->context.entry_commit != 0 && !entry_commit_valid(p->context.entry_commit)) + return sd_varlink_error_invalid_parameter_name(link, "entryCommit"); + + return 0; +} + +/* Resolves $BOOT, runs the link operation and sends the reply. Expects the kernel and extra files to already + * be set up in the context. If with_ids is true the reply carries the list of created entry IDs (as expected + * by Link()/LinkAuto()), otherwise an empty reply is sent (as expected by + * io.systemd.SysUpdate.Notify.OnCompletedUpdate(), which declares no output fields). */ +static int vl_link_finish(sd_varlink *link, LinkParameters *p, bool with_ids) { + int r; + + assert(link); + assert(p); + + r = find_xbootldr_and_warn_at( + p->context.root_fd, + /* path= */ NULL, + /* unprivileged_mode= */ false, + &p->context.dollar_boot_path, + &p->context.dollar_boot_fd); + if (r < 0) { + if (r != -ENOKEY) + return r; + + /* No XBOOTLDR found, let's look for ESP then. */ + + r = find_esp_and_warn_at( + p->context.root_fd, + /* path= */ NULL, + /* unprivileged_mode= */ false, + &p->context.dollar_boot_path, + &p->context.dollar_boot_fd); + if (r == -ENOKEY) + return sd_varlink_error(link, "io.systemd.BootControl.NoDollarBootFound", NULL); + if (r < 0) + return r; + + p->context.dollar_boot_source = BOOT_ENTRY_ESP; + } else + p->context.dollar_boot_source = BOOT_ENTRY_XBOOTLDR; + + r = run_link(&p->context); + if (r == -EUNATCH) /* no boot entry token is set */ + return sd_varlink_error(link, "io.systemd.BootControl.BootEntryTokenUnavailable", NULL); + if (r < 0) + return r; + + if (with_ids) + return sd_varlink_replybo(link, SD_JSON_BUILD_PAIR_STRV("ids", p->context.linked_ids)); + + return sd_varlink_reply(link, NULL); +} + int vl_method_link( sd_varlink *link, sd_json_variant *parameters, @@ -1125,45 +1582,13 @@ int vl_method_link( if (r != 0) return r; - if (p.root_fd_index != UINT_MAX) { - p.context.root_fd = sd_varlink_peek_dup_fd(link, p.root_fd_index); - if (p.context.root_fd < 0) - return log_debug_errno(p.context.root_fd, "Failed to acquire root fd from Varlink: %m"); + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; - r = fd_verify_safe_flags_full(p.context.root_fd, O_DIRECTORY); - if (r < 0) - return sd_varlink_error_invalid_parameter_name(link, "rootFileDescriptor"); - - r = fd_verify_directory(p.context.root_fd); - if (r < 0) - return log_debug_errno(r, "Specified file descriptor does not refer to a directory: %m"); - - if (!p.context.root) { - r = fd_get_path(p.context.root_fd, &p.context.root); - if (r < 0) - return log_debug_errno(r, "Failed to get path of file descriptor: %m"); - - if (empty_or_root(p.context.root)) - p.context.root = mfree(p.context.root); - } - } else if (p.context.root) { - p.context.root_fd = open(p.context.root, O_RDONLY|O_CLOEXEC|O_DIRECTORY); - if (p.context.root_fd < 0) - return log_debug_errno(errno, "Failed to open '%s': %m", p.context.root); - } else - p.context.root_fd = XAT_FDROOT; - - if (p.context.entry_token_type < 0) - p.context.entry_token_type = BOOT_ENTRY_TOKEN_AUTO; - - if (p.context.entry_title && !efi_loader_entry_title_valid(p.context.entry_title)) - return sd_varlink_error_invalid_parameter_name(link, "entryTitle"); - - if (p.context.entry_version && !version_is_valid_versionspec(p.context.entry_version)) - return sd_varlink_error_invalid_parameter_name(link, "entryVersion"); - - if (p.context.entry_commit != 0 && !entry_commit_valid(p.context.entry_commit)) - return sd_varlink_error_invalid_parameter_name(link, "entryCommit"); + r = vl_link_prepare(link, &p); + if (r != 0) + return r; p.context.kernel_fd = sd_varlink_peek_dup_fd(link, p.kernel_fd_index); if (p.context.kernel_fd < 0) @@ -1186,38 +1611,95 @@ int vl_method_link( if (kit != KERNEL_IMAGE_TYPE_UKI) return sd_varlink_error(link, "io.systemd.BootControl.InvalidKernelImage", NULL); - r = find_xbootldr_and_warn_at( - p.context.root_fd, - /* path= */ NULL, - /* unprivileged_mode= */ false, - &p.context.dollar_boot_path, - &p.context.dollar_boot_fd); - if (r < 0) { - if (r != -ENOKEY) - return r; + return vl_link_finish(link, &p, /* with_ids= */ true); +} - /* No XBOOTLDR found, let's look for ESP then. */ +int vl_method_link_auto( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { - r = find_esp_and_warn_at( - p.context.root_fd, - /* path= */ NULL, - /* unprivileged_mode= */ false, - &p.context.dollar_boot_path, - &p.context.dollar_boot_fd); - if (r == -ENOKEY) - return sd_varlink_error(link, "io.systemd.BootControl.NoDollarBootFound", NULL); - if (r < 0) - return r; + int r; - p.context.dollar_boot_source = BOOT_ENTRY_ESP; - } else - p.context.dollar_boot_source = BOOT_ENTRY_XBOOTLDR; + assert(link); - r = run_link(&p.context); - if (r == -EUNATCH) /* no boot entry token is set */ - return sd_varlink_error(link, "io.systemd.BootControl.BootEntryTokenUnavailable", NULL); + _cleanup_(link_parameters_done) LinkParameters p = { + .context = LINK_CONTEXT_NULL, + .root_fd_index = UINT_MAX, + .kernel_fd_index = UINT_MAX, + .link = link, + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "rootFileDescriptor", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, voffsetof(p, root_fd_index), 0 }, + { "rootDirectory", SD_JSON_VARIANT_STRING, json_dispatch_path, voffsetof(p, context.root), 0 }, + { "bootEntryTokenType", SD_JSON_VARIANT_STRING, json_dispatch_boot_entry_token_type, voffsetof(p, context.entry_token_type), 0 }, + { "entryTitle", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, voffsetof(p, context.entry_title), 0 }, + { "entryVersion", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, voffsetof(p, context.entry_version), 0 }, + { "entryCommit", SD_JSON_VARIANT_INTEGER, sd_json_dispatch_uint64, voffsetof(p, context.entry_commit), 0 }, + { "triesLeft", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, voffsetof(p, context.tries_left), 0 }, + { "keepFree", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, context.keep_free), 0 }, + {}, + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + r = varlink_check_privileged_peer(link); if (r < 0) return r; - return sd_varlink_replybo(link, SD_JSON_BUILD_PAIR_STRV("ids", p.context.linked_ids)); + r = vl_link_prepare(link, &p); + if (r != 0) + return r; + + r = link_context_discover_resources(&p.context); + if (r < 0) + return r; + if (r == 0) /* Nothing staged for linking. */ + return sd_varlink_replybo(link, SD_JSON_BUILD_PAIR_STRV("ids", STRV_EMPTY)); + + return vl_link_finish(link, &p, /* with_ids= */ true); +} + +int vl_method_on_completed_update( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + int r; + + assert(link); + + /* Only honour update notifications if they come from root */ + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + + /* Triggered by systemd-sysupdate after an update completed. We deliberately ignore all parameters + * (we don't even dispatch them) and behave like LinkAuto() with default parameters: discover the + * staged UKI and extra resources and link them in. Contrary to LinkAuto() we reply without any + * parameters, matching the io.systemd.SysUpdate.Notify.OnCompletedUpdate() signature. */ + + _cleanup_(link_parameters_done) LinkParameters p = { + .context = LINK_CONTEXT_NULL, + .root_fd_index = UINT_MAX, + .kernel_fd_index = UINT_MAX, + .link = link, + }; + + r = vl_link_prepare(link, &p); + if (r != 0) + return r; + + r = link_context_discover_resources(&p.context); + if (r < 0) + return r; + if (r == 0) /* Nothing staged for linking. */ + return sd_varlink_reply(link, NULL); + + return vl_link_finish(link, &p, /* with_ids= */ false); } diff --git a/src/bootctl/bootctl-link.h b/src/bootctl/bootctl-link.h index de64563b87..fb04c31511 100644 --- a/src/bootctl/bootctl-link.h +++ b/src/bootctl/bootctl-link.h @@ -4,5 +4,8 @@ #include "shared-forward.h" int verb_link(int argc, char *argv[], uintptr_t data, void *userdata); +int verb_link_auto(int argc, char *argv[], uintptr_t data, void *userdata); int vl_method_link(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); +int vl_method_link_auto(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); +int vl_method_on_completed_update(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); diff --git a/src/bootctl/bootctl-reboot-to-firmware.c b/src/bootctl/bootctl-reboot-to-firmware.c index aa5f186af9..42ee7286a0 100644 --- a/src/bootctl/bootctl-reboot-to-firmware.c +++ b/src/bootctl/bootctl-reboot-to-firmware.c @@ -11,6 +11,7 @@ #include "errno-util.h" #include "log.h" #include "parse-util.h" +#include "varlink-util.h" int verb_reboot_to_firmware(int argc, char *argv[], uintptr_t _data, void *userdata) { int r; @@ -61,6 +62,10 @@ int vl_method_set_reboot_to_firmware(sd_varlink *link, sd_json_variant *paramete if (r != 0) return r; + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + r = efi_set_reboot_to_firmware(b); if (ERRNO_IS_NEG_NOT_SUPPORTED(r)) return sd_varlink_error(link, "io.systemd.BootControl.RebootToFirmwareNotSupported", NULL); diff --git a/src/bootctl/bootctl-unlink.c b/src/bootctl/bootctl-unlink.c index 6e8e5c4bb1..44954d4fa3 100644 --- a/src/bootctl/bootctl-unlink.c +++ b/src/bootctl/bootctl-unlink.c @@ -26,6 +26,7 @@ #include "stat-util.h" #include "string-util.h" #include "strv.h" +#include "varlink-util.h" typedef struct UnlinkContext { char *root; @@ -593,6 +594,10 @@ int vl_method_unlink( if (p.id && !efi_loader_entry_name_valid(p.id)) return sd_varlink_error_invalid_parameter_name(link, "id"); + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + if (p.root_fd_index != UINT_MAX) { p.context.root_fd = sd_varlink_peek_dup_fd(link, p.root_fd_index); if (p.context.root_fd < 0) diff --git a/src/bootctl/bootctl.c b/src/bootctl/bootctl.c index b5cca6d222..6e522f01e6 100644 --- a/src/bootctl/bootctl.c +++ b/src/bootctl/bootctl.c @@ -43,6 +43,7 @@ #include "string-util.h" #include "strv.h" #include "varlink-io.systemd.BootControl.h" +#include "varlink-io.systemd.SysUpdate.Notify.h" #include "varlink-util.h" #include "verbs.h" #include "virt.h" @@ -380,6 +381,9 @@ VERB_SCOPE(, verb_unlink, "unlink", "ID", VERB_ANY, 2, 0, VERB_SCOPE(, verb_link, "link", "KERNEL", 2, 2, 0, "Create boot loader entry for specified kernel"); +VERB_SCOPE_NOARG(, verb_link_auto, "link-auto", + "Create boot loader entry for the kernel and extra resources staged in /var/lib/systemd/uki/"); + VERB_SCOPE_NOARG(, verb_cleanup, "cleanup", "Remove files in ESP not referenced in any boot entry"); @@ -822,18 +826,23 @@ static int vl_server(void) { if (r < 0) return log_error_errno(r, "Failed to allocate Varlink server: %m"); - r = sd_varlink_server_add_interface(varlink_server, &vl_interface_io_systemd_BootControl); + r = sd_varlink_server_add_interface_many( + varlink_server, + &vl_interface_io_systemd_BootControl, + &vl_interface_io_systemd_SysUpdate_Notify); if (r < 0) - return log_error_errno(r, "Failed to add Varlink interface: %m"); + return log_error_errno(r, "Failed to add Varlink interfaces: %m"); r = sd_varlink_server_bind_method_many( varlink_server, - "io.systemd.BootControl.ListBootEntries", vl_method_list_boot_entries, - "io.systemd.BootControl.SetRebootToFirmware", vl_method_set_reboot_to_firmware, - "io.systemd.BootControl.GetRebootToFirmware", vl_method_get_reboot_to_firmware, - "io.systemd.BootControl.Install", vl_method_install, - "io.systemd.BootControl.Link", vl_method_link, - "io.systemd.BootControl.Unlink", vl_method_unlink); + "io.systemd.BootControl.ListBootEntries", vl_method_list_boot_entries, + "io.systemd.BootControl.SetRebootToFirmware", vl_method_set_reboot_to_firmware, + "io.systemd.BootControl.GetRebootToFirmware", vl_method_get_reboot_to_firmware, + "io.systemd.BootControl.Install", vl_method_install, + "io.systemd.BootControl.Link", vl_method_link, + "io.systemd.BootControl.LinkAuto", vl_method_link_auto, + "io.systemd.BootControl.Unlink", vl_method_unlink, + "io.systemd.SysUpdate.Notify.OnCompletedUpdate", vl_method_on_completed_update); if (r < 0) return log_error_errno(r, "Failed to bind Varlink methods: %m"); diff --git a/src/shared/varlink-io.systemd.BootControl.c b/src/shared/varlink-io.systemd.BootControl.c index 62306f5d79..0be93990d9 100644 --- a/src/shared/varlink-io.systemd.BootControl.c +++ b/src/shared/varlink-io.systemd.BootControl.c @@ -185,6 +185,27 @@ static SD_VARLINK_DEFINE_METHOD( SD_VARLINK_FIELD_COMMENT("The IDs of the created boot loader entries."), SD_VARLINK_DEFINE_OUTPUT(ids, SD_VARLINK_STRING, SD_VARLINK_ARRAY)); +static SD_VARLINK_DEFINE_METHOD( + LinkAuto, + SD_VARLINK_FIELD_COMMENT("Index into array of file descriptors passed along with this message, pointing to file descriptor to root file system to operate on"), + SD_VARLINK_DEFINE_INPUT(rootFileDescriptor, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Root directory to operate relative to. If both this and rootFileDescriptor is specified, this is purely informational. If only this is specified, it is what will be used."), + SD_VARLINK_DEFINE_INPUT(rootDirectory, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Selects how to identify boot entries"), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(bootEntryTokenType, BootEntryTokenType, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The entry title for the newly created boot menu entry"), + SD_VARLINK_DEFINE_INPUT(entryTitle, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The entry version for the newly created boot menu entry"), + SD_VARLINK_DEFINE_INPUT(entryVersion, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The commit number for the newly created boot menu entry"), + SD_VARLINK_DEFINE_INPUT(entryCommit, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("What to set the triesLeft counter of the boot menu entry to initially."), + SD_VARLINK_DEFINE_INPUT(triesLeft, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("How much space to always keep free on ESP/XBOOTLDR. Defaults to 1 MiB"), + SD_VARLINK_DEFINE_INPUT(keepFree, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The IDs of the created boot loader entries."), + SD_VARLINK_DEFINE_OUTPUT(ids, SD_VARLINK_STRING, SD_VARLINK_ARRAY)); + static SD_VARLINK_DEFINE_ERROR( RebootToFirmwareNotSupported); @@ -233,6 +254,8 @@ SD_VARLINK_DEFINE_INTERFACE( &vl_method_Unlink, SD_VARLINK_SYMBOL_COMMENT("Install a kernel as boot menu item"), &vl_method_Link, + SD_VARLINK_SYMBOL_COMMENT("Like Link(), but automatically discovers the UKI and extra resources staged below /var/lib/systemd/uki/"), + &vl_method_LinkAuto, SD_VARLINK_SYMBOL_COMMENT("SetRebootToFirmware() and GetRebootToFirmware() return this if the firmware does not actually support the reboot-to-firmware-UI concept."), &vl_error_RebootToFirmwareNotSupported, SD_VARLINK_SYMBOL_COMMENT("No boot entry defined."), diff --git a/units/meson.build b/units/meson.build index f2e1006bad..b126f8f1bc 100644 --- a/units/meson.build +++ b/units/meson.build @@ -303,6 +303,14 @@ units = [ 'conditions' : ['ENABLE_BOOTLOADER'], 'symlinks' : ['sockets.target.wants/'], }, + { + 'file' : 'systemd-sysupdate-notify-bootctl@.service', + 'conditions' : ['ENABLE_BOOTLOADER'], + }, + { + 'file' : 'systemd-sysupdate-notify-bootctl.socket', + 'conditions' : ['ENABLE_BOOTLOADER'], + }, { 'file' : 'systemd-confext.service', 'conditions' : ['ENABLE_SYSEXT'], diff --git a/units/systemd-sysupdate-notify-bootctl.socket b/units/systemd-sysupdate-notify-bootctl.socket new file mode 100644 index 0000000000..46114ddb20 --- /dev/null +++ b/units/systemd-sysupdate-notify-bootctl.socket @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Boot Loader UKI Auto-Link on Completed System Update +Documentation=man:bootctl(1) +DefaultDependencies=no +ConditionFirmware=uefi +After=local-fs.target +Before=sockets.target + +[Socket] +ListenStream=/run/systemd/sysupdate/notify/io.systemd.BootControl +FileDescriptorName=varlink +SocketMode=0644 +Accept=yes +MaxConnectionsPerSource=16 +XAttrEntryPoint=user.varlink=entrypoint +XAttrListen=user.varlink=listen +XAttrAccept=user.varlink=server + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-sysupdate-notify-bootctl@.service b/units/systemd-sysupdate-notify-bootctl@.service new file mode 100644 index 0000000000..6b0604c2f1 --- /dev/null +++ b/units/systemd-sysupdate-notify-bootctl@.service @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Boot Loader UKI Auto-Link on Completed System Update +Documentation=man:bootctl(1) +DefaultDependencies=no +Conflicts=shutdown.target +After=local-fs.target +Before=shutdown.target + +[Service] +ExecStart=bootctl From 942283288d8860603f7189839577e8d58a258006 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 11:42:47 +0200 Subject: [PATCH 5/9] sysext: refresh sysexts and confexts on completed system update Bind the io.systemd.SysUpdate.Notify.OnCompletedUpdate() method in the sysext Varlink server. systemd-sysext provides a single Varlink service covering both the sysext and confext image classes, so one notification refreshes both (equivalent to "systemd-sysext refresh" plus "systemd-confext refresh"). Hook a socket into /run/systemd/sysupdate/notify/ via systemd-sysupdate-notify-sysext.socket, enabled by default via the preset. --- presets/90-systemd.preset | 1 + src/sysext/sysext.c | 47 +++++++++++++++++-- units/meson.build | 8 ++++ units/systemd-sysupdate-notify-sysext.socket | 29 ++++++++++++ .../systemd-sysupdate-notify-sysext@.service | 19 ++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 units/systemd-sysupdate-notify-sysext.socket create mode 100644 units/systemd-sysupdate-notify-sysext@.service diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset index 9ecebf0e08..bce96b7a6d 100644 --- a/presets/90-systemd.preset +++ b/presets/90-systemd.preset @@ -42,6 +42,7 @@ enable systemd-resolved.service enable systemd-sysext.service enable systemd-sysupdate-notify-bootctl.socket enable systemd-sysupdate-notify-pcrlock.socket +enable systemd-sysupdate-notify-sysext.socket enable systemd-timesyncd.service enable systemd-tpm2-clear.service enable systemd-userdbd.socket diff --git a/src/sysext/sysext.c b/src/sysext/sysext.c index 0f4102d850..4cc9883628 100644 --- a/src/sysext/sysext.c +++ b/src/sysext/sysext.c @@ -68,6 +68,7 @@ #include "strv.h" #include "time-util.h" #include "unit-name.h" +#include "varlink-io.systemd.SysUpdate.Notify.h" #include "varlink-io.systemd.sysext.h" #include "varlink-util.h" #include "verbs.h" @@ -2898,6 +2899,42 @@ static int vl_method_refresh(sd_varlink *link, sd_json_variant *parameters, sd_v return sd_varlink_reply(link, NULL); } +static int refresh_class(ImageClass image_class) { + _cleanup_strv_free_ char **hierarchies = NULL; + int r; + + r = parse_env_extension_hierarchies(&hierarchies, image_class_info[image_class].name_env); + if (r < 0) + return log_error_errno(r, "Failed to determine %s hierarchies: %m", image_class_info[image_class].short_identifier); + + return refresh(image_class, hierarchies, arg_force, arg_no_reload, arg_always_refresh, arg_noexec); +} + +static int vl_method_on_completed_update(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + int r; + + assert(link); + + /* Only honour update notifications if they come from root */ + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + + /* Triggered by systemd-sysupdate after an update completed. We deliberately ignore all parameters + * (we don't even dispatch them). This method is reached through the sysext socket, but a single + * notification refreshes both image classes, so freshly downloaded sysexts and confexts are both + * picked up, equivalent to "systemd-sysext refresh" plus "systemd-confext refresh". We attempt both + * classes and only fail afterwards, so a problem with one does not prevent refreshing the other. */ + + r = 0; + RET_GATHER(r, refresh_class(IMAGE_SYSEXT)); + RET_GATHER(r, refresh_class(IMAGE_CONFEXT)); + if (r < 0) + return r; + + return sd_varlink_reply(link, NULL); +} + VERB_NOARG(verb_list, "list", "List installed extensions"); static int verb_list(int argc, char *argv[], uintptr_t _data, void *userdata) { _cleanup_hashmap_free_ Hashmap *images = NULL; @@ -3184,16 +3221,20 @@ static int run(int argc, char *argv[]) { if (r < 0) return log_error_errno(r, "Failed to allocate Varlink server: %m"); - r = sd_varlink_server_add_interface(varlink_server, &vl_interface_io_systemd_sysext); + r = sd_varlink_server_add_interface_many( + varlink_server, + &vl_interface_io_systemd_sysext, + &vl_interface_io_systemd_SysUpdate_Notify); if (r < 0) - return log_error_errno(r, "Failed to add Varlink interface: %m"); + return log_error_errno(r, "Failed to add Varlink interfaces: %m"); r = sd_varlink_server_bind_method_many( varlink_server, "io.systemd.sysext.Merge", vl_method_merge, "io.systemd.sysext.Unmerge", vl_method_unmerge, "io.systemd.sysext.Refresh", vl_method_refresh, - "io.systemd.sysext.List", vl_method_list); + "io.systemd.sysext.List", vl_method_list, + "io.systemd.SysUpdate.Notify.OnCompletedUpdate", vl_method_on_completed_update); if (r < 0) return log_error_errno(r, "Failed to bind Varlink methods: %m"); diff --git a/units/meson.build b/units/meson.build index b126f8f1bc..83dae605fa 100644 --- a/units/meson.build +++ b/units/meson.build @@ -823,6 +823,14 @@ units = [ 'conditions' : ['ENABLE_SYSEXT'], 'symlinks' : ['sockets.target.wants/'], }, + { + 'file' : 'systemd-sysupdate-notify-sysext@.service', + 'conditions' : ['ENABLE_SYSEXT'], + }, + { + 'file' : 'systemd-sysupdate-notify-sysext.socket', + 'conditions' : ['ENABLE_SYSEXT'], + }, { 'file' : 'systemd-sysext@.service', 'conditions' : ['ENABLE_SYSEXT'], diff --git a/units/systemd-sysupdate-notify-sysext.socket b/units/systemd-sysupdate-notify-sysext.socket new file mode 100644 index 0000000000..55d29a28dd --- /dev/null +++ b/units/systemd-sysupdate-notify-sysext.socket @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=System Extension and Configuration Refresh on Completed System Update +Documentation=man:systemd-sysext(8) +DefaultDependencies=no +After=local-fs.target +Before=sockets.target +ConditionCapability=CAP_SYS_ADMIN + +[Socket] +ListenStream=/run/systemd/sysupdate/notify/io.systemd.sysext +FileDescriptorName=varlink +SocketMode=0644 +Accept=yes +MaxConnectionsPerSource=16 +XAttrEntryPoint=user.varlink=entrypoint +XAttrListen=user.varlink=listen +XAttrAccept=user.varlink=server + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-sysupdate-notify-sysext@.service b/units/systemd-sysupdate-notify-sysext@.service new file mode 100644 index 0000000000..c0efe542dd --- /dev/null +++ b/units/systemd-sysupdate-notify-sysext@.service @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=System Extension and Configuration Refresh on Completed System Update +Documentation=man:systemd-sysext(8) +DefaultDependencies=no +Conflicts=shutdown.target initrd-switch-root.target +After=local-fs.target +Before=shutdown.target initrd-switch-root.target + +[Service] +ExecStart=-systemd-sysext From be1643b549e32eb91d704454e629120a38b5861c Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 29 May 2026 15:40:45 +0200 Subject: [PATCH 6/9] systemd-boot-update: condition on UEFI Our boot loader logic only supports UEFI, hence let's condition the updater on it. --- units/systemd-boot-update.service | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/units/systemd-boot-update.service b/units/systemd-boot-update.service index 9a4fbc1e71..4c829ad314 100644 --- a/units/systemd-boot-update.service +++ b/units/systemd-boot-update.service @@ -10,9 +10,9 @@ [Unit] Description=Automatic Boot Loader Update Documentation=man:bootctl(1) -ConditionPathExists=!/etc/initrd-release - DefaultDependencies=no +ConditionPathExists=!/etc/initrd-release +ConditionFirmware=uefi After=local-fs.target Before=sysinit.target systemd-update-done.service Conflicts=shutdown.target From 89dd06505bc3dffd152fd4ff9dcff59b71bba56a Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 11:42:57 +0200 Subject: [PATCH 7/9] test: verify sysupdate invokes the notification callout directory Extend TEST-72-SYSUPDATE with a check that, after a successful update, systemd-sysupdate connects to every socket linked into /run/systemd/sysupdate/notify/ and invokes io.systemd.SysUpdate.Notify.OnCompletedUpdate(). A tiny recorder socket is hooked into that directory; it captures the request and replies with success. We assert the recorded call carries the expected method, version and resource list, and that a subsequent no-op update emits no notification. --- test/units/TEST-72-SYSUPDATE.sh | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index 4ac8b8a52d..6cabcab5bb 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -49,6 +49,10 @@ systemctl daemon-reload at_exit() { set +e + systemctl stop test-sysupdate-notify-recorder.socket + rm -f /run/systemd/system/test-sysupdate-notify-recorder.socket \ + /run/systemd/system/test-sysupdate-notify-recorder@.service + losetup -n --output NAME --associated "$BACKING_FILE" | while read -r loop_dev; do losetup --detach "$loop_dev" done @@ -947,4 +951,85 @@ test ! -f "$CLEANUP/target/alpha-v2.bin" rm -rf "$CONFIGDIR" "$INSTALLDB" "$CLEANUP" +# Verify the notification callout: after a successful update, sysupdate must connect to every socket in +# /run/systemd/sysupdate/notify/ and invoke io.systemd.SysUpdate.Notify.OnCompletedUpdate(). We hook a tiny +# recorder socket into that directory that captures the request and replies with success. +NOTIFY_LOG="$WORKDIR/notify.log" +rm -f "$NOTIFY_LOG" + +cat >"$WORKDIR/notify-recorder.py" </run/systemd/system/test-sysupdate-notify-recorder.socket <"/run/systemd/system/test-sysupdate-notify-recorder@.service" <"$WORKDIR/source/notifytest-v1.bin" +(cd "$WORKDIR/source" && sha256sum notifytest-v1.bin >SHA256SUMS) +cat >"$CONFIGDIR/01-notifytest.transfer" </dev/null +jq -e '.parameters.version == "v1"' <<<"$notify_line" >/dev/null +jq -e '.parameters.resources | length >= 1' <<<"$notify_line" >/dev/null +jq -e '.parameters.resources | all(has("transfer"))' <<<"$notify_line" >/dev/null + +# A no-op update ("No update needed") must NOT emit a notification. +rm -f "$NOTIFY_LOG" +"$SYSUPDATE" --verify=no update +test ! -s "$NOTIFY_LOG" + +systemctl stop test-sysupdate-notify-recorder.socket +rm -f /run/systemd/system/test-sysupdate-notify-recorder.socket \ + /run/systemd/system/test-sysupdate-notify-recorder@.service +systemctl daemon-reload +rm -rf "$CONFIGDIR" "$WORKDIR/blobs" +rm -f "$WORKDIR/source/notifytest-v1.bin" "$WORKDIR/source/SHA256SUMS" \ + "$WORKDIR/notify-recorder.py" "$NOTIFY_LOG" + touch /testok From 9ac1b6c12433acc531cbced384553472476e2271 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 12:20:59 +0200 Subject: [PATCH 8/9] test: verify bootctl link-auto and io.systemd.BootControl.LinkAuto Add a TEST-87 testcase exercising "bootctl link-auto" and the equivalent io.systemd.BootControl.LinkAuto() Varlink method: a UKI plus extras are staged below the search directories and we assert the kernel and sidecar resources are linked into $BOOT. Covered: plain kernel.efi + extras.d/, versioned kernel.efi.v/ and extras .v/ resolved via vpick, directory priority (/etc wins over /run), the no-op case when nothing is staged, and the Varlink method including its empty reply when there is nothing to link. --- test/units/TEST-87-AUX-UTILS-VM.bootctl.sh | 158 +++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh b/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh index 2b095c1fbc..0b19acd017 100755 --- a/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh +++ b/test/units/TEST-87-AUX-UTILS-VM.bootctl.sh @@ -710,4 +710,162 @@ EOF '{"id":"foo*.conf"}') } +cleanup_link_auto() { + rm -rf /run/systemd/uki /etc/systemd/uki + if [[ -n "${LINK_WORKDIR:-}" ]]; then + rm -rf "$LINK_WORKDIR" + unset LINK_WORKDIR + fi + restore_esp +} + +testcase_bootctl_link_auto() { + if ! command -v ukify >/dev/null; then + echo "ukify not found, skipping." + return 0 + fi + + backup_esp + LINK_WORKDIR="$(mktemp --directory /tmp/test-bootctl-link-auto.XXXXXXXXXX)" + trap cleanup_link_auto RETURN ERR + + # Ensure loader/entries directory is present + bootctl install --make-entry-directory=yes + + local ESP + ESP="$(bootctl --print-esp-path)" + + cat >"$LINK_WORKDIR/os-release" <<'EOF' +ID=testos +NAME="Test OS" +PRETTY_NAME="Test OS" +EOF + echo "fake-kernel" >"$LINK_WORKDIR/vmlinuz" + echo "fake-initrd" >"$LINK_WORKDIR/initrd" + + # Two distinct UKIs, so we can tell which one was picked up. + ukify build \ + --linux "$LINK_WORKDIR/vmlinuz" \ + --initrd "$LINK_WORKDIR/initrd" \ + --os-release "@$LINK_WORKDIR/os-release" \ + --uname "1.2.3-testkernel" \ + --cmdline "quiet uki=a" \ + --output "$LINK_WORKDIR/uki_a.efi" + ukify build \ + --linux "$LINK_WORKDIR/vmlinuz" \ + --initrd "$LINK_WORKDIR/initrd" \ + --os-release "@$LINK_WORKDIR/os-release" \ + --uname "1.2.3-testkernel" \ + --cmdline "quiet uki=b" \ + --output "$LINK_WORKDIR/uki_b.efi" + + local TOKEN="systemdtest" + local BOOTCTL=(bootctl "--entry-token=literal:$TOKEN") + local ENTRY="$ESP/loader/entries/${TOKEN}-commit_1.conf" + + # --- Test 1: link-auto picks up kernel.efi + extras.d/ from /run/systemd/uki/ --- + rm -rf /run/systemd/uki + mkdir -p /run/systemd/uki/extras.d + cp "$LINK_WORKDIR/uki_a.efi" /run/systemd/uki/kernel.efi + echo "sysext-data" >/run/systemd/uki/extras.d/hello.sysext.raw + echo "confext-data" >/run/systemd/uki/extras.d/hello.confext.raw + echo "cred-data" >/run/systemd/uki/extras.d/hello.cred + + "${BOOTCTL[@]}" link-auto + + test -f "$ENTRY" + test -f "$ESP/$TOKEN/kernel.efi" + cmp "$LINK_WORKDIR/uki_a.efi" "$ESP/$TOKEN/kernel.efi" + test -f "$ESP/$TOKEN/hello.sysext.raw" + test -f "$ESP/$TOKEN/hello.confext.raw" + test -f "$ESP/$TOKEN/hello.cred" + grep "^uki /${TOKEN}/kernel.efi\$" "$ENTRY" >/dev/null + grep "^extra /${TOKEN}/hello.sysext.raw\$" "$ENTRY" >/dev/null + grep "^extra /${TOKEN}/hello.confext.raw\$" "$ENTRY" >/dev/null + grep "^extra /${TOKEN}/hello.cred\$" "$ENTRY" >/dev/null + + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_1.conf" + test ! -e "$ENTRY" + test ! -e "$ESP/$TOKEN/kernel.efi" + + # --- Test 2: versioned kernel.efi.v/ and extras .v/ are resolved via vpick --- + rm -rf /run/systemd/uki + mkdir -p /run/systemd/uki/kernel.efi.v /run/systemd/uki/extras.d/hello.sysext.raw.v + cp "$LINK_WORKDIR/uki_a.efi" /run/systemd/uki/kernel.efi.v/kernel_1.0.efi + cp "$LINK_WORKDIR/uki_a.efi" /run/systemd/uki/kernel.efi.v/kernel_2.0.efi + echo "sysext-1" >/run/systemd/uki/extras.d/hello.sysext.raw.v/hello_1.0.sysext.raw + echo "sysext-2" >/run/systemd/uki/extras.d/hello.sysext.raw.v/hello_2.0.sysext.raw + + "${BOOTCTL[@]}" link-auto + + test -f "$ENTRY" + # vpick must select the newest version + test -f "$ESP/$TOKEN/kernel_2.0.efi" + test ! -e "$ESP/$TOKEN/kernel_1.0.efi" + test -f "$ESP/$TOKEN/hello_2.0.sysext.raw" + test ! -e "$ESP/$TOKEN/hello_1.0.sysext.raw" + grep "^uki /${TOKEN}/kernel_2.0.efi\$" "$ENTRY" >/dev/null + grep "^extra /${TOKEN}/hello_2.0.sysext.raw\$" "$ENTRY" >/dev/null + + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_1.conf" + + # --- Test 3: priority — /etc/systemd/uki/ wins over /run/systemd/uki/ --- + rm -rf /run/systemd/uki /etc/systemd/uki + mkdir -p /run/systemd/uki/extras.d /etc/systemd/uki/extras.d + cp "$LINK_WORKDIR/uki_b.efi" /run/systemd/uki/kernel.efi + cp "$LINK_WORKDIR/uki_a.efi" /etc/systemd/uki/kernel.efi + echo "run-cred" >/run/systemd/uki/extras.d/hello.cred + echo "etc-cred" >/etc/systemd/uki/extras.d/hello.cred + + "${BOOTCTL[@]}" link-auto + + test -f "$ESP/$TOKEN/kernel.efi" + # The /etc copy (uki_a) must win over the /run copy (uki_b) + cmp "$LINK_WORKDIR/uki_a.efi" "$ESP/$TOKEN/kernel.efi" + cmp <(echo "etc-cred") "$ESP/$TOKEN/hello.cred" + + "${BOOTCTL[@]}" unlink "${TOKEN}-commit_1.conf" + rm -rf /etc/systemd/uki + + # --- Test 4: with nothing staged, link-auto is a successful no-op --- + rm -rf /run/systemd/uki + "${BOOTCTL[@]}" link-auto + # No entries referencing our token should remain. + local leftover + leftover="$(find "$ESP/loader/entries/" -name "*$TOKEN*" -print -quit 2>/dev/null)" + test -z "$leftover" + + # === Varlink coverage: io.systemd.BootControl.LinkAuto === + local BOOTCTL_BIN vreply vid vtoken + BOOTCTL_BIN="$(type -p bootctl)" + + # --- Test 5: LinkAuto discovers kernel.efi + extras --- + rm -rf /run/systemd/uki + mkdir -p /run/systemd/uki/extras.d + cp "$LINK_WORKDIR/uki_a.efi" /run/systemd/uki/kernel.efi + echo "cred-data" >/run/systemd/uki/extras.d/hello.cred + + vreply="$(varlinkctl call --json=short "$BOOTCTL_BIN" io.systemd.BootControl.LinkAuto '{}')" + vid="$(echo "$vreply" | jq -r '.ids[0]')" + test -n "$vid" + test "$vid" != "null" + vtoken="${vid%%-commit_*}" + test -n "$vtoken" + + test -f "$ESP/loader/entries/$vid" + test -f "$ESP/$vtoken/kernel.efi" + test -f "$ESP/$vtoken/hello.cred" + grep "^uki /$vtoken/kernel.efi\$" "$ESP/loader/entries/$vid" >/dev/null + + varlinkctl call --quiet "$BOOTCTL_BIN" io.systemd.BootControl.Unlink "{\"id\":\"$vid\"}" + test ! -e "$ESP/loader/entries/$vid" + test ! -e "$ESP/$vtoken/kernel.efi" + test ! -e "$ESP/$vtoken/hello.cred" + + # --- Test 6: LinkAuto with nothing staged returns an empty id list --- + rm -rf /run/systemd/uki + vreply="$(varlinkctl call --json=short "$BOOTCTL_BIN" io.systemd.BootControl.LinkAuto '{}')" + assert_eq "$(echo "$vreply" | jq -r '.ids | length')" "0" +} + run_testcases From 01f3f2aa2ffa14b5faae112847eeab03ae93fc75 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 28 May 2026 11:42:57 +0200 Subject: [PATCH 9/9] TODO: drop bootctl link + sysupdate integration item This is now implemented: sysupdate calls out to the /run/systemd/sysupdate/notify/ Varlink directory on completion, and bootctl binds a socket there that links a UKI plus extras staged below /var/lib/systemd/uki/ (with .v/ vpick support) via "bootctl link-auto". --- TODO.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/TODO.md b/TODO.md index a302415b84..d8badaf5ab 100644 --- a/TODO.md +++ b/TODO.md @@ -280,12 +280,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later - firstboot/sysinstall: add simple interface for prompting users to enable "features" exposed by of sysupdate. -- bootctl link + sysupdate integration - - make sysupdate call out to a special varlink dir on completion - - bind bootctl link socket in there, which when invoked goes to new dir in - /var/ where downloaded kernels+confext+sysext are dropped in (place in - .v/) and then does "bootctl link" on them. - - a tool that can prep credentials, put them in the ESP, for provisioning systems for SBC or UEFI/HTTP boot. Should be doing what sysinstall does with the credentials, and maybe even *be* sysinstall.