]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
sysext: Skip refresh if no changes are found 39980/head
authorKai Lueke <kailuke@microsoft.com>
Tue, 25 Nov 2025 15:04:43 +0000 (00:04 +0900)
committerKai Lüke <kai@amutable.com>
Tue, 3 Feb 2026 23:05:24 +0000 (00:05 +0100)
When the extensions for the final system are already set up from the
initrd we should avoid disrupting the boot process with the remount
(which currently isn't atomic) and the daemon reload for
systemd-confext and systemd-sysext. Similarly, when sysupdate ran and
updated extensions it's best to avoid the remount and daemon reload if
no changes are found.
To do this, encode the current extension state in more detail than
before where only the names of the extensions where encoded in the
overlay mount. This can also be used to provide more details about the
extension origin in "systemd-sysext status (--json=)". During the
refresh add a check whether the old state matches the new state and in
this case skip the refresh unless the user provides a flag to always
refresh. Besides the extension name and the resolved path the best
method for identification is the verity hash but that is not available
for plain image files or directories. Therefore, also include data to
check for file/directory replacements. The creation/modification times
are not always real on reproducible images or extracted archive content.
The file handle together with the unique mount ID is the next best
identifier we can use when we have no verity hash. Fall back to an inode
when we get no handle. With the creation/modification time and the path
this should be good enough. Using a unique mount ID is important (with
a fallback to the regular non-unique mount ID) instead of st_dev because
st_dev gets reused too easily, e.g., by a loop device mount and the
mount ID helps to catch this. For the mount ID to be valid it has to be
resolved before we enter the new mount namespace. Thus, it gets provided
by the image dissect logic and handed over to the sysext subprocess
which runs in a new mount namespace.
Luckily, we can rule out online modification of directories or image
files because this is anyway not well supported with overlay mounts, so
we don't do a file checksum nor do we recurse into a directory to look
for the most recently touched files.  But, as said, with the
always-refresh flag one can force a reload.

man/systemd-sysext.xml
shell-completion/bash/systemd-sysext
src/basic/mountpoint-util.c
src/basic/mountpoint-util.h
src/include/override/fcntl.h
src/shared/discover-image.c
src/shared/discover-image.h
src/shared/varlink-io.systemd.sysext.c
src/sysext/sysext.c
test/units/TEST-50-DISSECT.sysext.sh

index b5ab6826a2e8c9c752e1a44e2608b81b90953291..d6bbc0141dfc36e86d09ab9ba8ef76cbc5ad8204 100644 (file)
         <xi:include href="version-info.xml" xpointer="v248"/></listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><option>--always-refresh=yes|no</option></term>
+
+        <listitem><para>When refreshing system extensions on <filename>/usr/</filename> and <filename>/opt/</filename>
+        for sysext and <filename>/etc/</filename> for confext, ignore when the existing merged extensions
+        already match what would be merged.
+        By default the refresh is skipped when no changes are found. Note that changes done to an extension
+        directory while it's merged are ignored without this flag (unless an other extension got changed).
+        Note that changing the contents while merged is also undefined behavior in overlayfs.</para>
+
+        <xi:include href="version-info.xml" xpointer="v260"/></listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><option>--image-policy=<replaceable>policy</replaceable></option></term>
 
index c605237ed6773b0ca4b53a2011a7e38d6d08adc3..69d786c33e4f69d78577ef079c0da0a30436db9b 100644 (file)
@@ -36,6 +36,7 @@ _systemd-sysext() {
         [ARG]='--root
                --json
                --noexec
+               --always-refresh
                --image-policy
                --mutable'
     )
index 547183395e4e61db7343f5048bd09e134b51cb17..d8f57c6b7ae041c65a84d0db925d2e57d89682e7 100644 (file)
@@ -50,12 +50,13 @@ int name_to_handle_at_loop(
                 const char *path,
                 struct file_handle **ret_handle,
                 int *ret_mnt_id,
+                uint64_t *ret_unique_mnt_id,
                 int flags) {
 
         size_t n = ORIGINAL_MAX_HANDLE_SZ;
 
         assert(fd >= 0 || fd == AT_FDCWD);
-        assert((flags & ~(AT_SYMLINK_FOLLOW|AT_EMPTY_PATH|AT_HANDLE_FID)) == 0);
+        assert((flags & ~(AT_SYMLINK_FOLLOW|AT_EMPTY_PATH|AT_HANDLE_FID|AT_HANDLE_MNT_ID_UNIQUE)) == 0);
 
         /* We need to invoke name_to_handle_at() in a loop, given that it might return EOVERFLOW when the specified
          * buffer is too small. Note that in contrast to what the docs might suggest, MAX_HANDLE_SZ is only good as a
@@ -71,7 +72,8 @@ int name_to_handle_at_loop(
 
         for (;;) {
                 _cleanup_free_ struct file_handle *h = NULL;
-                int mnt_id = -1;
+                int mnt_id = -1, r;
+                uint64_t unique_mnt_id = 0;
 
                 h = malloc0(offsetof(struct file_handle, f_handle) + n);
                 if (!h)
@@ -79,11 +81,18 @@ int name_to_handle_at_loop(
 
                 h->handle_bytes = n;
 
-                if (name_to_handle_at(fd, path, h, &mnt_id, flags) >= 0) {
+                if (FLAGS_SET(flags, AT_HANDLE_MNT_ID_UNIQUE))
+                        /* The kernel will still use this as uint64_t pointer */
+                        r = name_to_handle_at(fd, path, h, (int *) &unique_mnt_id, flags);
+                else
+                        r = name_to_handle_at(fd, path, h, &mnt_id, flags);
+                if (r >= 0) {
 
                         if (ret_handle)
                                 *ret_handle = TAKE_PTR(h);
 
+                        if (ret_unique_mnt_id)
+                                *ret_unique_mnt_id = unique_mnt_id;
                         if (ret_mnt_id)
                                 *ret_mnt_id = mnt_id;
 
@@ -92,13 +101,16 @@ int name_to_handle_at_loop(
                 if (errno != EOVERFLOW)
                         return -errno;
 
-                if (!ret_handle && ret_mnt_id && mnt_id >= 0) {
+                if (!ret_handle && ((ret_mnt_id && mnt_id >= 0) || (ret_unique_mnt_id && unique_mnt_id > 0))) {
 
                         /* As it appears, name_to_handle_at() fills in mnt_id even when it returns EOVERFLOW when the
                          * buffer is too small, but that's undocumented. Hence, let's make use of this if it appears to
                          * be filled in, and the caller was interested in only the mount ID an nothing else. */
 
-                        *ret_mnt_id = mnt_id;
+                        if (ret_unique_mnt_id)
+                                *ret_unique_mnt_id = unique_mnt_id;
+                        if (ret_mnt_id)
+                                *ret_mnt_id = mnt_id;
                         return 0;
                 }
 
@@ -132,11 +144,55 @@ int name_to_handle_at_try_fid(
          * we'll try without the flag, in order to support older kernels that didn't have AT_HANDLE_FID
          * (i.e. older than Linux 6.5). */
 
-        r = name_to_handle_at_loop(fd, path, ret_handle, ret_mnt_id, flags | AT_HANDLE_FID);
+        r = name_to_handle_at_loop(fd, path, ret_handle, ret_mnt_id, /* ret_unique_mnt_id= */ NULL, flags | AT_HANDLE_FID);
         if (r >= 0 || is_name_to_handle_at_fatal_error(r))
                 return r;
 
-        return name_to_handle_at_loop(fd, path, ret_handle, ret_mnt_id, flags & ~AT_HANDLE_FID);
+        return name_to_handle_at_loop(fd, path, ret_handle, ret_mnt_id, /* ret_unique_mnt_id= */ NULL, flags & ~AT_HANDLE_FID);
+}
+
+int name_to_handle_at_try_unique_mntid_fid(
+                int fd,
+                const char *path,
+                struct file_handle **ret_handle,
+                uint64_t *ret_mnt_id,
+                int flags) {
+
+        int mnt_id = -1, r;
+
+        assert(fd >= 0 || fd == AT_FDCWD);
+
+        /* First issues name_to_handle_at() with AT_HANDLE_MNT_ID_UNIQUE and AT_HANDLE_FID.
+         * If this fails and this is not a fatal error we'll try without the
+         * AT_HANDLE_MNT_ID_UNIQUE flag because it's only available from Linux 6.12 onwards. */
+        r = name_to_handle_at_loop(fd, path, ret_handle, /* ret_mnt_id= */ NULL, ret_mnt_id, flags | AT_HANDLE_MNT_ID_UNIQUE | AT_HANDLE_FID);
+        if (r >= 0 || is_name_to_handle_at_fatal_error(r))
+                return r;
+
+        flags &= ~AT_HANDLE_MNT_ID_UNIQUE;
+
+        /* Then issues name_to_handle_at() with AT_HANDLE_FID. If this fails and this is not a fatal error
+         * we'll try without the flag, in order to support older kernels that didn't have AT_HANDLE_FID
+         * (i.e. older than Linux 6.5). */
+
+        r = name_to_handle_at_loop(fd, path, ret_handle, &mnt_id, /* ret_unique_mnt_id= */ NULL, flags | AT_HANDLE_FID);
+        if (r < 0 && is_name_to_handle_at_fatal_error(r))
+                return r;
+        if (r >= 0) {
+                if (ret_mnt_id && mnt_id >= 0) {
+                        /* See if we can do better because statx can do unique mount IDs since Linux 6.8
+                         * and only if this doesn't work we use the non-unique mnt_id as returned. */
+                        if (path_get_unique_mnt_id_at(fd, path, ret_mnt_id) < 0)
+                                *ret_mnt_id = mnt_id;
+                }
+
+                return r;
+        }
+
+        r = name_to_handle_at_loop(fd, path, ret_handle, &mnt_id, /* ret_unique_mnt_id= */ NULL, flags & ~AT_HANDLE_FID);
+        if (ret_mnt_id && mnt_id >= 0)
+                *ret_mnt_id = mnt_id;
+        return r;
 }
 
 int name_to_handle_at_u64(int fd, const char *path, uint64_t *ret) {
@@ -147,7 +203,7 @@ int name_to_handle_at_u64(int fd, const char *path, uint64_t *ret) {
 
         /* This provides the first 64bit of the file handle. */
 
-        r = name_to_handle_at_loop(fd, path, &h, /* ret_mnt_id= */ NULL, /* flags= */ 0);
+        r = name_to_handle_at_loop(fd, path, &h, /* ret_mnt_id= */ NULL, /* ret_unique_mnt_id= */ NULL, /* flags= */ 0);
         if (r < 0)
                 return r;
         if (h->handle_bytes < sizeof(uint64_t))
@@ -171,6 +227,22 @@ bool file_handle_equal(const struct file_handle *a, const struct file_handle *b)
         return memcmp_nn(a->f_handle, a->handle_bytes, b->f_handle, b->handle_bytes) == 0;
 }
 
+struct file_handle* file_handle_dup(const struct file_handle *fh) {
+        _cleanup_free_ struct file_handle *fh_copy = NULL;
+
+        assert(fh);
+
+        fh_copy = malloc0(offsetof(struct file_handle, f_handle) + fh->handle_bytes);
+        if (!fh_copy)
+                return NULL;
+
+        fh_copy->handle_bytes = fh->handle_bytes;
+        fh_copy->handle_type = fh->handle_type;
+        memcpy(fh_copy->f_handle, fh->f_handle, fh->handle_bytes);
+
+        return TAKE_PTR(fh_copy);
+}
+
 int is_mount_point_at(int dir_fd, const char *path, int flags) {
         int r;
 
@@ -259,6 +331,28 @@ int path_get_mnt_id_at(int dir_fd, const char *path, int *ret) {
         return 0;
 }
 
+int path_get_unique_mnt_id_at(int dir_fd, const char *path, uint64_t *ret) {
+        struct statx sx;
+
+        assert(dir_fd >= 0 || dir_fd == AT_FDCWD);
+        assert(ret);
+
+        if (statx(dir_fd,
+                  strempty(path),
+                  (isempty(path) ? AT_EMPTY_PATH : AT_SYMLINK_NOFOLLOW) |
+                  AT_NO_AUTOMOUNT |    /* don't trigger automounts, mnt_id is a local concept */
+                  AT_STATX_DONT_SYNC,  /* don't go to the network, mnt_id is a local concept */
+                  STATX_MNT_ID_UNIQUE,
+                  &sx) < 0)
+                return -errno;
+
+        if (!FLAGS_SET(sx.stx_mask, STATX_MNT_ID_UNIQUE))
+                return -EOPNOTSUPP;
+
+        *ret = sx.stx_mnt_id;
+        return 0;
+}
+
 bool fstype_is_network(const char *fstype) {
         const char *x;
 
index 8799224f3d436b4ac2fb49f8c490c1b6b6a0df69..82eb1516c3390a78344a167103606e5d92ea3a02 100644 (file)
@@ -34,8 +34,9 @@
 
 bool is_name_to_handle_at_fatal_error(int err);
 
-int name_to_handle_at_loop(int fd, const char *path, struct file_handle **ret_handle, int *ret_mnt_id, int flags);
+int name_to_handle_at_loop(int fd, const char *path, struct file_handle **ret_handle, int *ret_mnt_id, uint64_t *ret_unique_mnt_id, int flags);
 int name_to_handle_at_try_fid(int fd, const char *path, struct file_handle **ret_handle, int *ret_mnt_id, int flags);
+int name_to_handle_at_try_unique_mntid_fid(int fd, const char *path, struct file_handle **ret_handle, uint64_t *ret_mnt_id, int flags);
 int name_to_handle_at_u64(int fd, const char *path, uint64_t *ret);
 static inline int path_to_handle_u64(const char *path, uint64_t *ret) {
         return name_to_handle_at_u64(AT_FDCWD, path, ret);
@@ -45,11 +46,13 @@ static inline int fd_to_handle_u64(int fd, uint64_t *ret) {
 }
 
 bool file_handle_equal(const struct file_handle *a, const struct file_handle *b);
+struct file_handle* file_handle_dup(const struct file_handle *fh);
 
 int path_get_mnt_id_at(int dir_fd, const char *path, int *ret);
 static inline int path_get_mnt_id(const char *path, int *ret) {
         return path_get_mnt_id_at(AT_FDCWD, path, ret);
 }
+int path_get_unique_mnt_id_at(int dir_fd, const char *path, uint64_t *ret);
 
 int is_mount_point_at(int dir_fd, const char *path, int flags);
 int path_is_mount_point_full(const char *path, const char *root, int flags);
index f2b40a6a17fff67b010bf20e1eb279b1e51c6451..b41f364534174dc5abe51d2c57c7e21eb1c44568 100644 (file)
@@ -17,3 +17,8 @@
 #ifndef AT_HANDLE_FID
 #define AT_HANDLE_FID AT_REMOVEDIR
 #endif
+
+/* This is defined since glibc-2.42. */
+#ifndef AT_HANDLE_MNT_ID_UNIQUE
+#define AT_HANDLE_MNT_ID_UNIQUE 0x001  /* Return the u64 unique mount ID. */
+#endif
index 5bd495ee1684b646cb6f1ef33f21998524bf9daf..d80ecdaf7f8570981c19e3b03761894401853912 100644 (file)
@@ -35,6 +35,7 @@
 #include "lock-util.h"
 #include "log.h"
 #include "loop-util.h"
+#include "mountpoint-util.h"
 #include "namespace-util.h"
 #include "nsresource.h"
 #include "nulstr-util.h"
@@ -148,6 +149,8 @@ static Image* image_free(Image *i) {
         free(i->name);
         free(i->path);
 
+        free(i->fh);
+
         free(i->hostname);
         strv_free(i->machine_info);
         strv_free(i->os_release);
@@ -249,6 +252,9 @@ static int image_new(
                 bool read_only,
                 usec_t crtime,
                 usec_t mtime,
+                struct file_handle *fh,
+                uint64_t on_mount_id,
+                uint64_t inode,
                 Image **ret) {
 
         _cleanup_(image_unrefp) Image *i = NULL;
@@ -270,12 +276,20 @@ static int image_new(
                 .read_only = read_only,
                 .crtime = crtime,
                 .mtime = mtime,
+                .on_mount_id = on_mount_id,
+                .inode = inode,
                 .usage = UINT64_MAX,
                 .usage_exclusive = UINT64_MAX,
                 .limit = UINT64_MAX,
                 .limit_exclusive = UINT64_MAX,
         };
 
+        if (fh) {
+                i->fh = file_handle_dup(fh);
+                if (!i->fh)
+                        return -ENOMEM;
+        }
+
         i->name = strdup(pretty);
         if (!i->name)
                 return -ENOMEM;
@@ -437,6 +451,28 @@ static int image_make(
                 path_startswith(path, "/usr") ||
                 (faccessat(fd, "", W_OK, AT_EACCESS|AT_EMPTY_PATH) < 0 && errno == EROFS);
 
+        uint64_t on_mount_id = 0;
+        _cleanup_free_ struct file_handle *fh = NULL;
+
+        r = name_to_handle_at_try_unique_mntid_fid(fd, /* path= */ NULL, &fh, &on_mount_id, /* flags= */ 0);
+        if (r < 0) {
+                if (is_name_to_handle_at_fatal_error(r))
+                        return r;
+
+                r = path_get_unique_mnt_id_at(fd, /* path= */ NULL, &on_mount_id);
+                if (r < 0) {
+                        if (!ERRNO_IS_NEG_NOT_SUPPORTED(r))
+                                return r;
+
+                        int on_mount_id_fallback = -1;
+                        r = path_get_mnt_id_at(fd, /* path= */ NULL, &on_mount_id_fallback);
+                        if (r < 0)
+                                return r;
+
+                        on_mount_id = on_mount_id_fallback;
+                }
+        }
+
         if (S_ISDIR(st->st_mode)) {
                 unsigned file_attr = 0;
                 usec_t crtime = 0;
@@ -478,6 +514,9 @@ static int image_make(
                                               info.read_only || read_only,
                                               info.otime,
                                               info.ctime,
+                                              fh,
+                                              on_mount_id,
+                                              (uint64_t) st->st_ino,
                                               ret);
                                 if (r < 0)
                                         return r;
@@ -503,6 +542,9 @@ static int image_make(
                               read_only || (file_attr & FS_IMMUTABLE_FL),
                               crtime,
                               0, /* we don't use mtime of stat() here, since it's not the time of last change of the tree, but only of the top-level dir */
+                              fh,
+                              on_mount_id,
+                              (uint64_t) st->st_ino,
                               ret);
                 if (r < 0)
                         return r;
@@ -540,6 +582,9 @@ static int image_make(
                               !(st->st_mode & 0222) || read_only,
                               crtime,
                               timespec_load(&st->st_mtim),
+                              fh,
+                              on_mount_id,
+                              (uint64_t) st->st_ino,
                               ret);
                 if (r < 0)
                         return r;
@@ -597,6 +642,9 @@ static int image_make(
                               !(st->st_mode & 0222) || read_only,
                               0,
                               0,
+                              fh,
+                              on_mount_id,
+                              (uint64_t) st->st_ino,
                               ret);
                 if (r < 0)
                         return r;
index 1ed8b335aaca8ff882919107d5d5fcf4ee91584b..c2c1a5e93d9771f65125b31a8593e6b830b6b131 100644 (file)
@@ -27,6 +27,10 @@ typedef struct Image {
         usec_t crtime;
         usec_t mtime;
 
+        struct file_handle *fh;
+        uint64_t on_mount_id;
+        uint64_t inode;
+
         uint64_t usage;
         uint64_t usage_exclusive;
         uint64_t limit;
index 13d39a89ff5573d45522b9f833e691865506db5b..337769c3ddf3a0c918a8dc108ee4aa1bd5929861 100644 (file)
@@ -34,6 +34,7 @@ static SD_VARLINK_DEFINE_METHOD(
                 SD_VARLINK_DEFINE_INPUT_BY_TYPE(class, ImageClass, SD_VARLINK_NULLABLE),
                 SD_VARLINK_DEFINE_INPUT(force, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE),
                 SD_VARLINK_DEFINE_INPUT(noReload, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE),
+                SD_VARLINK_DEFINE_INPUT(alwaysRefresh, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE),
                 SD_VARLINK_DEFINE_INPUT(noexec, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE),
                 VARLINK_DEFINE_POLKIT_INPUT);
 
index 5374a89a659d3880a78a63c50646ce04ef806b9b..92431262f33c8300db3fd0f8f256ba9c79361e3a 100644 (file)
@@ -8,6 +8,7 @@
 #include <sys/mount.h>
 #include <unistd.h>
 
+#include "sd-json.h"
 #include "sd-varlink.h"
 
 #include "argv-util.h"
@@ -86,6 +87,17 @@ static const char* const mutable_mode_table[_MUTABLE_MAX] = {
 
 DEFINE_PRIVATE_STRING_TABLE_LOOKUP_WITH_BOOLEAN(mutable_mode, MutableMode, MUTABLE_YES);
 
+enum {
+        MERGE_NOTHING_FOUND,
+        MERGE_MOUNTED,
+        MERGE_SKIP_REFRESH,
+};
+
+enum {
+        MERGE_EXIT_NOTHING_FOUND = 123,
+        MERGE_EXIT_SKIP_REFRESH  = 124,
+};
+
 static char **arg_hierarchies = NULL; /* "/usr" + "/opt" by default for sysext and /etc by default for confext */
 static char *arg_root = NULL;
 static sd_json_format_flags_t arg_json_format_flags = SD_JSON_FORMAT_OFF;
@@ -93,6 +105,7 @@ static PagerFlags arg_pager_flags = 0;
 static bool arg_legend = true;
 static bool arg_force = false;
 static bool arg_no_reload = false;
+static bool arg_always_refresh = false;
 static int arg_noexec = -1;
 static ImagePolicy *arg_image_policy = NULL;
 static bool arg_image_policy_set = false; /* Tracks initialization */
@@ -1491,6 +1504,30 @@ static int write_extensions_file(ImageClass image_class, char **extensions, cons
         return 0;
 }
 
+static int write_origin_file(ImageClass image_class, const char *origin_content, const char *meta_path, const char *hierarchy) {
+        _cleanup_free_ char *f = NULL;
+        int r;
+
+        assert(meta_path);
+
+        /* The origin file is compared to know if a refresh can be skipped (opt-in, used at service startup). */
+        f = path_join(meta_path, image_class_info[image_class].dot_directory_name, "origin");
+        if (!f)
+                return log_oom();
+
+        _cleanup_free_ char *hierarchy_path = path_join(hierarchy, image_class_info[image_class].dot_directory_name, image_class_info[image_class].short_identifier_plural);
+        if (!hierarchy_path)
+                return log_oom();
+
+        r = write_string_file_full(AT_FDCWD, f, strempty(origin_content),
+                                   WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_MKDIR_0755|WRITE_STRING_FILE_LABEL|WRITE_STRING_FILE_AVOID_NEWLINE,
+                                   /* ts= */ NULL, hierarchy_path);
+        if (r < 0)
+                return log_error_errno(r, "Failed to write origin meta file '%s': %m", f);
+
+        return 0;
+}
+
 static int write_dev_file(ImageClass image_class, const char *meta_path, const char *overlay_path, const char *hierarchy) {
         _cleanup_free_ char *f = NULL;
         struct stat st;
@@ -1595,6 +1632,7 @@ static int write_work_dir_file(ImageClass image_class, const char *meta_path, co
 static int store_info_in_meta(
                 ImageClass image_class,
                 char **extensions,
+                const char *origin_content,
                 const char *meta_path,
                 const char *overlay_path,
                 const char *work_dir,
@@ -1627,6 +1665,10 @@ static int store_info_in_meta(
         if (r < 0)
                 return r;
 
+        r = write_origin_file(image_class, origin_content, meta_path, hierarchy);
+        if (r < 0)
+                return r;
+
         r = write_dev_file(image_class, meta_path, overlay_path, hierarchy);
         if (r < 0)
                 return r;
@@ -1685,6 +1727,7 @@ static int merge_hierarchy(
                 int noexec,
                 char **extensions,
                 char **paths,
+                const char *origin_content,
                 const char *meta_path,
                 const char *overlay_path,
                 const char *workspace_path) {
@@ -1730,7 +1773,7 @@ static int merge_hierarchy(
         if (r < 0)
                 return r;
 
-        r = store_info_in_meta(image_class, extensions, meta_path, overlay_path, op->work_dir, op->hierarchy, backing);
+        r = store_info_in_meta(image_class, extensions, origin_content, meta_path, overlay_path, op->work_dir, op->hierarchy, backing);
         if (r < 0)
                 return r;
 
@@ -1782,19 +1825,29 @@ static int merge_subprocess(
                 ImageClass image_class,
                 char **hierarchies,
                 bool force,
+                bool always_refresh,
                 int noexec,
                 Hashmap *images,
                 const char *workspace) {
 
         _cleanup_free_ char *host_os_release_id = NULL, *host_os_release_id_like = NULL,
                         *host_os_release_version_id = NULL, *host_os_release_api_level = NULL,
-                        *filename = NULL;
+                        *filename = NULL, *old_origin_content = NULL,
+                        *extensions_origin_content = NULL, *root_resolved = NULL;
         _cleanup_strv_free_ char **extensions = NULL, **extensions_v = NULL, **paths = NULL;
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *extensions_origin_entries = NULL,
+                        *extensions_origin_json = NULL, *mutable_dir_entries = NULL;
         size_t n_extensions = 0;
         unsigned n_ignored = 0;
         Image *img;
         int r;
 
+        if (!isempty(arg_root)) {
+                r = chase(arg_root, /* root= */ NULL, CHASE_MUST_BE_DIRECTORY, &root_resolved, /* ret_fd= */ NULL);
+                if (r < 0)
+                        return log_error_errno(r, "Failed to resolve --root='%s': %m", strempty(arg_root));
+        }
+
         assert(path_startswith(workspace, "/run/"));
 
         /* Mark the whole of /run as MS_SLAVE, so that we can mount stuff below it that doesn't show up on
@@ -1832,7 +1885,8 @@ static int merge_subprocess(
 
         /* Let's now mount all images */
         HASHMAP_FOREACH(img, images) {
-                _cleanup_free_ char *p = NULL;
+                _cleanup_free_ char *p = NULL, *path_without_root = NULL;
+                _cleanup_(sd_json_variant_unrefp) sd_json_variant *verity_hash = NULL;
 
                 p = path_join(workspace, image_class_info[image_class].short_identifier_plural, img->name);
                 if (!p)
@@ -1931,6 +1985,12 @@ static int merge_subprocess(
                         if (r < 0)
                                 return r;
 
+                        if (iovec_is_set(&verity_settings.root_hash)) {
+                                r = sd_json_variant_new_hex(&verity_hash, verity_settings.root_hash.iov_base, verity_settings.root_hash.iov_len);
+                                if (r < 0)
+                                        return log_error_errno(r, "Failed to create origin verity entry for '%s': %m", img->name);
+                        }
+
                         r = dissected_image_decrypt(m, arg_root, /* passphrase= */ NULL, &verity_settings, pick_image_policy(img), flags);
                         if (r < 0)
                                 return r;
@@ -2000,6 +2060,60 @@ static int merge_subprocess(
                 if (r < 0)
                         return log_oom();
 
+                /* Encode extension image origin to check if we can skip the refresh.
+                 * It can also be used to provide more detail in "systemd-sysext status". */
+
+                if (!isempty(arg_root)) {
+                        const char *without_root = NULL;
+                        without_root = path_startswith(img->path, root_resolved);
+                        if (!isempty(without_root)) {
+                                path_without_root = strjoin("/", without_root);
+                                if (!path_without_root)
+                                        return log_oom();
+                        }
+                }
+                if (!path_without_root) {
+                        path_without_root = strdup(img->path);
+                        if (!path_without_root)
+                                return log_oom();
+                }
+
+                /* The verity hash is not available for all extension types, thus, but only as fallback,
+                 * also include data to check for file/directory replacements through a file handle and
+                 * unique mount ID (or inode and mount ID as fallback).
+                 * A unique mount ID is best because st_dev gets reused too easily, e.g., by a loop dev
+                 * mount. For the mount ID to be valid it has to be resolved before we enter the new mount
+                 * namespace. Thus, here it wouldn't work and so instead it gets provided by the image
+                 * dissect logic and handed over to this subprocess we are in.
+                 * Online modification is not well supported with overlay mounts, so we don't do a file
+                 * checksum nor do we recurse into a directory to look for touched files. If users want
+                 * modifications to be picked up, they need to set the --always-refresh=yes flag (as will be
+                 * printed out). */
+
+                _cleanup_(sd_json_variant_unrefp) sd_json_variant *origin_entry = NULL;
+
+                /* We suppress inclusion of weak identifiers when a strong one is there so that, e.g.,
+                 * a confext image stored on /usr gets identified only by the verity hash instead of also
+                 * the mount ID because that changes when a sysext overlay mount appears but since the
+                 * verity hash is the same for the confext it can actually be reused. */
+                r = sd_json_buildo(&origin_entry,
+                                   SD_JSON_BUILD_PAIR_STRING("path", path_without_root),
+                                   SD_JSON_BUILD_PAIR_CONDITION(!!verity_hash, "verityHash", SD_JSON_BUILD_VARIANT(verity_hash)),
+                                   SD_JSON_BUILD_PAIR_CONDITION(!verity_hash, "onMountId", SD_JSON_BUILD_UNSIGNED(img->on_mount_id)),
+                                   SD_JSON_BUILD_PAIR_CONDITION(!verity_hash && !!img->fh, "fileHandle",
+                                                                SD_JSON_BUILD_OBJECT(SD_JSON_BUILD_PAIR_INTEGER("type", img->fh->handle_type),
+                                                                                     SD_JSON_BUILD_PAIR_HEX("handle", img->fh->f_handle,
+                                                                                                            img->fh->handle_bytes))),
+                                   SD_JSON_BUILD_PAIR_CONDITION(!verity_hash && !img->fh, "inode", SD_JSON_BUILD_UNSIGNED(img->inode)),
+                                   SD_JSON_BUILD_PAIR_CONDITION(!verity_hash, "crtime", SD_JSON_BUILD_UNSIGNED(img->crtime)),
+                                   SD_JSON_BUILD_PAIR_CONDITION(!verity_hash, "mtime", SD_JSON_BUILD_UNSIGNED(img->mtime)));
+                if (r < 0)
+                        return log_error_errno(r, "Failed to create origin entry for '%s': %m", img->name);
+
+                r = sd_json_variant_set_field(&extensions_origin_entries, img->name, origin_entry);
+                if (r < 0)
+                        return log_error_errno(r, "Failed to add origin entry for '%s': %m", img->name);
+
                 n_extensions++;
         }
 
@@ -2009,13 +2123,112 @@ static int merge_subprocess(
                         log_info("No suitable extensions found (%u ignored due to incompatible image(s)).", n_ignored);
                 else
                         log_info("No extensions found.");
-                return 0;
+                return MERGE_NOTHING_FOUND;
         }
 
         /* Order by version sort with strverscmp_improved() */
         typesafe_qsort(extensions, n_extensions, strverscmp_improvedp);
         typesafe_qsort(extensions_v, n_extensions, strverscmp_improvedp);
 
+        STRV_FOREACH(h, hierarchies) {
+                _cleanup_(overlayfs_paths_freep) OverlayFSPaths *op = NULL;
+                _cleanup_free_ char *f = NULL, *buf = NULL, *resolved = NULL, *mutable_directory_without_root = NULL;
+
+                /* The origin file includes the backing directories for mutable overlays. */
+                r = overlayfs_paths_new(*h, workspace, &op);
+                if (r < 0)
+                        return r;
+
+                if (op->resolved_mutable_directory && !isempty(arg_root)) {
+                        const char *without_root = NULL;
+                        without_root = path_startswith(op->resolved_mutable_directory, root_resolved);
+                        if (!isempty(without_root)) {
+                                mutable_directory_without_root = strjoin("/", without_root);
+                                if (!mutable_directory_without_root)
+                                        return log_oom();
+                        }
+                }
+                if (!mutable_directory_without_root && op->resolved_mutable_directory) {
+                        mutable_directory_without_root = strdup(op->resolved_mutable_directory);
+                        if (!mutable_directory_without_root)
+                                return log_oom();
+                }
+
+                if (mutable_directory_without_root) {
+                        r = sd_json_variant_set_field_string(&mutable_dir_entries, *h, mutable_directory_without_root);
+                        if (r < 0)
+                                return log_error_errno(r, "Failed to add mutable directory to origin JSON entry: %m");
+                }
+
+                /* Find existing origin file for comparison. */
+                r = chase(*h, arg_root, CHASE_PREFIX_ROOT|CHASE_NONEXISTENT, &resolved, /* ret_fd= */ NULL);
+                if (r < 0)
+                        return log_error_errno(r, "Failed to resolve hierarchy '%s%s': %m", strempty(arg_root), *h);
+
+                f = path_join(resolved, image_class_info[image_class].dot_directory_name, "origin");
+                if (!f)
+                        return log_oom();
+
+                r = is_our_mount_point(image_class, resolved);
+                if (r < 0)
+                        return r;
+                if (r == 0)
+                        continue;
+
+                if (old_origin_content)
+                        continue;
+
+                r = read_full_file(f, &buf, /* ret_size */ NULL);
+                if (r < 0) {
+                        log_debug_errno(r, "Failed to open '%s', continuing search: %m", f);
+                        continue;
+                }
+
+                old_origin_content = TAKE_PTR(buf);
+        }
+
+        r = sd_json_buildo(&extensions_origin_json,
+                           SD_JSON_BUILD_PAIR_OBJECT("mutable",
+                                                     SD_JSON_BUILD_PAIR_STRING("mode", mutable_mode_to_string(arg_mutable)),
+                                                     SD_JSON_BUILD_PAIR_CONDITION(!!mutable_dir_entries,
+                                                                                  "mutableDirs",
+                                                                                  SD_JSON_BUILD_VARIANT(mutable_dir_entries))),
+                           SD_JSON_BUILD_PAIR_CONDITION(!isempty(arg_overlayfs_mount_options),
+                                                        "mountOptions",
+                                                        SD_JSON_BUILD_STRING(arg_overlayfs_mount_options)),
+                           SD_JSON_BUILD_PAIR_CONDITION(!!extensions_origin_entries,
+                                                        "extensions",
+                                                        SD_JSON_BUILD_VARIANT(extensions_origin_entries)));
+        if (r < 0)
+                return log_error_errno(r, "Failed to create extensions origin JSON object: %m");
+
+        r = sd_json_variant_format(extensions_origin_json, SD_JSON_FORMAT_PRETTY|SD_JSON_FORMAT_NEWLINE, &extensions_origin_content);
+        if (r < 0)
+                return log_error_errno(r, "Failed to format extension origin as JSON: %m");
+
+        log_debug("New extension origin entry (unordered):\n%s\n", extensions_origin_content);
+
+        if (old_origin_content) {
+                _cleanup_(sd_json_variant_unrefp) sd_json_variant *old_origin_json = NULL;
+
+                log_debug("Old extension origin entry (unordered):\n%s\n", old_origin_content);
+                r = sd_json_parse(old_origin_content, /* flags= */ 0, &old_origin_json, /* reterr_line= */ NULL, /* reterr_column= */ NULL);
+                if (r < 0)
+                        return log_error_errno(r, "Failed to parse existing extension origin content: %m");
+
+                /* This works well with unordered entries. */
+                if (sd_json_variant_equal(extensions_origin_json, old_origin_json)) {
+                        if (!always_refresh) {
+                                /* This only happens during refresh, not merge, thus talk about refresh here. */
+                                log_info("Skipping extension refresh because no change was found, use --always-refresh=yes to always do a refresh.");
+                                return MERGE_SKIP_REFRESH;
+                        }
+
+                        log_debug("No change found based on origin entry but continuing as requested by --always-refresh=yes.");
+                } else
+                        log_debug("Found changes based on origin entry, continuing with the refresh.");
+        }
+
         if (n_extensions == 0) {
                 assert(arg_mutable != MUTABLE_NO);
                 log_info("No extensions found, proceeding in mutable mode.");
@@ -2098,6 +2311,7 @@ static int merge_subprocess(
                                 noexec,
                                 extensions,
                                 paths,
+                                extensions_origin_content,
                                 meta_path,
                                 overlay_path,
                                 merge_hierarchy_workspace);
@@ -2142,13 +2356,14 @@ static int merge_subprocess(
                 log_info("Merged extensions into '%s'.", resolved);
         }
 
-        return 1;
+        return MERGE_MOUNTED;
 }
 
 static int merge(ImageClass image_class,
                  char **hierarchies,
                  bool force,
                  bool no_reload,
+                 bool always_refresh,
                  int noexec,
                  Hashmap *images) {
 
@@ -2165,21 +2380,28 @@ static int merge(ImageClass image_class,
         if (r == 0) {
                 /* Child with its own mount namespace */
 
-                r = merge_subprocess(image_class, hierarchies, force, noexec, images, "/run/systemd/sysext");
-                if (r < 0)
-                        _exit(EXIT_FAILURE);
+                r = merge_subprocess(image_class, hierarchies, force, always_refresh, noexec, images, "/run/systemd/sysext");
 
                 /* Our namespace ceases to exist here, also implicitly detaching all temporary mounts we
                  * created below /run. Nice! */
 
-                _exit(r > 0 ? EXIT_SUCCESS : 123); /* 123 means: didn't find any extensions */
+                if (r < 0)
+                        _exit(EXIT_FAILURE);
+                if (r == MERGE_NOTHING_FOUND)
+                        _exit(MERGE_EXIT_NOTHING_FOUND);
+                if (r == MERGE_SKIP_REFRESH)
+                        _exit(MERGE_EXIT_SKIP_REFRESH);
+
+                _exit(EXIT_SUCCESS);
         }
 
         r = pidref_wait_for_terminate_and_check("(sd-merge)", &pidref, WAIT_LOG_ABNORMAL);
         if (r < 0)
                 return r;
-        if (r == 123) /* exit code 123 means: didn't do anything */
-                return 0;
+        if (r == MERGE_EXIT_NOTHING_FOUND)
+                return 0; /* Tell refresh to unmount */
+        if (r == MERGE_EXIT_SKIP_REFRESH)
+                return 1; /* Same return code as below when we have merged new */
         if (r > 0)
                 return log_error_errno(SYNTHETIC_ERRNO(EPROTO), "Failed to merge hierarchies");
 
@@ -2277,6 +2499,7 @@ static int verb_merge(int argc, char **argv, void *userdata) {
                      arg_hierarchies,
                      arg_force,
                      arg_no_reload,
+                     arg_always_refresh,
                      arg_noexec,
                      images);
 }
@@ -2285,16 +2508,18 @@ typedef struct MethodMergeParameters {
         const char *class;
         int force;
         int no_reload;
+        int always_refresh;
         int noexec;
 } MethodMergeParameters;
 
 static int parse_merge_parameters(sd_varlink *link, sd_json_variant *parameters, MethodMergeParameters *p) {
 
         static const sd_json_dispatch_field dispatch_table[] = {
-                { "class",    SD_JSON_VARIANT_STRING,  sd_json_dispatch_const_string, offsetof(MethodMergeParameters, class),     0 },
-                { "force",    SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate,     offsetof(MethodMergeParameters, force),     0 },
-                { "noReload", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate,     offsetof(MethodMergeParameters, no_reload), 0 },
-                { "noexec",   SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate,     offsetof(MethodMergeParameters, noexec),    0 },
+                { "class",         SD_JSON_VARIANT_STRING,  sd_json_dispatch_const_string, offsetof(MethodMergeParameters, class),          0 },
+                { "force",         SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate,     offsetof(MethodMergeParameters, force),          0 },
+                { "noReload",      SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate,     offsetof(MethodMergeParameters, no_reload),      0 },
+                { "alwaysRefresh", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate,     offsetof(MethodMergeParameters, always_refresh), 0 },
+                { "noexec",        SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate,     offsetof(MethodMergeParameters, noexec),         0 },
                 VARLINK_DISPATCH_POLKIT_FIELD,
                 {}
         };
@@ -2312,11 +2537,12 @@ static int vl_method_merge(sd_varlink *link, sd_json_variant *parameters, sd_var
         MethodMergeParameters p = {
                 .force = -1,
                 .no_reload = -1,
+                .always_refresh = -1,
                 .noexec = -1,
         };
         _cleanup_strv_free_ char **hierarchies = NULL;
         ImageClass image_class = arg_image_class;
-        bool force, no_reload;
+        bool force, no_reload, always_refresh;
         int r, noexec;
 
         assert(link);
@@ -2331,6 +2557,7 @@ static int vl_method_merge(sd_varlink *link, sd_json_variant *parameters, sd_var
 
         force = p.force >= 0 ? p.force : arg_force;
         no_reload = p.no_reload >= 0 ? p.no_reload : arg_no_reload;
+        always_refresh = p.always_refresh >= 0 ? p.always_refresh : arg_always_refresh;
         noexec = p.noexec >= 0 ? p.noexec : arg_noexec;
 
         r = varlink_verify_polkit_async(
@@ -2360,7 +2587,7 @@ static int vl_method_merge(sd_varlink *link, sd_json_variant *parameters, sd_var
         if (r > 0)
                 return sd_varlink_errorbo(link, "io.systemd.sysext.AlreadyMerged", SD_JSON_BUILD_PAIR_STRING("hierarchy", which));
 
-        r = merge(image_class, hierarchies ?: arg_hierarchies, force, no_reload, noexec, images);
+        r = merge(image_class, hierarchies ?: arg_hierarchies, force, no_reload, always_refresh, noexec, images);
         if (r < 0)
                 return r;
 
@@ -2372,6 +2599,7 @@ static int refresh(
                 char **hierarchies,
                 bool force,
                 bool no_reload,
+                bool always_refresh,
                 int noexec) {
 
         _cleanup_hashmap_free_ Hashmap *images = NULL;
@@ -2382,9 +2610,10 @@ static int refresh(
                 return r;
 
         /* Returns > 0 if it did something, i.e. a new overlayfs is mounted now. When it does so it
-         * implicitly unmounts any overlayfs placed there before. Returns == 0 if it did nothing, i.e. no
+         * implicitly unmounts any overlayfs placed there before. It also returns == 1 if there were
+         * no changes found to apply and the mount stays intact. Returns == 0 if it did nothing, i.e. no
          * extension images found. In this case the old overlayfs remains in place if there was one. */
-        r = merge(image_class, hierarchies, force, no_reload, noexec, images);
+        r = merge(image_class, hierarchies, force, no_reload, always_refresh, noexec, images);
         if (r < 0)
                 return r;
         if (r == 0) /* No images found? Then unmerge. The goal of --refresh is after all that after having
@@ -2396,7 +2625,8 @@ static int refresh(
          * 1. If an overlayfs was mounted before and no extensions exist anymore, we'll have unmerged things.
          *
          * 2. If an overlayfs was mounted before, and there are still extensions installed' we'll have
-         *    unmerged and then merged things again.
+         *    unmerged and then merged things again or we have skipped the refresh because no changes
+         *    were found.
          *
          * 3. If an overlayfs so far wasn't mounted, and there are extensions installed, we'll have it
          *    mounted now.
@@ -2420,6 +2650,7 @@ static int verb_refresh(int argc, char **argv, void *userdata) {
                        arg_hierarchies,
                        arg_force,
                        arg_no_reload,
+                       arg_always_refresh,
                        arg_noexec);
 }
 
@@ -2428,12 +2659,13 @@ static int vl_method_refresh(sd_varlink *link, sd_json_variant *parameters, sd_v
         MethodMergeParameters p = {
                 .force = -1,
                 .no_reload = -1,
+                .always_refresh = -1,
                 .noexec = -1,
         };
         Hashmap **polkit_registry = ASSERT_PTR(userdata);
         _cleanup_strv_free_ char **hierarchies = NULL;
         ImageClass image_class = arg_image_class;
-        bool force, no_reload;
+        bool force, no_reload, always_refresh;
         int r, noexec;
 
         assert(link);
@@ -2448,6 +2680,7 @@ static int vl_method_refresh(sd_varlink *link, sd_json_variant *parameters, sd_v
 
         force = p.force >= 0 ? p.force : arg_force;
         no_reload = p.no_reload >= 0 ? p.no_reload : arg_no_reload;
+        always_refresh = p.always_refresh >= 0 ? p.always_refresh : arg_always_refresh;
         noexec = p.noexec >= 0 ? p.noexec : arg_noexec;
 
         r = varlink_verify_polkit_async(
@@ -2463,7 +2696,7 @@ static int vl_method_refresh(sd_varlink *link, sd_json_variant *parameters, sd_v
         if (r <= 0)
                 return r;
 
-        r = refresh(image_class, hierarchies ?: arg_hierarchies, force, no_reload, noexec);
+        r = refresh(image_class, hierarchies ?: arg_hierarchies, force, no_reload, always_refresh, noexec);
         if (r < 0)
                 return r;
 
@@ -2592,6 +2825,8 @@ static int verb_help(int argc, char **argv, void *userdata) {
                "                          Generate JSON output\n"
                "     --force              Ignore version incompatibilities\n"
                "     --no-reload          Do not reload the service manager\n"
+               "     --always-refresh=yes|no\n"
+               "                          Do not skip refresh when no changes were found\n"
                "     --image-policy=POLICY\n"
                "                          Specify disk image dissection policy\n"
                "     --noexec=BOOL        Whether to mount extension overlay with noexec\n"
@@ -2619,21 +2854,23 @@ static int parse_argv(int argc, char *argv[]) {
                 ARG_IMAGE_POLICY,
                 ARG_NOEXEC,
                 ARG_NO_RELOAD,
+                ARG_ALWAYS_REFRESH,
                 ARG_MUTABLE,
         };
 
         static const struct option options[] = {
-                { "help",         no_argument,       NULL, 'h'              },
-                { "version",      no_argument,       NULL, ARG_VERSION      },
-                { "no-pager",     no_argument,       NULL, ARG_NO_PAGER     },
-                { "no-legend",    no_argument,       NULL, ARG_NO_LEGEND    },
-                { "root",         required_argument, NULL, ARG_ROOT         },
-                { "json",         required_argument, NULL, ARG_JSON         },
-                { "force",        no_argument,       NULL, ARG_FORCE        },
-                { "image-policy", required_argument, NULL, ARG_IMAGE_POLICY },
-                { "noexec",       required_argument, NULL, ARG_NOEXEC       },
-                { "no-reload",    no_argument,       NULL, ARG_NO_RELOAD    },
-                { "mutable",      required_argument, NULL, ARG_MUTABLE      },
+                { "help",           no_argument,       NULL, 'h'                },
+                { "version",        no_argument,       NULL, ARG_VERSION        },
+                { "no-pager",       no_argument,       NULL, ARG_NO_PAGER       },
+                { "no-legend",      no_argument,       NULL, ARG_NO_LEGEND      },
+                { "root",           required_argument, NULL, ARG_ROOT           },
+                { "json",           required_argument, NULL, ARG_JSON           },
+                { "force",          no_argument,       NULL, ARG_FORCE          },
+                { "image-policy",   required_argument, NULL, ARG_IMAGE_POLICY   },
+                { "noexec",         required_argument, NULL, ARG_NOEXEC         },
+                { "no-reload",      no_argument,       NULL, ARG_NO_RELOAD      },
+                { "always-refresh", required_argument, NULL, ARG_ALWAYS_REFRESH },
+                { "mutable",        required_argument, NULL, ARG_MUTABLE        },
                 {}
         };
 
@@ -2700,6 +2937,12 @@ static int parse_argv(int argc, char *argv[]) {
                         arg_no_reload = true;
                         break;
 
+                case ARG_ALWAYS_REFRESH:
+                        r = parse_boolean_argument("--always-refresh", optarg, &arg_always_refresh);
+                        if (r < 0)
+                                return r;
+                        break;
+
                 case ARG_MUTABLE:
                         if (streq(optarg, "help")) {
                                 if (arg_legend)
index 0ebc115208ee97f21ecec1ab08668d3d89730306..60fb216ece67494a02caebc55bf953778858de9c 100755 (executable)
@@ -1587,6 +1587,94 @@ rm -rf "$fake_root/var/lib/extensions/test-extension.raw.v" "$fake_root/var/othe
 
 # Done with the above vpick symlink tests for --root= and without
 
+( init_trap
+: "Check if refresh skips correctly"
+fake_root=${roots_dir:+"$roots_dir/refresh-skip"}
+hierarchy=/opt
+
+findmnt --kernel=listmount >/dev/null || {
+    echo >&2 "Can't run test on old kernel, skipping test."
+    exit 0
+}
+
+prepare_root "$fake_root" "$hierarchy"
+prepare_extension_image "$fake_root" "$hierarchy"
+prepare_hierarchy "$fake_root" "$hierarchy"
+
+run_systemd_sysext "$fake_root" merge
+extension_verify_after_merge "$fake_root" "$hierarchy" -e -h
+# The mountinfo ID gets reused and is useless here, we require a unique ID from listmount
+MOUNTID1=$(findmnt --kernel=listmount -o UNIQ-ID --raw --noheadings --target "$fake_root$hierarchy")
+run_systemd_sysext "$fake_root" refresh
+extension_verify_after_merge "$fake_root" "$hierarchy" -e -h
+MOUNTID2=$(findmnt --kernel=listmount -o UNIQ-ID --raw --noheadings --target "$fake_root$hierarchy")
+if [ "$MOUNTID1" != "$MOUNTID2" ]; then
+    echo >&2 "Unexpected remount with 'refresh'"
+    exit 1
+fi
+rm -rf "$fake_root/var/lib/extensions/test-extension2"
+cp -ar "$fake_root/var/lib/extensions/test-extension" "$fake_root/var/lib/extensions/test-extension2"
+rm -rf "$fake_root/var/lib/extensions/test-extension"
+mv "$fake_root/var/lib/extensions/test-extension2" "$fake_root/var/lib/extensions/test-extension"
+run_systemd_sysext "$fake_root" refresh
+extension_verify_after_merge "$fake_root" "$hierarchy" -e -h
+MOUNTID3=$(findmnt --kernel=listmount -o UNIQ-ID --raw --noheadings --target "$fake_root$hierarchy")
+if [ "$MOUNTID2" = "$MOUNTID3" ]; then
+    echo >&2 "Unexpected skip with 'refresh'"
+    exit 1
+fi
+
+run_systemd_sysext "$fake_root" unmerge
+extension_verify_after_unmerge "$fake_root" "$hierarchy" -h
+)
+
+( init_trap
+: "Check that refresh does a skip if verity image changes file handle but has same hash"
+fake_root=${roots_dir:+"$roots_dir/refresh-skip-verity-filehandle-same-hash"}
+hierarchy=/opt
+
+# On OpenSUSE Tumbleweed EROFS is not supported
+if [ -e /usr/lib/modprobe.d/60-blacklist_fs-erofs.conf ]; then
+    echo >&2 "Skipping test due to missing erofs support"
+    exit 0
+fi
+
+findmnt --kernel=listmount >/dev/null || {
+    echo >&2 "Can't run test on old kernel, skipping test."
+    exit 0
+}
+
+prepare_root "$fake_root" "$hierarchy"
+prepare_extension_image_raw_verity "$fake_root" "$hierarchy"
+prepare_hierarchy "$fake_root" "$hierarchy"
+
+run_systemd_sysext "$fake_root" merge
+extension_verify_after_merge "$fake_root" "$hierarchy" -e -h
+# The mountinfo ID gets reused and is useless here, we require a unique ID from listmount
+MOUNTID1=$(findmnt --kernel=listmount -o UNIQ-ID --raw --noheadings --target "$fake_root$hierarchy")
+run_systemd_sysext "$fake_root" refresh
+extension_verify_after_merge "$fake_root" "$hierarchy" -e -h
+MOUNTID2=$(findmnt --kernel=listmount -o UNIQ-ID --raw --noheadings --target "$fake_root$hierarchy")
+if [ "$MOUNTID1" != "$MOUNTID2" ]; then
+    echo >&2 "Unexpected remount with 'refresh'"
+    exit 1
+fi
+# Force a new file handle (get a new inode)
+mv "$fake_root/var/lib/extensions/test-extension.raw" "$fake_root/var/lib/extensions/test-extension2.raw"
+cp "$fake_root/var/lib/extensions/test-extension2.raw" "$fake_root/var/lib/extensions/test-extension.raw"
+rm "$fake_root/var/lib/extensions/test-extension2.raw"
+run_systemd_sysext "$fake_root" refresh
+extension_verify_after_merge "$fake_root" "$hierarchy" -e -h
+MOUNTID3=$(findmnt --kernel=listmount -o UNIQ-ID --raw --noheadings --target "$fake_root$hierarchy")
+if [ "$MOUNTID2" != "$MOUNTID3" ]; then
+    echo >&2 "Unexpected remount with 'refresh' after verity image file handle changed"
+    exit 1
+fi
+
+run_systemd_sysext "$fake_root" unmerge
+extension_verify_after_unmerge "$fake_root" "$hierarchy" -h
+)
+
 } # End of run_sysext_tests