From 6d51f6eb049dfcf3b2747036395601fcd8f6b019 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 28 Aug 2026 04:27:16 +0000 Subject: [PATCH 1/6] mkfs-util: fix error reporting in the mkfs child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `r` at that point holds the return value of `pidref_safe_fork_full()`, which is 0 in the child, so a failing `setenv()` was reported as "Success". Also, fix a stray "°". --- src/shared/mkfs-util.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/mkfs-util.c b/src/shared/mkfs-util.c index a197eb28ce..35955eb00b 100644 --- a/src/shared/mkfs-util.c +++ b/src/shared/mkfs-util.c @@ -671,14 +671,14 @@ int make_filesystem( STRV_FOREACH_PAIR(k, v, env) if (setenv(*k, *v, /* replace= */ true) < 0) { - log_error_errno(r, "Failed to set %s=%s environment variable: %m", *k, *v); + log_error_errno(errno, "Failed to set %s=%s environment variable: %m", *k, *v); _exit(EXIT_FAILURE); } /* mkfs.btrfs refuses to operate on block devices with mounted partitions, even if operating * on unformatted free space, so let's trick it and other mkfs tools into thinking no * partitions are mounted. See https://github.com/kdave/btrfs-progs/issues/640 for more - ° information. */ + * information. */ if (fork_flags & FORK_NEW_MOUNTNS) (void) mount_nofollow_verbose(LOG_DEBUG, "/dev/null", "/proc/self/mounts", NULL, MS_BIND, NULL); From a7a58fc9a95779b83d92c0750f59fb0aa54e9335 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 28 Aug 2026 04:28:34 +0000 Subject: [PATCH 2/6] copy: fix argument naming in copy_directory_at_full() --- src/shared/copy.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/copy.c b/src/shared/copy.c index 96a9e99a19..f27eccd83c 100644 --- a/src/shared/copy.c +++ b/src/shared/copy.c @@ -1390,8 +1390,8 @@ int copy_directory_at_full( copy_flags, /* denylist= */ NULL, /* subvolumes= */ NULL, - /* progress_path= */ NULL, - /* progress_bytes= */ NULL, + /* hardlink_context= */ NULL, + /* display_path= */ NULL, progress_path, progress_bytes, userdata); From e6b35a8f009ae5fc3bd959eea1166d2e9314a528 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 28 Aug 2026 04:17:37 +0000 Subject: [PATCH 3/6] repart: report a failed parent directory time stamp Every other error in `do_copy_files()` reaches the user through `log_error_errno()` already. --- src/repart/repart.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repart/repart.c b/src/repart/repart.c index c77cad685a..dfd05f56ab 100644 --- a/src/repart/repart.c +++ b/src/repart/repart.c @@ -6981,7 +6981,7 @@ static int do_copy_files(Context *context, Partition *p, const char *root) { timespec_store(&tspec, ts); if (futimens(pfd, (const struct timespec[2]) { TIMESPEC_OMIT, tspec }) < 0) - return -errno; + return log_error_errno(errno, "Failed to set timestamp of '%s': %m", dn); } } } From edbf043333ac2f5a8f3e95d058f5f254776152da Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 27 Aug 2026 17:19:53 +0000 Subject: [PATCH 4/6] copy: support clamping the timestamps of copied inodes Add a `ts_clamp` argument to `copy_tree_at_full()` and `copy_times_full()` that clamps copied inodes to a given time stamp. This is the recommended `$SOURCE_DATE_EPOCH` semantics [1] for reproducible builds. Their non-`_full` APIs keep existing behaviour by clamping to `USEC_INFINITY`. atime and mtime are clamped separately: a file that was merely read after the epoch has to keep its mtime and still lose its atime. E.g. FAT (EFI partition) stores an access date. [1] https://reproducible-builds.org/specs/source-date-epoch/ --- src/shared/copy.c | 92 +++++++++++++++++++++++++++++++++++--------- src/shared/copy.h | 15 ++++++-- src/test/test-copy.c | 59 ++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 23 deletions(-) diff --git a/src/shared/copy.c b/src/shared/copy.c index f27eccd83c..51dbf080ad 100644 --- a/src/shared/copy.c +++ b/src/shared/copy.c @@ -459,6 +459,46 @@ int copy_bytes_full( return max_bytes <= 0; /* return 0 if we hit EOF earlier than the size limit */ } +/* Check whether *ts is within [epoch; clamp] */ +static bool timespec_within(const struct timespec *ts, usec_t ts_clamp) { + assert(ts); + + return ts->tv_sec < 0 || /* Date pre-1970, cannot clamp these */ + timespec_load(ts) <= ts_clamp; /* Date in [epoch; clamp] */ +} + +/* See ts_clamp in copy.h. */ +static void clamp_inode_times(const struct stat *st, usec_t ts_clamp, struct timespec ret[static 2]) { + assert(st); + assert(ret); + + ret[0] = timespec_within(&st->st_atim, ts_clamp) ? st->st_atim : *TIMESPEC_STORE(ts_clamp); + ret[1] = timespec_within(&st->st_mtim, ts_clamp) ? st->st_mtim : *TIMESPEC_STORE(ts_clamp); +} + +/* Put *st's clamped times on the inode referred to by fdt */ +static int clamp_futimens(int fdt, const struct stat *st, usec_t ts_clamp) { + struct timespec times[2]; + + assert(fdt >= 0); + assert(st); + + clamp_inode_times(st, ts_clamp, times); + return RET_NERRNO(futimens(fdt, times)); +} + +/* Same for (dt, to) */ +static int clamp_utimensat(int dt, const char *to, const struct stat *st, usec_t ts_clamp) { + struct timespec times[2]; + + assert(dt >= 0 || dt == AT_FDCWD); + assert(to); + assert(st); + + clamp_inode_times(st, ts_clamp, times); + return RET_NERRNO(utimensat(dt, to, times, AT_SYMLINK_NOFOLLOW)); +} + static int fd_copy_symlink( int df, const char *from, @@ -467,7 +507,8 @@ static int fd_copy_symlink( const char *to, uid_t override_uid, gid_t override_gid, - CopyFlags copy_flags) { + CopyFlags copy_flags, + usec_t ts_clamp) { _cleanup_free_ char *target = NULL; int r; @@ -506,7 +547,8 @@ static int fd_copy_symlink( r = -errno; (void) copy_xattr(df, from, dt, to, copy_flags); - (void) utimensat(dt, to, (struct timespec[]) { st->st_atim, st->st_mtim }, AT_SYMLINK_NOFOLLOW); + + (void) clamp_utimensat(dt, to, st, ts_clamp); return r; } @@ -798,6 +840,7 @@ static int fd_copy_tree_generic( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, Hashmap *denylist, Hashmap *subvolumes, HardlinkContext *hardlink_context, @@ -815,6 +858,7 @@ static int fd_copy_regular( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, HardlinkContext *hardlink_context, copy_progress_bytes_t progress, void *userdata) { @@ -862,7 +906,7 @@ static int fd_copy_regular( if (fchmod(fdt, st->st_mode & 07777) < 0) r = -errno; - (void) futimens(fdt, (struct timespec[]) { st->st_atim, st->st_mtim }); + (void) clamp_futimens(fdt, st, ts_clamp); (void) copy_xattr(fdf, NULL, fdt, NULL, copy_flags); if (FLAGS_SET(copy_flags, COPY_VERIFY_LINKED)) { @@ -910,6 +954,7 @@ static int fd_copy_fifo( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, HardlinkContext *hardlink_context) { int r; @@ -948,7 +993,7 @@ static int fd_copy_fifo( if (fchmodat(dt, to, st->st_mode & 07777, AT_SYMLINK_NOFOLLOW) < 0) r = -errno; - (void) utimensat(dt, to, (struct timespec[]) { st->st_atim, st->st_mtim }, AT_SYMLINK_NOFOLLOW); + (void) clamp_utimensat(dt, to, st, ts_clamp); (void) memorize_hardlink(hardlink_context, st, dt, to); return r; @@ -963,6 +1008,7 @@ static int fd_copy_node( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, HardlinkContext *hardlink_context) { int r; @@ -1001,7 +1047,7 @@ static int fd_copy_node( if (fchmodat(dt, to, st->st_mode & 07777, AT_SYMLINK_NOFOLLOW) < 0) r = -errno; - (void) utimensat(dt, to, (struct timespec[]) { st->st_atim, st->st_mtim }, AT_SYMLINK_NOFOLLOW); + (void) clamp_utimensat(dt, to, st, ts_clamp); (void) memorize_hardlink(hardlink_context, st, dt, to); return r; @@ -1018,6 +1064,7 @@ static int fd_copy_directory( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, Hashmap *denylist, Hashmap *subvolumes, HardlinkContext *hardlink_context, @@ -1169,7 +1216,7 @@ static int fd_copy_directory( r = fd_copy_tree_generic(dirfd(d), de->d_name, &buf, fdt, de->d_name, original_device, depth_left-1, override_uid, override_gid, copy_flags & ~COPY_LOCK_BSD, - denylist, subvolumes, hardlink_context, child_display_path, progress_path, + ts_clamp, denylist, subvolumes, hardlink_context, child_display_path, progress_path, progress_bytes, userdata); /* Propagate SIGINT/SIGTERM, ENOSPC, and fs-verity fails up instantly */ @@ -1193,7 +1240,8 @@ finish: /* Run hardlink context cleanup now because it potentially changes timestamps */ hardlink_context_destroy(&our_hardlink_context); (void) copy_xattr(dirfd(d), NULL, fdt, NULL, copy_flags); - (void) futimens(fdt, (struct timespec[]) { st->st_atim, st->st_mtim }); + + (void) clamp_futimens(fdt, st, ts_clamp); } else if (FLAGS_SET(copy_flags, COPY_RESTORE_DIRECTORY_TIMESTAMPS)) { /* Run hardlink context cleanup now because it potentially changes timestamps */ hardlink_context_destroy(&our_hardlink_context); @@ -1221,6 +1269,7 @@ static int fd_copy_leaf( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, HardlinkContext *hardlink_context, const char *display_path, copy_progress_bytes_t progress_bytes, @@ -1228,13 +1277,13 @@ static int fd_copy_leaf( int r; if (S_ISREG(st->st_mode)) - r = fd_copy_regular(df, from, st, dt, to, override_uid, override_gid, copy_flags, hardlink_context, progress_bytes, userdata); + r = fd_copy_regular(df, from, st, dt, to, override_uid, override_gid, copy_flags, ts_clamp, hardlink_context, progress_bytes, userdata); else if (S_ISLNK(st->st_mode)) - r = fd_copy_symlink(df, from, st, dt, to, override_uid, override_gid, copy_flags); + r = fd_copy_symlink(df, from, st, dt, to, override_uid, override_gid, copy_flags, ts_clamp); else if (S_ISFIFO(st->st_mode)) - r = fd_copy_fifo(df, from, st, dt, to, override_uid, override_gid, copy_flags, hardlink_context); + r = fd_copy_fifo(df, from, st, dt, to, override_uid, override_gid, copy_flags, ts_clamp, hardlink_context); else if (S_ISBLK(st->st_mode) || S_ISCHR(st->st_mode) || S_ISSOCK(st->st_mode)) - r = fd_copy_node(df, from, st, dt, to, override_uid, override_gid, copy_flags, hardlink_context); + r = fd_copy_node(df, from, st, dt, to, override_uid, override_gid, copy_flags, ts_clamp, hardlink_context); else r = -EOPNOTSUPP; @@ -1252,6 +1301,7 @@ static int fd_copy_tree_generic( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, Hashmap *denylist, Hashmap *subvolumes, HardlinkContext *hardlink_context, @@ -1266,7 +1316,7 @@ static int fd_copy_tree_generic( if (S_ISDIR(st->st_mode)) return fd_copy_directory(df, from, st, dt, to, original_device, depth_left-1, override_uid, - override_gid, copy_flags, denylist, subvolumes, hardlink_context, + override_gid, copy_flags, ts_clamp, denylist, subvolumes, hardlink_context, display_path, progress_path, progress_bytes, userdata); /* Only if we are copying a directory we are fine if the target dir is referenced by fd only */ @@ -1280,14 +1330,14 @@ static int fd_copy_tree_generic( } else if (t == DENY_CONTENTS) log_debug("%s is configured to have its contents excluded, but is not a directory", from ?: "file to copy"); - r = fd_copy_leaf(df, from, st, dt, to, override_uid, override_gid, copy_flags, hardlink_context, display_path, progress_bytes, userdata); + r = fd_copy_leaf(df, from, st, dt, to, override_uid, override_gid, copy_flags, ts_clamp, hardlink_context, display_path, progress_bytes, userdata); /* We just tried to copy a leaf node of the tree. If it failed because the node already exists *and* the COPY_REPLACE flag has been provided, we should unlink the node and re-copy. */ if (r == -EEXIST && (copy_flags & COPY_REPLACE)) { /* This codepath is us trying to address an error to copy, if the unlink fails, lets just return the original error. */ if (unlinkat(dt, to, 0) < 0) return r; - r = fd_copy_leaf(df, from, st, dt, to, override_uid, override_gid, copy_flags, hardlink_context, display_path, progress_bytes, userdata); + r = fd_copy_leaf(df, from, st, dt, to, override_uid, override_gid, copy_flags, ts_clamp, hardlink_context, display_path, progress_bytes, userdata); } return r; @@ -1301,6 +1351,7 @@ int copy_tree_at_full( uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, + usec_t ts_clamp, Hashmap *denylist, Hashmap *subvolumes, copy_progress_path_t progress_path, @@ -1316,7 +1367,7 @@ int copy_tree_at_full( return -errno; r = fd_copy_tree_generic(fdf, from, &st, fdt, to, st.st_dev, COPY_DEPTH_MAX, override_uid, - override_gid, copy_flags, denylist, subvolumes, NULL, NULL, progress_path, + override_gid, copy_flags, ts_clamp, denylist, subvolumes, NULL, NULL, progress_path, progress_bytes, userdata); if (r < 0) return r; @@ -1388,6 +1439,7 @@ int copy_directory_at_full( override_uid, override_gid, copy_flags, + /* ts_clamp= */ USEC_INFINITY, /* denylist= */ NULL, /* subvolumes= */ NULL, /* hardlink_context= */ NULL, @@ -1639,8 +1691,9 @@ fail: return r; } -int copy_times(int fdf, int fdt, CopyFlags flags) { +int copy_times_full(int fdf, int fdt, CopyFlags flags, usec_t ts_clamp) { struct stat st; + int r; assert(fdf >= 0); assert(fdt >= 0); @@ -1648,14 +1701,15 @@ int copy_times(int fdf, int fdt, CopyFlags flags) { if (fstat(fdf, &st) < 0) return -errno; - if (futimens(fdt, (struct timespec[2]) { st.st_atim, st.st_mtim }) < 0) - return -errno; + r = clamp_futimens(fdt, &st, ts_clamp); + if (r < 0) + return r; if (FLAGS_SET(flags, COPY_CRTIME)) { usec_t crtime; if (fd_getcrtime(fdf, &crtime) >= 0) - (void) fd_setcrtime(fdt, crtime); + (void) fd_setcrtime(fdt, MIN(crtime, ts_clamp)); } return 0; diff --git a/src/shared/copy.h b/src/shared/copy.h index 975b1e9942..9cc856515f 100644 --- a/src/shared/copy.h +++ b/src/shared/copy.h @@ -81,12 +81,15 @@ static inline int copy_file_atomic(const char *from, const char *to, mode_t mode return copy_file_atomic_full(from, to, mode, 0, 0, copy_flags, NULL, NULL); } -int copy_tree_at_full(int fdf, const char *from, int fdt, const char *to, uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, Hashmap *denylist, Hashmap *subvolumes, copy_progress_path_t progress_path, copy_progress_bytes_t progress_bytes, void *userdata); +/* ts_clamp: upper bound for the timestamps of created inodes. $SOURCE_DATE_EPOCH semantic, see + * https://reproducible-builds.org/specs/source-date-epoch/. USEC_INFINITY clamps nothing, i.e. keeps + * the source's own timestamps. */ +int copy_tree_at_full(int fdf, const char *from, int fdt, const char *to, uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, usec_t ts_clamp, Hashmap *denylist, Hashmap *subvolumes, copy_progress_path_t progress_path, copy_progress_bytes_t progress_bytes, void *userdata); static inline int copy_tree_at(int fdf, const char *from, int fdt, const char *to, uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, Hashmap *denylist, Hashmap *subvolumes) { - return copy_tree_at_full(fdf, from, fdt, to, override_uid, override_gid, copy_flags, denylist, subvolumes, NULL, NULL, NULL); + return copy_tree_at_full(fdf, from, fdt, to, override_uid, override_gid, copy_flags, USEC_INFINITY, denylist, subvolumes, NULL, NULL, NULL); } static inline int copy_tree(const char *from, const char *to, uid_t override_uid, gid_t override_gid, CopyFlags copy_flags, Hashmap *denylist, Hashmap *subvolumes) { - return copy_tree_at_full(AT_FDCWD, from, AT_FDCWD, to, override_uid, override_gid, copy_flags, denylist, subvolumes, NULL, NULL, NULL); + return copy_tree_at_full(AT_FDCWD, from, AT_FDCWD, to, override_uid, override_gid, copy_flags, USEC_INFINITY, denylist, subvolumes, 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); @@ -99,7 +102,11 @@ static inline int copy_bytes(int fdf, int fdt, uint64_t max_bytes, CopyFlags cop return copy_bytes_full(fdf, fdt, max_bytes, copy_flags, NULL, NULL, NULL, NULL); } -int copy_times(int fdf, int fdt, CopyFlags flags); +/* See ts_clamp above. Covers atime, mtime, and with COPY_CRTIME the birth time. */ +int copy_times_full(int fdf, int fdt, CopyFlags flags, usec_t ts_clamp); +static inline int copy_times(int fdf, int fdt, CopyFlags flags) { + return copy_times_full(fdf, fdt, flags, USEC_INFINITY); +} 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); diff --git a/src/test/test-copy.c b/src/test/test-copy.c index e3e93f3642..feed013351 100644 --- a/src/test/test-copy.c +++ b/src/test/test-copy.c @@ -26,6 +26,7 @@ #include "string-util.h" #include "strv.h" #include "tests.h" +#include "time-util.h" #include "tmpfile-util.h" #include "xattr-util.h" @@ -798,4 +799,62 @@ TEST_RET(copy_with_verity) { return 0; } +static void test_copy_times_one( + const struct timespec source[static 2], + usec_t ts_clamp, + const struct timespec expect[static 2]) { + _cleanup_(unlink_tempfilep) char in_fn[] = "/tmp/test-copy-times-in-XXXXXX"; + _cleanup_(unlink_tempfilep) char out_fn[] = "/tmp/test-copy-times-out-XXXXXX"; + _cleanup_close_ int in_fd = -EBADF, out_fd = -EBADF; + struct stat st; + + in_fd = mkostemp_safe(in_fn); + assert_se(in_fd >= 0); + out_fd = mkostemp_safe(out_fn); + assert_se(out_fd >= 0); + + assert_se(futimens(in_fd, source) >= 0); + + ASSERT_OK(copy_times_full(in_fd, out_fd, /* flags= */ 0, ts_clamp)); + + assert_se(fstat(out_fd, &st) >= 0); + ASSERT_EQ(st.st_atim.tv_sec, expect[0].tv_sec); + ASSERT_EQ(st.st_atim.tv_nsec, expect[0].tv_nsec); + ASSERT_EQ(st.st_mtim.tv_sec, expect[1].tv_sec); + ASSERT_EQ(st.st_mtim.tv_nsec, expect[1].tv_nsec); +} + +#define TIMESPEC_PAIR(a, m) ((const struct timespec[2]) { { .tv_sec = (a) }, { .tv_sec = (m) } }) + +TEST(copy_times_clamp) { + const time_t before = 1600000000, epoch = 1700000000, after = 1750000000; + const usec_t clamp = epoch * USEC_PER_SEC; + + /* Older than the clamp: kept. */ + test_copy_times_one(TIMESPEC_PAIR(before, before), clamp, TIMESPEC_PAIR(before, before)); + + /* Newer: pulled back onto the clamp. */ + test_copy_times_one(TIMESPEC_PAIR(after, after), clamp, TIMESPEC_PAIR(epoch, epoch)); + + /* Exactly on it: kept, the clamp is an inclusive upper bound. The comparison runs at microsecond + * resolution, so a nanosecond past the clamp still counts as "on it". */ + test_copy_times_one( + (const struct timespec[2]) { { .tv_sec = epoch, .tv_nsec = 1 }, + { .tv_sec = epoch, .tv_nsec = 1 } }, + clamp, + (const struct timespec[2]) { { .tv_sec = epoch, .tv_nsec = 1 }, + { .tv_sec = epoch, .tv_nsec = 1 } }); + + /* atime and mtime are decided independently: a file that was merely read after the epoch + * loses its atime and keeps its mtime. */ + test_copy_times_one(TIMESPEC_PAIR(after, before), clamp, TIMESPEC_PAIR(epoch, before)); + test_copy_times_one(TIMESPEC_PAIR(before, after), clamp, TIMESPEC_PAIR(before, epoch)); + + /* Before 1970: older than any epoch, so kept rather than pushed forward. */ + test_copy_times_one(TIMESPEC_PAIR(-1, -1), clamp, TIMESPEC_PAIR(-1, -1)); + + /* No clamp requested: the source's own timestamps. */ + test_copy_times_one(TIMESPEC_PAIR(after, after), USEC_INFINITY, TIMESPEC_PAIR(after, after)); +} + DEFINE_TEST_MAIN(LOG_DEBUG); From 94bb5f8e4dffa161669c1000cb446e8aa92dbfce Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 27 Aug 2026 17:19:53 +0000 Subject: [PATCH 5/6] repart: clamp copied timestamps to $SOURCE_DATE_EPOCH Use the new copy timestamp clamping mechanism from the previous commit to fix unreproducible wall-clock files in the generated images. That covers both modes: online mode writes through the kernel, and offline mode stages the tree in a temporary directory before handing it to mkfs or mcopy. `mcopy -m` then carries the clamped mtimes into the FAT. This complements commit 34c3d574742e86 (directory times) and commit f191ca982c (FAT insertion order). Cover all combinations (online/offline, with and without epoch) in the new `testcase_vfat_reproducibility()`. Update `testcase_make_symlinks()` to cover the symlink creation/re-fix their parent directory code paths. Sadly, images are still not reproducible byte for byte. Document systemd-repart's $SOURCE_DATE_EPOCH handling and remaining reproducibility issues in the manpage. --- man/systemd-repart.xml | 30 ++++++++ src/repart/repart.c | 80 +++++++++++++++++---- test/units/TEST-58-REPART.sh | 133 ++++++++++++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 16 deletions(-) diff --git a/man/systemd-repart.xml b/man/systemd-repart.xml index 096eb0c53d..75104c2abc 100644 --- a/man/systemd-repart.xml +++ b/man/systemd-repart.xml @@ -803,6 +803,36 @@ + + Environment + + + + $SOURCE_DATE_EPOCH + + Takes a UNIX timestamp in seconds. Clamps access and modification times written + into the image. See the + reproducible builds + specification. + + + + + + Byte-for-byte reproducible images require mode. Populating a file + system through the kernel writes metadata that cannot be corrected from userspace afterwards. + + Format=vfat images have additional noise: the + directory entry of the volume label is written with a wallclock timestamp by + mkfs.fat8 + with dosfstools versions up to and including 4.2. A later release or a backport of the + upstream fix + sets it to $SOURCE_DATE_EPOCH. + + Offline and online mode also write vfat short names differently, so images + built in both ways never match each other. + + Exit status diff --git a/src/repart/repart.c b/src/repart/repart.c index dfd05f56ab..388f25c398 100644 --- a/src/repart/repart.c +++ b/src/repart/repart.c @@ -6807,6 +6807,17 @@ static int file_is_denylisted(const char *source, Hashmap *denylist) { return 0; } +/* Adding an entry to a directory bumps its mtime. Clamp it back to ts. */ +static int clamp_directory_mtime(int dir_fd, usec_t ts) { + assert(dir_fd >= 0); + + if (ts == USEC_INFINITY) + return 0; + + /* chase() hands out O_PATH descriptors. */ + return futimens_opath(dir_fd, (const struct timespec[2]) { TIMESPEC_OMIT, *TIMESPEC_STORE(ts) }); +} + static int do_copy_files(Context *context, Partition *p, const char *root) { _cleanup_hashmap_free_ Hashmap *subvolumes = NULL; int r; @@ -6830,6 +6841,7 @@ static int do_copy_files(Context *context, Partition *p, const char *root) { } bool copy_ownership = fstype_can_ownership(p->format); + usec_t ts = parse_source_date_epoch(); /* copy_tree_at() automatically copies the permissions of source directories to target directories if * it created them. However, the root directory is created by us, so we have to manually take care @@ -6854,7 +6866,7 @@ static int do_copy_files(Context *context, Partition *p, const char *root) { (void) copy_xattr(sfd, NULL, rfd, NULL, COPY_ALL_XATTRS); if (copy_ownership) (void) copy_access(sfd, rfd); - (void) copy_times(sfd, rfd, 0); + (void) copy_times_full(sfd, rfd, /* flags= */ 0, ts); break; } @@ -6866,7 +6878,6 @@ static int do_copy_files(Context *context, Partition *p, const char *root) { _cleanup_hashmap_free_ Hashmap *denylist = NULL; _cleanup_hashmap_free_ Hashmap *subvolumes_by_source_inode = NULL; _cleanup_close_ int sfd = -EBADF, pfd = -EBADF, tfd = -EBADF; - usec_t ts = parse_source_date_epoch(); r = make_copy_files_denylist(context, p, line->source, line->target, &denylist); if (r < 0) @@ -6915,19 +6926,35 @@ static int do_copy_files(Context *context, Partition *p, const char *root) { if (pfd < 0) return log_error_errno(pfd, "Failed to open parent directory of target: %m"); - r = copy_tree_at( + r = copy_tree_at_full( sfd, ".", pfd, fn, uid, gid, line->flags, - denylist, subvolumes_by_source_inode); + ts, + denylist, subvolumes_by_source_inode, + /* progress_path= */ NULL, + /* progress_bytes= */ NULL, + /* userdata= */ NULL); + if (r >= 0) { + /* Creating the target bumped the parent directory's mtime, + * just like in the regular file case below. */ + r = clamp_directory_mtime(pfd, ts); + if (r < 0) + return log_error_errno( + r, "Failed to set timestamp of '%s': %m", dn); + } } else - r = copy_tree_at( + r = copy_tree_at_full( sfd, ".", tfd, ".", uid, gid, line->flags, - denylist, subvolumes_by_source_inode); + ts, + denylist, subvolumes_by_source_inode, + /* progress_path= */ NULL, + /* progress_bytes= */ NULL, + /* userdata= */ NULL); if (r < 0) return log_error_errno(r, "Failed to copy '%s%s' to '%s%s': %m", strempty(arg_copy_source), line->source, strempty(root), line->target); @@ -6974,15 +7001,12 @@ static int do_copy_files(Context *context, Partition *p, const char *root) { (void) copy_xattr(sfd, NULL, tfd, NULL, COPY_ALL_XATTRS); if (copy_ownership) (void) copy_access(sfd, tfd); - (void) copy_times(sfd, tfd, 0); + (void) copy_times_full(sfd, tfd, /* flags= */ 0, ts); - if (ts != USEC_INFINITY) { - struct timespec tspec; - timespec_store(&tspec, ts); - - if (futimens(pfd, (const struct timespec[2]) { TIMESPEC_OMIT, tspec }) < 0) - return log_error_errno(errno, "Failed to set timestamp of '%s': %m", dn); - } + /* Creating the file bumped the parent directory's mtime. */ + r = clamp_directory_mtime(pfd, ts); + if (r < 0) + return log_error_errno(r, "Failed to set timestamp of '%s': %m", dn); } } @@ -7017,6 +7041,7 @@ static int do_make_directories(Partition *p, const char *root) { } static int do_make_symlinks(Partition *p, const char *root) { + usec_t ts = parse_source_date_epoch(); int r; assert(p); @@ -7032,6 +7057,18 @@ static int do_make_symlinks(Partition *p, const char *root) { if (symlinkat(*target, parent_fd, f) < 0) return log_error_errno(errno, "Failed to create symlink at %s to %s: %m", *path, *target); + + /* The new symlink carries the wall clock, and creating it bumped the directory we put + * it into. We are the last population step, so nothing overwrites this again. */ + if (ts != USEC_INFINITY && + utimensat(parent_fd, f, + (const struct timespec[2]) { *TIMESPEC_STORE(ts), *TIMESPEC_STORE(ts) }, + AT_SYMLINK_NOFOLLOW) < 0) + return log_error_errno(errno, "Failed to set timestamp of symlink '%s': %m", *path); + + r = clamp_directory_mtime(parent_fd, ts); + if (r < 0) + return log_error_errno(r, "Failed to set timestamp of '%s': %m", *path); } return 0; @@ -7218,6 +7255,21 @@ static int partition_populate_directory(Context *context, Partition *p, char **r if (r < 0) return log_error_errno(errno, "Failed to create temporary directory: %m"); + /* Nothing below touches its atime (adding an entry to a directory only moves mtime), so stamp here. */ + usec_t ts = parse_source_date_epoch(); + if (ts != USEC_INFINITY) { + _cleanup_close_ int rfd = -EBADF; + struct timespec tspec; + + rfd = open(root, O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW); + if (rfd < 0) + return log_error_errno(errno, "Failed to open temporary directory: %m"); + + timespec_store(&tspec, ts); + if (futimens(rfd, (const struct timespec[2]) { tspec, tspec }) < 0) + return log_error_errno(errno, "Failed to set timestamp of temporary directory: %m"); + } + r = do_copy_files(context, p, root); if (r < 0) return r; diff --git a/test/units/TEST-58-REPART.sh b/test/units/TEST-58-REPART.sh index d2b0e95970..c862c07d2e 100755 --- a/test/units/TEST-58-REPART.sh +++ b/test/units/TEST-58-REPART.sh @@ -2166,7 +2166,7 @@ EOF } testcase_make_symlinks() { - local defs imgs output + local defs imgs output epoch if systemd-detect-virt --quiet --container; then echo "Skipping MakeSymlinks= test in container." @@ -2191,7 +2191,13 @@ MakeSymlinks=/dir/foo-%a:/bar-%a MakeSymlinks=/dir/bar-%a:../bar-%a EOF - systemd-repart --offline="$OFFLINE" \ + # Build with an epoch, so that this also covers do_make_symlinks()' time stamping. It has to + # stamp the symlink itself and repair the directory it lands in, because creating an entry there + # bumps that directory back to the wall clock. + epoch=1700000000 + + env SOURCE_DATE_EPOCH="$epoch" \ + systemd-repart --offline="$OFFLINE" \ --definitions="$defs" \ --empty=create \ --size=1G \ @@ -2205,6 +2211,12 @@ EOF assert_eq "$(readlink "$imgs/mnt/dir/foo")" "/bar" assert_eq "$(readlink "$imgs/mnt/dir/foo-${architecture}")" "/bar-${architecture}" assert_eq "$(readlink "$imgs/mnt/dir/bar-${architecture}")" "../bar-${architecture}" + + # the symlink's own mtime (no -L) + assert_eq "$(stat -c %Y "$imgs/mnt/dir/foo")" "$epoch" + # a MakeDirectories= dir, stamped with the epoch and then bumped by the symlinks below it. + assert_eq "$(stat -c %Y "$imgs/mnt/dir")" "$epoch" + systemd-dissect -U "$imgs/mnt" } @@ -2756,6 +2768,123 @@ EOF cmp "$imgs/test1.img" "$imgs/test2.img" } +testcase_vfat_reproducibility() { + local defs imgs img dot_efi dot_linux + + if ! command -v mdir >/dev/null; then + echo "Skipping vfat reproducibility test, mtools is not installed." + return 0 + fi + + defs="$(mktemp --directory "/tmp/test-repart.defs.XXXXXXXXXX")" + imgs="$(mktemp --directory "/var/tmp/test-repart.imgs.XXXXXXXXXX")" + # shellcheck disable=SC2064 + trap "rm -rf '$defs' '$imgs'" RETURN + + tee "$defs/esp.conf" <"$imgs/tree/EFI/Linux/old.efi" + echo new >"$imgs/tree/EFI/Linux/new.efi" + touch --date=@1600000000 "$imgs/tree/EFI/Linux/old.efi" + touch --date=@1750000000 "$imgs/tree/EFI/Linux/new.efi" + + # The timestamps as mdir(1) reports them. + local -r time_epoch="2023-11-14 22:13" # $SOURCE_DATE_EPOCH below, i.e. @1700000000 + local -r time_old="2020-09-13 12:26" # old.efi's mtime, before the epoch + local -r time_new="2025-06-15 15:06" # new.efi's mtime, after the epoch + + # Build $1 from the tree and set $img to it, in the "file@@offset" form mdir(1) wants. The + # remaining arguments are passed to env(1), to control $SOURCE_DATE_EPOCH. + build_image() { + local name="$1" output offset + shift + + output=$(env "$@" \ + systemd-repart \ + --offline="$OFFLINE" \ + --definitions="$defs" \ + --empty=create \ + --size=auto \ + --seed="$seed" \ + --dry-run=no \ + --root="$imgs/tree" \ + --json=pretty \ + "$imgs/$name") + + offset=$(jq -r '.[0].offset' <<<"$output") + img="$imgs/$name@@$offset" + + # dump listing for debugging + fat_entry -/ ::/ + } + + # Print $img's mdir(1) entry for the given path. mdir lists a directory's contents; a trailing `*` + # gets the directory's own entry instead. Insensitive path matching, thus works with mtools' 8.3 + # lower case and kernel vfat's upper case names. + fat_entry() { + env MTOOLS_SKIP_CHECK=1 TZ=UTC mdir -i "$img" "$@" + } + + # Same, for the "." entry of directory $1, which cannot be addressed directly. + fat_dot_entry() { + fat_entry "$1" | grep -E '^\. +' + } + + build_image epoch.img SOURCE_DATE_EPOCH=1700000000 + + # The directories and the file newer than the epoch are clamped down to it, ... + assert_in "$time_epoch" "$(fat_entry '::/EFI*')" + assert_in "$time_epoch" "$(fat_entry '::/EFI/Linux*')" + assert_in "$time_epoch" "$(fat_entry '::/EFI/Linux/new.efi')" + # ... while the older one keeps its own mtime. + assert_in "$time_old" "$(fat_entry '::/EFI/Linux/old.efi')" + + # "." and ".." behave differently + dot_efi=$(fat_dot_entry '::/EFI') + dot_linux=$(fat_dot_entry '::/EFI/Linux') + if [[ "$OFFLINE" == "yes" ]]; then + # mmd creates them and respects SOURCE_DATE_EPOCH + assert_in "$time_epoch" "$dot_efi" + assert_in "$time_epoch" "$dot_linux" + else + # The kernel's vfat driver creates them with wallclock mtime. Our utimensat() + # afterwards only rewrites the directory's entry in its *parent*, never this pair. + # Out of reach from userspace, remains unreproducible. Just log them. + echo "'.' entries carry the wall clock, as expected online: $dot_efi / $dot_linux" + fi + + # Without an epoch, both files keep their own mtime. + build_image wallclock.img -u SOURCE_DATE_EPOCH + + assert_in "$time_new" "$(fat_entry '::/EFI/Linux/new.efi')" + assert_in "$time_old" "$(fat_entry '::/EFI/Linux/old.efi')" + + # A value we cannot parse is dropped rather than passed on + build_image bogus.img SOURCE_DATE_EPOCH=99999999999999999999 + + assert_in "$time_new" "$(fat_entry '::/EFI/Linux/new.efi')" + assert_in "$time_old" "$(fat_entry '::/EFI/Linux/old.efi')" + + # SOURCE_DATE_EPOCH in hex - we do parse that, but mtools doesn't + build_image hex.img SOURCE_DATE_EPOCH=0x6553F100 + + assert_in "$time_epoch" "$(fat_entry '::/EFI*')" + + # Note the images still are not reproducible byte for byte: the volume label entry carries + # mkfs.fat's wall clock, in *both* modes, and that needs a dosfstools with $SOURCE_DATE_EPOCH + # support (https://github.com/dosfstools/dosfstools/commit/8da7bc93315c, release > 4.2). Once + # that lands, offline mode can move to a `cmp` like in testcase_ext_reproducibility; online + # mode cannot, because of the "." and ".." entries above. +} + testcase_luks2_keyhash() { local defs imgs output root From ab2ab441f7df31fa161a038042cdef92b54db416 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 27 Aug 2026 17:19:53 +0000 Subject: [PATCH 6/6] mkfs-util: forward a validated $SOURCE_DATE_EPOCH to mkfs.* The previous commit uses `parse_source_date_epoch()` for creating FAT time stamps. Do the same in `mtools_exec()`, `mkfs.*`, and `$E2FSPROGS_FAKE_TIME` instead of passing the raw environment variable. That avoids these programs creating time stamps from a value we would have rejected ourselves (unparsable, overflowing), read differently (e.g. octal numbers), or from one that `secure_getenv()` declines to look at under AT_SECURE. Note: mtools derives file and directory timestamps from $SOURCE_DATE_EPOCH since v4.0.20 (2018). --- src/shared/mkfs-util.c | 52 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/src/shared/mkfs-util.c b/src/shared/mkfs-util.c index 35955eb00b..cce6b6d55d 100644 --- a/src/shared/mkfs-util.c +++ b/src/shared/mkfs-util.c @@ -5,6 +5,7 @@ #include #include "escape.h" +#include "install-file.h" #include "log.h" #include "memory-util.h" #include "mkfs-util.h" @@ -17,6 +18,7 @@ #include "stdio-util.h" #include "string-util.h" #include "strv.h" +#include "time-util.h" #include "utf8.h" int mkfs_exists(const char *fstype) { @@ -150,6 +152,26 @@ static int mangle_fat_label(const char *s, char **ret) { return 0; } +/* $SOURCE_DATE_EPOCH in seconds as *we* read it, or NULL if we have none. The tools we call parse the + * variable themselves, so without this they would act on values we reject (unparsable, overflowing), + * read differently (octal 017), or that secure_getenv() declines to look at under AT_SECURE. */ +static int source_date_epoch_seconds(char **ret) { + usec_t epoch; + + assert(ret); + + epoch = parse_source_date_epoch(); + if (epoch == USEC_INFINITY) { + *ret = NULL; + return 0; + } + + if (asprintf(ret, USEC_FMT, epoch / USEC_PER_SEC) < 0) + return -ENOMEM; + + return 1; +} + static int mtools_exec(char *const *argv, ForkFlags extra_fork_flags) { int r; @@ -161,6 +183,16 @@ static int mtools_exec(char *const *argv, ForkFlags extra_fork_flags) { log_debug("Invoking mtools command: %s", strna(j)); } + _cleanup_free_ char *seconds = NULL, *source_date_epoch = NULL; + r = source_date_epoch_seconds(&seconds); + if (r < 0) + return log_oom(); + if (seconds) { + source_date_epoch = strjoin("SOURCE_DATE_EPOCH=", seconds); + if (!source_date_epoch) + return log_oom(); + } + r = pidref_safe_fork( "(mtools)", FORK_RESET_SIGNALS|FORK_RLIMIT_NOFILE_SAFE|FORK_DEATHSIG_SIGTERM|FORK_WAIT|FORK_STDOUT_TO_STDERR|FORK_CLOSE_ALL_FDS|extra_fork_flags, @@ -174,7 +206,7 @@ static int mtools_exec(char *const *argv, ForkFlags extra_fork_flags) { execve(argv[0], argv, STRV_MAKE("MTOOLS_SKIP_CHECK=1", "TZ=UTC", - strv_find_prefix(environ, "SOURCE_DATE_EPOCH="))); + source_date_epoch)); log_full_errno(FLAGS_SET(extra_fork_flags, FORK_LOG) ? LOG_ERR : LOG_DEBUG, errno, "Failed to execute %s: %m", argv[0]); _exit(EXIT_FAILURE); @@ -430,8 +462,11 @@ int make_filesystem( * 0 value handling, where $E2FSPROGS_FAKE_TIME=0 is ignored and the current time is used, * but $SOURCE_DATE_EPOCH=0 sets 1970-01-01 as the timestamp. */ if (!secure_getenv("E2FSPROGS_FAKE_TIME")) { /* honor $E2FSPROGS_FAKE_TIME if already set */ - const char *e = secure_getenv("SOURCE_DATE_EPOCH"); - if (e && strv_extend_strv(&env, STRV_MAKE("E2FSPROGS_FAKE_TIME", e), /* filter_duplicates= */ false) < 0) + _cleanup_free_ char *seconds = NULL; + + if (source_date_epoch_seconds(&seconds) < 0) + return log_oom(); + if (seconds && strv_extend_strv(&env, STRV_MAKE("E2FSPROGS_FAKE_TIME", seconds), /* filter_duplicates= */ false) < 0) return log_oom(); } @@ -650,6 +685,10 @@ int make_filesystem( fork_flags |= FORK_NEW_MOUNTNS; } + _cleanup_free_ char *source_date_epoch = NULL; + if (source_date_epoch_seconds(&source_date_epoch) < 0) + return log_oom(); + log_info("Formatting %s as %s", node, fstype); if (DEBUG_LOGGING) { @@ -675,6 +714,13 @@ int make_filesystem( _exit(EXIT_FAILURE); } + /* Same as in mtools_exec(): the mkfs tool gets our reading of the variable, or none. */ + if (source_date_epoch ? setenv("SOURCE_DATE_EPOCH", source_date_epoch, /* replace= */ true) < 0 + : unsetenv("SOURCE_DATE_EPOCH") < 0) { + log_error_errno(errno, "Failed to adjust $SOURCE_DATE_EPOCH: %m"); + _exit(EXIT_FAILURE); + } + /* mkfs.btrfs refuses to operate on block devices with mounted partitions, even if operating * on unformatted free space, so let's trick it and other mkfs tools into thinking no * partitions are mounted. See https://github.com/kdave/btrfs-progs/issues/640 for more