From 09e38c27096da719437092ab80569f693951283c Mon Sep 17 00:00:00 2001 From: DaanDeMeyer Date: Fri, 26 Dec 2025 21:51:00 +0100 Subject: [PATCH 1/9] dissect: Don't use private userns for --copy-to/--copy-from These actions interact with the host. The former needs privileges to write into the image, the latter needs privileges to write on the host. Neither will have the privileges required if the image is attached under a private userns, hence, don't use one. --- src/dissect/dissect.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index 94f796f675..b6b122afb7 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -2180,8 +2180,9 @@ static int run(int argc, char *argv[]) { if (arg_loop_ref || arg_loop_ref_auto) /* yes, the 2nd check is strictly speaking redundant, given the normalization we did above, but let's be explicit here */ return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "--loop-ref=/--loop-ref-auto not supported when operating via systemd-mountfsd."); - /* Don't run things in private userns, if the mount shall be attached to the host */ - if (!IN_SET(arg_action, ACTION_MOUNT, ACTION_WITH)) { + /* Don't run things in private userns, if the mount shall be attached to the host + * or if we're copying from/to the host. */ + if (!IN_SET(arg_action, ACTION_MOUNT, ACTION_WITH, ACTION_COPY_FROM, ACTION_COPY_TO)) { userns_fd = nsresource_allocate_userns(/* name= */ NULL, NSRESOURCE_UIDS_64K); /* allocate 64K users by default */ if (userns_fd < 0) return log_error_errno(userns_fd, "Failed to allocate user namespace with 64K users: %m"); From 53d1d6e49cd95c6525a089e3e4da89932756ef3c Mon Sep 17 00:00:00 2001 From: DaanDeMeyer Date: Fri, 26 Dec 2025 22:36:08 +0100 Subject: [PATCH 2/9] dissect: Fix logging in (with) --- src/dissect/dissect.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index b6b122afb7..bb0a0a16ef 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -1785,7 +1785,7 @@ static int action_with(DissectedImage *m, LoopDevice *d) { return log_error_errno(r, "Failed to unlock loopback block device: %m"); } - rcode = pidref_safe_fork("(with)", FORK_CLOSE_ALL_FDS|FORK_LOG|FORK_WAIT, /* ret= */ NULL); + rcode = pidref_safe_fork("(with)", FORK_CLOSE_ALL_FDS|FORK_LOG|FORK_REOPEN_LOG|FORK_WAIT, /* ret= */ NULL); if (rcode == 0) { /* Child */ From a0a4e9d7d22296b856a9f6118eb158cab9826a8d Mon Sep 17 00:00:00 2001 From: DaanDeMeyer Date: Fri, 26 Dec 2025 22:36:21 +0100 Subject: [PATCH 3/9] dissect: Fix segmentation fault if loop device is not provided --- src/dissect/dissect.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index bb0a0a16ef..a69bab8500 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -1799,7 +1799,7 @@ static int action_with(DissectedImage *m, LoopDevice *d) { _exit(EXIT_FAILURE); } - if (setenv("SYSTEMD_DISSECT_DEVICE", d->node, /* overwrite= */ true) < 0) { + if (d && setenv("SYSTEMD_DISSECT_DEVICE", d->node, /* overwrite= */ true) < 0) { log_error_errno(errno, "Failed to set $SYSTEMD_DISSECT_DEVICE: %m"); _exit(EXIT_FAILURE); } From fe0114db8e9f87042fcaa94fd3c16d5be85f8f16 Mon Sep 17 00:00:00 2001 From: DaanDeMeyer Date: Fri, 26 Dec 2025 22:36:39 +0100 Subject: [PATCH 4/9] dissect: Fix wrong errno passed to log message --- src/dissect/dissect.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index a69bab8500..95db618b83 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -1839,7 +1839,7 @@ static int action_with(DissectedImage *m, LoopDevice *d) { created_dir = TAKE_PTR(mounted_dir); if (rmdir(created_dir) < 0) - log_warning_errno(r, "Failed to remove directory '%s', ignoring: %m", created_dir); + log_warning_errno(errno, "Failed to remove directory '%s', ignoring: %m", created_dir); temp = TAKE_PTR(created_dir); From ef155c909a53fc1c04c8ea5cd4b032e548b91a87 Mon Sep 17 00:00:00 2001 From: DaanDeMeyer Date: Fri, 26 Dec 2025 22:18:29 +0100 Subject: [PATCH 5/9] dissect: Make --mount/--unmount/--with work unprivileged Let's check for CAP_SYS_ADMIN instead of root for these, and make unmounting more graceful if we can't access the backing loop device because of permission issues. This allows mounting and unmounting images from an unprvileged mount namespace. The actual files in the image will end up owned by nobody:nobody because we'll be in an unprivileged user namespace, but assuming the directory permissions are not too strict, this still allows interacting with the image in useful ways. --- src/dissect/dissect.c | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index 95db618b83..deee8f6c1d 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -14,6 +14,7 @@ #include "argv-util.h" #include "blockdev-util.h" #include "build.h" +#include "capability-util.h" #include "chase.h" #include "copy.h" #include "device-util.h" @@ -837,7 +838,14 @@ static int parse_argv(int argc, char *argv[]) { } else arg_via_service = r; - if (!IN_SET(arg_action, ACTION_DISSECT, ACTION_LIST, ACTION_MTREE, ACTION_COPY_FROM, ACTION_COPY_TO, ACTION_DISCOVER, ACTION_VALIDATE) && geteuid() != 0) + r = have_effective_cap(CAP_SYS_ADMIN); + if (r < 0) + return log_error_errno(r, "Failed to determine if we have CAP_SYS_ADMIN: %m"); + + if (IN_SET(arg_action, ACTION_MOUNT, ACTION_UMOUNT) && r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EPERM), "Need to have CAP_SYS_ADMIN to mount/unmount images"); + + if (IN_SET(arg_action, ACTION_ATTACH, ACTION_DETACH, ACTION_SHIFT) && geteuid() != 0) return log_error_errno(SYNTHETIC_ERRNO(EPERM), "Need to be root."); SET_FLAG(arg_flags, DISSECT_IMAGE_ALLOW_INTERACTIVE_AUTH, isatty_safe(STDIN_FILENO)); @@ -1721,7 +1729,7 @@ static int action_umount(const char *path) { return log_error_errno(r, "Failed to find backing block device for '%s': %m", canonical); r = loop_device_open(dev, 0, LOCK_EX, &d); - if (r < 0) + if (r < 0 && !ERRNO_IS_PRIVILEGE(r)) return log_device_error_errno(dev, r, "Failed to open loopback block device: %m"); /* We've locked the loop device, now we're ready to unmount. To allow the unmount to succeed, we have @@ -1733,7 +1741,8 @@ static int action_umount(const char *path) { return log_error_errno(r, "Failed to unmount '%s': %m", canonical); /* We managed to lock and unmount successfully? That means we can try to remove the loop device. */ - loop_device_unrelinquish(d); + if (d) + loop_device_unrelinquish(d); if (arg_rmdir) { r = RET_NERRNO(rmdir(canonical)); @@ -2114,7 +2123,7 @@ static int run(int argc, char *argv[]) { else r = loop_device_make_by_path(arg_image, open_flags, /* sector_size= */ UINT32_MAX, loop_flags, LOCK_SH, &d); if (r < 0) { - if (!ERRNO_IS_PRIVILEGE(r) || !IN_SET(arg_action, ACTION_DISSECT, ACTION_LIST, ACTION_MTREE, ACTION_COPY_FROM, ACTION_COPY_TO, ACTION_SHIFT)) + if (!ERRNO_IS_PRIVILEGE(r) || !IN_SET(arg_action, ACTION_MOUNT, ACTION_UMOUNT, ACTION_WITH, ACTION_DISSECT, ACTION_LIST, ACTION_MTREE, ACTION_COPY_FROM, ACTION_COPY_TO, ACTION_SHIFT)) return log_error_errno(r, "Failed to set up loopback device for %s: %m", arg_image); log_debug_errno(r, "Lacking permissions to set up loopback block device for %s, using service: %m", arg_image); From ba37ed963453467dc21b82b8841ecdbb83551a97 Mon Sep 17 00:00:00 2001 From: DaanDeMeyer Date: Sat, 27 Dec 2025 20:37:02 +0100 Subject: [PATCH 6/9] dissect: Introduce --copy-ownership= to configure chown behavior Currently, if we're copying a file, we won't copy the owner UID/GID from the source. If we're copying a directory, we will copy the owner UID/GID from the source. Let's give users a bit more control over this behavior by introducing --copy-ownership= which will default to the current behavior but allows users to explicitly enable/disable copying of ownership. --- man/systemd-dissect.xml | 13 +++ src/dissect/dissect.c | 127 +++++++++++++++++--------- src/import/import-fs.c | 2 + src/shared/btrfs-util.c | 2 + src/shared/copy.c | 22 ++++- src/shared/copy.h | 7 +- src/test/test-copy.c | 2 +- src/test/test-execute.c | 2 +- test/units/TEST-50-DISSECT.dissect.sh | 39 ++++++++ 9 files changed, 165 insertions(+), 51 deletions(-) diff --git a/man/systemd-dissect.xml b/man/systemd-dissect.xml index 669cea96f4..9368d90a52 100644 --- a/man/systemd-dissect.xml +++ b/man/systemd-dissect.xml @@ -551,6 +551,19 @@ + + + + Controls whether file ownership (user and group) is preserved when copying files + with or . Takes a boolean. If + yes, ownership is always preserved. If no, ownership is never + preserved and the current user's UID/GID is used instead. If not specified, ownership is preserved + when copying directory trees, but not when copying individual regular files. + + + + + diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index deee8f6c1d..fc818bf020 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -109,6 +109,7 @@ static bool arg_all = false; static uid_t arg_uid_base = UID_INVALID; static bool arg_quiet = false; static ImageFilter *arg_image_filter = NULL; +static int arg_copy_ownership = -1; STATIC_DESTRUCTOR_REGISTER(arg_image, freep); STATIC_DESTRUCTOR_REGISTER(arg_root, freep); @@ -170,6 +171,8 @@ static int help(void) { " --loop-ref=NAME Set reference string for loopback device\n" " --loop-ref-auto Derive reference string from image file name\n" " --mtree-hash=BOOL Whether to include SHA256 hash in the mtree output\n" + " --copy-ownership=BOOL\n" + " Whether to copy ownership when copying files\n" " --user Discover user images\n" " --system Discover system images\n" " --all Show hidden images too\n" @@ -306,48 +309,50 @@ static int parse_argv(int argc, char *argv[]) { ARG_USER, ARG_ALL, ARG_IMAGE_FILTER, + ARG_COPY_OWNERSHIP, }; 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 }, - { "mount", no_argument, NULL, 'm' }, - { "umount", no_argument, NULL, 'u' }, - { "attach", no_argument, NULL, ARG_ATTACH }, - { "detach", no_argument, NULL, ARG_DETACH }, - { "with", no_argument, NULL, ARG_WITH }, - { "read-only", no_argument, NULL, 'r' }, - { "discard", required_argument, NULL, ARG_DISCARD }, - { "fsck", required_argument, NULL, ARG_FSCK }, - { "growfs", required_argument, NULL, ARG_GROWFS }, - { "root-hash", required_argument, NULL, ARG_ROOT_HASH }, - { "root-hash-sig", required_argument, NULL, ARG_ROOT_HASH_SIG }, - { "usr-hash", required_argument, NULL, ARG_USR_HASH }, - { "usr-hash-sig", required_argument, NULL, ARG_USR_HASH_SIG }, - { "verity-data", required_argument, NULL, ARG_VERITY_DATA }, - { "mkdir", no_argument, NULL, ARG_MKDIR }, - { "rmdir", no_argument, NULL, ARG_RMDIR }, - { "in-memory", no_argument, NULL, ARG_IN_MEMORY }, - { "list", no_argument, NULL, 'l' }, - { "mtree", no_argument, NULL, ARG_MTREE }, - { "copy-from", no_argument, NULL, 'x' }, - { "copy-to", no_argument, NULL, 'a' }, - { "json", required_argument, NULL, ARG_JSON }, - { "discover", no_argument, NULL, ARG_DISCOVER }, - { "loop-ref", required_argument, NULL, ARG_LOOP_REF }, - { "loop-ref-auto", no_argument, NULL, ARG_LOOP_REF_AUTO }, - { "image-policy", required_argument, NULL, ARG_IMAGE_POLICY }, - { "validate", no_argument, NULL, ARG_VALIDATE }, - { "mtree-hash", required_argument, NULL, ARG_MTREE_HASH }, - { "make-archive", no_argument, NULL, ARG_MAKE_ARCHIVE }, - { "shift", no_argument, NULL, ARG_SHIFT }, - { "system", no_argument, NULL, ARG_SYSTEM }, - { "user", no_argument, NULL, ARG_USER }, - { "all", no_argument, NULL, ARG_ALL }, - { "quiet", no_argument, NULL, 'q' }, - { "image-filter", required_argument, NULL, ARG_IMAGE_FILTER }, + { "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 }, + { "mount", no_argument, NULL, 'm' }, + { "umount", no_argument, NULL, 'u' }, + { "attach", no_argument, NULL, ARG_ATTACH }, + { "detach", no_argument, NULL, ARG_DETACH }, + { "with", no_argument, NULL, ARG_WITH }, + { "read-only", no_argument, NULL, 'r' }, + { "discard", required_argument, NULL, ARG_DISCARD }, + { "fsck", required_argument, NULL, ARG_FSCK }, + { "growfs", required_argument, NULL, ARG_GROWFS }, + { "root-hash", required_argument, NULL, ARG_ROOT_HASH }, + { "root-hash-sig", required_argument, NULL, ARG_ROOT_HASH_SIG }, + { "usr-hash", required_argument, NULL, ARG_USR_HASH }, + { "usr-hash-sig", required_argument, NULL, ARG_USR_HASH_SIG }, + { "verity-data", required_argument, NULL, ARG_VERITY_DATA }, + { "mkdir", no_argument, NULL, ARG_MKDIR }, + { "rmdir", no_argument, NULL, ARG_RMDIR }, + { "in-memory", no_argument, NULL, ARG_IN_MEMORY }, + { "list", no_argument, NULL, 'l' }, + { "mtree", no_argument, NULL, ARG_MTREE }, + { "copy-from", no_argument, NULL, 'x' }, + { "copy-to", no_argument, NULL, 'a' }, + { "json", required_argument, NULL, ARG_JSON }, + { "discover", no_argument, NULL, ARG_DISCOVER }, + { "loop-ref", required_argument, NULL, ARG_LOOP_REF }, + { "loop-ref-auto", no_argument, NULL, ARG_LOOP_REF_AUTO }, + { "image-policy", required_argument, NULL, ARG_IMAGE_POLICY }, + { "validate", no_argument, NULL, ARG_VALIDATE }, + { "mtree-hash", required_argument, NULL, ARG_MTREE_HASH }, + { "make-archive", no_argument, NULL, ARG_MAKE_ARCHIVE }, + { "shift", no_argument, NULL, ARG_SHIFT }, + { "system", no_argument, NULL, ARG_SYSTEM }, + { "user", no_argument, NULL, ARG_USER }, + { "all", no_argument, NULL, ARG_ALL }, + { "quiet", no_argument, NULL, 'q' }, + { "image-filter", required_argument, NULL, ARG_IMAGE_FILTER }, + { "copy-ownership", required_argument, NULL, ARG_COPY_OWNERSHIP }, {} }; @@ -631,6 +636,12 @@ static int parse_argv(int argc, char *argv[]) { break; } + case ARG_COPY_OWNERSHIP: + r = parse_tristate_argument("--copy-ownership=", optarg, &arg_copy_ownership); + if (r < 0) + return r; + break; + case '?': return -EINVAL; @@ -1447,6 +1458,13 @@ static int action_list_or_mtree_or_copy_or_make_archive(DissectedImage *m, LoopD assert(IN_SET(arg_action, ACTION_LIST, ACTION_MTREE, ACTION_COPY_FROM, ACTION_COPY_TO, ACTION_MAKE_ARCHIVE, ACTION_SHIFT)); + /* Determine whether to copy ownership: + * --copy-ownership=yes: always try to preserve ownership + * --copy-ownership=no: never preserve ownership, use current user + * --copy-ownership=auto (default): preserve ownership for directory trees, + * but not for regular files (since DDI password tables are typically + * distinct from the host ones, individual file ownership is less meaningful) */ + if (arg_image) { assert(m); @@ -1509,7 +1527,12 @@ static int action_list_or_mtree_or_copy_or_make_archive(DissectedImage *m, LoopD } /* Try to copy as directory? */ - r = copy_directory_at(source_fd, NULL, AT_FDCWD, arg_target, COPY_REFLINK|COPY_MERGE|COPY_REPLACE|COPY_SIGINT|COPY_HARDLINKS); + r = copy_directory_at( + source_fd, /* from= */ NULL, + AT_FDCWD, arg_target, + arg_copy_ownership == 0 ? getuid() : UID_INVALID, + arg_copy_ownership == 0 ? getgid() : GID_INVALID, + COPY_REFLINK|COPY_MERGE|COPY_REPLACE|COPY_SIGINT|COPY_HARDLINKS); if (r >= 0) return 0; if (r != -ENOTDIR) @@ -1532,9 +1555,10 @@ static int action_list_or_mtree_or_copy_or_make_archive(DissectedImage *m, LoopD (void) copy_xattr(source_fd, NULL, target_fd, NULL, 0); (void) copy_access(source_fd, target_fd); + if (arg_copy_ownership > 0) + (void) copy_owner(source_fd, target_fd); (void) copy_times(source_fd, target_fd, 0); - /* When this is a regular file we don't copy ownership! */ return 0; } @@ -1588,9 +1612,23 @@ static int action_list_or_mtree_or_copy_or_make_archive(DissectedImage *m, LoopD if (errno != ENOENT) return log_error_errno(errno, "Failed to open destination '%s': %m", arg_target); - r = copy_tree_at(source_fd, ".", dfd, bn, UID_INVALID, GID_INVALID, COPY_REFLINK|COPY_MERGE|COPY_REPLACE|COPY_SIGINT|COPY_HARDLINKS, NULL, NULL); + r = copy_tree_at( + source_fd, ".", + dfd, bn, + arg_copy_ownership == 0 ? getuid() : UID_INVALID, + arg_copy_ownership == 0 ? getgid() : GID_INVALID, + COPY_REFLINK|COPY_MERGE|COPY_REPLACE|COPY_SIGINT|COPY_HARDLINKS, + /* denylist= */ NULL, + /* subvolumes= */ NULL); } else - r = copy_tree_at(source_fd, ".", target_fd, ".", UID_INVALID, GID_INVALID, COPY_REFLINK|COPY_MERGE|COPY_REPLACE|COPY_SIGINT|COPY_HARDLINKS, NULL, NULL); + r = copy_tree_at( + source_fd, ".", + target_fd, ".", + arg_copy_ownership == 0 ? getuid() : UID_INVALID, + arg_copy_ownership == 0 ? getgid() : GID_INVALID, + COPY_REFLINK|COPY_MERGE|COPY_REPLACE|COPY_SIGINT|COPY_HARDLINKS, + /* denylist= */ NULL, + /* subvolumes= */ NULL); if (r < 0) return log_error_errno(r, "Failed to copy '%s' to '%s' in image '%s': %m", arg_source, arg_target, arg_image); @@ -1611,9 +1649,10 @@ static int action_list_or_mtree_or_copy_or_make_archive(DissectedImage *m, LoopD (void) copy_xattr(source_fd, NULL, target_fd, NULL, 0); (void) copy_access(source_fd, target_fd); + if (arg_copy_ownership > 0) + (void) copy_owner(source_fd, target_fd); (void) copy_times(source_fd, target_fd, 0); - /* When this is a regular file we don't copy ownership! */ return 0; } diff --git a/src/import/import-fs.c b/src/import/import-fs.c index 947bc7c9b5..46daf3acc0 100644 --- a/src/import/import-fs.c +++ b/src/import/import-fs.c @@ -223,6 +223,8 @@ static int import_fs(int argc, char *argv[], void *userdata) { r = copy_directory_at_full( fd, NULL, AT_FDCWD, dest, + /* override_uid= */ UID_INVALID, + /* override_gid= */ GID_INVALID, COPY_REFLINK| COPY_SAME_MOUNT| COPY_HARDLINKS| diff --git a/src/shared/btrfs-util.c b/src/shared/btrfs-util.c index fffe9f2b34..9bd0a74c5f 100644 --- a/src/shared/btrfs-util.c +++ b/src/shared/btrfs-util.c @@ -1480,6 +1480,8 @@ int btrfs_subvol_snapshot_at_full( r = copy_directory_at_full( dir_fdf, from, new_fd, subvolume, + /* override_uid= */ UID_INVALID, + /* override_gid= */ GID_INVALID, COPY_MERGE_EMPTY| COPY_REFLINK| COPY_SAME_MOUNT| diff --git a/src/shared/copy.c b/src/shared/copy.c index 0598759313..445c246359 100644 --- a/src/shared/copy.c +++ b/src/shared/copy.c @@ -1442,6 +1442,8 @@ int copy_directory_at_full( const char *from, int dir_fdt, const char *to, + uid_t override_uid, + gid_t override_gid, CopyFlags copy_flags, copy_progress_path_t progress_path, copy_progress_bytes_t progress_bytes, @@ -1468,9 +1470,13 @@ int copy_directory_at_full( dir_fdt, to, st.st_dev, COPY_DEPTH_MAX, - UID_INVALID, GID_INVALID, + override_uid, + override_gid, copy_flags, - NULL, NULL, NULL, NULL, + /* denylist= */ NULL, + /* subvolumes= */ NULL, + /* progress_path= */ NULL, + /* progress_bytes= */ NULL, progress_path, progress_bytes, userdata); @@ -1750,6 +1756,18 @@ int copy_access(int fdf, int fdt) { return RET_NERRNO(fchmod(fdt, st.st_mode & 07777)); } +int copy_owner(int fdf, int fdt) { + struct stat st; + + assert(fdf >= 0); + assert(fdt >= 0); + + if (fstat(fdf, &st) < 0) + return -errno; + + return RET_NERRNO(fchown(fdt, st.st_uid, st.st_gid)); +} + int copy_rights_with_fallback(int fdf, int fdt, const char *patht) { struct stat st; diff --git a/src/shared/copy.h b/src/shared/copy.h index 704abdb84b..6e4a3b177b 100644 --- a/src/shared/copy.h +++ b/src/shared/copy.h @@ -88,9 +88,9 @@ static inline int copy_tree(const char *from, const char *to, uid_t override_uid return copy_tree_at_full(AT_FDCWD, from, AT_FDCWD, to, override_uid, override_gid, copy_flags, denylist, subvolumes, NULL, NULL, NULL); } -int copy_directory_at_full(int dir_fdf, const char *from, int dir_fdt, const char *to, CopyFlags copy_flags, copy_progress_path_t progress_path, copy_progress_bytes_t progress_bytes, void *userdata); -static inline int copy_directory_at(int dir_fdf, const char *from, int dir_fdt, const char *to, CopyFlags copy_flags) { - return copy_directory_at_full(dir_fdf, from, dir_fdt, to, copy_flags, NULL, NULL, NULL); +int copy_directory_at_full(int dir_fdf, const char *from, int dir_fdt, const char *to, uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, copy_progress_path_t progress_path, copy_progress_bytes_t progress_bytes, void *userdata); +static inline int copy_directory_at(int dir_fdf, const char *from, int dir_fdt, const char *to, uid_t override_uid, gid_t override_gid, CopyFlags copy_flags) { + return copy_directory_at_full(dir_fdf, from, dir_fdt, to, override_uid, override_gid, copy_flags, NULL, NULL, NULL); } int copy_bytes_full(int fdf, int fdt, uint64_t max_bytes, CopyFlags copy_flags, void **ret_remains, size_t *ret_remains_size, copy_progress_bytes_t progress, void *userdata); @@ -100,6 +100,7 @@ static inline int copy_bytes(int fdf, int fdt, uint64_t max_bytes, CopyFlags cop int copy_times(int fdf, int fdt, CopyFlags flags); int copy_access(int fdf, int fdt); +int copy_owner(int fdf, int fdt); int copy_rights_with_fallback(int fdf, int fdt, const char *patht); static inline int copy_rights(int fdf, int fdt) { return copy_rights_with_fallback(fdf, fdt, NULL); /* no fallback */ diff --git a/src/test/test-copy.c b/src/test/test-copy.c index e1bb7ae049..758a597fc5 100644 --- a/src/test/test-copy.c +++ b/src/test/test-copy.c @@ -560,7 +560,7 @@ TEST(copy_lock) { assert_se(mkdirat(tfd, "abc", 0755) >= 0); assert_se(write_string_file_at(tfd, "abc/def", "abc", WRITE_STRING_FILE_CREATE) >= 0); - assert_se((fd = copy_directory_at(tfd, "abc", tfd, "qed", COPY_LOCK_BSD)) >= 0); + assert_se((fd = copy_directory_at(tfd, "abc", tfd, "qed", UID_INVALID, GID_INVALID, COPY_LOCK_BSD)) >= 0); assert_se(faccessat(tfd, "qed", F_OK, 0) >= 0); assert_se(faccessat(tfd, "qed/def", F_OK, 0) >= 0); assert_se(xopenat_lock(tfd, "qed", 0, LOCK_BSD, LOCK_EX|LOCK_NB) == -EAGAIN); diff --git a/src/test/test-execute.c b/src/test/test-execute.c index 55b7c08924..ebd0260892 100644 --- a/src/test/test-execute.c +++ b/src/test/test-execute.c @@ -1497,7 +1497,7 @@ static int prepare_ns(const char *process_name) { /* Copy unit files to make them accessible even when unprivileged. */ ASSERT_OK(get_testdata_dir("test-execute/", &unit_dir)); - ASSERT_OK(copy_directory_at(AT_FDCWD, unit_dir, AT_FDCWD, PRIVATE_UNIT_DIR, COPY_MERGE_EMPTY)); + ASSERT_OK(copy_directory_at(AT_FDCWD, unit_dir, AT_FDCWD, PRIVATE_UNIT_DIR, UID_INVALID, GID_INVALID, COPY_MERGE_EMPTY)); /* Mount tmpfs on the following directories to make not StateDirectory= or friends disturb the host. */ ASSERT_OK_OR(get_build_exec_dir(&build_dir), -ENOEXEC); diff --git a/test/units/TEST-50-DISSECT.dissect.sh b/test/units/TEST-50-DISSECT.dissect.sh index ad8b14139c..07e0d1a8a0 100755 --- a/test/units/TEST-50-DISSECT.dissect.sh +++ b/test/units/TEST-50-DISSECT.dissect.sh @@ -1050,6 +1050,45 @@ echo abc >abc systemd-dissect --copy-to /tmp/img abc /abc test -f /tmp/img/abc +# Test --copy-ownership= option +rm -rf /tmp/copychown-test +mkdir -p /tmp/copychown-test/srcdir +echo "test file" >/tmp/copychown-test/srcdir/testfile +chown 1234:5678 /tmp/copychown-test/srcdir/testfile +chown 1234:5678 /tmp/copychown-test/srcdir + +# Test --copy-ownership=yes preserves ownership for regular files +systemd-dissect --copy-from /tmp/img etc/os-release /tmp/copychown-test/os-release-chown-yes --copy-ownership=yes +test "$(stat -c %u:%g /tmp/copychown-test/os-release-chown-yes)" = "0:0" + +# Test --copy-ownership=no uses current user for regular files +systemd-dissect --copy-from /tmp/img etc/os-release /tmp/copychown-test/os-release-chown-no --copy-ownership=no +test "$(stat -c %u:%g /tmp/copychown-test/os-release-chown-no)" = "0:0" + +# Test --copy-ownership=auto (default) does not preserve ownership for regular files +systemd-dissect --copy-from /tmp/img etc/os-release /tmp/copychown-test/os-release-chown-auto +test "$(stat -c %u:%g /tmp/copychown-test/os-release-chown-auto)" = "0:0" + +# Test --copy-ownership=yes preserves ownership for directories +systemd-dissect --copy-to /tmp/img /tmp/copychown-test/srcdir /copychown-dir-yes --copy-ownership=yes +test "$(stat -c %u:%g /tmp/img/copychown-dir-yes)" = "1234:5678" +test "$(stat -c %u:%g /tmp/img/copychown-dir-yes/testfile)" = "1234:5678" +rm -rf /tmp/img/copychown-dir-yes + +# Test --copy-ownership=no overrides ownership for directories +systemd-dissect --copy-to /tmp/img /tmp/copychown-test/srcdir /copychown-dir-no --copy-ownership=no +test "$(stat -c %u:%g /tmp/img/copychown-dir-no)" = "0:0" +test "$(stat -c %u:%g /tmp/img/copychown-dir-no/testfile)" = "0:0" +rm -rf /tmp/img/copychown-dir-no + +# Test --copy-ownership=auto (default) preserves ownership for directories +systemd-dissect --copy-to /tmp/img /tmp/copychown-test/srcdir /copychown-dir-auto +test "$(stat -c %u:%g /tmp/img/copychown-dir-auto)" = "1234:5678" +test "$(stat -c %u:%g /tmp/img/copychown-dir-auto/testfile)" = "1234:5678" +rm -rf /tmp/img/copychown-dir-auto + +rm -rf /tmp/copychown-test + # Test for dissect tool support with systemd-sysext mkdir -p /run/extensions/ testkit/usr/lib/extension-release.d/ echo "ID=_any" >testkit/usr/lib/extension-release.d/extension-release.testkit From dbd1a6e34f3ee05087dae8cf6a67c6fbcde0c3fd Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Thu, 5 Feb 2026 21:42:27 +0100 Subject: [PATCH 7/9] dissect: Allow --shift for users with CAP_CHOWN --- src/dissect/dissect.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index fc818bf020..4018e8233f 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -856,7 +856,14 @@ static int parse_argv(int argc, char *argv[]) { if (IN_SET(arg_action, ACTION_MOUNT, ACTION_UMOUNT) && r == 0) return log_error_errno(SYNTHETIC_ERRNO(EPERM), "Need to have CAP_SYS_ADMIN to mount/unmount images"); - if (IN_SET(arg_action, ACTION_ATTACH, ACTION_DETACH, ACTION_SHIFT) && geteuid() != 0) + r = have_effective_cap(CAP_CHOWN); + if (r < 0) + return log_error_errno(r, "Failed to determine if we have CAP_CHOWN: %m"); + + if (arg_action == ACTION_SHIFT && r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EPERM), "Need to have CAP_CHOWN to shift UID ranges"); + + if (IN_SET(arg_action, ACTION_ATTACH, ACTION_DETACH) && geteuid() != 0) return log_error_errno(SYNTHETIC_ERRNO(EPERM), "Need to be root."); SET_FLAG(arg_flags, DISSECT_IMAGE_ALLOW_INTERACTIVE_AUTH, isatty_safe(STDIN_FILENO)); From f933e959b1824e15cbd70d9745688d5820245089 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Thu, 5 Feb 2026 21:42:48 +0100 Subject: [PATCH 8/9] shift-uid: Add debug logging --- src/shared/shift-uid.c | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/shared/shift-uid.c b/src/shared/shift-uid.c index f0353f2391..2dcd5f2359 100644 --- a/src/shared/shift-uid.c +++ b/src/shared/shift-uid.c @@ -387,12 +387,13 @@ read_only: int path_patch_uid(const char *path, uid_t shift, uid_t range) { _cleanup_close_ int fd = -EBADF; struct stat st; + int r; assert(path); fd = open(path, O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW|O_NOATIME); if (fd < 0) - return -errno; + return log_debug_errno(errno, "Failed to open '%s': %m", path); /* Recursively adjusts the UID/GIDs of all files of a directory tree. This is used to automatically fix up an * OS tree to the used user namespace UID range. Note that this automatic adjustment only works for UID ranges @@ -401,21 +402,30 @@ int path_patch_uid(const char *path, uid_t shift, uid_t range) { /* We only support containers where the shift starts at a 2^16 boundary */ if ((shift & 0xFFFF) != 0) - return -EOPNOTSUPP; + return log_debug_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), + "UID shift 0x%"PRIx32" is not at a 2^16 boundary.", + (uint32_t) shift); if (shift == UID_BUSY_BASE) - return -EINVAL; + return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), + "UID shift 0x%"PRIx32" conflicts with busy base.", + (uint32_t) shift); /* We only support containers with 16-bit UID ranges for the patching logic */ if (range != 0x10000) - return -EOPNOTSUPP; + return log_debug_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), + "UID range 0x%"PRIx32" is not supported, must be 0x10000.", + (uint32_t) range); if (fstat(fd, &st) < 0) - return -errno; + return log_debug_errno(errno, "Failed to stat '%s': %m", path); /* We only support containers where the uid/gid container ID match */ if ((uint32_t) st.st_uid >> 16 != (uint32_t) st.st_gid >> 16) - return -EBADE; + return log_debug_errno(SYNTHETIC_ERRNO(EBADE), + "UID container ID 0x%"PRIx32" does not match GID container ID 0x%"PRIx32".", + (uint32_t) st.st_uid >> 16, + (uint32_t) st.st_gid >> 16); /* Try to detect if the range is already right. Of course, this a pretty drastic optimization, as we assume * that if the top-level dir has the right upper 16-bit assigned, then everything below will have too... */ @@ -430,7 +440,11 @@ int path_patch_uid(const char *path, uid_t shift, uid_t range) { if (fchown(fd, UID_BUSY_BASE | (st.st_uid & ~UID_BUSY_MASK), (gid_t) UID_BUSY_BASE | (st.st_gid & ~(gid_t) UID_BUSY_MASK)) < 0) - return -errno; + log_debug_errno(errno, "Failed to mark '%s' as busy, ignoring: %m", path); - return recurse_fd(TAKE_FD(fd), &st, shift, true); + r = recurse_fd(TAKE_FD(fd), &st, shift, /* is_toplevel= */ true); + if (r < 0) + return log_debug_errno(r, "Failed to recursively patch UID/GID of '%s': %m", path); + + return r; } From 01fb2d4a9e8708c1952f97881eee9d0907989420 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Thu, 12 Feb 2026 09:44:29 +0100 Subject: [PATCH 9/9] dissect: Use must_be_root() --- src/dissect/dissect.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index 4018e8233f..1eb17e67e4 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -863,8 +863,11 @@ static int parse_argv(int argc, char *argv[]) { if (arg_action == ACTION_SHIFT && r == 0) return log_error_errno(SYNTHETIC_ERRNO(EPERM), "Need to have CAP_CHOWN to shift UID ranges"); - if (IN_SET(arg_action, ACTION_ATTACH, ACTION_DETACH) && geteuid() != 0) - return log_error_errno(SYNTHETIC_ERRNO(EPERM), "Need to be root."); + if (IN_SET(arg_action, ACTION_ATTACH, ACTION_DETACH)) { + r = must_be_root(); + if (r < 0) + return r; + } SET_FLAG(arg_flags, DISSECT_IMAGE_ALLOW_INTERACTIVE_AUTH, isatty_safe(STDIN_FILENO));