Version in base suite: 3.4.1+ds1-5+deb13u2 Base version: rsync_3.4.1+ds1-5+deb13u2 Target version: rsync_3.4.1+ds1-5+deb13u3 Base file: /srv/ftp-master.debian.org/ftp/pool/main/r/rsync/rsync_3.4.1+ds1-5+deb13u2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/r/rsync/rsync_3.4.1+ds1-5+deb13u3.dsc changelog | 15 patches/2026-05-20/0001-bool-is-a-keyword-in-C23.patch | 26 patches/2026-05-20/0002-syscall-fix-a-Y2038-bug-by-replacing-Int32x32To64-wi.patch | 29 patches/2026-05-20/0003-options.c-Fix-segv-if-poptGetContext-returns-NULL.patch | 35 patches/2026-05-20/0004-Using-a-correct-time-in-log-file.patch | 53 patches/2026-05-20/0005-configure.ac-check-for-xattr-support-both-in-libc-an.patch | 54 patches/2026-05-20/0006-util-fixed-issue-in-clean_fname.patch | 43 patches/2026-05-20/0007-testsuite-added-clean-fname-underflow-test.patch | 85 patches/2026-05-20/0008-fixed-an-invalid-access-to-files-array.patch | 30 patches/2026-05-20/0009-fix-uninitialized-buf1-in-get_checksum2-MD4-path.patch | 33 patches/2026-05-20/0010-reject-negative-token-values-in-compressed-stream-re.patch | 72 patches/2026-05-20/0011-acl-fixed-ACL-ID-mapping-for-non-root.patch | 26 patches/2026-05-20/0012-fix-uninitialized-mul_one-in-AVX2-checksum-and-add-S.patch | 205 + patches/2026-05-20/0013-Fix-glibc-2.43-constness-warnings.patch | 115 patches/2026-05-20/0015-fix-signed-integer-overflow-in-proxy-protocol-v2-hea.patch | 38 patches/2026-05-20/0016-zero-all-new-memory-from-allocations.patch | 48 patches/2026-05-20/0017-xattrs-fixed-count-in-qsort.patch | 35 patches/2026-05-20/0018-call-tzset-before-chroot-to-cache-timezone-data.patch | 39 patches/2026-05-20/0019-testsuite-xattrs-ignore-SUNWattr_-in-the-Solaris-xls.patch | 45 patches/2026-05-20/0020-syscall-use-openat2-RESOLVE_BENEATH-on-Linux-for-sec.patch | 389 ++ patches/2026-05-20/0021-syscall-also-use-O_RESOLVE_BENEATH-on-FreeBSD-and-Ma.patch | 96 patches/2026-05-20/0022-testsuite-skip-symlink-dirlink-basis-on-platforms-wi.patch | 49 patches/2026-05-20/0023-syscall-clientserver-am_chrooted-and-use_secure_syml.patch | 325 + patches/2026-05-20/0024-sender-fix-read-path-TOCTOU-by-opening-from-module-r.patch | 68 patches/2026-05-20/0025-syscall-receiver-secure-receiver-side-do_chmod-again.patch | 463 ++ patches/2026-05-20/0026-util1-secure-change_dir-against-symlink-race-chdir-e.patch | 203 + patches/2026-05-20/0027-syscall-add-symlink-race-safe-do_-_at-wrappers-and-h.patch | 1774 ++++++++++ patches/2026-05-20/0028-util1-syscall-secure-copy_file-source-dest-opens-bar.patch | 565 +++ patches/2026-05-20/0029-testsuite-end-to-end-regression-test-for-chdir-symli.patch | 289 + patches/2026-05-20/0030-token-harden-compressed-token-decoding-against-integ.patch | 251 + patches/2026-05-20/0031-testsuite-cover-refuse-options-compress-for-the-daem.patch | 81 patches/2026-05-20/0032-receiver-add-parent_ndx-0-guard-mirroring-797e17f.patch | 119 patches/2026-05-20/0033-clientserver-fix-hostname-ACL-bypass-when-using-daem.patch | 192 + patches/2026-05-20/0034-defence-in-depth-bound-wire-supplied-counts-and-leng.patch | 261 + patches/2026-05-20/0035-defence-in-depth-guard-cumulative-snprintf-against-l.patch | 79 patches/2026-05-20/0036-defence-in-depth-receiver-block-index-bounds-read_de.patch | 80 patches/CVE-2025-10158.patch | 26 patches/CVE-2026-41035.patch | 32 patches/series | 38 patches/syscall_use_openat2_RESOLVE_BENEATH_on_Linux_for_secure_relative_open.patch | 386 -- tests/upstream-tests | 2 tests/upstream-tests-as-root | 2 42 files changed, 6347 insertions(+), 449 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9dlixnna/rsync_3.4.1+ds1-5+deb13u2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9dlixnna/rsync_3.4.1+ds1-5+deb13u3.dsc: no acceptable signature found diff -Nru rsync-3.4.1+ds1/debian/changelog rsync-3.4.1+ds1/debian/changelog --- rsync-3.4.1+ds1/debian/changelog 2026-04-30 13:05:39.000000000 +0000 +++ rsync-3.4.1+ds1/debian/changelog 2026-05-18 18:33:38.000000000 +0000 @@ -1,3 +1,18 @@ +rsync (3.4.1+ds1-5+deb13u3) trixie-security; urgency=high + + * Non-maintainer upload by the Security Team. + * Address several vulnerabilities + - CVE-2026-29518: Symlink-race TOCTOU in daemon (use chroot = no) + - CVE-2026-43617: Authorization bypass via hostname resolution (daemon + chroot mode) + - CVE-2026-43618: Integer overflow in compressed-token decoder (info + disclosure) + - CVE-2026-43619: Symlink-race conditions in path-based syscalls + - CVE-2026-43620: Out-of-bounds array read in receiver recv_files() + * d/t/upstream-tests: Build t_chmod_secure and t_secure_relpath + + -- Salvatore Bonaccorso Mon, 18 May 2026 20:33:38 +0200 + rsync (3.4.1+ds1-5+deb13u2) trixie; urgency=medium * d/p/syscall_use_openat2...: New patch to fix symlink handling on the diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0001-bool-is-a-keyword-in-C23.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0001-bool-is-a-keyword-in-C23.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0001-bool-is-a-keyword-in-C23.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0001-bool-is-a-keyword-in-C23.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,26 @@ +From 86c59ba94407cc0fe56cb4b00d6fa84fdd494218 Mon Sep 17 00:00:00 2001 +From: Michal Ruprich +Date: Fri, 17 Jan 2025 12:37:57 +0100 +Subject: [PATCH 01/38] bool is a keyword in C23 + +--- + wildtest.c | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/wildtest.c b/wildtest.c +index bea4cebb..482cdf17 100644 +--- a/wildtest.c ++++ b/wildtest.c +@@ -32,7 +32,9 @@ int fnmatch_errors = 0; + + int wildmatch_errors = 0; + ++#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 202311L + typedef char bool; ++#endif + + int output_iterations = 0; + int explode_mod = 0; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0002-syscall-fix-a-Y2038-bug-by-replacing-Int32x32To64-wi.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0002-syscall-fix-a-Y2038-bug-by-replacing-Int32x32To64-wi.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0002-syscall-fix-a-Y2038-bug-by-replacing-Int32x32To64-wi.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0002-syscall-fix-a-Y2038-bug-by-replacing-Int32x32To64-wi.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,29 @@ +From 4ce604114a80b1a7ce5580bbebffbfaa6df84bfb Mon Sep 17 00:00:00 2001 +From: Silent +Date: Mon, 13 Jan 2025 15:01:06 +0100 +Subject: [PATCH 02/38] syscall: fix a Y2038 bug by replacing Int32x32To64 with + multiplication + +Int32x32To64 macro internally truncates the arguments to int32, +while time_t is 64-bit on most/all modern platforms. +Therefore, usage of this macro creates a Year 2038 bug. +--- + syscall.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/syscall.c b/syscall.c +index 34a9bba0..604cebe2 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -480,7 +480,7 @@ int do_SetFileTime(const char *path, time_t crtime) + free(pathw); + if (handle == INVALID_HANDLE_VALUE) + return -1; +- int64 temp_time = Int32x32To64(crtime, 10000000) + 116444736000000000LL; ++ int64 temp_time = (crtime * 10000000LL) + 116444736000000000LL; + FILETIME birth_time; + birth_time.dwLowDateTime = (DWORD)temp_time; + birth_time.dwHighDateTime = (DWORD)(temp_time >> 32); +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0003-options.c-Fix-segv-if-poptGetContext-returns-NULL.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0003-options.c-Fix-segv-if-poptGetContext-returns-NULL.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0003-options.c-Fix-segv-if-poptGetContext-returns-NULL.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0003-options.c-Fix-segv-if-poptGetContext-returns-NULL.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,35 @@ +From bbecd5bc1ad23d83bf83e8b107c1e16b05901a79 Mon Sep 17 00:00:00 2001 +From: Ronnie Sahlberg +Date: Thu, 30 Jan 2025 13:27:38 +1000 +Subject: [PATCH 03/38] options.c: Fix segv if poptGetContext returns NULL + +If poptGetContext returns NULL, perhaps due to OOM, +a NULL pointer is passed into poptReadDefaultConfig() +which in turns SEGVs when trying to dereference it. + +This was found using https://github.com/sahlberg/malloc-fail-tester.git +$ ./test_malloc_failure.sh rsync -Pav crash crosh + +Signed-off-by: Ronnie Sahlberg +--- + options.c | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/options.c b/options.c +index 578507c6..7cfe6391 100644 +--- a/options.c ++++ b/options.c +@@ -1369,6 +1369,10 @@ int parse_arguments(int *argc_p, const char ***argv_p) + /* TODO: Call poptReadDefaultConfig; handle errors. */ + + pc = poptGetContext(RSYNC_NAME, argc, argv, long_options, 0); ++ if (pc == NULL) { ++ strlcpy(err_buf, "poptGetContext returned NULL\n", sizeof err_buf); ++ return 0; ++ } + if (!am_server) { + poptReadDefaultConfig(pc, 0); + popt_unalias(pc, "--daemon"); +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0004-Using-a-correct-time-in-log-file.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0004-Using-a-correct-time-in-log-file.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0004-Using-a-correct-time-in-log-file.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0004-Using-a-correct-time-in-log-file.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,53 @@ +From c966f3864d79aae6a4b97e78403f7441325edded Mon Sep 17 00:00:00 2001 +From: Michal Ruprich +Date: Fri, 31 Jan 2025 14:35:18 +0100 +Subject: [PATCH 04/38] Using a correct time in log file + +--- + options.c | 2 +- + tls.c | 2 +- + util1.c | 2 +- + 3 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/options.c b/options.c +index 7cfe6391..4ae1c58c 100644 +--- a/options.c ++++ b/options.c +@@ -1156,7 +1156,7 @@ static time_t parse_time(const char *arg) + { + const char *cp; + time_t val, now = time(NULL); +- struct tm t, *today = localtime(&now); ++ struct tm t, tmp, *today = localtime_r(&now, &tmp); + int in_date, old_mday, n; + + memset(&t, 0, sizeof t); +diff --git a/tls.c b/tls.c +index 858f8f10..7811e1fc 100644 +--- a/tls.c ++++ b/tls.c +@@ -127,7 +127,7 @@ static void storetime(char *dest, size_t destsize, time_t t, int nsecs) + { + if (t) { + int len; +- struct tm *mt = gmtime(&t); ++ struct tm tmp, *mt = gmtime_r(&t, &tmp); + + len = snprintf(dest, destsize, + " %04d-%02d-%02d %02d:%02d:%02d", +diff --git a/util1.c b/util1.c +index d84bc414..231d2206 100644 +--- a/util1.c ++++ b/util1.c +@@ -1389,7 +1389,7 @@ char *timestring(time_t t) + static int ndx = 0; + static char buffers[4][20]; /* We support 4 simultaneous timestring results. */ + char *TimeBuf = buffers[ndx = (ndx + 1) % 4]; +- struct tm *tm = localtime(&t); ++ struct tm tmp, *tm = localtime_r(&t, &tmp); + int len = snprintf(TimeBuf, sizeof buffers[0], "%4d/%02d/%02d %02d:%02d:%02d", + (int)tm->tm_year + 1900, (int)tm->tm_mon + 1, (int)tm->tm_mday, + (int)tm->tm_hour, (int)tm->tm_min, (int)tm->tm_sec); +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0005-configure.ac-check-for-xattr-support-both-in-libc-an.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0005-configure.ac-check-for-xattr-support-both-in-libc-an.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0005-configure.ac-check-for-xattr-support-both-in-libc-an.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0005-configure.ac-check-for-xattr-support-both-in-libc-an.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,54 @@ +From ca987c47fb9dcef471ba43132b1ca3ee5ed5a6cd Mon Sep 17 00:00:00 2001 +From: Eli Schwartz +Date: Tue, 22 Apr 2025 16:17:55 -0400 +Subject: [PATCH 05/38] configure.ac: check for xattr support both in libc and + in -lattr + +In 2015, the attr/xattr.h header was fully removed from upstream attr. + +In 2020, rsync started preferring the standard header, if it exists: +https://github.com/RsyncProject/rsync/pull/22 + +But the fix was incomplete. We still looked for the getxattr function in +-lattr, and used it if -lattr exists. This was the case even if the +system libc was sufficient to provide the needed functions. Result: +overlinking to -lattr, if it happened to be installed for any other +reason. + +``` +checking whether to support extended attributes... Using Linux xattrs +checking for getxattr in -lattr... yes +``` + +Instead, use a different autoconf macro that first checks if the +function is available for use without any libraries (e.g. it is in +libc). + +Result: + +``` +checking whether to support extended attributes... Using Linux xattrs +checking for library containing getxattr... none required +``` + +Signed-off-by: Eli Schwartz +--- + configure.ac | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/configure.ac b/configure.ac +index d2bcb471..4062651d 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -1392,7 +1392,7 @@ else + AC_DEFINE(HAVE_LINUX_XATTRS, 1, [True if you have Linux xattrs (or equivalent)]) + AC_DEFINE(SUPPORT_XATTRS, 1) + AC_DEFINE(NO_SYMLINK_USER_XATTRS, 1, [True if symlinks do not support user xattrs]) +- AC_CHECK_LIB(attr,getxattr) ++ AC_SEARCH_LIBS(getxattr,attr) + ;; + darwin*) + AC_MSG_RESULT(Using OS X xattrs) +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0006-util-fixed-issue-in-clean_fname.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0006-util-fixed-issue-in-clean_fname.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0006-util-fixed-issue-in-clean_fname.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0006-util-fixed-issue-in-clean_fname.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,43 @@ +From 21e0496559fb3b0209099c52977efe3516ea6ca3 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sat, 23 Aug 2025 19:14:59 +1000 +Subject: [PATCH 06/38] util: fixed issue in clean_fname() + +fixes buffer underflow (not exploitable) in clean_fname +--- + util1.c | 12 ++++++++---- + 1 file changed, 8 insertions(+), 4 deletions(-) + +diff --git a/util1.c b/util1.c +index 231d2206..de634a84 100644 +--- a/util1.c ++++ b/util1.c +@@ -942,7 +942,7 @@ int count_dir_elements(const char *p) + * resulting name would be empty, returns ".". */ + int clean_fname(char *name, int flags) + { +- char *limit = name - 1, *t = name, *f = name; ++ char *limit = name, *t = name, *f = name; + int anchored; + + if (!name) +@@ -987,9 +987,13 @@ int clean_fname(char *name, int flags) + f += 2; + continue; + } +- while (s > limit && *--s != '/') {} +- if (s != t - 1 && (s < name || *s == '/')) { +- t = s + 1; ++ /* backing up for ".." — avoid reading before 'name' */ ++ while (s > limit && s[-1] != '/') ++ s--; ++ ++ /* If found prior '/', or we reached the start, adjust t. */ ++ if (s != t - 1 && (s <= name || *s == '/')) { ++ t = (s == name) ? name : s + 1; + f += 2; + continue; + } +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0007-testsuite-added-clean-fname-underflow-test.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0007-testsuite-added-clean-fname-underflow-test.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0007-testsuite-added-clean-fname-underflow-test.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0007-testsuite-added-clean-fname-underflow-test.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,85 @@ +From 0df583089dc09a0a74a7434f493225d30f929102 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sat, 23 Aug 2025 18:29:06 +1000 +Subject: [PATCH 07/38] testsuite: added clean-fname-underflow test + +--- + testsuite/clean-fname-underflow.test | 66 ++++++++++++++++++++++++++++ + 1 file changed, 66 insertions(+) + create mode 100644 testsuite/clean-fname-underflow.test + +diff --git a/testsuite/clean-fname-underflow.test b/testsuite/clean-fname-underflow.test +new file mode 100644 +index 00000000..56d4fece +--- /dev/null ++++ b/testsuite/clean-fname-underflow.test +@@ -0,0 +1,66 @@ ++#!/bin/sh ++# clean-fname-underflow.test ++# Ensure clean_fname() does not read-before-buffer when collapsing "..". ++# This exercises the --server path where a crafted merge filename hits clean_fname(). ++# ++# Usage: ++# ./configure && make ++# make check TESTS='clean-fname-underflow.test' ++ ++set -eu ++ ++# Try to find the just-built rsync binary if RSYNC_BIN isn't set. ++if [ -z "${RSYNC_BIN:-}" ]; then ++ if [ -x "./rsync" ]; then ++ RSYNC_BIN=./rsync ++ elif [ -x "../rsync" ]; then ++ RSYNC_BIN=../rsync ++ else ++ RSYNC_BIN=rsync ++ fi ++fi ++ ++workdir="${TMPDIR:-/tmp}/rsync-clean-fname.$$" ++mkdir -p "$workdir" ++trap 'rm -rf "$workdir"' EXIT INT TERM ++cd "$workdir" ++ ++# Minimal rsyncd.conf using chroot so the crafted path reaches the server parser. ++cat > rsyncd.conf <<'EOF' ++pid file = rsyncd.pid ++use chroot = true ++[mod] ++ path = ./mod ++ read only = false ++EOF ++mkdir -p mod ++ ++# Start daemon on a random high port. ++PORT=$(awk 'BEGIN{srand(); printf "%d", 20000+int(rand()*20000)}') ++"$RSYNC_BIN" --daemon --no-detach --config=rsyncd.conf --port="$PORT" >/dev/null 2>&1 & ++DAEMON_PID=$! ++# Give the daemon a moment to come up. ++sleep 0.3 ++ ++# Invoke the server-side path. We don't need a real transfer; we just want to ++# ensure clean_fname() doesn't crash when given "a/../test" via --filter=merge. ++EXIT_OK=0 ++if "$RSYNC_BIN" --server --sender -vlr --filter='merge a/../test' . mod/ >/dev/null 2>&1; then ++ EXIT_OK=1 ++else ++ status=$? ++ # Non-zero exit is expected for bogus input; ensure it wasn't a signal/crash. ++ if [ $status -lt 128 ]; then ++ EXIT_OK=1 ++ fi ++fi ++ ++kill "$DAEMON_PID" >/dev/null 2>&1 || true ++ ++if [ "$EXIT_OK" -ne 1 ]; then ++ echo "clean-fname-underflow.test: rsync exited due to a signal or unexpected status" ++ exit 1 ++fi ++ ++echo "OK: clean_fname() handled 'a/../test' without crashing" ++exit 0 +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0008-fixed-an-invalid-access-to-files-array.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0008-fixed-an-invalid-access-to-files-array.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0008-fixed-an-invalid-access-to-files-array.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0008-fixed-an-invalid-access-to-files-array.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,30 @@ +From 82fe213f7f0b5e7221e248dd398bbb682de5800e Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sat, 23 Aug 2025 17:26:53 +1000 +Subject: [PATCH 08/38] fixed an invalid access to files array + +this was found by Calum Hutton from Rapid7. It is a real bug, but +analysis shows it can't be leverged into an exploit. Worth fixing +though. + +Many thanks to Calum and Rapid7 for finding and reporting this +--- + sender.c | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/sender.c b/sender.c +index a4d46c39..b1588b70 100644 +--- a/sender.c ++++ b/sender.c +@@ -262,6 +262,8 @@ void send_files(int f_in, int f_out) + + if (ndx - cur_flist->ndx_start >= 0) + file = cur_flist->files[ndx - cur_flist->ndx_start]; ++ else if (cur_flist->parent_ndx < 0) ++ exit_cleanup(RERR_PROTOCOL); + else + file = dir_flist->files[cur_flist->parent_ndx]; + if (F_PATHNAME(file)) { +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0009-fix-uninitialized-buf1-in-get_checksum2-MD4-path.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0009-fix-uninitialized-buf1-in-get_checksum2-MD4-path.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0009-fix-uninitialized-buf1-in-get_checksum2-MD4-path.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0009-fix-uninitialized-buf1-in-get_checksum2-MD4-path.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,33 @@ +From 487a548f70fa418d99e96f4ec77c6139d7e03ed6 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 30 Dec 2025 16:21:41 +1100 +Subject: [PATCH 09/38] fix uninitialized buf1 in get_checksum2() MD4 path + +The static buf1 pointer was only allocated when len > len1, but on +first call with len == 0, this condition is false (0 > 0), leaving +buf1 NULL when passed to memcpy(). + +Fixes #673 +--- + checksum.c | 5 ++--- + 1 file changed, 2 insertions(+), 3 deletions(-) + +diff --git a/checksum.c b/checksum.c +index 66e80896..6f0f95ab 100644 +--- a/checksum.c ++++ b/checksum.c +@@ -366,9 +366,8 @@ void get_checksum2(char *buf, int32 len, char *sum) + + mdfour_begin(&m); + +- if (len > len1) { +- if (buf1) +- free(buf1); ++ if (len > len1 || !buf1) { ++ free(buf1); + buf1 = new_array(char, len+4); + len1 = len; + } +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0010-reject-negative-token-values-in-compressed-stream-re.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0010-reject-negative-token-values-in-compressed-stream-re.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0010-reject-negative-token-values-in-compressed-stream-re.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0010-reject-negative-token-values-in-compressed-stream-re.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,72 @@ +From 4585f8a6f22fc93993002c7bae7ddd174a49dc86 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 30 Dec 2025 18:49:34 +1100 +Subject: [PATCH 10/38] reject negative token values in compressed stream + receivers + +Validate that token numbers read from compressed streams are +non-negative. A negative token value would cause the return value +of recv_*_token() to become positive, which callers interpret as +literal data length, but no data pointer is set on this code path. + +While this only causes the receiver to crash (which is process-isolated +and only affects the attacker's own connection), it's still undefined +behavior. + +Reported-by: Will Sergeant +--- + token.c | 21 ++++++++++++++++++--- + 1 file changed, 18 insertions(+), 3 deletions(-) + +diff --git a/token.c b/token.c +index c108b3af..b7a02ea1 100644 +--- a/token.c ++++ b/token.c +@@ -589,8 +589,13 @@ static int32 recv_deflated_token(int f, char **data) + if (flag & TOKEN_REL) { + rx_token += flag & 0x3f; + flag >>= 6; +- } else ++ } else { + rx_token = read_int(f); ++ if (rx_token < 0) { ++ rprintf(FERROR, "invalid token number in compressed stream\n"); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ } + if (flag & 1) { + rx_run = read_byte(f); + rx_run += read_byte(f) << 8; +@@ -831,8 +836,13 @@ static int32 recv_zstd_token(int f, char **data) + if (flag & TOKEN_REL) { + rx_token += flag & 0x3f; + flag >>= 6; +- } else ++ } else { + rx_token = read_int(f); ++ if (rx_token < 0) { ++ rprintf(FERROR, "invalid token number in compressed stream\n"); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ } + if (flag & 1) { + rx_run = read_byte(f); + rx_run += read_byte(f) << 8; +@@ -995,8 +1005,13 @@ static int32 recv_compressed_token(int f, char **data) + if (flag & TOKEN_REL) { + rx_token += flag & 0x3f; + flag >>= 6; +- } else ++ } else { + rx_token = read_int(f); ++ if (rx_token < 0) { ++ rprintf(FERROR, "invalid token number in compressed stream\n"); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ } + if (flag & 1) { + rx_run = read_byte(f); + rx_run += read_byte(f) << 8; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0011-acl-fixed-ACL-ID-mapping-for-non-root.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0011-acl-fixed-ACL-ID-mapping-for-non-root.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0011-acl-fixed-ACL-ID-mapping-for-non-root.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0011-acl-fixed-ACL-ID-mapping-for-non-root.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,26 @@ +From 79ffc5e3c5b9337a5840ab019107ca8c44a97eb4 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Mon, 19 Jan 2026 11:14:40 +1100 +Subject: [PATCH 11/38] acl: fixed ACL ID mapping for non-root + +closes issue #618 +--- + acls.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/acls.c b/acls.c +index bd119e8e..4d67ff4d 100644 +--- a/acls.c ++++ b/acls.c +@@ -713,7 +713,7 @@ static uchar recv_ida_entries(int f, ida_entries *ent) + else + id = recv_group_name(f, id, NULL); + } else if (access & NAME_IS_USER) { +- if (inc_recurse && am_root && !numeric_ids) ++ if (inc_recurse && !numeric_ids) + id = match_uid(id); + } else { + if (inc_recurse && (!am_root || !numeric_ids)) +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0012-fix-uninitialized-mul_one-in-AVX2-checksum-and-add-S.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0012-fix-uninitialized-mul_one-in-AVX2-checksum-and-add-S.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0012-fix-uninitialized-mul_one-in-AVX2-checksum-and-add-S.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0012-fix-uninitialized-mul_one-in-AVX2-checksum-and-add-S.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,205 @@ +From 350469f7cf1c25a4fcf991f1337202f4bcac8912 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sun, 1 Mar 2026 08:42:04 +1100 +Subject: [PATCH 12/38] fix uninitialized mul_one in AVX2 checksum and add SIMD + checksum test + +The AVX2 get_checksum1_avx2_64() read mul_one before initializing it, +which is undefined behavior. Replace the cmpeq/abs trick with +_mm256_set1_epi8(1) to match the SSSE3 and SSE2 versions. + +Add a TEST_SIMD_CHECKSUM1 test mode that verifies all SIMD paths +(SSE2, SSSE3, AVX2, and the full dispatch chain) produce identical +results to the C reference, across multiple buffer sizes with both +aligned and unaligned buffers. + +Co-Authored-By: Claude Opus 4.6 +--- + Makefile.in | 11 +++- + simd-checksum-x86_64.cpp | 115 ++++++++++++++++++++++++++++++++++- + testsuite/simd-checksum.test | 11 ++++ + 3 files changed, 134 insertions(+), 3 deletions(-) + create mode 100755 testsuite/simd-checksum.test + +diff --git a/Makefile.in b/Makefile.in +index 7c75c261..75fd03af 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -57,7 +57,8 @@ TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/perms + + # Programs we must have to run the test cases + CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ +- testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) wildtest$(EXEEXT) ++ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) wildtest$(EXEEXT) \ ++ simdtest$(EXEEXT) + + CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test + +@@ -326,6 +327,14 @@ wildtest.o: wildtest.c t_stub.o lib/wildmatch.c rsync.h config.h + wildtest$(EXEEXT): wildtest.o lib/compat.o lib/snprintf.o @BUILD_POPT@ + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ wildtest.o lib/compat.o lib/snprintf.o @BUILD_POPT@ $(LIBS) + ++simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS) ++ @if test x"@ROLL_SIMD@" != x; then \ ++ $(CXX) -I. $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) -DTEST_SIMD_CHECKSUM1 \ ++ -o $@ $(srcdir)/simd-checksum-x86_64.cpp @ROLL_ASM@ $(LIBS); \ ++ else \ ++ touch $@; \ ++ fi ++ + testsuite/chown-fake.test: + ln -s chown.test $(srcdir)/testsuite/chown-fake.test + +diff --git a/simd-checksum-x86_64.cpp b/simd-checksum-x86_64.cpp +index d649091e..99391cbe 100644 +--- a/simd-checksum-x86_64.cpp ++++ b/simd-checksum-x86_64.cpp +@@ -347,8 +347,7 @@ __attribute__ ((target("avx2"))) MVSTATIC int32 get_checksum1_avx2_64(schar* buf + __m128i tmp = _mm_load_si128((__m128i*) mul_t1_buf); + __m256i mul_t1 = _mm256_cvtepu8_epi16(tmp); + __m256i mul_const = _mm256_broadcastd_epi32(_mm_cvtsi32_si128(4 | (3 << 8) | (2 << 16) | (1 << 24))); +- __m256i mul_one; +- mul_one = _mm256_abs_epi8(_mm256_cmpeq_epi16(mul_one,mul_one)); // set all vector elements to 1 ++ __m256i mul_one = _mm256_set1_epi8(1); + + for (; i < (len-64); i+=64) { + // Load ... 4*[int8*16] +@@ -548,6 +547,118 @@ int main() { + #pragma clang optimize on + #endif /* BENCHMARK_SIMD_CHECKSUM1 */ + ++#ifdef TEST_SIMD_CHECKSUM1 ++ ++static uint32 checksum_via_default(char *buf, int32 len) ++{ ++ uint32 s1 = 0, s2 = 0; ++ get_checksum1_default_1((schar*)buf, len, 0, &s1, &s2); ++ return (s1 & 0xffff) + (s2 << 16); ++} ++ ++static uint32 checksum_via_sse2(char *buf, int32 len) ++{ ++ int32 i; ++ uint32 s1 = 0, s2 = 0; ++ i = get_checksum1_sse2_32((schar*)buf, len, 0, &s1, &s2); ++ get_checksum1_default_1((schar*)buf, len, i, &s1, &s2); ++ return (s1 & 0xffff) + (s2 << 16); ++} ++ ++static uint32 checksum_via_ssse3(char *buf, int32 len) ++{ ++ int32 i; ++ uint32 s1 = 0, s2 = 0; ++ i = get_checksum1_ssse3_32((schar*)buf, len, 0, &s1, &s2); ++ get_checksum1_default_1((schar*)buf, len, i, &s1, &s2); ++ return (s1 & 0xffff) + (s2 << 16); ++} ++ ++static uint32 checksum_via_avx2(char *buf, int32 len) ++{ ++ int32 i; ++ uint32 s1 = 0, s2 = 0; ++#ifdef USE_ROLL_ASM ++ i = get_checksum1_avx2_asm((schar*)buf, len, 0, &s1, &s2); ++#else ++ i = get_checksum1_avx2_64((schar*)buf, len, 0, &s1, &s2); ++#endif ++ get_checksum1_default_1((schar*)buf, len, i, &s1, &s2); ++ return (s1 & 0xffff) + (s2 << 16); ++} ++ ++int main() ++{ ++ static const int sizes[] = {1, 4, 31, 32, 33, 63, 64, 65, 128, 129, 256, 700, 1024, 4096, 65536}; ++ int num_sizes = sizeof(sizes) / sizeof(sizes[0]); ++ int max_size = sizes[num_sizes - 1]; ++ int failures = 0; ++ ++ /* Allocate with extra bytes for unaligned test */ ++ unsigned char *raw = (unsigned char *)malloc(max_size + 64 + 1); ++ if (!raw) { ++ fprintf(stderr, "malloc failed\n"); ++ return 1; ++ } ++ ++ /* Fill with deterministic data */ ++ for (int i = 0; i < max_size + 64 + 1; i++) ++ raw[i] = (i + (i % 3) + (i % 11)) % 256; ++ ++ /* Test with aligned buffer (64-byte aligned) */ ++ unsigned char *aligned = raw + (64 - ((uintptr_t)raw % 64)); ++ ++ /* Test with unaligned buffer (+1 byte offset) */ ++ unsigned char *unaligned = aligned + 1; ++ ++ struct { const char *name; unsigned char *buf; } buffers[] = { ++ {"aligned", aligned}, ++ {"unaligned", unaligned}, ++ }; ++ ++ for (int b = 0; b < 2; b++) { ++ char *buf = (char *)buffers[b].buf; ++ const char *bname = buffers[b].name; ++ ++ for (int s = 0; s < num_sizes; s++) { ++ int32 len = sizes[s]; ++ uint32 ref = checksum_via_default(buf, len); ++ uint32 cs_sse2 = checksum_via_sse2(buf, len); ++ uint32 cs_ssse3 = checksum_via_ssse3(buf, len); ++ uint32 cs_avx2 = checksum_via_avx2(buf, len); ++ uint32 cs_auto = get_checksum1(buf, len); ++ ++ if (cs_sse2 != ref) { ++ printf("FAIL %-9s size=%5d: SSE2=%08x ref=%08x\n", bname, len, cs_sse2, ref); ++ failures++; ++ } ++ if (cs_ssse3 != ref) { ++ printf("FAIL %-9s size=%5d: SSSE3=%08x ref=%08x\n", bname, len, cs_ssse3, ref); ++ failures++; ++ } ++ if (cs_avx2 != ref) { ++ printf("FAIL %-9s size=%5d: AVX2=%08x ref=%08x\n", bname, len, cs_avx2, ref); ++ failures++; ++ } ++ if (cs_auto != ref) { ++ printf("FAIL %-9s size=%5d: auto=%08x ref=%08x\n", bname, len, cs_auto, ref); ++ failures++; ++ } ++ } ++ } ++ ++ free(raw); ++ ++ if (failures) { ++ printf("%d checksum mismatches!\n", failures); ++ return 1; ++ } ++ printf("All SIMD checksum tests passed.\n"); ++ return 0; ++} ++ ++#endif /* TEST_SIMD_CHECKSUM1 */ ++ + #endif /* } USE_ROLL_SIMD */ + #endif /* } __cplusplus */ + #endif /* } __x86_64__ */ +diff --git a/testsuite/simd-checksum.test b/testsuite/simd-checksum.test +new file mode 100755 +index 00000000..cf7dba2e +--- /dev/null ++++ b/testsuite/simd-checksum.test +@@ -0,0 +1,11 @@ ++#!/bin/sh ++ ++# Test SIMD checksum implementations against the C reference ++ ++. "$suitedir/rsync.fns" ++ ++if ! test -x "$TOOLDIR/simdtest"; then ++ test_skipped "simdtest not built (SIMD not available)" ++fi ++ ++"$TOOLDIR/simdtest" +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0013-Fix-glibc-2.43-constness-warnings.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0013-Fix-glibc-2.43-constness-warnings.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0013-Fix-glibc-2.43-constness-warnings.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0013-Fix-glibc-2.43-constness-warnings.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,115 @@ +From 6994fdf50ef0dde11b8b014a6d198c6f423d67fc Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Holger=20Hoffst=C3=A4tte?= +Date: Mon, 6 Apr 2026 00:44:02 +0200 +Subject: [PATCH 13/38] Fix glibc-2.43 constness warnings +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Glibc 2.43 added C23 const-preserving overloads to various string functions, +which change the return type depending on the constness of the argument(s). +Currently this leads to warnings from calls to strtok() or strchr(). +Fix this by properly declaring the respective variable types. + +Signed-off-by: Holger Hoffstätte +--- + access.c | 2 +- + checksum.c | 2 +- + compat.c | 4 ++-- + exclude.c | 2 +- + io.c | 4 ++-- + loadparm.c | 2 +- + 6 files changed, 8 insertions(+), 8 deletions(-) + +diff --git a/access.c b/access.c +index b6afce37..b924e0a3 100644 +--- a/access.c ++++ b/access.c +@@ -99,7 +99,7 @@ static void make_mask(char *mask, int plen, int addrlen) + return; + } + +-static int match_address(const char *addr, const char *tok) ++static int match_address(const char *addr, char *tok) + { + char *p; + struct addrinfo hints, *resa, *rest; +diff --git a/checksum.c b/checksum.c +index 6f0f95ab..24e46bfb 100644 +--- a/checksum.c ++++ b/checksum.c +@@ -176,7 +176,7 @@ void parse_checksum_choice(int final_call) + if (valid_checksums.negotiated_nni) + xfer_sum_nni = file_sum_nni = valid_checksums.negotiated_nni; + else { +- char *cp = checksum_choice ? strchr(checksum_choice, ',') : NULL; ++ const char *cp = checksum_choice ? strchr(checksum_choice, ',') : NULL; + if (cp) { + xfer_sum_nni = parse_csum_name(checksum_choice, cp - checksum_choice); + file_sum_nni = parse_csum_name(cp+1, -1); +diff --git a/compat.c b/compat.c +index 4ce8c6d0..37d20f4f 100644 +--- a/compat.c ++++ b/compat.c +@@ -131,7 +131,7 @@ static const char *client_info; + * of that protocol for it to be advertised as available. */ + static void check_sub_protocol(void) + { +- char *dot; ++ const char *dot; + int their_protocol, their_sub; + int our_sub = get_subprotocol_version(); + +@@ -414,7 +414,7 @@ static const char *getenv_nstr(int ntype) + env_str = ntype == NSTR_COMPRESS ? "zlib" : protocol_version >= 30 ? "md5" : "md4"; + + if (am_server && env_str) { +- char *cp = strchr(env_str, '&'); ++ const char *cp = strchr(env_str, '&'); + if (cp) + env_str = cp + 1; + } +diff --git a/exclude.c b/exclude.c +index 87edbcf7..24de64f8 100644 +--- a/exclude.c ++++ b/exclude.c +@@ -904,7 +904,7 @@ static int rule_matches(const char *fname, filter_rule *ex, int name_flags) + { + int slash_handling, str_cnt = 0, anchored_match = 0; + int ret_match = ex->rflags & FILTRULE_NEGATE ? 0 : 1; +- char *p, *pattern = ex->pattern; ++ const char *p, *pattern = ex->pattern; + const char *strings[16]; /* more than enough */ + const char *name = fname + (*fname == '/'); + +diff --git a/io.c b/io.c +index bb60eeca..8d1cf7f2 100644 +--- a/io.c ++++ b/io.c +@@ -1158,8 +1158,8 @@ void set_io_timeout(int secs) + + static void check_for_d_option_error(const char *msg) + { +- static char rsync263_opts[] = "BCDHIKLPRSTWabceghlnopqrtuvxz"; +- char *colon; ++ static const char rsync263_opts[] = "BCDHIKLPRSTWabceghlnopqrtuvxz"; ++ const char *colon; + int saw_d = 0; + + if (*msg != 'r' +diff --git a/loadparm.c b/loadparm.c +index 3906bc0f..4f371d77 100644 +--- a/loadparm.c ++++ b/loadparm.c +@@ -178,7 +178,7 @@ static char *expand_vars(const char *str) + + for (t = buf, f = str; bufsize && *f; ) { + if (*f == '%' && isUpper(f+1)) { +- char *percent = strchr(f+1, '%'); ++ const char *percent = strchr(f+1, '%'); + if (percent && percent - f < bufsize) { + char *val; + strlcpy(t, f+1, percent - f); +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0015-fix-signed-integer-overflow-in-proxy-protocol-v2-hea.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0015-fix-signed-integer-overflow-in-proxy-protocol-v2-hea.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0015-fix-signed-integer-overflow-in-proxy-protocol-v2-hea.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0015-fix-signed-integer-overflow-in-proxy-protocol-v2-hea.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,38 @@ +From c35df318adc2429ca0b2b34c75eae02f29e8edcc Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Thu, 16 Apr 2026 10:50:49 +1000 +Subject: [PATCH 15/38] fix signed integer overflow in proxy protocol v2 header + parsing + +The len field in the proxy v2 header was declared as signed char, +allowing a negative size to bypass the validation check and cause +a stack buffer overflow when passed to read_buf() as size_t. + +This bug was reported by John Walker from ZeroPath, many thanks for +the clear report! + +With the current code this bug does not represent a security issue as +it only results in the exit of the forked process that is specific to +the attached client, so it is equivalent to the client closing the +socket, so no CVE for this, but it is good to fix it to prevent a +future issue. +--- + clientname.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/clientname.c b/clientname.c +index ea94894b..dbac38b9 100644 +--- a/clientname.c ++++ b/clientname.c +@@ -167,7 +167,7 @@ int read_proxy_protocol_header(int fd) + char sig[PROXY_V2_SIG_SIZE]; + char ver_cmd; + char fam; +- char len[2]; ++ unsigned char len[2]; + union { + struct { + char src_addr[4]; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0016-zero-all-new-memory-from-allocations.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0016-zero-all-new-memory-from-allocations.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0016-zero-all-new-memory-from-allocations.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0016-zero-all-new-memory-from-allocations.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,48 @@ +From dff93c92d1b0d576b385688db9c66773da4ab0a1 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 22 Apr 2026 10:59:11 +1000 +Subject: [PATCH 16/38] zero all new memory from allocations + +Change my_alloc() to use calloc instead of malloc so all fresh +allocations return zeroed memory. Also zero the expanded portion +in expand_item_list() after realloc, since it knows both old and +new sizes. This gives more predictable behaviour in case of bugs +where uninitialised or stale memory is accidentally accessed. + +Co-Authored-By: Claude Opus 4.6 (1M context) +--- + util1.c | 2 ++ + util2.c | 4 +--- + 2 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/util1.c b/util1.c +index de634a84..25ac7c9b 100644 +--- a/util1.c ++++ b/util1.c +@@ -1718,6 +1718,8 @@ void *expand_item_list(item_list *lp, size_t item_size, const char *desc, int in + new_ptr == lp->items ? " not" : ""); + } + ++ memset((char *)new_ptr + lp->malloced * item_size, 0, ++ (expand_size - lp->malloced) * item_size); + lp->items = new_ptr; + lp->malloced = expand_size; + } +diff --git a/util2.c b/util2.c +index b59bff0a..ce6f7de1 100644 +--- a/util2.c ++++ b/util2.c +@@ -79,9 +79,7 @@ void *my_alloc(void *ptr, size_t num, size_t size, const char *file, int line) + who_am_i(), do_big_num(max_alloc, 0, NULL), src_file(file), line); + exit_cleanup(RERR_MALLOC); + } +- if (!ptr) +- ptr = malloc(num * size); +- else if (ptr == do_calloc) ++ if (!ptr || ptr == do_calloc) + ptr = calloc(num, size); + else + ptr = realloc(ptr, num * size); +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0017-xattrs-fixed-count-in-qsort.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0017-xattrs-fixed-count-in-qsort.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0017-xattrs-fixed-count-in-qsort.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0017-xattrs-fixed-count-in-qsort.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,35 @@ +From c009fcc8e6e61f20231e4a512faa174fa7c2eecd Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 22 Apr 2026 09:57:45 +1000 +Subject: [PATCH 17/38] xattrs: fixed count in qsort + +this fixes the count passed to the sort of the xattr list. This issue +was reported here: + +https://www.openwall.com/lists/oss-security/2026/04/16/2 + +the bug is not exploitable due to the fork-per-connection design of +rsync, the attack is the equivalent of the user closing the socket +themselves. +--- + xattrs.c | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/xattrs.c b/xattrs.c +index 26e50a6f..65166eed 100644 +--- a/xattrs.c ++++ b/xattrs.c +@@ -860,8 +860,8 @@ void receive_xattr(int f, struct file_struct *file) + rxa->num = num; + } + +- if (need_sort && count > 1) +- qsort(temp_xattr.items, count, sizeof (rsync_xa), rsync_xal_compare_names); ++ if (need_sort && temp_xattr.count > 1) ++ qsort(temp_xattr.items, temp_xattr.count, sizeof (rsync_xa), rsync_xal_compare_names); + + ndx = rsync_xal_store(&temp_xattr); /* adds item to rsync_xal_l */ + +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0018-call-tzset-before-chroot-to-cache-timezone-data.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0018-call-tzset-before-chroot-to-cache-timezone-data.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0018-call-tzset-before-chroot-to-cache-timezone-data.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0018-call-tzset-before-chroot-to-cache-timezone-data.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,39 @@ +From 892b48a60b9e6d604de54034002bee65d7bcb4e2 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 22 Apr 2026 12:53:13 +1000 +Subject: [PATCH 18/38] call tzset() before chroot to cache timezone data + +localtime/localtime_r need /etc/localtime for timezone info. +After chroot this file is inaccessible, causing log timestamps +to fall back to UTC. Calling tzset() before chroot ensures the +timezone data is cached by glibc for subsequent calls. + +Co-Authored-By: Claude Opus 4.6 (1M context) +--- + clientserver.c | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/clientserver.c b/clientserver.c +index 7c897abc..3800f0d6 100644 +--- a/clientserver.c ++++ b/clientserver.c +@@ -976,6 +976,8 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char + } + + if (use_chroot) { ++ /* Cache timezone data before chroot makes /etc/localtime inaccessible */ ++ tzset(); + if (chroot(module_chdir)) { + rsyserr(FLOG, errno, "chroot(\"%s\") failed", module_chdir); + io_printf(f_out, "@ERROR: chroot failed\n"); +@@ -1301,6 +1303,7 @@ int start_daemon(int f_in, int f_out) + p = lp_daemon_chroot(); + if (*p) { + log_init(0); /* Make use we've initialized syslog before chrooting. */ ++ tzset(); + if (chroot(p) < 0) { + rsyserr(FLOG, errno, "daemon chroot(\"%s\") failed", p); + return -1; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0019-testsuite-xattrs-ignore-SUNWattr_-in-the-Solaris-xls.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0019-testsuite-xattrs-ignore-SUNWattr_-in-the-Solaris-xls.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0019-testsuite-xattrs-ignore-SUNWattr_-in-the-Solaris-xls.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0019-testsuite-xattrs-ignore-SUNWattr_-in-the-Solaris-xls.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,45 @@ +From 3e5e159459fbb46b01517d5f9e6f98d914bab861 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Thu, 30 Apr 2026 08:18:01 +1000 +Subject: [PATCH 19/38] testsuite/xattrs: ignore SUNWattr_* in the Solaris xls + helper + +The Solaris xls() function listed every entry in the file's xattr +directory, which on Solaris includes OS-managed SUNWattr_ro and +SUNWattr_rw pseudo-attributes. SUNWattr_rw embeds the file creation +time, so its bytes naturally differ between the source and destination +files, making the xattrs and xattrs-hlink tests fail with diffs that +have nothing to do with rsync. + +Rsync's own listxattr wrapper already filters these out +(lib/sysxattrs.c), so the right fix is to filter them in the test +display too. Other platforms are unaffected because each has its own +xls() branch in the case statement. + +With the test now actually passing on Solaris, drop the CI hack that +overwrote testsuite/xattrs.test with a skip stub. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + testsuite/xattrs.test | 5 ++++- + 1 file changed, 4 insertions(+), 1 deletion(-) + +diff --git a/testsuite/xattrs.test b/testsuite/xattrs.test +index d94d5f95..c0f3784b 100644 +--- a/testsuite/xattrs.test ++++ b/testsuite/xattrs.test +@@ -38,7 +38,10 @@ EOF + xls() { + for fn in "${@}"; do + runat "$fn" "$SHELL_PATH" < +Date: Thu, 30 Apr 2026 08:39:22 +1000 +Subject: [PATCH 20/38] syscall: use openat2(RESOLVE_BENEATH) on Linux for + secure_relative_open + +The CVE fix in commit c35e283 made secure_relative_open() walk every +component of relpath with O_NOFOLLOW. That blocks every symlink in the +path, which is stricter than the threat model required: legitimate +directory symlinks within the destination tree (e.g. when using -K / +--copy-dirlinks) are also rejected, breaking delta transfers with +"failed verification -- update discarded". See issue #715. + +On Linux 5.6+, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) gives +us exactly what we want: the kernel rejects any resolution that would +escape the starting directory (via "..", absolute paths, or symlinks +pointing outside dirfd) while still following symlinks that resolve +within it. /proc magic-links are blocked too. + +Use openat2 first; fall back to the existing per-component O_NOFOLLOW +walk on ENOSYS (kernel < 5.6). The lexical "../" checks at the head +of the function are kept as defense in depth. The Linux gate is +plain #ifdef __linux__: the runtime ENOSYS fallback covers the only +case that actually matters (header present + old kernel), and any +Linux build environment without linux/openat2.h will fail with a +clear "no such file" error rather than silently disabling the +protection. + +Verified manually that openat2(RESOLVE_BENEATH) blocks all four +escape patterns (absolute symlink, ../ symlink, lexical .., absolute +path) while allowing direct and within-tree symlinks. The new +testsuite/symlink-dirlink-basis.test (taken from PR #864 by Samuel +Henrique) exercises the issue #715 regression and passes; full +make check passes 47/47. + +Test: testsuite/symlink-dirlink-basis.test (8 scenarios) +Fixes: https://github.com/RsyncProject/rsync/issues/715 + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + syscall.c | 62 ++++++- + testsuite/symlink-dirlink-basis.test | 247 +++++++++++++++++++++++++++ + 2 files changed, 304 insertions(+), 5 deletions(-) + create mode 100755 testsuite/symlink-dirlink-basis.test + +diff --git a/syscall.c b/syscall.c +index 604cebe2..0881c7ab 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -33,6 +33,11 @@ + #include + #endif + ++#ifdef __linux__ ++#include ++#include ++#endif ++ + #include "ifuncs.h" + + extern int dry_run; +@@ -715,12 +720,49 @@ int do_open_nofollow(const char *pathname, int flags) + /* + open a file relative to a base directory. The basedir can be NULL, + in which case the current working directory is used. The relpath +- must be a relative path, and the relpath must not contain any +- elements in the path which follow symlinks (ie. like O_NOFOLLOW, but +- applies to all path components, not just the last component) +- +- The relpath must also not contain any ../ elements in the path ++ must be a relative path. The kernel must guarantee that resolution ++ cannot escape basedir (or the cwd, when basedir is NULL): no ".." ++ jumps above the start, no symlinks pointing outside, no absolute ++ paths, no /proc magic-link tricks. ++ ++ Symlinks *within* basedir are followed normally — earlier rsync ++ versions rejected every symlink with O_NOFOLLOW on each component, ++ which broke legitimate directory symlinks on the receiver side ++ (https://github.com/RsyncProject/rsync/issues/715). The escape ++ prevention is handled by the kernel via openat2(RESOLVE_BENEATH) ++ on Linux 5.6+; older systems fall back to the per-component ++ O_NOFOLLOW walk below. ++ ++ The relpath must also not contain any ../ elements in the path. + */ ++ ++#ifdef __linux__ ++static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) ++{ ++ struct open_how how; ++ int dirfd, retfd; ++ ++ memset(&how, 0, sizeof how); ++ how.flags = flags; ++ how.mode = mode; ++ how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; ++ ++ if (basedir == NULL) { ++ dirfd = AT_FDCWD; ++ } else { ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) ++ return -1; ++ } ++ ++ retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how); ++ ++ if (dirfd != AT_FDCWD) ++ close(dirfd); ++ return retfd; ++} ++#endif ++ + int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) + { + if (!relpath || relpath[0] == '/') { +@@ -734,6 +776,16 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + return -1; + } + ++#ifdef __linux__ ++ { ++ int fd = secure_relative_open_linux(basedir, relpath, flags, mode); ++ /* ENOSYS = kernel < 5.6 doesn't have the syscall even though ++ * glibc/kernel-headers do; fall through to the portable path. */ ++ if (fd != -1 || errno != ENOSYS) ++ return fd; ++ } ++#endif ++ + #if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) + // really old system, all we can do is live with the risks + if (!basedir) { +diff --git a/testsuite/symlink-dirlink-basis.test b/testsuite/symlink-dirlink-basis.test +new file mode 100755 +index 00000000..9065dd81 +--- /dev/null ++++ b/testsuite/symlink-dirlink-basis.test +@@ -0,0 +1,247 @@ ++#!/bin/sh ++ ++# Test that updating a file through a directory symlink works when using ++# -K (--copy-dirlinks). This is a regression test for: ++# https://github.com/RsyncProject/rsync/issues/715 ++# ++# The CVE fix in commit c35e283 introduced secure_relative_open() which ++# uses O_NOFOLLOW on all path components, breaking legitimate directory ++# symlinks on the receiver side. The fix splits the path into basedir ++# (dirname, symlinks followed) and basename (O_NOFOLLOW) so that ++# directory symlinks are traversed while the final file component is ++# still protected. ++# ++# The regression only manifests when delta matching is triggered (i.e., ++# the sender finds matching blocks in the old file). Small files with ++# completely different content are transferred in full and don't trigger ++# the bug. We use a large file with a small modification to ensure ++# delta transfer is used. ++# ++# In addition to the original regression, this test covers edge cases ++# in the fix itself: ++# - --backup with directory symlinks (finish_transfer pointer identity) ++# - --partial-dir with protocol < 29 (fnamecmp != partialptr guard) ++# - --inplace with directory symlinks (updating_basis_or_equiv check) ++# - Files without a dirname (top-level files, no split needed) ++ ++. "$suitedir/rsync.fns" ++ ++RSYNC_RSH="$scratchdir/src/support/lsh.sh" ++export RSYNC_RSH ++ ++# $HOME is set to $scratchdir by rsync.fns ++# localhost: destination will cd to $HOME (i.e., $scratchdir) ++ ++# Helper: create a large file suitable for delta transfers. ++# ~32KB is large enough for rsync's block matching to find matches. ++make_testfile() { ++ dd if=/dev/urandom of="$1" bs=1024 count=32 2>/dev/null \ ++ || test_fail "failed to create test file $1" ++} ++ ++# Set up source tree ++srcbase="$tmpdir/src" ++ ++###################################################################### ++# Test 1: Basic directory symlink update (the original issue #715) ++###################################################################### ++ ++mkdir -p "$HOME/real-dir" ++ln -s real-dir "$HOME/dir" ++ ++mkdir -p "$srcbase/dir" ++make_testfile "$srcbase/dir/file" ++ ++# First transfer (initial): should create the file through the symlink ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 1: initial transfer failed" ++ ++if [ ! -f "$HOME/real-dir/file" ]; then ++ test_fail "test 1: initial transfer did not create file through symlink" ++fi ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 1: initial transfer content mismatch" ++ ++# Small modification to trigger delta transfer ++echo "appended update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++# Second transfer (update): was failing with "failed verification" ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 1: update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 1: update transfer content mismatch" ++ ++###################################################################### ++# Test 2: Compression (-z) as in the original reproducer ++###################################################################### ++ ++echo "another line" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptzv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 2: compressed update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 2: compressed update content mismatch" ++ ++###################################################################### ++# Test 3: Nested directory symlinks (nested/sub/data.txt where ++# "nested" is a symlink to "nested_real") ++###################################################################### ++ ++mkdir -p "$HOME/nested_real/sub" ++ln -s nested_real "$HOME/nested" ++ ++mkdir -p "$srcbase/nested/sub" ++make_testfile "$srcbase/nested/sub/data.txt" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ ++ || test_fail "test 3: initial nested transfer failed" ++ ++echo "appended nested" >> "$srcbase/nested/sub/data.txt" ++sleep 1 ++touch "$srcbase/nested/sub/data.txt" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ ++ || test_fail "test 3: update through nested directory symlink failed" ++ ++diff "$srcbase/nested/sub/data.txt" "$HOME/nested_real/sub/data.txt" >/dev/null \ ++ || test_fail "test 3: nested update content mismatch" ++ ++###################################################################### ++# Test 4: --backup with directory symlinks ++# ++# Exercises the finish_transfer() "fnamecmp == fname" pointer ++# comparison that determines whether to update fnamecmp to the ++# backup name. If broken, --backup would reference a renamed file ++# for xattr handling. ++###################################################################### ++ ++# Reset destination ++rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" ++ ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 4: initial transfer for backup test failed" ++ ++echo "backup update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --backup --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 4: update with --backup through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 4: backup update content mismatch" ++ ++if [ ! -f "$HOME/real-dir/file~" ]; then ++ test_fail "test 4: backup file was not created" ++fi ++ ++###################################################################### ++# Test 5: --inplace with directory symlinks ++# ++# Exercises the updating_basis_or_equiv check which uses ++# "fnamecmp == fname". With --inplace, rsync writes directly to ++# the destination file instead of a temp file. ++###################################################################### ++ ++rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" ++ ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 5: initial inplace transfer failed" ++ ++echo "inplace update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 5: inplace update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 5: inplace update content mismatch" ++ ++###################################################################### ++# Test 6: Top-level file (no dirname, no split needed) ++# ++# Ensures the dirname/basename split is not attempted for files ++# at the top level (file->dirname is NULL). ++###################################################################### ++ ++make_testfile "$srcbase/topfile" ++mkdir -p "$HOME" ++ ++(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ ++ || test_fail "test 6: initial top-level transfer failed" ++ ++echo "toplevel update" >> "$srcbase/topfile" ++sleep 1 ++touch "$srcbase/topfile" ++ ++(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ ++ || test_fail "test 6: top-level update failed" ++ ++diff "$srcbase/topfile" "$HOME/topfile" >/dev/null \ ++ || test_fail "test 6: top-level update content mismatch" ++ ++###################################################################### ++# Test 7: --partial-dir with protocol < 29 ++# ++# For protocol < 29, fnamecmp_type stays FNAMECMP_FNAME even when ++# fnamecmp is set to partialptr. The dirname/basename split must ++# NOT trigger in this case (guarded by "fnamecmp == fname"). ++###################################################################### ++ ++rm -f "$HOME/real-dir/file" ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 7: initial proto28 partial-dir transfer failed" ++ ++echo "partial-dir update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 7: proto28 partial-dir update through dirlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 7: proto28 partial-dir update content mismatch" ++ ++###################################################################### ++# Test 8: Protocol < 29 basic directory symlink update ++# ++# Exercises the protocol < 29 code path and its fallback logic ++# (clearing basedir on retry). ++###################################################################### ++ ++rm -f "$HOME/real-dir/file" ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 8: initial proto28 transfer failed" ++ ++echo "proto28 update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 8: proto28 update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 8: proto28 update content mismatch" ++ ++# The script would have aborted on error, so getting here means we've won. ++exit 0 +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0021-syscall-also-use-O_RESOLVE_BENEATH-on-FreeBSD-and-Ma.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0021-syscall-also-use-O_RESOLVE_BENEATH-on-FreeBSD-and-Ma.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0021-syscall-also-use-O_RESOLVE_BENEATH-on-FreeBSD-and-Ma.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0021-syscall-also-use-O_RESOLVE_BENEATH-on-FreeBSD-and-Ma.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,96 @@ +From b32ba3ddb3736114887a452f8a89e33fe8e5dda8 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Thu, 30 Apr 2026 08:44:11 +1000 +Subject: [PATCH 21/38] syscall: also use O_RESOLVE_BENEATH on FreeBSD and + MacOS + +FreeBSD and MacOS have O_RESOLVE_BENEATH as an openat() flag with the same +"must not escape dirfd" semantics as Linux's RESOLVE_BENEATH. The +kernel rejects ".." escapes, absolute symlinks, and symlinks whose +target lies outside dirfd, while still following symlinks that +resolve within it -- the same trade-off that fixes issue #715 on +Linux. + +Add a parallel BSD path in secure_relative_open(), gated on +declared. Unlike Linux, BSD doesn't have the header/runtime split +where the symbol can exist without kernel support, so no runtime +fallback is needed: if the flag compiles in, the kernel honours it. + +OpenBSD and NetBSD have no equivalent kernel primitive and continue +to use the existing per-component O_NOFOLLOW walk; issue #715 +remains visible on those platforms (a userland resolver or +unveil(2)-based fence would be follow-up work). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + syscall.c | 40 +++++++++++++++++++++++++++++++++++++--- + 1 file changed, 37 insertions(+), 3 deletions(-) + +diff --git a/syscall.c b/syscall.c +index 0881c7ab..045c6ce3 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -729,9 +729,13 @@ int do_open_nofollow(const char *pathname, int flags) + versions rejected every symlink with O_NOFOLLOW on each component, + which broke legitimate directory symlinks on the receiver side + (https://github.com/RsyncProject/rsync/issues/715). The escape +- prevention is handled by the kernel via openat2(RESOLVE_BENEATH) +- on Linux 5.6+; older systems fall back to the per-component +- O_NOFOLLOW walk below. ++ prevention is handled by: ++ Linux 5.6+: openat2(RESOLVE_BENEATH) ++ FreeBSD 13+: openat() with O_RESOLVE_BENEATH ++ macOS 15+ / iOS 18+: openat() with O_RESOLVE_BENEATH (same ++ flag name, picked up by the same #ifdef; ++ flag value differs from FreeBSD) ++ Other systems fall back to the per-component O_NOFOLLOW walk below. + + The relpath must also not contain any ../ elements in the path. + */ +@@ -763,6 +767,32 @@ static int secure_relative_open_linux(const char *basedir, const char *relpath, + } + #endif + ++#ifdef O_RESOLVE_BENEATH ++/* FreeBSD 13+ and macOS 15+ (Sequoia) / iOS 18+: O_RESOLVE_BENEATH is ++ * an openat() flag with the same "must not escape dirfd" semantics as ++ * Linux's RESOLVE_BENEATH. The kernel rejects ".." escapes, absolute ++ * symlinks, and symlinks whose target lies outside dirfd. (FreeBSD and ++ * Apple use different flag bit values, but the same symbolic name.) */ ++static int secure_relative_open_resolve_beneath(const char *basedir, const char *relpath, int flags, mode_t mode) ++{ ++ int dirfd, retfd; ++ ++ if (basedir == NULL) { ++ dirfd = AT_FDCWD; ++ } else { ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) ++ return -1; ++ } ++ ++ retfd = openat(dirfd, relpath, flags | O_RESOLVE_BENEATH, mode); ++ ++ if (dirfd != AT_FDCWD) ++ close(dirfd); ++ return retfd; ++} ++#endif ++ + int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) + { + if (!relpath || relpath[0] == '/') { +@@ -786,6 +816,10 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + } + #endif + ++#ifdef O_RESOLVE_BENEATH ++ return secure_relative_open_resolve_beneath(basedir, relpath, flags, mode); ++#endif ++ + #if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) + // really old system, all we can do is live with the risks + if (!basedir) { +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0022-testsuite-skip-symlink-dirlink-basis-on-platforms-wi.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0022-testsuite-skip-symlink-dirlink-basis-on-platforms-wi.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0022-testsuite-skip-symlink-dirlink-basis-on-platforms-wi.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0022-testsuite-skip-symlink-dirlink-basis-on-platforms-wi.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,49 @@ +From 6226386332e126d22231c7b9a0d40abdc8b0837b Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Thu, 30 Apr 2026 09:00:09 +1000 +Subject: [PATCH 22/38] testsuite: skip symlink-dirlink-basis on platforms + without RESOLVE_BENEATH + +secure_relative_open() has a kernel-enforced "stay below dirfd" path +on Linux 5.6+ (openat2 RESOLVE_BENEATH) and FreeBSD 13+ (openat +O_RESOLVE_BENEATH). On Solaris, OpenBSD, NetBSD, and Cygwin the code +falls back to the per-component O_NOFOLLOW walk, which by design +rejects every directory symlink in the path -- the very case this +test exercises. Mark the test skipped there rather than have it +fail with a known regression that's tracked separately. + +macOS is intentionally not in the skip list: although it does not +have O_RESOLVE_BENEATH either, the test passes there in practice; +investigation of the underlying reason is left as follow-up. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + testsuite/symlink-dirlink-basis.test | 12 ++++++++++++ + 1 file changed, 12 insertions(+) + +diff --git a/testsuite/symlink-dirlink-basis.test b/testsuite/symlink-dirlink-basis.test +index 9065dd81..a14eb5cf 100755 +--- a/testsuite/symlink-dirlink-basis.test ++++ b/testsuite/symlink-dirlink-basis.test +@@ -26,6 +26,18 @@ + + . "$suitedir/rsync.fns" + ++# secure_relative_open() uses kernel-enforced "stay below dirfd" via ++# openat2(RESOLVE_BENEATH) on Linux 5.6+ and openat(O_RESOLVE_BENEATH) ++# on FreeBSD 13+. Other platforms fall back to a per-component ++# O_NOFOLLOW walk that rejects every symlink including legitimate ++# directory symlinks -- the very case this test exercises. Skip on ++# those rather than report a known failure. ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "secure_relative_open lacks RESOLVE_BENEATH equivalent on $(uname -s); issue #715 still affects this platform" ++ ;; ++esac ++ + RSYNC_RSH="$scratchdir/src/support/lsh.sh" + export RSYNC_RSH + +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0023-syscall-clientserver-am_chrooted-and-use_secure_syml.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0023-syscall-clientserver-am_chrooted-and-use_secure_syml.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0023-syscall-clientserver-am_chrooted-and-use_secure_syml.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0023-syscall-clientserver-am_chrooted-and-use_secure_syml.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,325 @@ +From f629772cc686903773fe7b78b57f4011c427f6ef Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 31 Dec 2025 10:01:23 +1100 +Subject: [PATCH 23/38] syscall+clientserver: am_chrooted and + use_secure_symlinks for daemon-no-chroot (CVE-2026-29518) + +CVE-2026-29518: an rsync daemon configured with "use chroot = no" +is exposed to a TOCTOU race on parent path components. A local +attacker with write access to a module can replace a parent +directory component with a symlink between the receiver's check +and its open(), redirecting reads (basis-file disclosure) and +writes (file overwrite) outside the module. Under elevated daemon +privilege this allows privilege escalation. Default +"use chroot = yes" is not exposed. + +Add secure_relative_open() in syscall.c. It walks the parent +components under RESOLVE_BENEATH (Linux 5.6+) / +O_RESOLVE_BENEATH (FreeBSD 13+, macOS 15+) / per-component +O_NOFOLLOW elsewhere, anchored at a trusted dirfd, so a parent- +symlink swap is rejected by the kernel. Route the receiver's +basis-file open in receiver.c through it when use_secure_symlinks +is set in clientserver.c rsync_module(). + +Reporters: Nullx3D (Batuhan SANCAK); Damien Neil; Michael Stapelberg. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + clientserver.c | 25 +++++++++ + options.c | 9 ++++ + receiver.c | 22 ++++++-- + syscall.c | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ + 4 files changed, 192 insertions(+), 3 deletions(-) + +diff --git a/clientserver.c b/clientserver.c +index 3800f0d6..e8dfddb1 100644 +--- a/clientserver.c ++++ b/clientserver.c +@@ -30,6 +30,7 @@ extern int list_only; + extern int am_sender; + extern int am_server; + extern int am_daemon; ++extern int am_chrooted; + extern int am_root; + extern int msgs2stderr; + extern int rsync_port; +@@ -38,6 +39,7 @@ extern int ignore_errors; + extern int preserve_xattrs; + extern int kluge_around_eof; + extern int munge_symlinks; ++extern int use_secure_symlinks; + extern int open_noatime; + extern int sanitize_paths; + extern int numeric_ids; +@@ -983,6 +985,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char + io_printf(f_out, "@ERROR: chroot failed\n"); + return -1; + } ++ am_chrooted = 1; + module_chdir = module_dir; + } + +@@ -1005,6 +1008,15 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char + } + } + ++ /* Enable secure symlink handling for any non-chrooted daemon module. ++ * This prevents TOCTOU race attacks where an attacker could switch a ++ * directory to a symlink between path validation and file open. ++ * Match the gate used by the do_*_at() wrappers in syscall.c ++ * (am_daemon && !am_chrooted) -- the protection has nothing to do ++ * with symlink munging, so a module configured with ++ * "munge symlinks = false" must still get the secure-open path. */ ++ use_secure_symlinks = am_daemon && !am_chrooted; ++ + if (gid_list.count) { + gid_t *gid_array = gid_list.items; + if (setgid(gid_array[0])) { +@@ -1308,6 +1320,19 @@ int start_daemon(int f_in, int f_out) + rsyserr(FLOG, errno, "daemon chroot(\"%s\") failed", p); + return -1; + } ++ /* Deliberately do NOT set am_chrooted here. am_chrooted ++ * gates the per-module symlink-race defenses ++ * (secure_relative_open() and the do_*_at() wrappers in ++ * syscall.c) and means "the kernel is enforcing path ++ * confinement at the module boundary". The daemon chroot ++ * confines path resolution to the daemon-chroot directory, ++ * not to any individual module path -- modules sharing the ++ * daemon chroot are still distinguishable filesystem ++ * subtrees and a sender-controlled symlink in module A ++ * could redirect a syscall to module B (or to other files ++ * inside the daemon chroot) without the per-module ++ * defenses. Leave am_chrooted=0 here so secure_relative_open() ++ * still fires for "use chroot = no" modules. */ + if (chdir("/") < 0) { + rsyserr(FLOG, errno, "daemon chdir(\"/\") failed"); + return -1; +diff --git a/options.c b/options.c +index 4ae1c58c..bebb5018 100644 +--- a/options.c ++++ b/options.c +@@ -113,11 +113,20 @@ int mkpath_dest_arg = 0; + int allow_inc_recurse = 1; + int xfer_dirs = -1; + int am_daemon = 0; ++/* Set after a successful per-module chroot ("use chroot = yes") in ++ * clientserver.c. NOT set for the daemon-level "daemon chroot = /X" ++ * chroot: that confines path resolution to /X, but module paths ++ * /X/modA, /X/modB, etc. are not chroot boundaries, so the per-module ++ * symlink-race defenses (secure_relative_open() / do_*_at() in ++ * syscall.c, gated by `am_daemon && !am_chrooted`) must still fire ++ * even when the daemon is inside a daemon chroot. */ ++int am_chrooted = 0; + int connect_timeout = 0; + int keep_partial = 0; + int safe_symlinks = 0; + int copy_unsafe_links = 0; + int munge_symlinks = 0; ++int use_secure_symlinks = 0; + int size_only = 0; + int daemon_bwlimit = 0; + int bwlimit = 0; +diff --git a/receiver.c b/receiver.c +index edfbb210..5a2c8c5a 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -70,6 +70,7 @@ extern int fuzzy_basis; + + extern struct name_num_item *xfer_sum_nni; + extern int xfer_sum_len; ++extern int use_secure_symlinks; + + static struct bitbag *delayed_bits = NULL; + static int phase = 0, redoing = 0; +@@ -214,7 +215,12 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) + * access to ensure that there is no race condition. They will be + * correctly updated after the right owner and group info is set. + * (Thanks to snabb@epipe.fi for pointing this out.) */ +- fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); ++ /* When use_secure_symlinks is on (non-chroot daemon with munge_symlinks), ++ * use secure_mkstemp to prevent symlink race attacks on parent directories. */ ++ if (use_secure_symlinks) ++ fd = secure_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); ++ else ++ fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); + + #if 0 + /* In most cases parent directories will already exist because their +@@ -854,11 +860,21 @@ int recv_files(int f_in, int f_out, char *local_name) + /* We now check to see if we are writing the file "inplace" */ + if (inplace || one_inplace) { + fnametmp = one_inplace ? partialptr : fname; +- fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); ++ /* When use_secure_symlinks is on (non-chroot daemon), ++ * use secure open to prevent symlink race attacks where an ++ * attacker could switch a directory to a symlink between ++ * path validation and file open. */ ++ if (use_secure_symlinks) ++ fd2 = secure_relative_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600); ++ else ++ fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); + #ifdef linux + if (fd2 == -1 && errno == EACCES) { + /* Maybe the error was due to protected_regular setting? */ +- fd2 = do_open(fname, O_WRONLY, 0600); ++ if (use_secure_symlinks) ++ fd2 = secure_relative_open(NULL, fname, O_WRONLY, 0600); ++ else ++ fd2 = do_open(fname, O_WRONLY, 0600); + } + #endif + if (fd2 == -1) { +diff --git a/syscall.c b/syscall.c +index 045c6ce3..bfaaaa63 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -877,6 +877,145 @@ cleanup: + #endif // O_NOFOLLOW, O_DIRECTORY + } + ++/* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic ++ * quality; falls back to rand() if /dev/urandom cannot be opened or read ++ * (e.g. inside a chroot or container without /dev populated). */ ++static void rand_bytes(unsigned char *buf, size_t len) ++{ ++#ifndef O_CLOEXEC ++#define O_CLOEXEC 0 ++#endif ++ int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); ++ if (fd >= 0) { ++ ssize_t n = read(fd, buf, len); ++ close(fd); ++ if (n == (ssize_t)len) { ++ return; ++ } ++ } ++ for (size_t i = 0; i < len; i++) { ++ buf[i] = (unsigned char)rand(); ++ } ++} ++ ++/* ++ Secure version of mkstemp that prevents symlink attacks on parent directories. ++ Like secure_relative_open(), this walks the path checking each component ++ with O_NOFOLLOW to prevent TOCTOU race conditions. ++ ++ The template may be relative or absolute, but must not contain ../ components. ++ Returns fd on success, -1 on error. ++*/ ++int secure_mkstemp(char *template, mode_t perms) ++{ ++#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) ++ /* Fall back to regular mkstemp on old systems */ ++ return do_mkstemp(template, perms); ++#else ++ char *lastslash; ++ int dirfd = AT_FDCWD; ++ int fd = -1; ++ ++ if (!template) { ++ errno = EINVAL; ++ return -1; ++ } ++ if (strncmp(template, "../", 3) == 0 || strstr(template, "/../")) { ++ errno = EINVAL; ++ return -1; ++ } ++ ++ /* For absolute paths, start the secure walk from "/" rather than CWD. */ ++ if (template[0] == '/') { ++ dirfd = open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW); ++ if (dirfd < 0) ++ return -1; ++ } ++ ++ /* Find the last slash to separate directory from filename */ ++ lastslash = strrchr(template, '/'); ++ if (lastslash) { ++ char *path_copy = my_strdup(template, __FILE__, __LINE__); ++ if (!path_copy) ++ return -1; ++ ++ /* Null-terminate at the last slash to get directory part */ ++ path_copy[lastslash - template] = '\0'; ++ ++ /* Walk the directory path securely */ ++ for (const char *part = strtok(path_copy, "/"); ++ part != NULL; ++ part = strtok(NULL, "/")) ++ { ++ int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); ++ if (next_fd == -1) { ++ int save_errno = errno; ++ free(path_copy); ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = (save_errno == ELOOP) ? ELOOP : save_errno; ++ return -1; ++ } ++ if (dirfd != AT_FDCWD) close(dirfd); ++ dirfd = next_fd; ++ } ++ free(path_copy); ++ } ++ ++ /* Now create the temp file in the securely-opened directory */ ++ perms |= S_IWUSR; ++ ++ /* Generate unique filename - we need to modify the template in place */ ++ char *filename = lastslash ? lastslash + 1 : template; ++ size_t filename_len = strlen(filename); ++ ++ if (filename_len < 6) { ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = EINVAL; ++ return -1; ++ } ++ char *suffix = filename + filename_len - 6; /* Points to XXXXXX */ ++ if (strcmp(suffix, "XXXXXX") != 0) { ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = EINVAL; ++ return -1; ++ } ++ ++ /* Try random suffixes until we find one that works */ ++ static const char letters[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; ++ for (int tries = 0; tries < 100; tries++) { ++ unsigned char rbytes[6]; ++ rand_bytes(rbytes, sizeof(rbytes)); ++ for (int i = 0; i < 6; i++) ++ suffix[i] = letters[rbytes[i] % (sizeof(letters) - 1)]; ++ ++ fd = openat(dirfd, filename, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, perms); ++ if (fd >= 0) ++ break; ++ if (errno != EEXIST) { ++ if (dirfd != AT_FDCWD) close(dirfd); ++ return -1; ++ } ++ } ++ ++ if (fd >= 0) { ++ if (fchmod(fd, perms) != 0 && preserve_perms) { ++ int errno_save = errno; ++ close(fd); ++ unlinkat(dirfd, filename, 0); ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = errno_save; ++ return -1; ++ } ++#if defined HAVE_SETMODE && O_BINARY ++ setmode(fd, O_BINARY); ++#endif ++ } ++ ++ if (dirfd != AT_FDCWD) close(dirfd); ++ return fd; ++#endif ++} ++ + /* + varient of do_open/do_open_nofollow which does do_open() if the + copy_links or copy_unsafe_links options are set and does +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0024-sender-fix-read-path-TOCTOU-by-opening-from-module-r.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0024-sender-fix-read-path-TOCTOU-by-opening-from-module-r.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0024-sender-fix-read-path-TOCTOU-by-opening-from-module-r.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0024-sender-fix-read-path-TOCTOU-by-opening-from-module-r.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,68 @@ +From e57fd03284bbb2c1feaaf7fa1e71ba7a6e1c16ce Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sun, 1 Mar 2026 09:28:40 +1100 +Subject: [PATCH 24/38] sender: fix read-path TOCTOU by opening from module + root (CVE-2026-29518) + +The sender's file open was vulnerable to the same TOCTOU symlink +race as the receiver-side basis-file open. change_pathname() calls +chdir() into subdirectories, which follows symlinks; an attacker +could race to swap a directory for a symlink between the chdir and +the file open, allowing reads of privileged files through the +daemon. + +Reconstruct the full relative path (F_PATHNAME + fname) and open +via secure_relative_open() from the trusted module_dir, which +walks each path component without following symlinks. This is +independent of CWD, so the chdir race is neutralised. + +CVE-2026-29518. + +Co-Authored-By: Claude Opus 4.6 +--- + sender.c | 22 +++++++++++++++++++++- + 1 file changed, 21 insertions(+), 1 deletion(-) + +diff --git a/sender.c b/sender.c +index b1588b70..99f431fe 100644 +--- a/sender.c ++++ b/sender.c +@@ -48,6 +48,8 @@ extern int make_backups; + extern int inplace; + extern int inplace_partial; + extern int batch_fd; ++extern int use_secure_symlinks; ++extern char *module_dir; + extern int write_batch; + extern int file_old_total; + extern BOOL want_progress_now; +@@ -352,7 +354,25 @@ void send_files(int f_in, int f_out) + exit_cleanup(RERR_PROTOCOL); + } + +- fd = do_open_checklinks(fname); ++ if (use_secure_symlinks) { ++ /* Open from module root to prevent TOCTOU race where ++ * change_pathname's chdir follows a directory symlink. ++ * Reconstruct the full path relative to module_dir ++ * from F_PATHNAME (path) and f_name (fname). */ ++ char secure_path[MAXPATHLEN]; ++ int slen = snprintf(secure_path, sizeof secure_path, "%s%s%s", path, slash, fname); ++ if (slen >= (int)sizeof secure_path) { ++ io_error |= IOERR_GENERAL; ++ rprintf(FERROR_XFER, "path too long: %s%s%s\n", path, slash, fname); ++ free_sums(s); ++ if (protocol_version >= 30) ++ send_msg_int(MSG_NO_SEND, ndx); ++ continue; ++ } ++ fd = secure_relative_open(module_dir, secure_path, O_RDONLY, 0); ++ } else { ++ fd = do_open_checklinks(fname); ++ } + if (fd == -1) { + if (errno == ENOENT) { + enum logcode c = am_daemon && protocol_version < 28 ? FERROR : FWARNING; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0025-syscall-receiver-secure-receiver-side-do_chmod-again.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0025-syscall-receiver-secure-receiver-side-do_chmod-again.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0025-syscall-receiver-secure-receiver-side-do_chmod-again.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0025-syscall-receiver-secure-receiver-side-do_chmod-again.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,463 @@ +From d870b43a32289c760c1c34d933bfb7d81922fdc2 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Mon, 4 May 2026 21:53:14 +1000 +Subject: [PATCH 25/38] syscall+receiver: secure receiver-side do_chmod against + symlink-race TOCTOU + +CVE-2026-29518's fix routed the receiver's open() through +secure_relative_open(), but every other path-based syscall the +receiver runs on sender-controllable paths is vulnerable to the +same TOCTOU primitive. This commit closes the chmod variant. + +Add do_chmod_at() that opens the parent of fname under +secure_relative_open() and uses fchmodat() against the resulting +dirfd. Gate the secure path on am_daemon && !am_chrooted (the same +gate use_secure_symlinks already uses for the receiver basis-file +open), so non-daemon callers and chrooted daemons keep the original +do_chmod() fast path. + +Migrate the receiver-side do_chmod() call sites in delete.c, +generator.c, rsync.c, and xattrs.c. + +Adds testsuite/chmod-symlink-race.test (with t_chmod_secure helper) +as regression coverage. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + Makefile.in | 10 ++- + delete.c | 4 +- + generator.c | 4 +- + rsync.c | 2 +- + syscall.c | 80 ++++++++++++++++++++ + t_chmod_secure.c | 117 ++++++++++++++++++++++++++++++ + t_stub.c | 2 + + testsuite/chmod-symlink-race.test | 68 +++++++++++++++++ + xattrs.c | 6 +- + 9 files changed, 282 insertions(+), 11 deletions(-) + create mode 100644 t_chmod_secure.c + create mode 100755 testsuite/chmod-symlink-race.test + +diff --git a/Makefile.in b/Makefile.in +index 75fd03af..fe0a5494 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -57,13 +57,13 @@ TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/perms + + # Programs we must have to run the test cases + CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ +- testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) wildtest$(EXEEXT) \ +- simdtest$(EXEEXT) ++ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ ++ wildtest$(EXEEXT) simdtest$(EXEEXT) + + CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test + + # Objects for CHECK_PROGS to clean +-CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o trimslash.o wildtest.o ++CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o trimslash.o wildtest.o + + # note that the -I. is needed to handle config.h when using VPATH + .c.o: +@@ -179,6 +179,10 @@ T_UNSAFE_OBJ = t_unsafe.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/sn + t_unsafe$(EXEEXT): $(T_UNSAFE_OBJ) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_UNSAFE_OBJ) $(LIBS) + ++T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o ++t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ) ++ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS) ++ + .PHONY: conf + conf: configure.sh config.h.in + +diff --git a/delete.c b/delete.c +index 89c1f8d6..ded0ab2a 100644 +--- a/delete.c ++++ b/delete.c +@@ -98,7 +98,7 @@ static enum delret delete_dir_contents(char *fname, uint16 flags) + + strlcpy(p, fp->basename, remainder); + if (!(fp->mode & S_IWUSR) && !am_root && fp->flags & FLAG_OWNED_BY_US) +- do_chmod(fname, fp->mode | S_IWUSR); ++ do_chmod_at(fname, fp->mode | S_IWUSR); + /* Save stack by recursing to ourself directly. */ + if (S_ISDIR(fp->mode)) { + if (delete_dir_contents(fname, flags | DEL_RECURSE) != DR_SUCCESS) +@@ -139,7 +139,7 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) + } + + if (flags & DEL_NO_UID_WRITE) +- do_chmod(fbuf, mode | S_IWUSR); ++ do_chmod_at(fbuf, mode | S_IWUSR); + + if (S_ISDIR(mode) && !(flags & DEL_DIR_IS_EMPTY)) { + /* This only happens on the first call to delete_item() since +diff --git a/generator.c b/generator.c +index b56fa569..e5aff654 100644 +--- a/generator.c ++++ b/generator.c +@@ -1499,7 +1499,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + #ifdef HAVE_CHMOD + if (!am_root && (file->mode & S_IRWXU) != S_IRWXU && dir_tweaking) { + mode_t mode = file->mode | S_IRWXU; +- if (do_chmod(fname, mode) < 0) { ++ if (do_chmod_at(fname, mode) < 0) { + rsyserr(FERROR_XFER, errno, + "failed to modify permissions on %s", + full_fname(fname)); +@@ -2111,7 +2111,7 @@ static void touch_up_dirs(struct file_list *flist, int ndx) + continue; + fname = f_name(file, NULL); + if (fix_dir_perms) +- do_chmod(fname, file->mode); ++ do_chmod_at(fname, file->mode); + if (need_retouch_dir_times) { + STRUCT_STAT st; + if (link_stat(fname, &st, 0) == 0 && mtime_differs(&st, file)) { +diff --git a/rsync.c b/rsync.c +index b130aba5..cc46a2f9 100644 +--- a/rsync.c ++++ b/rsync.c +@@ -657,7 +657,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, + + #ifdef HAVE_CHMOD + if (!BITS_EQUAL(sxp->st.st_mode, new_mode, CHMOD_BITS)) { +- int ret = am_root < 0 ? 0 : do_chmod(fname, new_mode); ++ int ret = am_root < 0 ? 0 : do_chmod_at(fname, new_mode); + if (ret < 0) { + rsyserr(FERROR_XFER, errno, + "failed to set permissions on %s", +diff --git a/syscall.c b/syscall.c +index bfaaaa63..167aae0e 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -281,6 +281,86 @@ int do_chmod(const char *path, mode_t mode) + return code; + return 0; + } ++ ++/* ++ Symlink-race-safe variant of do_chmod() for receiver-side use. ++ ++ Threat model: on a daemon running with "use chroot = no" (the prerequisite ++ for CVE-2026-29518), a local attacker can race a symlink swap of one of ++ the parent directory components of a path the receiver is about to chmod. ++ Because chmod() resolves symlinks at every component, the swap redirects ++ the chmod outside the receiver's confinement. ++ ++ Defence: open the *parent* directory of fname under secure_relative_open() ++ (which uses openat2(RESOLVE_BENEATH) on Linux 5.6+, openat() with ++ O_RESOLVE_BENEATH on FreeBSD 13+ and macOS 15+ (Sequoia), or a per-component ++ O_NOFOLLOW walk elsewhere) and do fchmodat() against that dirfd. A symlink ++ substituted into one of the parent components is then either followed ++ within the tree (legitimate dir-symlinks still work) or rejected by the ++ kernel (escape attempts fail). ++ ++ Final-component handling matches do_chmod(): fchmodat() with flag 0 ++ follows a symlink at the final component, which is the same behaviour as ++ chmod() and matches every current call site (the file being chmod'd is ++ one the receiver itself just created or transferred). For the rare case ++ where the caller wants to chmod a symlink-as-an-object (S_ISLNK in the ++ mode bits), we fall through to do_chmod() which has portability code for ++ that case. ++ ++ Falls back to do_chmod() for absolute paths and for paths with no parent ++ component, where there is nothing to protect against. ++*/ ++int do_chmod_at(const char *fname, mode_t mode) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ /* Only the daemon-without-chroot case is exposed to the symlink- ++ * race attack: a chroot already confines the receiver, and a ++ * non-daemon rsync runs with the user's own authority so a ++ * symlink they planted can only redirect to files they could ++ * already access. Everywhere else, fall through to plain ++ * do_chmod() to avoid the dirfd-open overhead on every call. */ ++ if (!am_daemon || am_chrooted) ++ return do_chmod(fname, mode); ++ ++ if (!fname || !*fname || *fname == '/' || S_ISLNK(mode)) ++ return do_chmod(fname, mode); ++ ++ slash = strrchr(fname, '/'); ++ if (!slash) ++ return do_chmod(fname, mode); ++ ++ dlen = slash - fname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, fname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = fchmodat(dfd, bname, mode, 0); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_chmod(fname, mode); ++#endif ++} + #endif + + int do_rename(const char *old_path, const char *new_path) +diff --git a/t_chmod_secure.c b/t_chmod_secure.c +new file mode 100644 +index 00000000..114dfb2d +--- /dev/null ++++ b/t_chmod_secure.c +@@ -0,0 +1,117 @@ ++/* ++ * Test harness for do_chmod_at(). Confirms the symlink-TOCTOU ++ * primitive used by CVE-2026-29518 (and its incomplete-fix follow-up ++ * for chmod) is closed by do_chmod_at(): a parent directory component ++ * being a symlink that escapes the receiver's confinement must be ++ * rejected, while a parent symlink that resolves *within* the tree ++ * must still work (so legitimate dir-symlinks are not regressed). ++ * ++ * Not linked into rsync itself. ++ * ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License version 2 as ++ * published by the Free Software Foundation. ++ */ ++ ++#include "rsync.h" ++ ++#include ++ ++int dry_run = 0; ++int am_root = 0; ++int am_sender = 0; ++int read_only = 0; ++int list_only = 0; ++int copy_links = 0; ++int copy_unsafe_links = 0; ++extern int am_daemon, am_chrooted; ++ ++short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; ++ ++static int errs = 0; ++ ++static void check(const char *label, int actual_rc, int expect_ok, ++ const char *path, mode_t expected_mode) ++{ ++ struct stat st; ++ int got_ok = (actual_rc == 0); ++ if (got_ok != expect_ok) { ++ fprintf(stderr, "FAIL [%s]: rc=%d errno=%d (%s), expected %s\n", ++ label, actual_rc, errno, strerror(errno), ++ expect_ok ? "success" : "rejection"); ++ errs++; ++ return; ++ } ++ if (path && stat(path, &st) < 0) { ++ fprintf(stderr, "FAIL [%s]: stat(%s) failed: %s\n", ++ label, path, strerror(errno)); ++ errs++; ++ return; ++ } ++ if (path && (st.st_mode & 07777) != expected_mode) { ++ fprintf(stderr, ++ "FAIL [%s]: %s mode is 0%o, expected 0%o\n", ++ label, path, st.st_mode & 07777, expected_mode); ++ errs++; ++ return; ++ } ++ fprintf(stderr, "OK [%s]\n", label); ++} ++ ++int main(int argc, char **argv) ++{ ++ if (argc != 2) { ++ fprintf(stderr, "usage: %s \n", argv[0]); ++ return 2; ++ } ++ if (chdir(argv[1]) < 0) { ++ perror("chdir"); ++ return 2; ++ } ++ ++ /* Simulate the daemon-without-chroot deployment that do_chmod_at() ++ * defends. With am_daemon=0 or am_chrooted=1 the wrapper falls ++ * through to plain do_chmod() and the symlink-race test would be ++ * meaningless. */ ++ am_daemon = 1; ++ am_chrooted = 0; ++ ++ /* Test layout (all inside the directory we just chdir'd to): ++ * ++ * ./realdir/sentinel -- regular target file ++ * ./inside_link -> realdir -- legitimate dir-symlink within the tree ++ * ./escape_link -> ../trap -- attacker swap, target outside tree ++ * ../trap/sentinel -- the file the attacker wants to alter ++ * ++ * The shell wrapper that calls this helper has set both sentinel ++ * files to mode 0600 so we have a clean baseline to compare. ++ */ ++ ++ /* Scenario A: legitimate parent dir-symlink, chmod must succeed. */ ++ int rc = do_chmod_at("inside_link/sentinel", 0640); ++ check("A: legit dir-symlink within tree", ++ rc, 1, "realdir/sentinel", 0640); ++ ++ /* Scenario B: parent symlink escapes the tree -- chmod must be ++ * rejected and the outside file's mode must be unchanged. */ ++ rc = do_chmod_at("escape_link/sentinel", 0666); ++ check("B: parent symlink escapes tree (the attack)", ++ rc, 0, "../trap/sentinel", 0600); ++ ++ /* Scenario C: plain relative path with no symlink components, ++ * regression check that the safe wrapper doesn't break the ++ * normal case. */ ++ rc = do_chmod_at("realdir/sentinel", 0644); ++ check("C: plain relative path (regression check)", ++ rc, 1, "realdir/sentinel", 0644); ++ ++ /* Scenario D: top-level file, no parent directory component. ++ * Falls back to do_chmod(); should succeed. */ ++ rc = do_chmod_at("topfile", 0640); ++ check("D: top-level file, no parent component", ++ rc, 1, "topfile", 0640); ++ ++ if (errs) ++ fprintf(stderr, "%d failure(s)\n", errs); ++ return errs ? 1 : 0; ++} +diff --git a/t_stub.c b/t_stub.c +index eee92729..63bc144c 100644 +--- a/t_stub.c ++++ b/t_stub.c +@@ -23,6 +23,8 @@ + + int do_fsync = 0; + int inplace = 0; ++int am_daemon = 0; ++int am_chrooted = 0; + int modify_window = 0; + int preallocate_files = 0; + int protect_args = 0; +diff --git a/testsuite/chmod-symlink-race.test b/testsuite/chmod-symlink-race.test +new file mode 100755 +index 00000000..48bbfbb4 +--- /dev/null ++++ b/testsuite/chmod-symlink-race.test +@@ -0,0 +1,68 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for the symlink-TOCTOU class of bug applied to ++# chmod() on the receiver side. The CVE-2026-29518 fix used ++# secure_relative_open() for the basis-file open, but every other ++# path-based syscall the receiver runs on sender-controllable paths ++# is vulnerable to the same primitive: a local attacker swaps a ++# symlink into one of the parent directory components between the ++# receiver's check and its act, and the syscall escapes the module. ++# ++# This test exercises the new do_chmod_at() wrapper via the ++# t_chmod_secure helper. The helper sets up two scenarios: ++# - a parent dir-symlink that resolves WITHIN the module tree ++# (legitimate -K-style use, must continue to work) ++# - a parent dir-symlink that escapes the module tree (the ++# attack, must be rejected) ++# plus two regression scenarios (plain relative path, top-level ++# file) that just confirm the safe wrapper doesn't break the ++# normal case. ++# ++# The kernel-enforced "stay below dirfd" path resolution is ++# only available on Linux 5.6+, FreeBSD 13+, and macOS 15+. ++# Skip on platforms that fall back to per-component O_NOFOLLOW ++# (Solaris, OpenBSD, NetBSD, Cygwin); the per-component fallback ++# would also reject the attack but the legitimate dir-symlink ++# scenario would fail there. ++ ++. "$suitedir/rsync.fns" ++ ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "do_chmod_at relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" ++ ;; ++esac ++ ++mod="$scratchdir/module" ++trap_outside="$scratchdir/trap" ++rm -rf "$mod" "$trap_outside" ++mkdir -p "$mod/realdir" "$trap_outside" ++ ++# Set up the four file-system objects the helper expects: ++echo bystander > "$mod/realdir/sentinel" ++chmod 0600 "$mod/realdir/sentinel" ++echo target > "$trap_outside/sentinel" ++chmod 0600 "$trap_outside/sentinel" ++ln -s realdir "$mod/inside_link" ++ln -s ../trap "$mod/escape_link" ++echo top > "$mod/topfile" ++chmod 0600 "$mod/topfile" ++ ++"$TOOLDIR/t_chmod_secure" "$mod" || \ ++ test_fail "t_chmod_secure reported failures (see stderr above)" ++ ++# Sanity-check from the shell side too: the outside file's mode must ++# still be 0600 -- the helper checked this, but a second look from ++# the shell guards against a helper-internal stat() bug. ++mode=$(stat -c '%a' "$trap_outside/sentinel" 2>/dev/null \ ++ || stat -f '%Lp' "$trap_outside/sentinel" 2>/dev/null) ++if [ "$mode" != "600" ]; then ++ test_fail "outside sentinel mode changed from 600 to $mode -- chmod escaped the module" ++fi ++ ++exit 0 +diff --git a/xattrs.c b/xattrs.c +index 65166eed..e5d0dd43 100644 +--- a/xattrs.c ++++ b/xattrs.c +@@ -1086,7 +1086,7 @@ int set_xattr(const char *fname, const struct file_struct *file, const char *fna + && !S_ISLNK(sxp->st.st_mode) + #endif + && access(fname, W_OK) < 0 +- && do_chmod(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) == 0) ++ && do_chmod_at(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) == 0) + added_write_perm = 1; + + ndx = F_XATTR(file); +@@ -1094,7 +1094,7 @@ int set_xattr(const char *fname, const struct file_struct *file, const char *fna + lst = &glst->xa_items; + int return_value = rsync_xal_set(fname, lst, fnamecmp, sxp); + if (added_write_perm) /* remove the temporary write permission */ +- do_chmod(fname, sxp->st.st_mode); ++ do_chmod_at(fname, sxp->st.st_mode); + return return_value; + } + +@@ -1211,7 +1211,7 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode) + mode = (fst.st_mode & _S_IFMT) | (fmode & ACCESSPERMS) + | (S_ISDIR(fst.st_mode) ? 0700 : 0600); + if (fst.st_mode != mode) +- do_chmod(fname, mode); ++ do_chmod_at(fname, mode); + if (!IS_DEVICE(fst.st_mode)) + fst.st_rdev = 0; /* just in case */ + +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0026-util1-secure-change_dir-against-symlink-race-chdir-e.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0026-util1-secure-change_dir-against-symlink-race-chdir-e.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0026-util1-secure-change_dir-against-symlink-race-chdir-e.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0026-util1-secure-change_dir-against-symlink-race-chdir-e.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,203 @@ +From a2c1b98c2a0886b989f768464c61c4c93009c1f4 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 5 May 2026 14:34:33 +1000 +Subject: [PATCH 26/38] util1: secure change_dir() against symlink-race + chdir-escape + +The receiver's chdir(2) into a destination subdirectory followed +attacker-planted symlinks at every path component. Once CWD +escaped the module, every subsequent path-relative syscall (open, +chmod, lchown, ...) inherited the escape -- defeating +secure_relative_open's RESOLVE_BENEATH anchor against AT_FDCWD, +since the anchor itself was now outside the module. + +Route change_dir's relative target through secure_relative_open() +and fchdir() to the resulting dirfd in am_daemon && !am_chrooted +mode, so the chdir step itself can no longer follow a parent- +symlink. Same treatment applied to the CD_SKIP_CHDIR / +set_path_only path so it also can't follow attacker symlinks +during path tracking. + +Adds testsuite/sender-flist-symlink-leak.test covering the +sender-side flist resolution variant of the same primitive. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + testsuite/sender-flist-symlink-leak.test | 90 ++++++++++++++++++++++++ + util1.c | 56 +++++++++++++-- + 2 files changed, 142 insertions(+), 4 deletions(-) + create mode 100755 testsuite/sender-flist-symlink-leak.test + +diff --git a/testsuite/sender-flist-symlink-leak.test b/testsuite/sender-flist-symlink-leak.test +new file mode 100755 +index 00000000..011d93d0 +--- /dev/null ++++ b/testsuite/sender-flist-symlink-leak.test +@@ -0,0 +1,90 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex re-check finding: the sender-side file- ++# list generator can still follow an attacker-planted symlink out of ++# the module via change_pathname() -> change_dir(...,CD_SKIP_CHDIR) ++# followed by change_dir(...,CD_NORMAL). The CD_SKIP_CHDIR sets ++# skipped_chdir=1, and the next CD_NORMAL call's secure-branch in ++# util1.c is gated on `!skipped_chdir`, so the secure path is ++# bypassed and a raw chdir(curr_dir) follows attacker-controlled ++# symlinks during flist generation. ++# ++# Reach: rsync daemon module with `use chroot = no`. A local ++# attacker plants module/cd -> /outside. A client (innocent or ++# malicious) pulls rsync:////cd/. The daemon, as ++# sender, enumerates files in /outside and ships their metadata ++# (names, sizes, modes, mtimes) to the client. The actual content ++# transfer fails later at the secure_relative_open step with EXDEV, ++# but by then the metadata has already leaked. ++# ++# We detect by running a dry-run pull of the symlinked subdir and ++# checking whether the client's --list-only output mentions any ++# file from /outside. With the bug, /outside/secret.txt appears in ++# the list with its size; with the fix, the daemon's chdir into ++# the symlinked subdir is rejected and no /outside file is listed. ++ ++. "$suitedir/rsync.fns" ++ ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "secure change_dir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" ++ ;; ++esac ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++listfile="$scratchdir/listed.txt" ++conf="$scratchdir/test-rsyncd.conf" ++ ++rm -rf "$mod" "$outside" ++mkdir -p "$mod" "$outside" ++ ++# Outside-the-module file the daemon should NOT enumerate to clients. ++# A distinctive name + non-trivial size makes the leak easy to spot. ++echo "OUTSIDE_PROTECTED_FILE_USED_AS_LEAK_DETECTOR" > "$outside/leak_marker.txt" ++chmod 0644 "$outside/leak_marker.txt" ++ ++# The symlink trap planted by the local attacker. ++ln -s "$outside" "$mod/cd" ++ ++my_uid=`get_testuid` ++root_uid=`get_rootuid` ++root_gid=`get_rootgid` ++uid_setting="uid = $root_uid" ++gid_setting="gid = $root_gid" ++if test x"$my_uid" != x"$root_uid"; then ++ uid_setting="#$uid_setting" ++ gid_setting="#$gid_setting" ++fi ++ ++cat > "$conf" < "$listfile" 2>&1 || true ++ ++if grep -q "leak_marker\.txt" "$listfile"; then ++ echo "----- leaked listing follows" >&2 ++ sed 's/^/ /' "$listfile" >&2 ++ echo "----- leaked listing ends" >&2 ++ test_fail "sender flist leak: outside/leak_marker.txt was enumerated to the client (daemon's chdir followed the cd symlink during flist generation)" ++fi ++ ++exit 0 +diff --git a/util1.c b/util1.c +index 25ac7c9b..796604f6 100644 +--- a/util1.c ++++ b/util1.c +@@ -1116,6 +1116,7 @@ char *sanitize_path(char *dest, const char *p, const char *rootdir, int depth, i + * Also cleans the path using the clean_fname() function. */ + int change_dir(const char *dir, int set_path_only) + { ++ extern int am_daemon, am_chrooted; + static int initialised, skipped_chdir; + unsigned int len; + +@@ -1154,10 +1155,57 @@ int change_dir(const char *dir, int set_path_only) + curr_dir[curr_dir_len++] = '/'; + memcpy(curr_dir + curr_dir_len, dir, len + 1); + +- if (!set_path_only && chdir(curr_dir)) { +- curr_dir_len = save_dir_len; +- curr_dir[curr_dir_len] = '\0'; +- return 0; ++ if (!set_path_only) { ++ int chdir_failed; ++ /* In the daemon-without-chroot deployment we must not ++ * follow a symlink in any component of the chdir ++ * target -- otherwise CWD escapes the module and ++ * every subsequent path-relative syscall (open, ++ * chmod, lchown, ...) inherits the escape, which ++ * defeats secure_relative_open's RESOLVE_BENEATH ++ * anchor and re-opens the CVE-2026-29518 class of ++ * symlink TOCTOU attacks. Use the secure resolver ++ * to get a confined dirfd, then fchdir() to it. ++ * ++ * If skipped_chdir is set, a previous CD_SKIP_CHDIR ++ * call buffered an absolute prefix in curr_dir ++ * (e.g. change_pathname's CD_SKIP_CHDIR to orig_dir) ++ * without syncing the kernel's CWD. Resolve `dir` ++ * relative to that prefix as basedir so the secure ++ * branch still anchors at the operator-trusted ++ * directory rather than wherever the kernel CWD ++ * happens to be. */ ++ if (am_daemon && !am_chrooted) { ++ const char *basedir = NULL; ++ char prefix[MAXPATHLEN]; ++ int dfd; ++ if (skipped_chdir) { ++ if (save_dir_len >= sizeof prefix) { ++ errno = ENAMETOOLONG; ++ chdir_failed = 1; ++ goto chdir_cleanup; ++ } ++ memcpy(prefix, curr_dir, save_dir_len); ++ prefix[save_dir_len] = '\0'; ++ basedir = prefix; ++ } ++ dfd = secure_relative_open(basedir, dir, ++ O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) { ++ chdir_failed = 1; ++ } else { ++ chdir_failed = fchdir(dfd) != 0; ++ close(dfd); ++ } ++ } else { ++ chdir_failed = chdir(curr_dir) != 0; ++ } ++ chdir_cleanup: ++ if (chdir_failed) { ++ curr_dir_len = save_dir_len; ++ curr_dir[curr_dir_len] = '\0'; ++ return 0; ++ } + } + skipped_chdir = set_path_only; + } +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0027-syscall-add-symlink-race-safe-do_-_at-wrappers-and-h.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0027-syscall-add-symlink-race-safe-do_-_at-wrappers-and-h.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0027-syscall-add-symlink-race-safe-do_-_at-wrappers-and-h.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0027-syscall-add-symlink-race-safe-do_-_at-wrappers-and-h.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,1774 @@ +From 72a6634479b6f9b980efdb40815abd04c6c20d9d Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 5 May 2026 15:02:48 +1000 +Subject: [PATCH 27/38] syscall: add symlink-race-safe do_*_at() wrappers and + harden secure_relative_open + +Add the rest of the path-based syscall wrappers and migrate every +receiver-side caller: + - do_lchown_at, do_rename_at, do_mkdir_at, do_symlink_at, + do_mknod_at, do_link_at, do_unlink_at, do_rmdir_at, + do_utimensat_at, do_stat_at, do_lstat_at + +Same shape as do_chmod_at: open each parent under +secure_relative_open(), call the *at() variant against the dirfd, +fall through to the bare path-based syscall in non-daemon / +chrooted / absolute-path / no-parent cases. macOS's +setattrlist-based set_times tier is also routed through the +utimensat_at path on daemon-no-chroot. + +Hardenings to secure_relative_open() itself: + - confine basedir resolution under the same kernel mechanism + used for relpath (basedirs from --copy-dest / --link-dest are + sender-controllable in daemon mode) + - reject any '..' component (bare '..', 'foo/..', 'subdir/..') + so the per-component O_NOFOLLOW fallback can't escape + - return the dirfd we built up from the per-component fallback + when the caller passed O_DIRECTORY (otherwise every do_*_at + failed with EINVAL on platforms without RESOLVE_BENEATH) + +Adds testsuite/alt-dest-symlink-race.test and +testsuite/secure-relpath-validation.test (with t_secure_relpath +helper) as regression coverage for the new hardenings. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + Makefile.in | 8 +- + backup.c | 14 +- + cleanup.c | 2 +- + delete.c | 2 +- + generator.c | 22 +- + hlink.c | 2 +- + receiver.c | 8 +- + rsync.c | 6 +- + syscall.c | 841 ++++++++++++++++++++++- + t_secure_relpath.c | 151 ++++ + testsuite/alt-dest-symlink-race.test | 96 +++ + testsuite/secure-relpath-validation.test | 34 + + util1.c | 20 +- + xattrs.c | 9 +- + 14 files changed, 1164 insertions(+), 51 deletions(-) + create mode 100644 t_secure_relpath.c + create mode 100755 testsuite/alt-dest-symlink-race.test + create mode 100755 testsuite/secure-relpath-validation.test + +diff --git a/Makefile.in b/Makefile.in +index fe0a5494..624c4d69 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -58,12 +58,12 @@ TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/perms + # Programs we must have to run the test cases + CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ + testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ +- wildtest$(EXEEXT) simdtest$(EXEEXT) ++ t_secure_relpath$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) + + CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test + + # Objects for CHECK_PROGS to clean +-CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o trimslash.o wildtest.o ++CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_secure_relpath.o trimslash.o wildtest.o + + # note that the -I. is needed to handle config.h when using VPATH + .c.o: +@@ -183,6 +183,10 @@ T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util1.o util2.o t_stub.o lib/com + t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS) + ++T_SECURE_RELPATH_OBJ = t_secure_relpath.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o ++t_secure_relpath$(EXEEXT): $(T_SECURE_RELPATH_OBJ) ++ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SECURE_RELPATH_OBJ) $(LIBS) ++ + .PHONY: conf + conf: configure.sh config.h.in + +diff --git a/backup.c b/backup.c +index 686cb297..ae8cb49e 100644 +--- a/backup.c ++++ b/backup.c +@@ -39,7 +39,7 @@ static int validate_backup_dir(void) + { + STRUCT_STAT st; + +- if (do_lstat(backup_dir_buf, &st) < 0) { ++ if (do_lstat_at(backup_dir_buf, &st) < 0) { + if (errno == ENOENT) + return 0; + rsyserr(FERROR, errno, "backup lstat %s failed", backup_dir_buf); +@@ -98,7 +98,7 @@ static BOOL copy_valid_path(const char *fname) + for ( ; b; name = b + 1, b = strchr(name, '/')) { + *b = '\0'; + +- while (do_mkdir(backup_dir_buf, ACCESSPERMS) < 0) { ++ while (do_mkdir_at(backup_dir_buf, ACCESSPERMS) < 0) { + if (errno == EEXIST) { + val = validate_backup_dir(); + if (val > 0) +@@ -197,7 +197,7 @@ static inline int link_or_rename(const char *from, const char *to, + if (IS_SPECIAL(stp->st_mode) || IS_DEVICE(stp->st_mode)) + return 0; /* Use copy code. */ + #endif +- if (do_link(from, to) == 0) { ++ if (do_link_at(from, to) == 0) { + if (DEBUG_GTE(BACKUP, 1)) + rprintf(FINFO, "make_backup: HLINK %s successful.\n", from); + return 2; +@@ -207,7 +207,7 @@ static inline int link_or_rename(const char *from, const char *to, + return 0; + } + #endif +- if (do_rename(from, to) == 0) { ++ if (do_rename_at(from, to) == 0) { + if (stp->st_nlink > 1 && !S_ISDIR(stp->st_mode)) { + /* If someone has hard-linked the file into the backup + * dir, rename() might return success but do nothing! */ +@@ -246,7 +246,7 @@ int make_backup(const char *fname, BOOL prefer_rename) + goto success; + if (errno == EEXIST || errno == EISDIR) { + STRUCT_STAT bakst; +- if (do_lstat(buf, &bakst) == 0) { ++ if (do_lstat_at(buf, &bakst) == 0) { + int flags = get_del_for_flag(bakst.st_mode) | DEL_FOR_BACKUP | DEL_RECURSE; + if (delete_item(buf, bakst.st_mode, flags) != 0) + return 0; +@@ -277,7 +277,7 @@ int make_backup(const char *fname, BOOL prefer_rename) + /* Check to see if this is a device file, or link */ + if ((am_root && preserve_devices && IS_DEVICE(file->mode)) + || (preserve_specials && IS_SPECIAL(file->mode))) { +- if (do_mknod(buf, file->mode, sx.st.st_rdev) < 0) ++ if (do_mknod_at(buf, file->mode, sx.st.st_rdev) < 0) + rsyserr(FERROR, errno, "mknod %s failed", full_fname(buf)); + else if (DEBUG_GTE(BACKUP, 1)) + rprintf(FINFO, "make_backup: DEVICE %s successful.\n", fname); +@@ -294,7 +294,7 @@ int make_backup(const char *fname, BOOL prefer_rename) + } + ret = 2; + } else { +- if (do_symlink(sl, buf) < 0) ++ if (do_symlink_at(sl, buf) < 0) + rsyserr(FERROR, errno, "link %s -> \"%s\"", full_fname(buf), sl); + else if (DEBUG_GTE(BACKUP, 1)) + rprintf(FINFO, "make_backup: SYMLINK %s successful.\n", fname); +diff --git a/cleanup.c b/cleanup.c +index 40d26baa..0493fbbb 100644 +--- a/cleanup.c ++++ b/cleanup.c +@@ -198,7 +198,7 @@ NORETURN void _exit_cleanup(int code, const char *file, int line) + switch_step++; + + if (cleanup_fname) +- do_unlink(cleanup_fname); ++ do_unlink_at(cleanup_fname); + if (exit_code) + kill_all(SIGUSR1); + if (cleanup_pid && cleanup_pid == getpid()) { +diff --git a/delete.c b/delete.c +index ded0ab2a..4a52122d 100644 +--- a/delete.c ++++ b/delete.c +@@ -160,7 +160,7 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) + + if (S_ISDIR(mode)) { + what = "rmdir"; +- ok = do_rmdir(fbuf) == 0; ++ ok = do_rmdir_at(fbuf) == 0; + } else { + if (make_backups > 0 && !(flags & DEL_FOR_BACKUP) && (backup_dir || !is_backup_file(fbuf))) { + what = "make_backup"; +diff --git a/generator.c b/generator.c +index e5aff654..e5b2d176 100644 +--- a/generator.c ++++ b/generator.c +@@ -984,7 +984,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, + if (find_exact_for_existing) { + if (alt_dest_type == LINK_DEST && real_st.st_dev == sxp->st.st_dev && real_st.st_ino == sxp->st.st_ino) + return -1; +- if (do_unlink(fname) < 0 && errno != ENOENT) ++ if (do_unlink_at(fname) < 0 && errno != ENOENT) + goto got_nothing_for_ya; + } + #ifdef SUPPORT_HARD_LINKS +@@ -1112,7 +1112,7 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, + && !IS_SPECIAL(file->mode) && !IS_DEVICE(file->mode) + #endif + && !S_ISDIR(file->mode)) { +- if (do_link(cmpbuf, fname) < 0) { ++ if (do_link_at(cmpbuf, fname) < 0) { + rsyserr(FERROR_XFER, errno, + "failed to hard-link %s with %s", + cmpbuf, fname); +@@ -1315,7 +1315,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + } + } + if (relative_paths && !implied_dirs && file->mode != 0 +- && do_stat(dn, &sx.st) < 0) { ++ && do_stat_at(dn, &sx.st) < 0) { + if (dry_run) + goto parent_is_dry_missing; + if (make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0) { +@@ -1427,7 +1427,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + && (stype == FT_DIR + || delete_item(fname, sx.st.st_mode, del_opts | DEL_FOR_DIR) != 0)) + goto cleanup; /* Any errors get reported later. */ +- if (do_mkdir(fname, (file->mode|added_perms) & 0700) == 0) ++ if (do_mkdir_at(fname, (file->mode|added_perms) & 0700) == 0) + file->flags |= FLAG_DIR_CREATED; + goto cleanup; + } +@@ -1469,10 +1469,10 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + itemize(fnamecmp, file, ndx, statret, &sx, + statret ? ITEM_LOCAL_CHANGE : 0, 0, NULL); + } +- if (real_ret != 0 && do_mkdir(fname,file->mode|added_perms) < 0 && errno != EEXIST) { ++ if (real_ret != 0 && do_mkdir_at(fname,file->mode|added_perms) < 0 && errno != EEXIST) { + if (!relative_paths || errno != ENOENT + || make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0 +- || (do_mkdir(fname, file->mode|added_perms) < 0 && errno != EEXIST)) { ++ || (do_mkdir_at(fname, file->mode|added_perms) < 0 && errno != EEXIST)) { + rsyserr(FERROR_XFER, errno, + "recv_generator: mkdir %s failed", + full_fname(fname)); +@@ -1808,7 +1808,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + ; + else if (quick_check_ok(FT_REG, fnamecmp, file, &sx.st)) { + if (partialptr) { +- do_unlink(partialptr); ++ do_unlink_at(partialptr); + handle_partial_dir(partialptr, PDIR_DELETE); + } + set_file_attrs(fname, file, &sx, NULL, maybe_ATTRS_REPORT | maybe_ATTRS_ACCURATE_TIME); +@@ -2016,7 +2016,7 @@ int atomic_create(struct file_struct *file, char *fname, const char *slnk, const + + if (slnk) { + #ifdef SUPPORT_LINKS +- if (do_symlink(slnk, create_name) < 0) { ++ if (do_symlink_at(slnk, create_name) < 0) { + rsyserr(FERROR_XFER, errno, "symlink %s -> \"%s\" failed", + full_fname(create_name), slnk); + return 0; +@@ -2032,7 +2032,7 @@ int atomic_create(struct file_struct *file, char *fname, const char *slnk, const + return 0; + #endif + } else { +- if (do_mknod(create_name, file->mode, rdev) < 0) { ++ if (do_mknod_at(create_name, file->mode, rdev) < 0) { + rsyserr(FERROR_XFER, errno, "mknod %s failed", + full_fname(create_name)); + return 0; +@@ -2040,14 +2040,14 @@ int atomic_create(struct file_struct *file, char *fname, const char *slnk, const + } + + if (!skip_atomic) { +- if (do_rename(tmpname, fname) < 0) { ++ if (do_rename_at(tmpname, fname) < 0) { + char *full_tmpname = strdup(full_fname(tmpname)); + if (full_tmpname == NULL) + out_of_memory("atomic_create"); + rsyserr(FERROR_XFER, errno, "rename %s -> \"%s\" failed", + full_tmpname, full_fname(fname)); + free(full_tmpname); +- do_unlink(tmpname); ++ do_unlink_at(tmpname); + return 0; + } + } +diff --git a/hlink.c b/hlink.c +index 2c14407a..eb36730f 100644 +--- a/hlink.c ++++ b/hlink.c +@@ -454,7 +454,7 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname, + int hard_link_one(struct file_struct *file, const char *fname, + const char *oldname, int terse) + { +- if (do_link(oldname, fname) < 0) { ++ if (do_link_at(oldname, fname) < 0) { + enum logcode code; + if (terse) { + if (!INFO_GTE(NAME, 1)) +diff --git a/receiver.c b/receiver.c +index 5a2c8c5a..8cf8366b 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -442,7 +442,7 @@ static void handle_delayed_updates(char *local_name) + } + /* We don't use robust_rename() here because the + * partial-dir must be on the same drive. */ +- if (do_rename(partialptr, fname) < 0) { ++ if (do_rename_at(partialptr, fname) < 0) { + rsyserr(FERROR_XFER, errno, + "rename failed for %s (from %s)", + full_fname(fname), partialptr); +@@ -926,7 +926,7 @@ int recv_files(int f_in, int f_out, char *local_name) + recv_ok = -1; + else if (fnamecmp == partialptr) { + if (!one_inplace) +- do_unlink(partialptr); ++ do_unlink_at(partialptr); + handle_partial_dir(partialptr, PDIR_DELETE); + } + } else if (keep_partial && partialptr && (!one_inplace || delay_updates)) { +@@ -935,7 +935,7 @@ int recv_files(int f_in, int f_out, char *local_name) + "Unable to create partial-dir for %s -- discarding %s.\n", + local_name ? local_name : f_name(file, NULL), + recv_ok ? "completed file" : "partial file"); +- do_unlink(fnametmp); ++ do_unlink_at(fnametmp); + recv_ok = -1; + } else if (!finish_transfer(partialptr, fnametmp, fnamecmp, NULL, + file, recv_ok, !partial_dir)) +@@ -946,7 +946,7 @@ int recv_files(int f_in, int f_out, char *local_name) + } else + partialptr = NULL; + } else if (!one_inplace) +- do_unlink(fnametmp); ++ do_unlink_at(fnametmp); + + cleanup_disable(); + +diff --git a/rsync.c b/rsync.c +index cc46a2f9..1d2ae82a 100644 +--- a/rsync.c ++++ b/rsync.c +@@ -547,7 +547,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, + if (am_root >= 0) { + uid_t uid = change_uid ? (uid_t)F_OWNER(file) : sxp->st.st_uid; + gid_t gid = change_gid ? (gid_t)F_GROUP(file) : sxp->st.st_gid; +- if (do_lchown(fname, uid, gid) != 0) { ++ if (do_lchown_at(fname, uid, gid) != 0) { + /* We shouldn't have attempted to change uid + * or gid unless have the privilege. */ + rsyserr(FERROR_XFER, errno, "%s %s failed", +@@ -758,7 +758,7 @@ int finish_transfer(const char *fname, const char *fnametmp, + full_fname(fnametmp), fname); + if (!partialptr || (ret == -2 && temp_copy_name) + || robust_rename(fnametmp, partialptr, NULL, file->mode) < 0) +- do_unlink(fnametmp); ++ do_unlink_at(fnametmp); + return 0; + } + if (ret == 0) { +@@ -774,7 +774,7 @@ int finish_transfer(const char *fname, const char *fnametmp, + ok_to_set_time ? ATTRS_ACCURATE_TIME : ATTRS_SKIP_MTIME | ATTRS_SKIP_ATIME | ATTRS_SKIP_CRTIME); + + if (temp_copy_name) { +- if (do_rename(fnametmp, fname) < 0) { ++ if (do_rename_at(fnametmp, fname) < 0) { + rsyserr(FERROR_XFER, errno, "rename %s -> \"%s\"", + full_fname(fnametmp), fname); + return 0; +diff --git a/syscall.c b/syscall.c +index 167aae0e..2cff0b38 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -93,6 +93,63 @@ int do_unlink(const char *path) + return unlink(path); + } + ++/* ++ Symlink-race-safe variant of do_unlink() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. unlink() resolves ++ parent components, so a parent-symlink swap can delete an outside ++ file under the daemon's authority. Defence: open the parent of path ++ under secure_relative_open() and use unlinkat() (flags=0) against ++ that dirfd. ++ ++ Falls through to do_unlink() for the same dry-run / non-daemon / ++ chrooted / no-parent / absolute-path cases as the other wrappers. ++*/ ++int do_unlink_at(const char *path) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return unlink(path); ++ ++ if (!path || !*path || *path == '/') ++ return unlink(path); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return unlink(path); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = unlinkat(dfd, bname, 0); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_unlink(path); ++#endif ++} ++ + #ifdef SUPPORT_LINKS + int do_symlink(const char *lnk, const char *path) + { +@@ -117,6 +174,70 @@ int do_symlink(const char *lnk, const char *path) + return symlink(lnk, path); + } + ++/* ++ Symlink-race-safe variant of do_symlink() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. Only the parent ++ directory of `path` needs protection -- symlinkat() does not resolve ++ the final component (it creates it). Defence: open parent of `path` ++ under secure_relative_open() and call symlinkat() against that ++ dirfd. The link target string `lnk` is stored verbatim and not ++ resolved at creation time, so it doesn't need scrutiny here. ++ ++ Falls through to do_symlink() for the --fake-super (am_root < 0) ++ path -- that code path opens `path` with do_open() which has its ++ own (separate) symlink-race exposure tracked elsewhere. ++*/ ++int do_symlink_at(const char *lnk, const char *path) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_symlink(lnk, path); ++ ++#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS ++ if (am_root < 0) ++ return do_symlink(lnk, path); ++#endif ++ ++ if (!path || !*path || *path == '/') ++ return do_symlink(lnk, path); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return do_symlink(lnk, path); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = symlinkat(lnk, dfd, bname); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_symlink(lnk, path); ++#endif ++} ++ + #if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS + ssize_t do_readlink(const char *path, char *buf, size_t bufsiz) + { +@@ -153,6 +274,106 @@ int do_link(const char *old_path, const char *new_path) + return link(old_path, new_path); + #endif + } ++ ++/* ++ Symlink-race-safe variant of do_link() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. link() resolves ++ parent components of *both* old_path and new_path, so a parent- ++ symlink swap on either side can plant the new hard link outside ++ the module, or hard-link an outside file into the module (read ++ disclosure). ++ ++ Defence: open each parent under secure_relative_open() and use ++ linkat() between the two dirfds, reusing one when the parents ++ match. flags=0 matches the existing do_link() (don't follow a ++ symbolic-link old_path). Only available on systems with linkat(); ++ pre-AT_FDCWD systems fall through to do_link(). ++*/ ++int do_link_at(const char *old_path, const char *new_path) ++{ ++#if defined AT_FDCWD && defined HAVE_LINKAT ++ extern int am_daemon, am_chrooted; ++ char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; ++ const char *old_bname, *new_bname; ++ const char *old_slash, *new_slash; ++ int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; ++ BOOL old_owns = False, new_owns = False; ++ int ret, e; ++ size_t old_dlen = 0, new_dlen = 0; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_link(old_path, new_path); ++ ++ if (!old_path || !*old_path || *old_path == '/' ++ || !new_path || !*new_path || *new_path == '/') ++ return do_link(old_path, new_path); ++ ++ old_slash = strrchr(old_path, '/'); ++ new_slash = strrchr(new_path, '/'); ++ ++ /* Resolve each path's parent dir independently. A path without a ++ * slash lives in CWD (AT_FDCWD), no parent open required. A path ++ * with a slash needs secure_relative_open to confine its parent ++ * resolution -- otherwise a parent symlink (e.g. --link-dest=cd ++ * where cd -> /outside) lets the kernel-level linkat(AT_FDCWD, ++ * "cd/target.txt", ...) escape the module. */ ++ if (old_slash) { ++ old_dlen = old_slash - old_path; ++ if (old_dlen >= sizeof old_dirpath) { errno = ENAMETOOLONG; return -1; } ++ memcpy(old_dirpath, old_path, old_dlen); ++ old_dirpath[old_dlen] = '\0'; ++ old_bname = old_slash + 1; ++ old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (old_dfd < 0) ++ return -1; ++ old_owns = True; ++ } else { ++ old_bname = old_path; ++ } ++ ++ if (new_slash) { ++ new_dlen = new_slash - new_path; ++ if (new_dlen >= sizeof new_dirpath) { ++ e = ENAMETOOLONG; ++ if (old_owns) close(old_dfd); ++ errno = e; ++ return -1; ++ } ++ memcpy(new_dirpath, new_path, new_dlen); ++ new_dirpath[new_dlen] = '\0'; ++ new_bname = new_slash + 1; ++ if (old_owns && old_dlen == new_dlen ++ && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { ++ new_dfd = old_dfd; ++ } else { ++ new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (new_dfd < 0) { ++ e = errno; ++ if (old_owns) close(old_dfd); ++ errno = e; ++ return -1; ++ } ++ new_owns = True; ++ } ++ } else { ++ new_bname = new_path; ++ } ++ ++ ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); ++ e = errno; ++ if (new_owns) ++ close(new_dfd); ++ if (old_owns) ++ close(old_dfd); ++ errno = e; ++ return ret; ++#else ++ return do_link(old_path, new_path); ++#endif ++} + #endif + + int do_lchown(const char *path, uid_t owner, gid_t group) +@@ -165,6 +386,66 @@ int do_lchown(const char *path, uid_t owner, gid_t group) + return lchown(path, owner, group); + } + ++/* ++ Symlink-race-safe variant of do_lchown() for receiver-side use. See the ++ comment on do_chmod_at() for the threat model and design rationale. ++ ++ Resolves the parent directory under secure_relative_open() and invokes ++ fchownat(..., AT_SYMLINK_NOFOLLOW) against that dirfd, so that an ++ attacker who substitutes a symlink into one of the parent components ++ cannot redirect the chown outside the receiver's confinement. The ++ AT_SYMLINK_NOFOLLOW flag matches lchown()'s "do not follow a final- ++ component symlink" semantics. ++ ++ Falls through to do_lchown() in the dry-run / non-daemon / chrooted / ++ absolute-path / no-parent cases, identical to do_chmod_at(). ++*/ ++int do_lchown_at(const char *fname, uid_t owner, gid_t group) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_lchown(fname, owner, group); ++ ++ if (!fname || !*fname || *fname == '/') ++ return do_lchown(fname, owner, group); ++ ++ slash = strrchr(fname, '/'); ++ if (!slash) ++ return do_lchown(fname, owner, group); ++ ++ dlen = slash - fname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, fname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = fchownat(dfd, bname, owner, group, AT_SYMLINK_NOFOLLOW); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_lchown(fname, owner, group); ++#endif ++} ++ + int do_mknod(const char *pathname, mode_t mode, dev_t dev) + { + if (dry_run) return 0; +@@ -215,6 +496,76 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) + #endif + } + ++/* ++ Symlink-race-safe variant of do_mknod() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. Defence: open ++ the parent of pathname under secure_relative_open() and use ++ mknodat() against that dirfd. mknodat() covers both regular-file ++ (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. ++ ++ Falls through to do_mknod() for fake-super (am_root < 0) and for ++ sockets, both of which use auxiliary path-based syscalls that ++ don't have an *at() variant in any portable form. ++*/ ++int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_mknod(pathname, mode, dev); ++ ++ if (am_root < 0) ++ return do_mknod(pathname, mode, dev); ++ ++#if !defined MKNOD_CREATES_SOCKETS && defined HAVE_SYS_UN_H ++ if (S_ISSOCK(mode)) ++ return do_mknod(pathname, mode, dev); ++#endif ++ ++ if (!pathname || !*pathname || *pathname == '/') ++ return do_mknod(pathname, mode, dev); ++ ++ slash = strrchr(pathname, '/'); ++ if (!slash) ++ return do_mknod(pathname, mode, dev); ++ ++ dlen = slash - pathname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, pathname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++#if !defined MKNOD_CREATES_FIFOS && defined HAVE_MKFIFO ++ if (S_ISFIFO(mode)) ++ ret = mkfifoat(dfd, bname, mode); ++ else ++#endif ++ ret = mknodat(dfd, bname, mode, dev); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_mknod(pathname, mode, dev); ++#endif ++} ++ + int do_rmdir(const char *pathname) + { + if (dry_run) return 0; +@@ -222,6 +573,57 @@ int do_rmdir(const char *pathname) + return rmdir(pathname); + } + ++/* ++ Symlink-race-safe variant of do_rmdir(). See do_unlink_at() above; ++ same shape but with AT_REMOVEDIR set to require the target be a ++ directory. ++*/ ++int do_rmdir_at(const char *pathname) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return rmdir(pathname); ++ ++ if (!pathname || !*pathname || *pathname == '/') ++ return rmdir(pathname); ++ ++ slash = strrchr(pathname, '/'); ++ if (!slash) ++ return rmdir(pathname); ++ ++ dlen = slash - pathname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, pathname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = unlinkat(dfd, bname, AT_REMOVEDIR); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_rmdir(pathname); ++#endif ++} ++ + int do_open(const char *pathname, int flags, mode_t mode) + { + if (flags != O_RDONLY) { +@@ -370,6 +772,89 @@ int do_rename(const char *old_path, const char *new_path) + return rename(old_path, new_path); + } + ++/* ++ Symlink-race-safe variant of do_rename() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model and design rationale. ++ ++ rename() is the central tmp -> final operation in rsync; if either the ++ source or the destination has an attacker-substituted symlink in one ++ of its parent components, the rename can publish or vanish files ++ outside the module. Defence: open the parent of *each* path under ++ secure_relative_open() and use renameat() against the resulting ++ dirfds. When old_path and new_path share the same parent (the common ++ case -- tmp file living next to its final name), we reuse the same ++ dirfd for both sides. ++ ++ Falls through to do_rename() in dry-run, non-daemon, chrooted, no- ++ parent and absolute-path cases, identical to the other do_*_at() ++ wrappers. ++*/ ++int do_rename_at(const char *old_path, const char *new_path) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; ++ const char *old_bname, *new_bname; ++ const char *old_slash, *new_slash; ++ int old_dfd = -1, new_dfd = -1, ret = -1, e; ++ size_t old_dlen, new_dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_rename(old_path, new_path); ++ ++ if (!old_path || !*old_path || *old_path == '/' ++ || !new_path || !*new_path || *new_path == '/') ++ return do_rename(old_path, new_path); ++ ++ old_slash = strrchr(old_path, '/'); ++ new_slash = strrchr(new_path, '/'); ++ if (!old_slash || !new_slash) ++ return do_rename(old_path, new_path); ++ ++ old_dlen = old_slash - old_path; ++ new_dlen = new_slash - new_path; ++ if (old_dlen >= sizeof old_dirpath || new_dlen >= sizeof new_dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(old_dirpath, old_path, old_dlen); ++ old_dirpath[old_dlen] = '\0'; ++ memcpy(new_dirpath, new_path, new_dlen); ++ new_dirpath[new_dlen] = '\0'; ++ old_bname = old_slash + 1; ++ new_bname = new_slash + 1; ++ ++ old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (old_dfd < 0) ++ return -1; ++ ++ if (old_dlen == new_dlen && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { ++ new_dfd = old_dfd; ++ } else { ++ new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (new_dfd < 0) { ++ e = errno; ++ close(old_dfd); ++ errno = e; ++ return -1; ++ } ++ } ++ ++ ret = renameat(old_dfd, old_bname, new_dfd, new_bname); ++ e = errno; ++ if (new_dfd != old_dfd) ++ close(new_dfd); ++ close(old_dfd); ++ errno = e; ++ return ret; ++#else ++ return do_rename(old_path, new_path); ++#endif ++} ++ + #ifdef HAVE_FTRUNCATE + int do_ftruncate(int fd, OFF_T size) + { +@@ -412,6 +897,66 @@ int do_mkdir(char *path, mode_t mode) + return mkdir(path, mode); + } + ++/* ++ Symlink-race-safe variant of do_mkdir() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model and design rationale. ++ ++ mkdir() resolves parent symlinks at every component, so a parent- ++ component swap can place an attacker-named directory outside the ++ module. Defence: open the parent of fname under secure_relative_open() ++ and call mkdirat() against that dirfd. ++ ++ Mutates path in place to trim trailing slashes (matches do_mkdir()). ++ Falls through to do_mkdir() in dry-run, non-daemon, chrooted, no- ++ parent and absolute-path cases. ++*/ ++int do_mkdir_at(char *path, mode_t mode) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ trim_trailing_slashes(path); ++ ++ if (!am_daemon || am_chrooted) ++ return mkdir(path, mode); ++ ++ if (!path || !*path || *path == '/') ++ return mkdir(path, mode); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return mkdir(path, mode); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = mkdirat(dfd, bname, mode); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_mkdir(path, mode); ++#endif ++} ++ + /* like mkstemp but forces permissions */ + int do_mkstemp(char *template, mode_t perms) + { +@@ -465,6 +1010,76 @@ int do_lstat(const char *path, STRUCT_STAT *st) + #endif + } + ++/* ++ Symlink-race-safe variants of do_stat() / do_lstat() for receiver- ++ side use. See the comment on do_chmod_at() for the threat model. ++ stat() and lstat() resolve parent components, so a parent-symlink ++ swap can make the receiver's stat see attributes of a victim file ++ outside the module -- which then drives later behaviour (e.g. ++ "this isn't a directory, delete it" -> attacker-controlled unlink ++ on something outside the module). ++ ++ Defence: open the parent under secure_relative_open() and use ++ fstatat() with AT_SYMLINK_NOFOLLOW (lstat) or 0 (stat) against ++ that dirfd. Same fall-through gating as the other wrappers. ++*/ ++static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *)) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (!am_daemon || am_chrooted) ++ return fallback(path, st); ++ ++ if (!path || !*path || *path == '/') ++ return fallback(path, st); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return fallback(path, st); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = fstatat(dfd, bname, st, at_flags); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return fallback(path, st); ++#endif ++} ++ ++int do_stat_at(const char *path, STRUCT_STAT *st) ++{ ++ return do_xstat_at(path, st, 0, do_stat); ++} ++ ++int do_lstat_at(const char *path, STRUCT_STAT *st) ++{ ++#ifdef SUPPORT_LINKS ++ return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, do_lstat); ++#else ++ return do_xstat_at(path, st, 0, do_stat); ++#endif ++} ++ + int do_fstat(int fd, STRUCT_STAT *st) + { + #ifdef USE_STAT64_FUNCS +@@ -486,12 +1101,26 @@ OFF_T do_lseek(int fd, OFF_T offset, int whence) + #ifdef HAVE_SETATTRLIST + int do_setattrlist_times(const char *path, STRUCT_STAT *stp) + { ++ extern int am_daemon, am_chrooted; + struct attrlist attrList; + struct timespec ts[2]; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + ++ /* setattrlist() takes a raw path and follows parent symlinks ++ * (FSOPT_NOFOLLOW only blocks the final component). On a ++ * daemon-no-chroot deployment, return ENOSYS so set_times()' ++ * tier walk falls through to do_utimensat_at(), which routes ++ * the timestamp update through a secure parent dirfd. The ++ * macOS-specific attribute set this function would have used ++ * (ATTR_CMN_MODTIME / ATTR_CMN_ACCTIME) is the same set ++ * utimensat() handles, so no functionality is lost. */ ++ if (am_daemon && !am_chrooted) { ++ errno = ENOSYS; ++ return -1; ++ } ++ + /* Yes, this is in the opposite order of utime and similar. */ + ts[0].tv_sec = stp->st_mtime; + ts[0].tv_nsec = stp->ST_MTIME_NSEC; +@@ -508,12 +1137,25 @@ int do_setattrlist_times(const char *path, STRUCT_STAT *stp) + #ifdef SUPPORT_CRTIMES + int do_setattrlist_crtime(const char *path, time_t crtime) + { ++ extern int am_daemon, am_chrooted; + struct attrlist attrList; + struct timespec ts; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + ++ /* Same path-follows-parent-symlinks concern as ++ * do_setattrlist_times. There is no portable at-aware variant ++ * of setattrlist that targets ATTR_CMN_CRTIME, so on a ++ * daemon-no-chroot deployment we return -1 and accept that ++ * crtime preservation is silently dropped for that file (the ++ * caller treats this as "crtime not updated"). The transfer ++ * itself continues normally. */ ++ if (am_daemon && !am_chrooted) { ++ errno = ENOSYS; ++ return -1; ++ } ++ + ts.tv_sec = crtime; + ts.tv_nsec = 0; + +@@ -529,10 +1171,19 @@ int do_setattrlist_crtime(const char *path, time_t crtime) + time_t get_create_time(const char *path, STRUCT_STAT *stp) + { + #ifdef HAVE_GETATTRLIST ++ extern int am_daemon, am_chrooted; + static struct create_time attrBuf; + struct attrlist attrList; + + (void)stp; ++ /* getattrlist() is also path-based and follows parent ++ * symlinks. In daemon-no-chroot, refuse rather than read the ++ * crtime of a file the parent-symlink chain might point at ++ * outside the module. The caller's "no crtime available" ++ * path returns 0; the file gets a fresh crtime instead of ++ * preserving the source's. */ ++ if (am_daemon && !am_chrooted) ++ return 0; + memset(&attrList, 0, sizeof attrList); + attrList.bitmapcount = ATTR_BIT_MAP_COUNT; + attrList.commonattr = ATTR_CMN_CRTIME; +@@ -598,6 +1249,81 @@ int do_utimensat(const char *path, STRUCT_STAT *stp) + #endif + return utimensat(AT_FDCWD, path, t, AT_SYMLINK_NOFOLLOW); + } ++ ++/* ++ Symlink-race-safe variant of do_utimensat() for receiver-side use. ++ See the comment on do_chmod_at() for the threat model. utimes() ++ resolves parent components and follows a final-component symlink; ++ lutimes() doesn't follow the final component but still resolves ++ parents. Either way, a parent-symlink swap can redirect the ++ timestamp update outside the module. Defence: open the parent of ++ path under secure_relative_open() and call utimensat() with ++ AT_SYMLINK_NOFOLLOW against that dirfd. ++ ++ Falls through to do_utimensat() in the same dry-run / non-daemon / ++ chrooted / no-parent / absolute-path cases as the other wrappers. ++ Returns -1 with errno=ENOSYS on systems without utimensat() ++ (caller is expected to fall back to the legacy tier walk). ++*/ ++int do_utimensat_at(const char *path, STRUCT_STAT *stp) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ struct timespec t[2]; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_utimensat(path, stp); ++ ++ if (!path || !*path || *path == '/') ++ return do_utimensat(path, stp); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return do_utimensat(path, stp); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ t[0].tv_sec = stp->st_atime; ++#ifdef ST_ATIME_NSEC ++ t[0].tv_nsec = stp->ST_ATIME_NSEC; ++#else ++ t[0].tv_nsec = 0; ++#endif ++ t[1].tv_sec = stp->st_mtime; ++#ifdef ST_MTIME_NSEC ++ t[1].tv_nsec = stp->ST_MTIME_NSEC; ++#else ++ t[1].tv_nsec = 0; ++#endif ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = utimensat(dfd, bname, t, AT_SYMLINK_NOFOLLOW); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_utimensat(path, stp); ++#endif ++} + #endif + + #ifdef HAVE_LUTIMES +@@ -820,6 +1546,30 @@ int do_open_nofollow(const char *pathname, int flags) + The relpath must also not contain any ../ elements in the path. + */ + ++/* Returns 1 if path has any "/"-separated component that is exactly ++ * "..", 0 otherwise. Used by secure_relative_open's front-door ++ * validation to reject inputs that the per-component walk fallback ++ * would otherwise resolve through ".." -- e.g. bare "..", "foo/..", ++ * "subdir/.." -- which RESOLVE_BENEATH-equivalent kernels reject in ++ * the kernel but the per-component fallback (NetBSD/OpenBSD/Solaris/ ++ * Cygwin/pre-5.6 Linux) does not. */ ++static int path_has_dotdot_component(const char *path) ++{ ++ const char *p = path; ++ ++ while (*p) { ++ const char *q; ++ if (*p == '/') { p++; continue; } ++ q = p; ++ while (*q && *q != '/') ++ q++; ++ if (q - p == 2 && p[0] == '.' && p[1] == '.') ++ return 1; ++ p = q; ++ } ++ return 0; ++} ++ + #ifdef __linux__ + static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) + { +@@ -833,10 +1583,25 @@ static int secure_relative_open_linux(const char *basedir, const char *relpath, + + if (basedir == NULL) { + dirfd = AT_FDCWD; +- } else { ++ } else if (basedir[0] == '/') { ++ /* Absolute basedir: operator-trusted (module_dir and the ++ * like). Plain openat. */ + dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); + if (dirfd == -1) + return -1; ++ } else { ++ /* Relative basedir: may be wire-influenced via ++ * --link-dest / --copy-dest / --compare-dest. Resolve it ++ * under the same RESOLVE_BENEATH guarantee as relpath, so ++ * a parent symlink on basedir cannot redirect the dirfd ++ * outside the CWD anchor. */ ++ struct open_how bhow; ++ memset(&bhow, 0, sizeof bhow); ++ bhow.flags = O_RDONLY | O_DIRECTORY; ++ bhow.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; ++ dirfd = syscall(SYS_openat2, AT_FDCWD, basedir, &bhow, sizeof bhow); ++ if (dirfd == -1) ++ return -1; + } + + retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how); +@@ -859,10 +1624,17 @@ static int secure_relative_open_resolve_beneath(const char *basedir, const char + + if (basedir == NULL) { + dirfd = AT_FDCWD; +- } else { ++ } else if (basedir[0] == '/') { ++ /* Absolute basedir: operator-trusted, plain openat. */ + dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); + if (dirfd == -1) + return -1; ++ } else { ++ /* Relative basedir: confine its resolution beneath CWD ++ * (see secure_relative_open_linux for the rationale). */ ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY | O_RESOLVE_BENEATH); ++ if (dirfd == -1) ++ return -1; + } + + retfd = openat(dirfd, relpath, flags | O_RESOLVE_BENEATH, mode); +@@ -880,8 +1652,20 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + errno = EINVAL; + return -1; + } +- if (strncmp(relpath, "../", 3) == 0 || strstr(relpath, "/../")) { +- // no ../ elements allowed in the relpath ++ /* Reject any path with a literal ".." component (bare "..", ++ * "../foo", "foo/..", "foo/../bar", "subdir/.."). The previous ++ * substring-based check caught only "../" prefix and "/../" ++ * substring; bare ".." and trailing "/.." escape on the per- ++ * component walk fallback used by NetBSD/OpenBSD/Solaris/Cygwin ++ * and pre-5.6 Linux. RESOLVE_BENEATH on Linux/FreeBSD/macOS ++ * catches some of these in-kernel with EXDEV, but the front ++ * door must reject them consistently with EINVAL across all ++ * platforms so callers can rely on the validation. */ ++ if (path_has_dotdot_component(relpath)) { ++ errno = EINVAL; ++ return -1; ++ } ++ if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { + errno = EINVAL; + return -1; + } +@@ -911,15 +1695,47 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + #else + int dirfd = AT_FDCWD; + if (basedir != NULL) { +- dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); +- if (dirfd == -1) { +- return -1; ++ if (basedir[0] == '/') { ++ /* Absolute basedir: operator-trusted, plain openat. */ ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) { ++ return -1; ++ } ++ } else { ++ /* Relative basedir: walk it component-by-component ++ * with O_NOFOLLOW. This is the per-component ++ * RESOLVE_BENEATH equivalent for platforms without ++ * kernel-supported confinement, and matches the ++ * relpath walk below. Symlinks in basedir are ++ * rejected outright on this fallback path; the ++ * Linux openat2 / O_RESOLVE_BENEATH paths above ++ * still allow within-tree symlinks. */ ++ char *bcopy = my_strdup(basedir, __FILE__, __LINE__); ++ if (!bcopy) ++ return -1; ++ for (const char *part = strtok(bcopy, "/"); ++ part != NULL; ++ part = strtok(NULL, "/")) ++ { ++ int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); ++ if (next_fd == -1) { ++ int save_errno = errno; ++ if (dirfd != AT_FDCWD) close(dirfd); ++ free(bcopy); ++ errno = save_errno; ++ return -1; ++ } ++ if (dirfd != AT_FDCWD) close(dirfd); ++ dirfd = next_fd; ++ } ++ free(bcopy); + } + } + int retfd = -1; + + char *path_copy = my_strdup(relpath, __FILE__, __LINE__); + if (!path_copy) { ++ if (dirfd != AT_FDCWD) close(dirfd); + return -1; + } + +@@ -945,8 +1761,15 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + dirfd = next_fd; + } + +- // the path must be a directory +- errno = EINVAL; ++ /* All components walked as directories. If the caller asked for ++ * O_DIRECTORY, return the dirfd we built up; otherwise the path ++ * resolved to a directory but the caller wanted a regular file. */ ++ if ((flags & O_DIRECTORY) && dirfd != AT_FDCWD) { ++ retfd = dirfd; ++ dirfd = AT_FDCWD; ++ goto cleanup; ++ } ++ errno = EISDIR; + + cleanup: + free(path_copy); +diff --git a/t_secure_relpath.c b/t_secure_relpath.c +new file mode 100644 +index 00000000..a0fdf0d2 +--- /dev/null ++++ b/t_secure_relpath.c +@@ -0,0 +1,151 @@ ++/* ++ * Test harness for secure_relative_open()'s front-door input ++ * validation. Codex audit Finding 5 noted that the existing check ++ * ++ * if (strncmp(relpath, "../", 3) == 0 || strstr(relpath, "/../")) ++ * ++ * catches "../foo" and "foo/../bar" but misses bare ".." (an actual ++ * one-level escape on platforms that fall back to the per-component ++ * walk), as well as "a/..", "foo/..", and any other form that ++ * decomposes to a ".." component when split on "/". The kernel- ++ * enforced RESOLVE_BENEATH (Linux 5.6+) and O_RESOLVE_BENEATH ++ * (FreeBSD 13+, macOS 15+) reject these in-kernel; the per- ++ * component fallback used on NetBSD, OpenBSD, Solaris, Cygwin and ++ * pre-5.6 Linux does not, so the validation must happen at the ++ * front door. ++ * ++ * This helper invokes secure_relative_open() with each suspect ++ * input and checks both the failure (rc < 0) and the errno ++ * (EINVAL means "rejected at the front door"). Pre-fix, the kernel ++ * may reject with a different errno (EXDEV from RESOLVE_BENEATH); ++ * post-fix, the front-door check catches every variant up front ++ * with a consistent EINVAL across platforms. ++ * ++ * Not linked into rsync itself. ++ */ ++ ++#include "rsync.h" ++ ++#include ++ ++int dry_run = 0; ++int am_root = 0; ++int am_sender = 0; ++int read_only = 0; ++int list_only = 0; ++int copy_links = 0; ++int copy_unsafe_links = 0; ++extern int am_daemon, am_chrooted; ++ ++short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; ++ ++static int errs = 0; ++ ++static void check_relpath(const char *relpath) ++{ ++ int fd; ++ int saved_errno; ++ ++ errno = 0; ++ fd = secure_relative_open(NULL, relpath, O_RDONLY | O_DIRECTORY, 0); ++ saved_errno = errno; ++ ++ if (fd >= 0) { ++ fprintf(stderr, ++ "FAIL [relpath=%-12s]: returned valid fd %d (escape) -- expected -1 EINVAL\n", ++ relpath, fd); ++ close(fd); ++ errs++; ++ return; ++ } ++ ++ if (saved_errno != EINVAL) { ++ fprintf(stderr, ++ "FAIL [relpath=%-12s]: rejected but errno=%d (%s), expected EINVAL\n", ++ relpath, saved_errno, strerror(saved_errno)); ++ errs++; ++ return; ++ } ++ ++ fprintf(stderr, "OK [relpath=%-12s]: rejected with EINVAL\n", relpath); ++} ++ ++static void check_basedir(const char *basedir) ++{ ++ int fd; ++ int saved_errno; ++ ++ errno = 0; ++ fd = secure_relative_open(basedir, "ok", O_RDONLY | O_DIRECTORY, 0); ++ saved_errno = errno; ++ ++ if (fd >= 0) { ++ fprintf(stderr, ++ "FAIL [basedir=%-12s]: returned valid fd %d -- expected -1 EINVAL\n", ++ basedir, fd); ++ close(fd); ++ errs++; ++ return; ++ } ++ ++ if (saved_errno != EINVAL) { ++ fprintf(stderr, ++ "FAIL [basedir=%-12s]: rejected but errno=%d (%s), expected EINVAL\n", ++ basedir, saved_errno, strerror(saved_errno)); ++ errs++; ++ return; ++ } ++ ++ fprintf(stderr, "OK [basedir=%-12s]: rejected with EINVAL\n", basedir); ++} ++ ++int main(int argc, char **argv) ++{ ++ if (argc != 2) { ++ fprintf(stderr, "usage: %s \n", argv[0]); ++ return 2; ++ } ++ if (chdir(argv[1]) < 0) { ++ perror("chdir"); ++ return 2; ++ } ++ ++ /* secure_relative_open's daemon-only confinement protections only ++ * fire when am_daemon && !am_chrooted (the threat model is the ++ * daemon-no-chroot deployment), but the front-door input ++ * validation runs unconditionally. We set am_daemon anyway so the ++ * helper exercises the same code shape the receiver does. */ ++ am_daemon = 1; ++ am_chrooted = 0; ++ ++ mkdir("subdir", 0755); ++ ++ /* Each of these relpaths must be rejected with EINVAL at the ++ * secure_relative_open() front door. ".." is the actual one-level ++ * escape; the others ("subdir/..", "subdir/../subdir") resolve ++ * back to the start dir on systems that allow them, but we still ++ * reject them as defence-in-depth: a path containing a ".." token ++ * is suspicious and the caller should normalise before passing ++ * it in. The "../foo" / "foo/../bar" / "/foo" / "/" cases are ++ * regression checks for the existing checks. */ ++ check_relpath(".."); ++ check_relpath("../foo"); ++ check_relpath("subdir/.."); ++ check_relpath("subdir/../subdir"); ++ check_relpath("foo/../bar"); ++ check_relpath("/foo"); ++ check_relpath("/"); ++ ++ /* Same checks against basedir (which the codex Finding 2 fix ++ * routes through the same RESOLVE_BENEATH-equivalent). Absolute ++ * basedirs are operator-trusted and intentionally not validated ++ * here. */ ++ check_basedir(".."); ++ check_basedir("../subdir"); ++ check_basedir("subdir/.."); ++ check_basedir("foo/../bar"); ++ ++ if (errs) ++ fprintf(stderr, "\n%d failure(s)\n", errs); ++ return errs ? 1 : 0; ++} +diff --git a/testsuite/alt-dest-symlink-race.test b/testsuite/alt-dest-symlink-race.test +new file mode 100755 +index 00000000..2256f2f2 +--- /dev/null ++++ b/testsuite/alt-dest-symlink-race.test +@@ -0,0 +1,96 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for the basedir-confinement gap in ++# secure_relative_open(). The function opens basedir with a plain ++# openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY), without ++# RESOLVE_BENEATH or a per-component O_NOFOLLOW walk, so a parent ++# symlink ON basedir is followed unrestrictedly. RESOLVE_BENEATH is ++# then applied only to relpath, anchored at the wrong directory. ++# ++# The receiver's basis-file lookup at receiver.c passes ++# basis_dir[fnamecmp_type] (from --copy-dest / --link-dest / ++# --compare-dest -- all sender-controllable in daemon mode) as ++# basedir. A daemon-module attacker with write access can plant a ++# symlink at module/cd -> /outside, then run --link-dest=cd to ++# make the daemon's basis-file lookup resolve into /outside, ++# leaking the contents of daemon-readable files via the rsync ++# delta-rolling read-disclosure primitive. ++# ++# We detect the escape by leveraging --link-dest: when basis ++# matches source exactly (content + mtime + mode), --link-dest ++# hard-links the destination to the basis file. With the bug, the ++# destination ends up as a hard link to the outside-the-module ++# file (same inode). With the fix, no basis is found and the ++# destination is a fresh copy (different inode). ++# ++# The vulnerable code path is the same on every platform ++# (including the per-component fallback on systems without ++# RESOLVE_BENEATH), so this test is not platform-gated. ++ ++. "$suitedir/rsync.fns" ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++src="$scratchdir/src" ++conf="$scratchdir/test-rsyncd.conf" ++ ++rm -rf "$mod" "$outside" "$src" ++mkdir -p "$mod" "$outside" "$src" ++ ++# Portable inode-number helper (GNU coreutils stat -c, BSD stat -f). ++file_inode() { ++ stat -c %i "$1" 2>/dev/null || stat -f %i "$1" ++} ++ ++# Outside-the-module file an attacker would like the daemon to ++# treat as a basis. ++echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt" ++chmod 0644 "$outside/target.txt" ++ ++# The symlink trap planted in the module by the local attacker. ++ln -s "$outside" "$mod/cd" ++ ++# Source file matches outside/target.txt exactly (content + mtime ++# + mode) so --link-dest will hard-link the destination to the ++# basis file iff the daemon's basedir lookup reaches outside/. ++echo "OUTSIDE_SECRET_DATA" > "$src/target.txt" ++touch -r "$outside/target.txt" "$src/target.txt" ++chmod 0644 "$src/target.txt" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++if [ ! -f "$mod/target.txt" ]; then ++ test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour" ++fi ++ ++outside_inode=$(file_inode "$outside/target.txt") ++dst_inode=$(file_inode "$mod/target.txt") ++ ++if [ "$outside_inode" = "$dst_inode" ]; then ++ test_fail "basedir-escape: --link-dest hard-linked module/target.txt to outside/target.txt (inode $outside_inode); daemon's basis-file lookup followed the parent symlink on the basedir" ++fi ++ ++exit 0 +diff --git a/testsuite/secure-relpath-validation.test b/testsuite/secure-relpath-validation.test +new file mode 100755 +index 00000000..5b77f7cc +--- /dev/null ++++ b/testsuite/secure-relpath-validation.test +@@ -0,0 +1,34 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex audit Finding 5: secure_relative_open()'s ++# front-door input check rejects "../foo" and "foo/../bar" but ++# misses bare "..", "subdir/..", and other variants whose "/"-split ++# components contain a literal "..". The kernel-enforced ++# RESOLVE_BENEATH (Linux 5.6+) and O_RESOLVE_BENEATH ++# (FreeBSD 13+, macOS 15+) reject these in-kernel; the per-component ++# walk fallback used on NetBSD, OpenBSD, Solaris, Cygwin and pre-5.6 ++# Linux does not -- so the validation must happen at the front door. ++# ++# This test invokes the t_secure_relpath helper, which calls ++# secure_relative_open() with each suspect input and verifies the ++# return value is -1 with errno == EINVAL. EINVAL is the marker ++# that the front-door rejected the input, not the kernel; pre-fix ++# the kernel returns -1 with EXDEV (or, on the per-component ++# fallback, may return a valid fd at all -- "escape"). ++ ++. "$suitedir/rsync.fns" ++ ++testdir="$scratchdir/relpath-test" ++rm -rf "$testdir" ++mkdir -p "$testdir" ++ ++if ! "$TOOLDIR/t_secure_relpath" "$testdir"; then ++ test_fail "t_secure_relpath rejected one or more inputs incorrectly (see stderr above for the specific case)" ++fi ++ ++exit 0 +diff --git a/util1.c b/util1.c +index 796604f6..f85f33e9 100644 +--- a/util1.c ++++ b/util1.c +@@ -141,7 +141,7 @@ int set_times(const char *fname, STRUCT_STAT *stp) + + #ifdef HAVE_UTIMENSAT + #include "case_N.h" +- if (do_utimensat(fname, stp) == 0) ++ if (do_utimensat_at(fname, stp) == 0) + break; + if (errno != ENOSYS) + return -1; +@@ -479,13 +479,13 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) + int robust_unlink(const char *fname) + { + #ifndef ETXTBSY +- return do_unlink(fname); ++ return do_unlink_at(fname); + #else + static int counter = 1; + int rc, pos, start; + char path[MAXPATHLEN]; + +- rc = do_unlink(fname); ++ rc = do_unlink_at(fname); + if (rc == 0 || errno != ETXTBSY) + return rc; + +@@ -515,7 +515,7 @@ int robust_unlink(const char *fname) + } + + /* maybe we should return rename()'s exit status? Nah. */ +- if (do_rename(fname, path) != 0) { ++ if (do_rename_at(fname, path) != 0) { + errno = ETXTBSY; + return -1; + } +@@ -538,7 +538,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, + return 0; + + while (tries--) { +- if (do_rename(from, to) == 0) ++ if (do_rename_at(from, to) == 0) + return 0; + + switch (errno) { +@@ -559,7 +559,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, + } + if (copy_file(from, to, -1, mode) != 0) + return -2; +- do_unlink(from); ++ do_unlink_at(from); + return 1; + default: + return -1; +@@ -1333,20 +1333,20 @@ int handle_partial_dir(const char *fname, int create) + dir = partial_fname; + if (create) { + STRUCT_STAT st; +- int statret = do_lstat(dir, &st); ++ int statret = do_lstat_at(dir, &st); + if (statret == 0 && !S_ISDIR(st.st_mode)) { +- if (do_unlink(dir) < 0) { ++ if (do_unlink_at(dir) < 0) { + *fn = '/'; + return 0; + } + statret = -1; + } +- if (statret < 0 && do_mkdir(dir, 0700) < 0) { ++ if (statret < 0 && do_mkdir_at(dir, 0700) < 0) { + *fn = '/'; + return 0; + } + } else +- do_rmdir(dir); ++ do_rmdir_at(dir); + *fn = '/'; + + return 1; +diff --git a/xattrs.c b/xattrs.c +index e5d0dd43..5f740bb5 100644 +--- a/xattrs.c ++++ b/xattrs.c +@@ -1249,7 +1249,12 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode) + + int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) + { +- int ret = do_stat(fname, fst); ++ /* Use the *_at variants so that on a daemon-no-chroot deployment ++ * the metadata read goes through a secure parent dirfd instead ++ * of bare path resolution. The *_at wrappers fall through to ++ * plain do_stat outside the daemon-no-chroot context, so this ++ * change is transparent for non-daemon use. */ ++ int ret = do_stat_at(fname, fst); + if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) + xst->st_mode = 0; + return ret; +@@ -1257,7 +1262,7 @@ int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) + + int x_lstat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) + { +- int ret = do_lstat(fname, fst); ++ int ret = do_lstat_at(fname, fst); + if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) + xst->st_mode = 0; + return ret; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0028-util1-syscall-secure-copy_file-source-dest-opens-bar.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0028-util1-syscall-secure-copy_file-source-dest-opens-bar.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0028-util1-syscall-secure-copy_file-source-dest-opens-bar.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0028-util1-syscall-secure-copy_file-source-dest-opens-bar.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,565 @@ +From 90495eecd0039cab6f59f203afec2f37c47bc165 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 6 May 2026 09:45:30 +1000 +Subject: [PATCH 28/38] util1+syscall: secure copy_file source/dest opens; + bare-path defence-in-depth + +Three related codex audit findings: + + Finding 3a: copy_file()'s source open in util1.c used + do_open_nofollow(), which only rejects a final-component + symlink. A parent-component symlink (e.g. --copy-dest=cd where + cd -> /outside) follows freely and reads outside the module. + Route through secure_relative_open() with O_NOFOLLOW. + + Finding 3b: generator.c's in-place backup-file create still + used a bare do_open with O_CREAT, leaving a tiny but reachable + parent-symlink window between the secure unlink (already + through do_unlink_at) and the create. Add do_open_at() that + goes through a secure parent dirfd, and route the call site + through it. + + Finding 3c: copy_file()'s destination open in + unlink_and_reopen() had the same bare-do_open pattern; route + through do_open_at as well. + +Adds testsuite/copy-dest-source-symlink.test and +testsuite/bare-do-open-symlink-race.test as regression coverage +for both attack shapes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + generator.c | 2 +- + syscall.c | 138 +++++++++++++++-- + testsuite/bare-do-open-symlink-race.test | 186 +++++++++++++++++++++++ + testsuite/copy-dest-source-symlink.test | 83 ++++++++++ + util1.c | 21 ++- + 5 files changed, 416 insertions(+), 14 deletions(-) + create mode 100755 testsuite/bare-do-open-symlink-race.test + create mode 100755 testsuite/copy-dest-source-symlink.test + +diff --git a/generator.c b/generator.c +index e5b2d176..311e9b78 100644 +--- a/generator.c ++++ b/generator.c +@@ -1896,7 +1896,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + back_file = NULL; + goto cleanup; + } +- if ((f_copy = do_open(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { ++ if ((f_copy = do_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { + rsyserr(FERROR_XFER, errno, "open %s", full_fname(backupptr)); + unmake_file(back_file); + back_file = NULL; +diff --git a/syscall.c b/syscall.c +index 2cff0b38..47777350 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -203,11 +203,6 @@ int do_symlink_at(const char *lnk, const char *path) + if (!am_daemon || am_chrooted) + return do_symlink(lnk, path); + +-#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS +- if (am_root < 0) +- return do_symlink(lnk, path); +-#endif +- + if (!path || !*path || *path == '/') + return do_symlink(lnk, path); + +@@ -228,6 +223,34 @@ int do_symlink_at(const char *lnk, const char *path) + if (dfd < 0) + return -1; + ++#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS ++ /* For --fake-super, do_symlink writes the link target into a ++ * regular file rather than creating a real symlink. Do that ++ * here against the secure dirfd, with O_NOFOLLOW so a pre- ++ * planted symlink at the basename can't redirect the file ++ * creation. (Previously the fake-super branch fell through to ++ * the bare-path do_symlink at the top of the function.) */ ++ if (am_root < 0) { ++ int len = strlen(lnk); ++ int fd = openat(dfd, bname, ++ O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, ++ S_IWUSR | S_IRUSR); ++ if (fd < 0) { ++ e = errno; ++ close(dfd); ++ errno = e; ++ return -1; ++ } ++ ret = (write(fd, lnk, len) == len) ? 0 : -1; ++ if (close(fd) < 0) ++ ret = -1; ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++ } ++#endif ++ + ret = symlinkat(lnk, dfd, bname); + e = errno; + close(dfd); +@@ -503,9 +526,12 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) + mknodat() against that dirfd. mknodat() covers both regular-file + (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. + +- Falls through to do_mknod() for fake-super (am_root < 0) and for +- sockets, both of which use auxiliary path-based syscalls that +- don't have an *at() variant in any portable form. ++ Fake-super (am_root < 0) is handled inline against the secure ++ parent dirfd: it creates a regular empty file (the same file-as- ++ metadata-placeholder pattern do_mknod uses) via openat() with ++ O_NOFOLLOW. Sockets fall through to do_mknod() because their ++ bind(2) takes a path argument with no portable bindat() variant; ++ this is documented as a residual. + */ + int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) + { +@@ -523,9 +549,6 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) + if (!am_daemon || am_chrooted) + return do_mknod(pathname, mode, dev); + +- if (am_root < 0) +- return do_mknod(pathname, mode, dev); +- + #if !defined MKNOD_CREATES_SOCKETS && defined HAVE_SYS_UN_H + if (S_ISSOCK(mode)) + return do_mknod(pathname, mode, dev); +@@ -551,6 +574,29 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) + if (dfd < 0) + return -1; + ++ if (am_root < 0) { ++ /* For --fake-super, do_mknod creates a regular empty ++ * file as a placeholder for the special-file metadata ++ * (which is stored in xattrs elsewhere). Do that against ++ * the secure dirfd, with O_NOFOLLOW so a pre-planted ++ * symlink at the basename can't redirect the file ++ * creation. */ ++ int fd = openat(dfd, bname, ++ O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, ++ S_IWUSR | S_IRUSR); ++ if (fd < 0) { ++ e = errno; ++ close(dfd); ++ errno = e; ++ return -1; ++ } ++ ret = (close(fd) < 0) ? -1 : 0; ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++ } ++ + #if !defined MKNOD_CREATES_FIFOS && defined HAVE_MKFIFO + if (S_ISFIFO(mode)) + ret = mkfifoat(dfd, bname, mode); +@@ -639,6 +685,76 @@ int do_open(const char *pathname, int flags, mode_t mode) + return open(pathname, flags | O_BINARY, mode); + } + ++/* ++ Symlink-race-safe variant of do_open() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. open() resolves ++ parent components, so a parent-symlink swap can redirect the open ++ to a file outside the module. This wrapper is defence-in-depth for ++ bare-path do_open() sites that callers know are otherwise ++ protected by secure parent-syscalls (e.g. generator.c's in-place ++ backup creation, where robust_unlink() rejects the symlinked ++ parent before this open is reached): if any of those upstream ++ protections is later removed or regresses, the open here still ++ refuses to escape the module. ++ ++ Defence: open the parent of pathname under secure_relative_open() ++ and call openat() against the resulting dirfd with O_NOFOLLOW ++ (so the basename itself isn't followed if it happens to be a ++ pre-planted symlink, which is what we want for O_CREAT|O_EXCL). ++*/ ++int do_open_at(const char *pathname, int flags, mode_t mode) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (flags != O_RDONLY) { ++ RETURN_ERROR_IF(dry_run, 0); ++ RETURN_ERROR_IF_RO_OR_LO; ++ } ++ ++ if (!am_daemon || am_chrooted) ++ return do_open(pathname, flags, mode); ++ ++ if (!pathname || !*pathname || *pathname == '/') ++ return do_open(pathname, flags, mode); ++ ++ slash = strrchr(pathname, '/'); ++ if (!slash) ++ return do_open(pathname, flags, mode); ++ ++ dlen = slash - pathname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, pathname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++#ifdef O_NOATIME ++ if (open_noatime) ++ flags |= O_NOATIME; ++#endif ++ ++ ret = openat(dfd, bname, flags | O_NOFOLLOW | O_BINARY, mode); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_open(pathname, flags, mode); ++#endif ++} ++ + #ifdef HAVE_CHMOD + int do_chmod(const char *path, mode_t mode) + { +diff --git a/testsuite/bare-do-open-symlink-race.test b/testsuite/bare-do-open-symlink-race.test +new file mode 100755 +index 00000000..b8c51bbe +--- /dev/null ++++ b/testsuite/bare-do-open-symlink-race.test +@@ -0,0 +1,186 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex audit Findings 3b and 3c: ++# ++# 3b: generator.c:1905 -- the in-place backup creation opens ++# backupptr via bare do_open(O_WRONLY|O_CREAT|O_TRUNC|O_EXCL). ++# With --backup-dir set to an attacker-planted parent symlink, ++# the backup file is written outside the module under the ++# daemon's authority. ++# ++# 3c-symlink: syscall.c:207 -- do_symlink_at falls through to bare ++# do_symlink for am_root < 0 (fake-super), which then opens ++# the destination path with bare open() (final-component ++# fake-super file). A parent symlink on the destination path ++# redirects the file creation outside the module. ++# ++# 3c-mknod: syscall.c:506 -- do_mknod_at falls through to bare ++# do_mknod for am_root < 0, same path-based open(). For ++# FIFOs/sockets/devices the bare path is also used. ++# ++# Each scenario plants a "secret" file outside the module at a ++# location the symlink trap points to. The check is that the ++# outside file's content and mode are unchanged after the attack ++# attempt. ++ ++. "$suitedir/rsync.fns" ++ ++# All three scenarios depend on receiver-side daemon code paths ++# that are only secured on platforms with a working ++# secure_relative_open. The chdir/chmod tests already skip the ++# same set; mirror that. ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "secure_relative_open relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" ++ ;; ++esac ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++src="$scratchdir/src" ++conf="$scratchdir/test-rsyncd.conf" ++ ++# Portable inode-and-mode helpers. ++file_mode() { ++ stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" ++} ++ ++setup() { ++ rm -rf "$mod" "$outside" "$src" ++ mkdir -p "$mod" "$outside" "$src" ++ ++ echo "OUTSIDE_PROTECTED_DATA" > "$outside/target.txt" ++ chmod 0644 "$outside/target.txt" ++ outside_pristine="$scratchdir/outside-pristine.txt" ++ cp -p "$outside/target.txt" "$outside_pristine" ++ ++ ln -s "$outside" "$mod/cd" ++} ++ ++verify_outside_unchanged() { ++ label="$1" ++ mode=$(file_mode "$outside/target.txt") ++ case "$mode" in ++ 644|0644) ;; ++ *) test_fail "$label: outside/target.txt mode changed from 644 to $mode" ;; ++ esac ++ if ! cmp -s "$outside/target.txt" "$outside_pristine"; then ++ test_fail "$label: outside/target.txt content changed -- daemon followed the cd symlink" ++ fi ++} ++ ++verify_outside_unchanged_or_absent() { ++ label="$1" ++ target="$2" # specific file under outside/ to check absence of ++ if [ -e "$outside/$target" ]; then ++ test_fail "$label: outside/$target was created -- daemon followed the cd symlink" ++ fi ++} ++ ++ ++############################################################ ++# Scenario 3b: --inplace --backup --backup-dir=cd ++# ++# Pre-create module/target.txt so the receiver enters the in-place ++# update path; a backup of the existing content must be made ++# before the update. With --backup-dir=cd, backupptr resolves to ++# "cd/target.txt"; with the bug, robust_unlink and the bare ++# do_open at generator.c:1905 both follow the cd symlink, the ++# unlink deletes outside/target.txt and the create writes the ++# pre-existing module/target.txt content there. ++############################################################ ++ ++setup ++echo "EXISTING_MODULE_DATA" > "$mod/target.txt" ++chmod 0666 "$mod/target.txt" ++echo "NEW_DATA_FROM_SENDER" > "$src/target.txt" ++chmod 0644 "$src/target.txt" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++verify_outside_unchanged "3b inplace+backup-dir=cd" ++ ++ ++############################################################ ++# Scenario 3c-symlink: fake-super symlink push to a path with a ++# symlinked parent ++# ++# With "fake super = yes" set on the module, the receiver ++# represents symlinks as fake-super files (regular files with the ++# link target written to them). The path-based open() in ++# do_symlink's fake-super branch follows parent symlinks. We push ++# a single symlink to the destination path "cd/sym" so the ++# receiver's create-file call lands at "cd/sym" relative to the ++# module root, where cd is the symlink trap. ++############################################################ ++ ++setup ++ ++mkdir -p "$src/cd" ++ln -s /etc/passwd "$src/cd/sym" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++verify_outside_unchanged_or_absent "3c-symlink fake-super symlink push" "sym" ++ ++ ++############################################################ ++# Scenario 3c-mknod: fake-super FIFO push to a path with a ++# symlinked parent ++# ++# Similar to 3c-symlink but for special files. mkfifo works ++# without root; we push a FIFO and verify the receiver doesn't ++# create a fake-super file at outside/fifo. ++############################################################ ++ ++setup ++ ++mkdir -p "$src/cd" ++mkfifo "$src/cd/fifo" 2>/dev/null ++if [ ! -p "$src/cd/fifo" ]; then ++ test_skipped "mkfifo unavailable; cannot exercise 3c-mknod" ++fi ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++verify_outside_unchanged_or_absent "3c-mknod fake-super FIFO push" "fifo" ++ ++exit 0 +diff --git a/testsuite/copy-dest-source-symlink.test b/testsuite/copy-dest-source-symlink.test +new file mode 100755 +index 00000000..2d20fab4 +--- /dev/null ++++ b/testsuite/copy-dest-source-symlink.test +@@ -0,0 +1,83 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex audit Finding 3a: copy_file()'s source ++# open in copy_altdest_file() is via do_open_nofollow(), which only ++# refuses a final-component symlink. Parent components are still ++# resolved with normal symlink-following. A daemon module attacker ++# who plants a parent symlink at module/cd -> /outside, then runs ++# --copy-dest=cd against a source file matching the size+mtime of ++# /outside/target.txt, drives the receiver to: ++# ++# 1. Find a match-level >= 2 basis at "cd/target.txt" ++# 2. Call copy_altdest_file -> copy_file(src="cd/target.txt", ...) ++# 3. do_open_nofollow follows the "cd" parent symlink and reads ++# the contents of /outside/target.txt under the daemon's ++# authority ++# 4. Copy that content into the module destination ++# ++# Result: outside/target.txt content lands at module/target.txt, ++# accessible to the attacker on a subsequent pull. ++# ++# We detect by content: src/target.txt and outside/target.txt have ++# identical metadata (size + mtime + mode) but different content. ++# After the transfer, module/target.txt should match src (no ++# basedir escape) -- if it matches outside, the bug copied across ++# the symlink boundary. ++ ++. "$suitedir/rsync.fns" ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++src="$scratchdir/src" ++conf="$scratchdir/test-rsyncd.conf" ++ ++rm -rf "$mod" "$outside" "$src" ++mkdir -p "$mod" "$outside" "$src" ++ ++# Outside-the-module file the daemon should not read on the ++# attacker's behalf. ++echo "OUTSIDE_LEAKED_DATA!" > "$outside/target.txt" ++chmod 0644 "$outside/target.txt" ++ ++# The symlink trap. ++ln -s "$outside" "$mod/cd" ++ ++# Source: same size, same mtime, same mode as outside -- so the ++# generator's link_stat + quick_check_ok finds a match-level >= 2 ++# basis and calls copy_altdest_file. ++echo "ATTACKER_KNOWN_DATA!" > "$src/target.txt" ++touch -r "$outside/target.txt" "$src/target.txt" ++chmod 0644 "$src/target.txt" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++if [ ! -f "$mod/target.txt" ]; then ++ test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour" ++fi ++ ++if cmp -s "$mod/target.txt" "$outside/target.txt"; then ++ test_fail "basedir-escape via copy_file source: module/target.txt now contains the contents of outside/target.txt -- daemon read /outside via the cd symlink and copied it into the module" ++fi ++ ++if ! cmp -s "$mod/target.txt" "$src/target.txt"; then ++ test_fail "destination doesn't match source content (and isn't outside content either): unexpected state" ++fi ++ ++exit 0 +diff --git a/util1.c b/util1.c +index f85f33e9..49ead492 100644 +--- a/util1.c ++++ b/util1.c +@@ -336,7 +336,13 @@ static int unlink_and_reopen(const char *dest, mode_t mode) + mode |= S_IWUSR; + #endif + mode &= INITACCESSPERMS; +- if ((ofd = do_open(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { ++ /* Use do_open_at so the create/truncate goes through a secure ++ * parent dirfd in the daemon-no-chroot deployment. Otherwise ++ * an attacker could swap a parent component with a symlink in ++ * the window between robust_unlink (which uses do_unlink_at, ++ * already secure) and the create here, and redirect the new ++ * file outside the module. */ ++ if ((ofd = do_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, save_errno, "open %s", full_fname(dest)); + errno = save_errno; +@@ -360,12 +366,23 @@ static int unlink_and_reopen(const char *dest, mode_t mode) + * --copy-dest options. */ + int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) + { ++ extern int am_daemon, am_chrooted; + int ifd, ofd; + char buf[1024 * 8]; + int len; /* Number of bytes read into `buf'. */ + OFF_T prealloc_len = 0, offset = 0; + +- if ((ifd = do_open_nofollow(source, O_RDONLY)) < 0) { ++ /* On a daemon without chroot, route the source open through ++ * secure_relative_open so a parent-symlink on the source path ++ * (e.g. --copy-dest=cd where cd is a symlink to an outside ++ * directory) cannot redirect the read to a file the daemon can ++ * see but the attacker should not. Plain do_open_nofollow only ++ * refuses a final-component symlink; parents are still followed. */ ++ if (am_daemon && !am_chrooted && source && *source && source[0] != '/') ++ ifd = secure_relative_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); ++ else ++ ifd = do_open_nofollow(source, O_RDONLY); ++ if (ifd < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, errno, "open %s", full_fname(source)); + errno = save_errno; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0029-testsuite-end-to-end-regression-test-for-chdir-symli.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0029-testsuite-end-to-end-regression-test-for-chdir-symli.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0029-testsuite-end-to-end-regression-test-for-chdir-symli.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0029-testsuite-end-to-end-regression-test-for-chdir-symli.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,289 @@ +From 0cac014f894c7a11e3841a7fd7459e90f4b1c0bb Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 5 May 2026 14:34:50 +1000 +Subject: [PATCH 29/38] testsuite: end-to-end regression test for + chdir-symlink-race + +testsuite/chdir-symlink-race.test runs an actual rsync daemon +(via RSYNC_CONNECT_PROG to avoid the network) configured with +"use chroot = no", plants a symlink at module/subdir -> ../outside, +and runs four flavours of attacker-shaped transfer (single-file +poc_chmod, -r push into the symlinked subdir with --size-only and +without, -r push into the module root). All four must leave the +outside-the-module sentinel file's mode AND content unchanged. + +Portability: + - file_mode() helper falls back to BSD stat -f %Lp when GNU + stat -c %a is unavailable (macOS, FreeBSD). + - Pre-saved pristine copy + cmp(1) replaces sha1sum, which + differs across platforms (sha1sum / shasum / sha1). + +Tests are kept running as root in the user-namespace re-exec +wrapper used by symlink-race tests so the daemon's setuid path +doesn't drop into the test user's identity (which on Linux +would mean the chmod-escape code path can't trigger because +the test user doesn't have CAP_FOWNER over the outside file). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + testsuite/alt-dest-symlink-race.test | 17 +++ + testsuite/bare-do-open-symlink-race.test | 20 ++++ + testsuite/chdir-symlink-race.test | 135 +++++++++++++++++++++++ + testsuite/copy-dest-source-symlink.test | 15 +++ + 4 files changed, 187 insertions(+) + create mode 100755 testsuite/chdir-symlink-race.test + +diff --git a/testsuite/alt-dest-symlink-race.test b/testsuite/alt-dest-symlink-race.test +index 2256f2f2..fd36c6e6 100755 +--- a/testsuite/alt-dest-symlink-race.test ++++ b/testsuite/alt-dest-symlink-race.test +@@ -62,8 +62,25 @@ echo "OUTSIDE_SECRET_DATA" > "$src/target.txt" + touch -r "$outside/target.txt" "$src/target.txt" + chmod 0644 "$src/target.txt" + ++# When running as root the daemon would drop to "nobody" by ++# default, which can't write into the test scratch dir. Force the ++# daemon to keep our uid/gid in that case so the basis-link ++# transfer can actually create the destination file. (Non-root ++# can't specify uid/gid in rsyncd.conf -- comment them out then.) ++my_uid=`get_testuid` ++root_uid=`get_rootuid` ++root_gid=`get_rootgid` ++uid_setting="uid = $root_uid" ++gid_setting="gid = $root_gid" ++if test x"$my_uid" != x"$root_uid"; then ++ uid_setting="#$uid_setting" ++ gid_setting="#$gid_setting" ++fi ++ + cat > "$conf" < "$conf" < "$conf" < "$conf" < ../outside, and runs four flavours of ++# rsync transfer that previously all reached files in ../outside: ++# ++# 1. single-file dest = subdir/target.txt (the original poc_chmod) ++# 2. -r src/subdir/ to upload/subdir/ (the chdir-escape case) ++# 3. -r src/subdir/ to upload/subdir/ (no --size-only: forces basis read+write) ++# 4. -r src/ to upload/ (was already protected by the ++# original CVE-2026-29518 fix; ++# regression-checked here) ++# ++# All four must leave the outside-the-module sentinel file's mode AND ++# content unchanged. ++ ++. "$suitedir/rsync.fns" ++ ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "secure chdir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" ++ ;; ++esac ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++src="$scratchdir/src" ++conf="$scratchdir/test-rsyncd.conf" ++ ++rm -rf "$mod" "$outside" "$src" ++mkdir -p "$mod" "$outside" "$src" "$src/subdir" ++ ++# Portable octal-mode helper -- macOS and FreeBSD's stat use -f, GNU ++# coreutils stat uses -c. ++file_mode() { ++ stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" ++} ++ ++# The "secret" file outside the module the attacker is trying to alter. ++# Save a pristine copy alongside it so we can compare with cmp(1) rather ++# than depending on sha1sum/shasum/sha1, which differ across platforms. ++echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt" ++chmod 0600 "$outside/target.txt" ++outside_pristine="$scratchdir/outside-pristine.txt" ++cp -p "$outside/target.txt" "$outside_pristine" ++ ++# Symlink trap planted in the module by the local attacker. ++ln -s "$outside" "$mod/subdir" ++ ++# Source files the sender will push: same size as the outside target, ++# different content, mode 0666 (the perms the attacker tries to push). ++SIZE=$(stat -c %s "$outside/target.txt" 2>/dev/null \ ++ || stat -f %z "$outside/target.txt") ++head -c "$SIZE" /dev/urandom > "$src/target.txt" ++head -c "$SIZE" /dev/urandom > "$src/subdir/target.txt" ++chmod 0666 "$src/target.txt" "$src/subdir/target.txt" ++ ++cat > "$conf" < "$outside/target.txt" ++} ++ ++verify_unchanged() { ++ label="$1" ++ mode=$(file_mode "$outside/target.txt") ++ case "$mode" in ++ 600|0600) ;; ++ *) test_fail "$label: outside file mode changed from 600 to $mode (chmod escape)" ;; ++ esac ++ if ! cmp -s "$outside/target.txt" "$outside_pristine"; then ++ test_fail "$label: outside file content changed (write escape)" ++ fi ++} ++ ++run_attack() { ++ label="$1"; shift ++ reset_outside ++ RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \ ++ $RSYNC "$@" >/dev/null 2>&1 || true ++ verify_unchanged "$label" ++} ++ ++# 1. The original poc_chmod scenario: single file, dest path with ++# the symlinked subdir as a path component. With --size-only the ++# receiver normally skips the basis open and goes straight to chmod ++# -- only the chdir-escape blocks the chmod from reaching outside. ++run_attack "single-file --size-only" \ ++ -tp --size-only \ ++ "$src/target.txt" rsync://localhost/upload/subdir/target.txt ++ ++# 2. -r push into the symlinked subdir: receiver chdir's into "subdir", ++# follows the symlink, ends up in outside. ++run_attack "-r --size-only into subdir/" \ ++ -rtp --size-only \ ++ "$src/subdir/" rsync://localhost/upload/subdir/ ++ ++# 3. Same but no --size-only -- forces the basis-file open and a real ++# rename, so this exercises the read-disclosure and write-escape ++# paths together. ++run_attack "-r without --size-only into subdir/" \ ++ -rtp \ ++ "$src/subdir/" rsync://localhost/upload/subdir/ ++ ++# 4. -r src/ to upload/ -- this case was already covered by the ++# original CVE-2026-29518 fix because the receiver stays at module ++# root and operates on slashed paths. Regression check. ++run_attack "-r --size-only into upload/ root" \ ++ -rtp --size-only \ ++ "$src/" rsync://localhost/upload/ ++ ++exit 0 +diff --git a/testsuite/copy-dest-source-symlink.test b/testsuite/copy-dest-source-symlink.test +index 2d20fab4..f91ee986 100755 +--- a/testsuite/copy-dest-source-symlink.test ++++ b/testsuite/copy-dest-source-symlink.test +@@ -54,8 +54,23 @@ echo "ATTACKER_KNOWN_DATA!" > "$src/target.txt" + touch -r "$outside/target.txt" "$src/target.txt" + chmod 0644 "$src/target.txt" + ++# When running as root the daemon would drop to "nobody" by ++# default and fail to mkstemp in the scratch dir; force it to ++# keep our uid/gid in that case. ++my_uid=`get_testuid` ++root_uid=`get_rootuid` ++root_gid=`get_rootgid` ++uid_setting="uid = $root_uid" ++gid_setting="gid = $root_gid" ++if test x"$my_uid" != x"$root_uid"; then ++ uid_setting="#$uid_setting" ++ gid_setting="#$gid_setting" ++fi ++ + cat > "$conf" < +Date: Wed, 29 Apr 2026 11:10:59 +1000 +Subject: [PATCH 30/38] token: harden compressed-token decoding against integer + overflow + +The receiver's three compressed-token decoders -- +recv_deflated_token (zlib), recv_zstd_token, and +recv_compressed_token (lz4) -- accumulated rx_token (a 32-bit +signed counter) without overflow checking. A malicious sender +could craft a compressed-token stream that walked rx_token past +INT32_MAX, with careful manipulation leaking process memory +contents to the wire (environment variables, passwords, heap +pointers, library pointers -- significantly weakening ASLR +and facilitating further exploitation). + +Cap rx_token at MAX_TOKEN_INDEX = 0x7ffffffe. Fold the +bookkeeping into recv_compressed_token_num() and +recv_compressed_token_run() shared by all three decoders. Reject +negative or out-of-range token values explicitly. Also cap the +simple_recv_token literal-block length at the source: any +wire-supplied length > CHUNK_SIZE is ill-formed (the matching +simple_send_token never writes a chunk larger than CHUNK_SIZE), +so reject before looping on attacker-controlled bytes. + +Reach: an authenticated daemon connection with compression +enabled (the default for protocols >= 30 when both peers +advertise it). Disabling compression on the daemon +("refuse options = compress" in rsyncd.conf) is the available +workaround. + +Reporter: Omar Elsayed (seks99x). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + receiver.c | 11 ++++- + token.c | 117 ++++++++++++++++++++++++++--------------------------- + 2 files changed, 67 insertions(+), 61 deletions(-) + +diff --git a/receiver.c b/receiver.c +index 8cf8366b..a487ad5a 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -318,7 +318,12 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, + } + } + +- while ((i = recv_token(f_in, &data)) != 0) { ++ while (1) { ++ data = NULL; ++ i = recv_token(f_in, &data); ++ if (i == 0) ++ break; ++ + if (INFO_GTE(PROGRESS, 1)) + show_progress(offset, total_size); + +@@ -326,6 +331,10 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, + maybe_send_keepalive(time(NULL), MSK_ALLOW_FLUSH | MSK_ACTIVE_RECEIVER); + + if (i > 0) { ++ if (!data) { ++ rprintf(FERROR, "Invalid literal token with no data [%s]\n", who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } + if (DEBUG_GTE(DELTASUM, 3)) { + rprintf(FINFO,"data recv %d at %s\n", + i, big_num(offset)); +diff --git a/token.c b/token.c +index b7a02ea1..02dabd8d 100644 +--- a/token.c ++++ b/token.c +@@ -291,6 +291,14 @@ static int32 simple_recv_token(int f, char **data) + int32 i = read_int(f); + if (i <= 0) + return i; ++ /* simple_send_token caps each literal chunk at CHUNK_SIZE; ++ * reject anything larger so a hostile peer cannot drive the ++ * read_buf below past our static CHUNK_SIZE buffer. */ ++ if (i > CHUNK_SIZE) { ++ rprintf(FERROR, "invalid uncompressed token length %ld [%s]\n", ++ (long)i, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } + residue = i; + } + +@@ -493,9 +501,52 @@ static char *cbuf; + static char *dbuf; + + /* for decoding runs of tokens */ ++#define MAX_TOKEN_INDEX ((int32)0x7ffffffe) ++ + static int32 rx_token; + static int32 rx_run; + ++static NORETURN void invalid_compressed_token(void) ++{ ++ rprintf(FERROR, "invalid token number in compressed stream\n"); ++ exit_cleanup(RERR_PROTOCOL); ++} ++ ++static int32 recv_compressed_token_num(int f, int32 flag) ++{ ++ if (flag & TOKEN_REL) { ++ int32 incr = flag & 0x3f; ++ if (rx_token > MAX_TOKEN_INDEX - incr) ++ invalid_compressed_token(); ++ rx_token += incr; ++ flag >>= 6; ++ } else { ++ rx_token = read_int(f); ++ if (rx_token < 0 || rx_token > MAX_TOKEN_INDEX) ++ invalid_compressed_token(); ++ } ++ ++ if (flag & 1) { ++ rx_run = read_byte(f); ++ rx_run += read_byte(f) << 8; ++ if (rx_run <= 0 || rx_token > MAX_TOKEN_INDEX - rx_run) ++ invalid_compressed_token(); ++ recv_state = r_running; ++ } ++ ++ return -1 - rx_token; ++} ++ ++static int32 recv_compressed_token_run(void) ++{ ++ if (rx_run <= 0 || rx_token >= MAX_TOKEN_INDEX) ++ invalid_compressed_token(); ++ ++rx_token; ++ if (--rx_run == 0) ++ recv_state = r_idle; ++ return -1 - rx_token; ++} ++ + /* Receive a deflated token and inflate it */ + static int32 recv_deflated_token(int f, char **data) + { +@@ -586,22 +637,7 @@ static int32 recv_deflated_token(int f, char **data) + } + + /* here we have a token of some kind */ +- if (flag & TOKEN_REL) { +- rx_token += flag & 0x3f; +- flag >>= 6; +- } else { +- rx_token = read_int(f); +- if (rx_token < 0) { +- rprintf(FERROR, "invalid token number in compressed stream\n"); +- exit_cleanup(RERR_PROTOCOL); +- } +- } +- if (flag & 1) { +- rx_run = read_byte(f); +- rx_run += read_byte(f) << 8; +- recv_state = r_running; +- } +- return -1 - rx_token; ++ return recv_compressed_token_num(f, flag); + + case r_inflating: + rx_strm.next_out = (Bytef *)dbuf; +@@ -621,10 +657,7 @@ static int32 recv_deflated_token(int f, char **data) + break; + + case r_running: +- ++rx_token; +- if (--rx_run == 0) +- recv_state = r_idle; +- return -1 - rx_token; ++ return recv_compressed_token_run(); + } + } + } +@@ -833,22 +866,7 @@ static int32 recv_zstd_token(int f, char **data) + return 0; + } + /* here we have a token of some kind */ +- if (flag & TOKEN_REL) { +- rx_token += flag & 0x3f; +- flag >>= 6; +- } else { +- rx_token = read_int(f); +- if (rx_token < 0) { +- rprintf(FERROR, "invalid token number in compressed stream\n"); +- exit_cleanup(RERR_PROTOCOL); +- } +- } +- if (flag & 1) { +- rx_run = read_byte(f); +- rx_run += read_byte(f) << 8; +- recv_state = r_running; +- } +- return -1 - rx_token; ++ return recv_compressed_token_num(f, flag); + + case r_inflated: /* zstd doesn't get into this state */ + break; +@@ -879,10 +897,7 @@ static int32 recv_zstd_token(int f, char **data) + break; + + case r_running: +- ++rx_token; +- if (--rx_run == 0) +- recv_state = r_idle; +- return -1 - rx_token; ++ return recv_compressed_token_run(); + } + } + } +@@ -1002,22 +1017,7 @@ static int32 recv_compressed_token(int f, char **data) + } + + /* here we have a token of some kind */ +- if (flag & TOKEN_REL) { +- rx_token += flag & 0x3f; +- flag >>= 6; +- } else { +- rx_token = read_int(f); +- if (rx_token < 0) { +- rprintf(FERROR, "invalid token number in compressed stream\n"); +- exit_cleanup(RERR_PROTOCOL); +- } +- } +- if (flag & 1) { +- rx_run = read_byte(f); +- rx_run += read_byte(f) << 8; +- recv_state = r_running; +- } +- return -1 - rx_token; ++ return recv_compressed_token_num(f, flag); + + case r_inflating: + avail_out = LZ4_decompress_safe(next_in, dbuf, avail_in, size); +@@ -1033,10 +1033,7 @@ static int32 recv_compressed_token(int f, char **data) + break; + + case r_running: +- ++rx_token; +- if (--rx_run == 0) +- recv_state = r_idle; +- return -1 - rx_token; ++ return recv_compressed_token_run(); + } + } + } +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0031-testsuite-cover-refuse-options-compress-for-the-daem.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0031-testsuite-cover-refuse-options-compress-for-the-daem.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0031-testsuite-cover-refuse-options-compress-for-the-daem.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0031-testsuite-cover-refuse-options-compress-for-the-daem.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,81 @@ +From 275258bd76bfaacf58fc647bd7b9af6117280fa9 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Fri, 1 May 2026 10:56:17 +1000 +Subject: [PATCH 31/38] testsuite: cover 'refuse options = compress' for the + daemon + +Add a daemon-refuse-compress test that builds a module configured with +'refuse options = compress' and asserts that: + 1. an attempted -z transfer to that module fails with an error + mentioning --compress, and + 2. the same transfer without -z still succeeds. + +This pins down the documented way to disable all compression on a +daemon, which previously had no automated coverage. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + testsuite/daemon-refuse-compress.test | 51 +++++++++++++++++++++++++++ + 1 file changed, 51 insertions(+) + create mode 100644 testsuite/daemon-refuse-compress.test + +diff --git a/testsuite/daemon-refuse-compress.test b/testsuite/daemon-refuse-compress.test +new file mode 100644 +index 00000000..a24e50d1 +--- /dev/null ++++ b/testsuite/daemon-refuse-compress.test +@@ -0,0 +1,51 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Test that a daemon module configured with "refuse options = compress" ++# rejects clients that ask for compression and still serves the same ++# transfer when the client does not. ++ ++. "$suitedir/rsync.fns" ++ ++build_rsyncd_conf ++ ++# Append a module that refuses --compress (-z). ++cat >>"$conf" </dev/null 2>"$errlog"; then ++ cat "$errlog" >&2 ++ test_fail "compressed transfer was not refused" ++fi ++ ++grep -- '--compress' "$errlog" >/dev/null || { ++ cat "$errlog" >&2 ++ test_fail "expected refuse error mentioning --compress" ++} ++ ++# The same transfer without -z must succeed. ++rm -rf "$todir" ++mkdir "$todir" ++checkit "$RSYNC -av localhost::no-compress/ '$todir/'" "$chkdir" "$todir" ++ ++# The script would have aborted on error, so getting here means we've won. ++exit 0 +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0032-receiver-add-parent_ndx-0-guard-mirroring-797e17f.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0032-receiver-add-parent_ndx-0-guard-mirroring-797e17f.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0032-receiver-add-parent_ndx-0-guard-mirroring-797e17f.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0032-receiver-add-parent_ndx-0-guard-mirroring-797e17f.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,119 @@ +From 5564c88150087eef6437e6c7aca00ed2593ca26f Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 5 May 2026 16:48:16 +1000 +Subject: [PATCH 32/38] receiver: add parent_ndx<0 guard, mirroring 797e17f + +Commit 797e17f ("fixed an invalid access to files array") added a +parent_ndx < 0 guard to send_files() in sender.c, but the visually- +identical block in recv_files() in receiver.c was not updated. A +malicious rsync:// server can therefore drive any connecting client +into the same out-of-bounds dir_flist->files[-1] read followed by a +file_struct dereference in f_name() one line later. + +Reach: protocol-30+ default (inc_recurse) makes flist.c:2745 set +parent_ndx = -1 on the first received flist when the sender omits a +leading "." entry; rsync.c flist_for_ndx() does not reject ndx == 0 +in that state because the range check evaluates 0 < 0 = false; and +read_ndx_and_attrs() only validates ndx with the ITEM_TRANSFER bit +set, so iflags=ITEM_IS_NEW (or any other non-transfer iflag word) +bypasses the check. + +Apply the same guard receiver-side. Confirmed: the same PoC (a +minimal Python rsyncd that handshakes with CF_INC_RECURSE, sends a +no-leading-"." flist, and emits ndx=0 with ITEM_IS_NEW) crashes +unpatched 3.4.2 with SEGV_MAPERR si_addr=0x4101a-class in the +receiver child; with this guard it exits cleanly with code 2 +(RERR_PROTOCOL). + +The attack surface delta over the sender variant is large: +the original was malicious-client -> daemon, this is +malicious-server -> any rsync client doing a normal rsync:// +or remote-shell pull. + +Reported by Pratham Gupta (alchemy1729). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + generator.c | 4 ++++ + io.c | 3 +++ + receiver.c | 7 ++++++- + sender.c | 2 ++ + 4 files changed, 15 insertions(+), 1 deletion(-) + +diff --git a/generator.c b/generator.c +index 311e9b78..89f99db4 100644 +--- a/generator.c ++++ b/generator.c +@@ -2146,6 +2146,8 @@ void check_for_finished_files(int itemizing, enum logcode code, int check_redo) + if (send_failed) + ndx = get_hlink_num(); + flist = flist_for_ndx(ndx, "check_for_finished_files.1"); ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); + file = flist->files[ndx - flist->ndx_start]; + assert(file->flags & FLAG_HLINKED); + if (send_failed) +@@ -2174,6 +2176,8 @@ void check_for_finished_files(int itemizing, enum logcode code, int check_redo) + + flist = cur_flist; + cur_flist = flist_for_ndx(ndx, "check_for_finished_files.2"); ++ if (ndx < cur_flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); + + file = cur_flist->files[ndx - cur_flist->ndx_start]; + if (solo_file) +diff --git a/io.c b/io.c +index 8d1cf7f2..2d94c1f4 100644 +--- a/io.c ++++ b/io.c +@@ -1090,6 +1090,9 @@ static void got_flist_entry_status(enum festatus status, int ndx) + { + struct file_list *flist = flist_for_ndx(ndx, "got_flist_entry_status"); + ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); ++ + if (remove_source_files) { + active_filecnt--; + active_bytecnt -= F_LENGTH(flist->files[ndx - flist->ndx_start]); +diff --git a/receiver.c b/receiver.c +index a487ad5a..3fa68d71 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -467,7 +467,10 @@ static void handle_delayed_updates(char *local_name) + static void no_batched_update(int ndx, BOOL is_redo) + { + struct file_list *flist = flist_for_ndx(ndx, "no_batched_update"); +- struct file_struct *file = flist->files[ndx - flist->ndx_start]; ++ struct file_struct *file; ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); ++ file = flist->files[ndx - flist->ndx_start]; + + rprintf(FERROR_XFER, "(No batched update for%s \"%s\")\n", + is_redo ? " resend of" : "", f_name(file, NULL)); +@@ -604,6 +607,8 @@ int recv_files(int f_in, int f_out, char *local_name) + + if (ndx - cur_flist->ndx_start >= 0) + file = cur_flist->files[ndx - cur_flist->ndx_start]; ++ else if (cur_flist->parent_ndx < 0) ++ exit_cleanup(RERR_PROTOCOL); + else + file = dir_flist->files[cur_flist->parent_ndx]; + fname = local_name ? local_name : f_name(file, fbuf); +diff --git a/sender.c b/sender.c +index 99f431fe..033f87e5 100644 +--- a/sender.c ++++ b/sender.c +@@ -140,6 +140,8 @@ void successful_send(int ndx) + return; + + flist = flist_for_ndx(ndx, "successful_send"); ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); + file = flist->files[ndx - flist->ndx_start]; + if (!change_pathname(file, NULL, 0)) + return; +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0033-clientserver-fix-hostname-ACL-bypass-when-using-daem.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0033-clientserver-fix-hostname-ACL-bypass-when-using-daem.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0033-clientserver-fix-hostname-ACL-bypass-when-using-daem.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0033-clientserver-fix-hostname-ACL-bypass-when-using-daem.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,192 @@ +From ac735929dc577e1b0da3b263d47df5a35b891f2e Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 31 Dec 2025 13:50:35 +1100 +Subject: [PATCH 33/38] clientserver: fix hostname ACL bypass when using daemon + chroot + +On an rsync daemon configured with "daemon chroot", the reverse-DNS +lookup of the connecting client was performed *after* the chroot +had been entered. If the chroot did not contain the files glibc +needs for resolution (/etc/resolv.conf, /etc/nsswitch.conf, +/etc/hosts, NSS service modules), the lookup failed and +client_name() returned "UNKNOWN". Hostname-based deny rules +("hosts deny = *.evil.example") therefore could not match, and +an attacker controlling their PTR record could connect from a +hostname the administrator had intended to deny. IP-based ACLs +were unaffected. + +Do the reverse DNS lookup before chroot/setuid; client_name() +caches its result, so the post-chroot call uses the cached value +and hostname-based ACLs work even when DNS is unavailable +post-chroot. + +Adds testsuite/daemon-chroot-acl.test as end-to-end regression +coverage. The test sets up an empty chroot directory, configures +"hosts deny = " with daemon chroot, and +asserts the connection is refused with @ERROR access denied. +Uses unshare --user --map-root-user for non-root CAP_SYS_CHROOT; +skips cleanly on non-Linux or when user namespaces aren't +available. + +Reporter: Joshua Rogers (MegaManSec). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + clientserver.c | 22 ++++++ + testsuite/daemon-chroot-acl.test | 111 +++++++++++++++++++++++++++++++ + 2 files changed, 133 insertions(+) + create mode 100644 testsuite/daemon-chroot-acl.test + +diff --git a/clientserver.c b/clientserver.c +index e8dfddb1..14daba3c 100644 +--- a/clientserver.c ++++ b/clientserver.c +@@ -1312,6 +1312,28 @@ int start_daemon(int f_in, int f_out) + if (lp_proxy_protocol() && !read_proxy_protocol_header(f_in)) + return -1; + ++ /* Do reverse DNS lookup before chroot/setuid. The result is cached, ++ * so the later client_name() call will use this cached value. This ++ * ensures hostname-based ACLs work even when DNS is unavailable ++ * after chroot. ++ * ++ * "reverse lookup" can be set globally OR per-module, so we also ++ * scan each module: a deployment with "reverse lookup = no" in the ++ * global section but "reverse lookup = yes" in a specific module ++ * still triggers a post-chroot lookup at access-check time ++ * (rsync_module() in this file), which would also fail in the ++ * chroot and turn hostname-based deny rules into silent bypasses. */ ++ { ++ int need_reverse = lp_reverse_lookup(-1); ++ int j, num_modules = lp_num_modules(); ++ for (j = 0; !need_reverse && j < num_modules; j++) { ++ if (lp_reverse_lookup(j)) ++ need_reverse = 1; ++ } ++ if (need_reverse) ++ (void)client_name(client_addr(f_in)); ++ } ++ + p = lp_daemon_chroot(); + if (*p) { + log_init(0); /* Make use we've initialized syslog before chrooting. */ +diff --git a/testsuite/daemon-chroot-acl.test b/testsuite/daemon-chroot-acl.test +new file mode 100644 +index 00000000..9d1c1b63 +--- /dev/null ++++ b/testsuite/daemon-chroot-acl.test +@@ -0,0 +1,111 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny" ++# rule must still match when the daemon performs a 'daemon chroot' and ++# the chroot does not contain the NSS files glibc needs for reverse DNS. ++# ++# Pre-fix, reverse DNS happened *after* the daemon chroot. With an empty ++# chroot the NSS lookup failed, client_name() returned "UNKNOWN", and a ++# deny rule referring to the connecting hostname silently failed to ++# match. ++# ++# Two scenarios are exercised so we can distinguish the case the fix ++# definitely covers from the per-module path that may still be ++# vulnerable: ++# A. global "reverse lookup = yes" (covered by b6abdb4c) ++# B. only module "reverse lookup = yes" (gap to verify) ++ ++. "$suitedir/rsync.fns" ++ ++case `uname -s` in ++Linux*) ;; ++*) test_skipped "test is Linux-specific (uses chroot+unshare)" ;; ++esac ++ ++# We need CAP_SYS_CHROOT. Re-exec under a user namespace if not root. ++if ! chroot / /bin/true 2>/dev/null; then ++ if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user true 2>/dev/null; then ++ echo "Re-running under unshare --user --map-root-user..." ++ RSYNC_UNSHARED=1 exec unshare --user --map-root-user "$SHELL_PATH" $RUNSHFLAGS "$0" ++ fi ++ test_skipped "need CAP_SYS_CHROOT (root or unshare --user --map-root-user)" ++fi ++ ++# We need 127.0.0.1 to reverse-resolve to a real hostname while NSS is ++# still working (i.e. before the daemon's chroot). The daemon will ++# look that name up itself as part of its hostname-based ACL check; ++# we then deny that name and assert the connection is rejected. ++client_hostname=`getent hosts 127.0.0.1 2>/dev/null | awk 'NR==1 {print $2}'` ++if [ -z "$client_hostname" ] || [ "$client_hostname" = "127.0.0.1" ]; then ++ test_skipped "no reverse DNS for 127.0.0.1" ++fi ++ ++chrootdir="$scratchdir/chroot" ++rm -rf "$chrootdir" ++mkdir -p "$chrootdir/modroot" ++echo "from chroot" > "$chrootdir/modroot/file1" ++ ++conf="$scratchdir/test-rsyncd.conf" ++logfile="$scratchdir/rsyncd.log" ++ ++write_conf() { ++ cat >"$conf" <"$out" 2>&1 ++ rc=$? ++ ++ echo "----- $label (rsync exit $rc):" ++ cat "$out" ++ echo "----- daemon log:" ++ [ -f "$logfile" ] && cat "$logfile" ++ echo "-----" ++ ++ grep -q '@ERROR.*access denied' "$out" ++} ++ ++# Scenario A: global reverse lookup. Covered by b6abdb4c. ++write_conf yes yes ++if ! run_check "Scenario A (global reverse lookup = yes)"; then ++ test_fail "Scenario A: hostname deny rule was bypassed" ++fi ++ ++# Scenario B: only the per-module reverse-lookup setting is enabled. ++# The b6abdb4c fix only pre-warms client_name()'s cache when the ++# global setting is on, so the post-chroot lookup in this path may ++# still produce "UNKNOWN" and bypass the deny rule. ++write_conf no yes ++if ! run_check "Scenario B (per-module reverse lookup only)"; then ++ test_fail "Scenario B: hostname deny rule was bypassed (per-module reverse lookup with daemon chroot still has the bypass)" ++fi ++ ++exit 0 +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0034-defence-in-depth-bound-wire-supplied-counts-and-leng.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0034-defence-in-depth-bound-wire-supplied-counts-and-leng.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0034-defence-in-depth-bound-wire-supplied-counts-and-leng.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0034-defence-in-depth-bound-wire-supplied-counts-and-leng.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,261 @@ +From ddd7b59a4f0492d957b4a4f0c318e7d81da31c6b Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 31 Dec 2025 12:56:54 +1100 +Subject: [PATCH 34/38] defence-in-depth: bound wire-supplied counts and + lengths + +Multiple receiver-side fields read from the wire were trusted +without upper-bound checks. A hostile peer could either request +extreme allocations (DoS via --max-alloc) or, on platforms where +read_varint returned a negative value, push ~SIZE_MAX through the +size_t conversion to wrap downstream length checks. + +Introduce read_int_bounded(), read_varint_bounded() and +read_varint_size() in io.c so wire-derived integer ranges are +checked at the read site rather than scattered across each +caller, with RERR_PROTOCOL on out-of-range input. + +Apply the bounded primitives to: + - sum->count (checksum count -- previously could overflow + (size_t)count * xfer_sum_len on 32-bit with raised max-alloc) + - xattrs: count, name_len, datum_len, plus rel_pos overflow + detect to stop chain wrapping the num accumulator + - acls: ida-entry count + - flist: file mode S_IFMT validation, modtime_nsec range check + - delete-stat counters in main: per-summand cap so the total + can't overflow a signed 32-bit accumulator + +Reporters include Joshua Rogers (checksum-count overflow finding). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + acls.c | 2 +- + flist.c | 17 ++++++++++++++--- + io.c | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ + main.c | 10 +++++----- + rsync.h | 17 +++++++++++++++++ + xattrs.c | 16 ++++++++++++---- + 6 files changed, 103 insertions(+), 13 deletions(-) + +diff --git a/acls.c b/acls.c +index 4d67ff4d..c60a7087 100644 +--- a/acls.c ++++ b/acls.c +@@ -697,7 +697,7 @@ static uint32 recv_acl_access(int f, uchar *name_follows_ptr) + static uchar recv_ida_entries(int f, ida_entries *ent) + { + uchar computed_mask_bits = 0; +- int i, count = read_varint(f); ++ int i, count = read_varint_bounded(f, 0, MAX_WIRE_ACL_COUNT, "ACL count"); + + ent->idas = count ? new_array(id_access, count) : NULL; + ent->count = count; +diff --git a/flist.c b/flist.c +index 17832533..30eeada6 100644 +--- a/flist.c ++++ b/flist.c +@@ -840,9 +840,9 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x + } + if (xflags & XMIT_MOD_NSEC) + #ifndef CAN_SET_NSEC +- (void)read_varint(f); ++ (void)read_varint_bounded(f, 0, MAX_WIRE_NSEC, "modtime_nsec"); + #else +- modtime_nsec = read_varint(f); ++ modtime_nsec = read_varint_bounded(f, 0, MAX_WIRE_NSEC, "modtime_nsec"); + else + modtime_nsec = 0; + #endif +@@ -861,8 +861,19 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x + #endif + } + #endif +- if (!(xflags & XMIT_SAME_MODE)) ++ if (!(xflags & XMIT_SAME_MODE)) { + mode = from_wire_mode(read_int(f)); ++ /* Reject modes whose type bits are not one of the standard ++ * file types; otherwise garbage mode values propagate through ++ * the file-type checks below unpredictably. */ ++ if (!S_ISREG(mode) && !S_ISDIR(mode) && !S_ISLNK(mode) ++ && !S_ISCHR(mode) && !S_ISBLK(mode) ++ && !S_ISFIFO(mode) && !S_ISSOCK(mode)) { ++ rprintf(FERROR, "invalid file mode 0%o for %s [%s]\n", ++ (unsigned)mode, lastname, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ } + if (atimes_ndx && !S_ISDIR(mode) && !(xflags & XMIT_SAME_ATIME)) { + atime = read_varlong(f, 4); + #if SIZEOF_TIME_T < SIZEOF_INT64 +diff --git a/io.c b/io.c +index 2d94c1f4..eb316383 100644 +--- a/io.c ++++ b/io.c +@@ -1868,6 +1868,45 @@ int64 read_varlong(int f, uchar min_bytes) + return u.x; + } + ++/* Read an int32 and verify lo <= v <= hi. On out-of-range, abort with a ++ * protocol error naming "what". The bound is co-located with the read so it ++ * cannot be forgotten by a downstream user. */ ++int32 read_int_bounded(int f, int32 lo, int32 hi, const char *what) ++{ ++ int32 v = read_int(f); ++ if (v < lo || v > hi) { ++ rprintf(FERROR, "wire value %s out of range: %ld not in [%ld,%ld] [%s]\n", ++ what, (long)v, (long)lo, (long)hi, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ return v; ++} ++ ++/* As read_int_bounded but for varint-encoded values. */ ++int32 read_varint_bounded(int f, int32 lo, int32 hi, const char *what) ++{ ++ int32 v = read_varint(f); ++ if (v < lo || v > hi) { ++ rprintf(FERROR, "wire value %s out of range: %ld not in [%ld,%ld] [%s]\n", ++ what, (long)v, (long)lo, (long)hi, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ return v; ++} ++ ++/* Read a varint that will be used as a size_t. Rejects negative values ++ * (which would wrap to ~SIZE_MAX) and values exceeding the supplied max. */ ++size_t read_varint_size(int f, size_t max, const char *what) ++{ ++ int32 v = read_varint(f); ++ if (v < 0 || (size_t)v > max) { ++ rprintf(FERROR, "wire size %s out of range: %ld > %lu [%s]\n", ++ what, (long)v, (unsigned long)max, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ return (size_t)v; ++} ++ + int64 read_longint(int f) + { + #if SIZEOF_INT64 >= 8 +@@ -1974,6 +2013,21 @@ void read_sum_head(int f, struct sum_struct *sum) + (long)sum->count, who_am_i()); + exit_cleanup(RERR_PROTOCOL); + } ++ /* Guard against integer overflow in downstream allocations sized by ++ * count*element_size. my_alloc uses divide-not-multiply so it is ++ * already wraparound-safe, but checking here gives a clearer error ++ * and also covers the (size_t)count * xfer_sum_len arithmetic that ++ * is performed *before* reaching my_alloc. */ ++ if (xfer_sum_len > 0 && (size_t)sum->count > SIZE_MAX / (size_t)xfer_sum_len) { ++ rprintf(FERROR, "Invalid checksum count %ld (too large) [%s]\n", ++ (long)sum->count, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ if ((size_t)sum->count > SIZE_MAX / sizeof(struct sum_buf)) { ++ rprintf(FERROR, "Invalid checksum count %ld (sum_buf overflow) [%s]\n", ++ (long)sum->count, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } + sum->blength = read_int(f); + if (sum->blength < 0 || sum->blength > max_blength) { + rprintf(FERROR, "Invalid block length %ld [%s]\n", +diff --git a/main.c b/main.c +index 4f070acc..e6dc134c 100644 +--- a/main.c ++++ b/main.c +@@ -239,11 +239,11 @@ void write_del_stats(int f) + + void read_del_stats(int f) + { +- stats.deleted_files = read_varint(f); +- stats.deleted_files += stats.deleted_dirs = read_varint(f); +- stats.deleted_files += stats.deleted_symlinks = read_varint(f); +- stats.deleted_files += stats.deleted_devices = read_varint(f); +- stats.deleted_files += stats.deleted_specials = read_varint(f); ++ stats.deleted_files = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_files"); ++ stats.deleted_files += stats.deleted_dirs = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_dirs"); ++ stats.deleted_files += stats.deleted_symlinks = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_symlinks"); ++ stats.deleted_files += stats.deleted_devices = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_devices"); ++ stats.deleted_files += stats.deleted_specials = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_specials"); + } + + static void become_copy_as_user() +diff --git a/rsync.h b/rsync.h +index 479ac484..4d40542e 100644 +--- a/rsync.h ++++ b/rsync.h +@@ -163,6 +163,23 @@ + /* For compatibility with older rsyncs */ + #define OLD_MAX_BLOCK_SIZE ((int32)1 << 29) + ++/* Policy ceilings on attacker-controlled wire values. Picked well above any ++ * legitimate filesystem / protocol traffic but well below sizes that could ++ * cause integer overflow or DoS-grade allocations. See input_checking.txt. ++ * ++ * Note on MAX_WIRE_XATTR_DATALEN: xattr datum size is bounded only by the ++ * wire-format maximum (signed int32 varint, ~2GB). macOS resource forks ++ * are transferred as the com.apple.ResourceFork xattr and can legitimately ++ * be many GB; --max-alloc (default 1GB, configurable) is the real ++ * allocation cap. read_varint_size() still rejects negative values so a ++ * hostile peer cannot wrap to ~SIZE_MAX. */ ++#define MAX_WIRE_XATTR_COUNT 65536 ++#define MAX_WIRE_XATTR_NAMELEN 4096 ++#define MAX_WIRE_XATTR_DATALEN ((int32)0x7fffffff) ++#define MAX_WIRE_ACL_COUNT 65536 ++#define MAX_WIRE_NSEC 999999999 ++#define MAX_WIRE_DEL_STAT ((int32)1 << 30) ++ + #define ROUND_UP_1024(siz) ((siz) & (1024-1) ? ((siz) | (1024-1)) + 1 : (siz)) + + #define IOERR_GENERAL (1<<0) /* For backward compatibility, this must == 1 */ +diff --git a/xattrs.c b/xattrs.c +index 5f740bb5..99795f24 100644 +--- a/xattrs.c ++++ b/xattrs.c +@@ -697,6 +697,13 @@ int recv_xattr_request(struct file_struct *file, int f_in) + rxa = lst->items; + num = 0; + while ((rel_pos = read_varint(f_in)) != 0) { ++ /* Detect signed overflow before the accumulating add. A hostile ++ * peer could otherwise wrap 'num' to land on an arbitrary value. */ ++ if ((rel_pos > 0 && num > INT_MAX - rel_pos) ++ || (rel_pos < 0 && num < INT_MIN - rel_pos)) { ++ rprintf(FERROR, "xattr rel_pos accumulation overflow [%s]\n", who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } + num += rel_pos; + if (am_sender) { + /* The sender-related num values are only in order on the sender. +@@ -742,7 +749,7 @@ int recv_xattr_request(struct file_struct *file, int f_in) + } + + old_datum = rxa->datum; +- rxa->datum_len = read_varint(f_in); ++ rxa->datum_len = read_varint_size(f_in, MAX_WIRE_XATTR_DATALEN, "xattr datum_len"); + + if (SIZE_MAX - rxa->name_len < rxa->datum_len) + overflow_exit("recv_xattr_request"); +@@ -783,7 +790,8 @@ void receive_xattr(int f, struct file_struct *file) + return; + } + +- if ((count = read_varint(f)) != 0) { ++ count = read_varint_bounded(f, 0, MAX_WIRE_XATTR_COUNT, "xattr count"); ++ if (count != 0) { + (void)EXPAND_ITEM_LIST(&temp_xattr, rsync_xa, count); + temp_xattr.count = 0; + } +@@ -791,8 +799,8 @@ void receive_xattr(int f, struct file_struct *file) + for (num = 1; num <= count; num++) { + char *ptr, *name; + rsync_xa *rxa; +- size_t name_len = read_varint(f); +- size_t datum_len = read_varint(f); ++ size_t name_len = read_varint_size(f, MAX_WIRE_XATTR_NAMELEN, "xattr name_len"); ++ size_t datum_len = read_varint_size(f, MAX_WIRE_XATTR_DATALEN, "xattr datum_len"); + size_t dget_len = datum_len > MAX_FULL_DATUM ? 1 + (size_t)xattr_sum_len : datum_len; + size_t extra_len = MIGHT_NEED_RPRE ? RPRE_LEN : 0; + if (SIZE_MAX - dget_len < extra_len || SIZE_MAX - dget_len - extra_len < name_len) +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0035-defence-in-depth-guard-cumulative-snprintf-against-l.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0035-defence-in-depth-guard-cumulative-snprintf-against-l.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0035-defence-in-depth-guard-cumulative-snprintf-against-l.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0035-defence-in-depth-guard-cumulative-snprintf-against-l.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,79 @@ +From 3d5a5a6568a90ef47d5b9d54ef6253effee19a6f Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Fri, 1 May 2026 09:30:31 +1000 +Subject: [PATCH 35/38] defence-in-depth: guard cumulative snprintf against + length underflow + +Two cumulative-snprintf patterns in log.c (rsyserr) and main.c +(output_itemized_counts) had the shape + + len = snprintf(buf, sizeof buf, ...); + len += snprintf(buf+len, sizeof buf - len, ...); + +with no guard between calls. snprintf returns the would-have-been +length on truncation, so a truncated first call leaves +"sizeof buf - len" as a negative-then-promoted-to-size_t value, +underflowing into a huge size_t and writing past buf. + +Realistic exposure is small in both cases (log header well under +buffer, only ~5 itemized iterations writing ~25 chars each into a +1024-byte buffer) but the defect class matches bb0a8118 and the +fix is cheap. Guard before each subsequent call. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + log.c | 12 +++++++++--- + main.c | 9 +++++++++ + 2 files changed, 18 insertions(+), 3 deletions(-) + +diff --git a/log.c b/log.c +index e4ba1cce..b948f16a 100644 +--- a/log.c ++++ b/log.c +@@ -456,11 +456,17 @@ void rsyserr(enum logcode code, int errcode, const char *format, ...) + char buf[BIGPATHBUFLEN]; + size_t len; + ++ /* snprintf returns the would-have-been length on truncation, so ++ * each cumulative call must be guarded; if not, sizeof buf - len ++ * can underflow when promoted to size_t and the next call writes ++ * past the buffer. */ + len = snprintf(buf, sizeof buf, RSYNC_NAME ": [%s] ", who_am_i()); + +- va_start(ap, format); +- len += vsnprintf(buf + len, sizeof buf - len, format, ap); +- va_end(ap); ++ if (len < sizeof buf) { ++ va_start(ap, format); ++ len += vsnprintf(buf + len, sizeof buf - len, format, ap); ++ va_end(ap); ++ } + + if (len < sizeof buf) { + len += snprintf(buf + len, sizeof buf - len, +diff --git a/main.c b/main.c +index e6dc134c..549b1da5 100644 +--- a/main.c ++++ b/main.c +@@ -394,9 +394,18 @@ static void output_itemized_counts(const char *prefix, int *counts) + counts[0] -= counts[1] + counts[2] + counts[3] + counts[4]; + for (j = 0; j < 5; j++) { + if (counts[j]) { ++ /* snprintf can return more than its size arg ++ * on truncation; keep len <= sizeof buf - 2 so ++ * the closing ')' and trailing NUL always ++ * have room and the next iteration's ++ * sizeof buf - len - 2 cannot underflow. */ ++ if (len >= (int)sizeof buf - 2) ++ break; + len += snprintf(buf+len, sizeof buf - len - 2, + "%s%s: %s", + pre, labels[j], comma_num(counts[j])); ++ if (len > (int)sizeof buf - 2) ++ len = (int)sizeof buf - 2; + pre = ", "; + } + } +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/2026-05-20/0036-defence-in-depth-receiver-block-index-bounds-read_de.patch rsync-3.4.1+ds1/debian/patches/2026-05-20/0036-defence-in-depth-receiver-block-index-bounds-read_de.patch --- rsync-3.4.1+ds1/debian/patches/2026-05-20/0036-defence-in-depth-receiver-block-index-bounds-read_de.patch 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/2026-05-20/0036-defence-in-depth-receiver-block-index-bounds-read_de.patch 2026-05-18 18:33:38.000000000 +0000 @@ -0,0 +1,80 @@ +From fc9a5d64521756dabfd9a17d4daa536fda556f1e Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 31 Dec 2025 14:01:34 +1100 +Subject: [PATCH 36/38] defence-in-depth: receiver block-index bounds + + read_delay_line null check + +Two assorted audit findings: + + - receive_data() never bounds-checked the block index returned + by recv_token() against sum.count before computing offset2 + and feeding it to map_ptr(). An out-of-bounds index from a + hostile sender produces invalid memory access. Add a + sum.count bounds check. + + - read_delay_line()'s strchr() call could return NULL when no + space was found, but the code unconditionally added 1 to the + result before dereferencing. Low impact (just a disconnect on + exit of the client-specific forked process) but the NULL + deref is real. Guard the NULL. + +Both reported by Joshua Rogers. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + generator.c | 14 ++++++++++---- + receiver.c | 5 +++++ + 2 files changed, 15 insertions(+), 4 deletions(-) + +diff --git a/generator.c b/generator.c +index 89f99db4..4d4ae72e 100644 +--- a/generator.c ++++ b/generator.c +@@ -229,11 +229,13 @@ static int read_delay_line(char *buf, int *flags_p) + *flags_p = 0; + + if (sscanf(bp, "%x ", &mode) != 1) { +- invalid_data: +- rprintf(FERROR, "ERROR: invalid data in delete-delay file.\n"); +- return -1; ++ goto invalid_data; ++ } ++ past_space = strchr(bp, ' '); ++ if (!past_space) { ++ goto invalid_data; + } +- past_space = strchr(bp, ' ') + 1; ++ past_space++; + len = j - read_pos - (past_space - bp) + 1; /* count the '\0' */ + read_pos = j + 1; + +@@ -247,6 +249,10 @@ static int read_delay_line(char *buf, int *flags_p) + memcpy(buf, past_space, len); + + return mode; ++ ++invalid_data: ++ rprintf(FERROR, "ERROR: invalid data in delete-delay file.\n"); ++ return -1; + } + + static void do_delayed_deletions(char *delbuf) +diff --git a/receiver.c b/receiver.c +index 3fa68d71..f49931bf 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -352,6 +352,11 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, + } + + i = -(i+1); ++ if (i < 0 || i >= sum.count) { ++ rprintf(FERROR, "Invalid block index %d (count=%ld) [%s]\n", ++ i, (long)sum.count, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } + offset2 = i * (OFF_T)sum.blength; + len = sum.blength; + if (i == (int)sum.count-1 && sum.remainder != 0) +-- +2.51.0 + diff -Nru rsync-3.4.1+ds1/debian/patches/CVE-2025-10158.patch rsync-3.4.1+ds1/debian/patches/CVE-2025-10158.patch --- rsync-3.4.1+ds1/debian/patches/CVE-2025-10158.patch 2026-04-30 13:05:39.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/CVE-2025-10158.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,26 +0,0 @@ -From: Andrew Tridgell -Date: Sat, 23 Aug 2025 17:26:53 +1000 -Subject: fixed an invalid access to files array - -this was found by Calum Hutton from Rapid7. It is a real bug, but -analysis shows it can't be leverged into an exploit. Worth fixing -though. - -Many thanks to Calum and Rapid7 for finding and reporting this ---- - sender.c | 2 ++ - 1 file changed, 2 insertions(+) - -diff --git a/sender.c b/sender.c -index a4d46c3..b1588b7 100644 ---- a/sender.c -+++ b/sender.c -@@ -262,6 +262,8 @@ void send_files(int f_in, int f_out) - - if (ndx - cur_flist->ndx_start >= 0) - file = cur_flist->files[ndx - cur_flist->ndx_start]; -+ else if (cur_flist->parent_ndx < 0) -+ exit_cleanup(RERR_PROTOCOL); - else - file = dir_flist->files[cur_flist->parent_ndx]; - if (F_PATHNAME(file)) { diff -Nru rsync-3.4.1+ds1/debian/patches/CVE-2026-41035.patch rsync-3.4.1+ds1/debian/patches/CVE-2026-41035.patch --- rsync-3.4.1+ds1/debian/patches/CVE-2026-41035.patch 2026-04-30 13:05:39.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/CVE-2026-41035.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,32 +0,0 @@ -From bb0a8118c2d2ab01140bac5e4e327e5e1ef90c9c Mon Sep 17 00:00:00 2001 -From: Andrew Tridgell -Date: Wed, 22 Apr 2026 09:57:45 +1000 -Subject: [PATCH] xattrs: fixed count in qsort - -this fixes the count passed to the sort of the xattr list. This issue -was reported here: - -https://www.openwall.com/lists/oss-security/2026/04/16/2 - -the bug is not exploitable due to the fork-per-connection design of -rsync, the attack is the equivalent of the user closing the socket -themselves. ---- - xattrs.c | 4 ++-- - 1 file changed, 2 insertions(+), 2 deletions(-) - -diff --git a/xattrs.c b/xattrs.c -index 26e50a6f9..65166eed9 100644 ---- a/xattrs.c -+++ b/xattrs.c -@@ -860,8 +860,8 @@ void receive_xattr(int f, struct file_struct *file) - rxa->num = num; - } - -- if (need_sort && count > 1) -- qsort(temp_xattr.items, count, sizeof (rsync_xa), rsync_xal_compare_names); -+ if (need_sort && temp_xattr.count > 1) -+ qsort(temp_xattr.items, temp_xattr.count, sizeof (rsync_xa), rsync_xal_compare_names); - - ndx = rsync_xal_store(&temp_xattr); /* adds item to rsync_xal_l */ - diff -Nru rsync-3.4.1+ds1/debian/patches/series rsync-3.4.1+ds1/debian/patches/series --- rsync-3.4.1+ds1/debian/patches/series 2026-04-30 13:05:39.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/series 2026-05-18 18:33:38.000000000 +0000 @@ -3,6 +3,38 @@ env_shebang.patch fix_rrsync_man_generation.patch fix-flaky-hardlinks-test.patch -CVE-2025-10158.patch -syscall_use_openat2_RESOLVE_BENEATH_on_Linux_for_secure_relative_open.patch -CVE-2026-41035.patch +2026-05-20/0001-bool-is-a-keyword-in-C23.patch +2026-05-20/0002-syscall-fix-a-Y2038-bug-by-replacing-Int32x32To64-wi.patch +2026-05-20/0003-options.c-Fix-segv-if-poptGetContext-returns-NULL.patch +2026-05-20/0004-Using-a-correct-time-in-log-file.patch +2026-05-20/0005-configure.ac-check-for-xattr-support-both-in-libc-an.patch +2026-05-20/0006-util-fixed-issue-in-clean_fname.patch +2026-05-20/0007-testsuite-added-clean-fname-underflow-test.patch +2026-05-20/0008-fixed-an-invalid-access-to-files-array.patch +2026-05-20/0009-fix-uninitialized-buf1-in-get_checksum2-MD4-path.patch +2026-05-20/0010-reject-negative-token-values-in-compressed-stream-re.patch +2026-05-20/0011-acl-fixed-ACL-ID-mapping-for-non-root.patch +2026-05-20/0012-fix-uninitialized-mul_one-in-AVX2-checksum-and-add-S.patch +2026-05-20/0013-Fix-glibc-2.43-constness-warnings.patch +2026-05-20/0015-fix-signed-integer-overflow-in-proxy-protocol-v2-hea.patch +2026-05-20/0016-zero-all-new-memory-from-allocations.patch +2026-05-20/0017-xattrs-fixed-count-in-qsort.patch +2026-05-20/0018-call-tzset-before-chroot-to-cache-timezone-data.patch +2026-05-20/0019-testsuite-xattrs-ignore-SUNWattr_-in-the-Solaris-xls.patch +2026-05-20/0020-syscall-use-openat2-RESOLVE_BENEATH-on-Linux-for-sec.patch +2026-05-20/0021-syscall-also-use-O_RESOLVE_BENEATH-on-FreeBSD-and-Ma.patch +2026-05-20/0022-testsuite-skip-symlink-dirlink-basis-on-platforms-wi.patch +2026-05-20/0023-syscall-clientserver-am_chrooted-and-use_secure_syml.patch +2026-05-20/0024-sender-fix-read-path-TOCTOU-by-opening-from-module-r.patch +2026-05-20/0025-syscall-receiver-secure-receiver-side-do_chmod-again.patch +2026-05-20/0026-util1-secure-change_dir-against-symlink-race-chdir-e.patch +2026-05-20/0027-syscall-add-symlink-race-safe-do_-_at-wrappers-and-h.patch +2026-05-20/0028-util1-syscall-secure-copy_file-source-dest-opens-bar.patch +2026-05-20/0029-testsuite-end-to-end-regression-test-for-chdir-symli.patch +2026-05-20/0030-token-harden-compressed-token-decoding-against-integ.patch +2026-05-20/0031-testsuite-cover-refuse-options-compress-for-the-daem.patch +2026-05-20/0032-receiver-add-parent_ndx-0-guard-mirroring-797e17f.patch +2026-05-20/0033-clientserver-fix-hostname-ACL-bypass-when-using-daem.patch +2026-05-20/0034-defence-in-depth-bound-wire-supplied-counts-and-leng.patch +2026-05-20/0035-defence-in-depth-guard-cumulative-snprintf-against-l.patch +2026-05-20/0036-defence-in-depth-receiver-block-index-bounds-read_de.patch diff -Nru rsync-3.4.1+ds1/debian/patches/syscall_use_openat2_RESOLVE_BENEATH_on_Linux_for_secure_relative_open.patch rsync-3.4.1+ds1/debian/patches/syscall_use_openat2_RESOLVE_BENEATH_on_Linux_for_secure_relative_open.patch --- rsync-3.4.1+ds1/debian/patches/syscall_use_openat2_RESOLVE_BENEATH_on_Linux_for_secure_relative_open.patch 2026-04-30 13:05:39.000000000 +0000 +++ rsync-3.4.1+ds1/debian/patches/syscall_use_openat2_RESOLVE_BENEATH_on_Linux_for_secure_relative_open.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,386 +0,0 @@ -From 4fa7156ccdb2ad34b034d18fe2fd6cd79adef8a1 Mon Sep 17 00:00:00 2001 -From: Andrew Tridgell -Date: Thu, 30 Apr 2026 08:39:22 +1000 -Subject: [PATCH] syscall: use openat2(RESOLVE_BENEATH) on Linux for - secure_relative_open - -The CVE fix in commit c35e283 made secure_relative_open() walk every -component of relpath with O_NOFOLLOW. That blocks every symlink in the -path, which is stricter than the threat model required: legitimate -directory symlinks within the destination tree (e.g. when using -K / ---copy-dirlinks) are also rejected, breaking delta transfers with -"failed verification -- update discarded". See issue #715. - -On Linux 5.6+, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) gives -us exactly what we want: the kernel rejects any resolution that would -escape the starting directory (via "..", absolute paths, or symlinks -pointing outside dirfd) while still following symlinks that resolve -within it. /proc magic-links are blocked too. - -Use openat2 first; fall back to the existing per-component O_NOFOLLOW -walk on ENOSYS (kernel < 5.6). The lexical "../" checks at the head -of the function are kept as defense in depth. The Linux gate is -plain #ifdef __linux__: the runtime ENOSYS fallback covers the only -case that actually matters (header present + old kernel), and any -Linux build environment without linux/openat2.h will fail with a -clear "no such file" error rather than silently disabling the -protection. - -Verified manually that openat2(RESOLVE_BENEATH) blocks all four -escape patterns (absolute symlink, ../ symlink, lexical .., absolute -path) while allowing direct and within-tree symlinks. The new -testsuite/symlink-dirlink-basis.test (taken from PR #864 by Samuel -Henrique) exercises the issue #715 regression and passes; full -make check passes 47/47. - -Test: testsuite/symlink-dirlink-basis.test (8 scenarios) -Fixes: https://github.com/RsyncProject/rsync/issues/715 - -Co-Authored-By: Claude Opus 4.7 (1M context) ---- - syscall.c | 62 ++++++- - testsuite/symlink-dirlink-basis.test | 247 +++++++++++++++++++++++++++ - 2 files changed, 304 insertions(+), 5 deletions(-) - create mode 100755 testsuite/symlink-dirlink-basis.test - -diff --git a/syscall.c b/syscall.c -index ec0e0708a..66c6d29c7 100644 ---- a/syscall.c -+++ b/syscall.c -@@ -33,6 +33,11 @@ - #include - #endif - -+#ifdef __linux__ -+#include -+#include -+#endif -+ - #include "ifuncs.h" - - extern int dry_run; -@@ -720,12 +725,49 @@ int do_open_nofollow(const char *pathname, int flags) - /* - open a file relative to a base directory. The basedir can be NULL, - in which case the current working directory is used. The relpath -- must be a relative path, and the relpath must not contain any -- elements in the path which follow symlinks (ie. like O_NOFOLLOW, but -- applies to all path components, not just the last component) -- -- The relpath must also not contain any ../ elements in the path -+ must be a relative path. The kernel must guarantee that resolution -+ cannot escape basedir (or the cwd, when basedir is NULL): no ".." -+ jumps above the start, no symlinks pointing outside, no absolute -+ paths, no /proc magic-link tricks. -+ -+ Symlinks *within* basedir are followed normally — earlier rsync -+ versions rejected every symlink with O_NOFOLLOW on each component, -+ which broke legitimate directory symlinks on the receiver side -+ (https://github.com/RsyncProject/rsync/issues/715). The escape -+ prevention is handled by the kernel via openat2(RESOLVE_BENEATH) -+ on Linux 5.6+; older systems fall back to the per-component -+ O_NOFOLLOW walk below. -+ -+ The relpath must also not contain any ../ elements in the path. - */ -+ -+#ifdef __linux__ -+static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) -+{ -+ struct open_how how; -+ int dirfd, retfd; -+ -+ memset(&how, 0, sizeof how); -+ how.flags = flags; -+ how.mode = mode; -+ how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; -+ -+ if (basedir == NULL) { -+ dirfd = AT_FDCWD; -+ } else { -+ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); -+ if (dirfd == -1) -+ return -1; -+ } -+ -+ retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how); -+ -+ if (dirfd != AT_FDCWD) -+ close(dirfd); -+ return retfd; -+} -+#endif -+ - int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) - { - if (!relpath || relpath[0] == '/') { -@@ -739,6 +781,16 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo - return -1; - } - -+#ifdef __linux__ -+ { -+ int fd = secure_relative_open_linux(basedir, relpath, flags, mode); -+ /* ENOSYS = kernel < 5.6 doesn't have the syscall even though -+ * glibc/kernel-headers do; fall through to the portable path. */ -+ if (fd != -1 || errno != ENOSYS) -+ return fd; -+ } -+#endif -+ - #if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) - // really old system, all we can do is live with the risks - if (!basedir) { -diff --git a/testsuite/symlink-dirlink-basis.test b/testsuite/symlink-dirlink-basis.test -new file mode 100755 -index 000000000..9065dd814 ---- /dev/null -+++ b/testsuite/symlink-dirlink-basis.test -@@ -0,0 +1,247 @@ -+#!/bin/sh -+ -+# Test that updating a file through a directory symlink works when using -+# -K (--copy-dirlinks). This is a regression test for: -+# https://github.com/RsyncProject/rsync/issues/715 -+# -+# The CVE fix in commit c35e283 introduced secure_relative_open() which -+# uses O_NOFOLLOW on all path components, breaking legitimate directory -+# symlinks on the receiver side. The fix splits the path into basedir -+# (dirname, symlinks followed) and basename (O_NOFOLLOW) so that -+# directory symlinks are traversed while the final file component is -+# still protected. -+# -+# The regression only manifests when delta matching is triggered (i.e., -+# the sender finds matching blocks in the old file). Small files with -+# completely different content are transferred in full and don't trigger -+# the bug. We use a large file with a small modification to ensure -+# delta transfer is used. -+# -+# In addition to the original regression, this test covers edge cases -+# in the fix itself: -+# - --backup with directory symlinks (finish_transfer pointer identity) -+# - --partial-dir with protocol < 29 (fnamecmp != partialptr guard) -+# - --inplace with directory symlinks (updating_basis_or_equiv check) -+# - Files without a dirname (top-level files, no split needed) -+ -+. "$suitedir/rsync.fns" -+ -+RSYNC_RSH="$scratchdir/src/support/lsh.sh" -+export RSYNC_RSH -+ -+# $HOME is set to $scratchdir by rsync.fns -+# localhost: destination will cd to $HOME (i.e., $scratchdir) -+ -+# Helper: create a large file suitable for delta transfers. -+# ~32KB is large enough for rsync's block matching to find matches. -+make_testfile() { -+ dd if=/dev/urandom of="$1" bs=1024 count=32 2>/dev/null \ -+ || test_fail "failed to create test file $1" -+} -+ -+# Set up source tree -+srcbase="$tmpdir/src" -+ -+###################################################################### -+# Test 1: Basic directory symlink update (the original issue #715) -+###################################################################### -+ -+mkdir -p "$HOME/real-dir" -+ln -s real-dir "$HOME/dir" -+ -+mkdir -p "$srcbase/dir" -+make_testfile "$srcbase/dir/file" -+ -+# First transfer (initial): should create the file through the symlink -+(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 1: initial transfer failed" -+ -+if [ ! -f "$HOME/real-dir/file" ]; then -+ test_fail "test 1: initial transfer did not create file through symlink" -+fi -+ -+diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ -+ || test_fail "test 1: initial transfer content mismatch" -+ -+# Small modification to trigger delta transfer -+echo "appended update" >> "$srcbase/dir/file" -+sleep 1 -+touch "$srcbase/dir/file" -+ -+# Second transfer (update): was failing with "failed verification" -+(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 1: update through directory symlink failed" -+ -+diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ -+ || test_fail "test 1: update transfer content mismatch" -+ -+###################################################################### -+# Test 2: Compression (-z) as in the original reproducer -+###################################################################### -+ -+echo "another line" >> "$srcbase/dir/file" -+sleep 1 -+touch "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptzv --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 2: compressed update through directory symlink failed" -+ -+diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ -+ || test_fail "test 2: compressed update content mismatch" -+ -+###################################################################### -+# Test 3: Nested directory symlinks (nested/sub/data.txt where -+# "nested" is a symlink to "nested_real") -+###################################################################### -+ -+mkdir -p "$HOME/nested_real/sub" -+ln -s nested_real "$HOME/nested" -+ -+mkdir -p "$srcbase/nested/sub" -+make_testfile "$srcbase/nested/sub/data.txt" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ -+ || test_fail "test 3: initial nested transfer failed" -+ -+echo "appended nested" >> "$srcbase/nested/sub/data.txt" -+sleep 1 -+touch "$srcbase/nested/sub/data.txt" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ -+ || test_fail "test 3: update through nested directory symlink failed" -+ -+diff "$srcbase/nested/sub/data.txt" "$HOME/nested_real/sub/data.txt" >/dev/null \ -+ || test_fail "test 3: nested update content mismatch" -+ -+###################################################################### -+# Test 4: --backup with directory symlinks -+# -+# Exercises the finish_transfer() "fnamecmp == fname" pointer -+# comparison that determines whether to update fnamecmp to the -+# backup name. If broken, --backup would reference a renamed file -+# for xattr handling. -+###################################################################### -+ -+# Reset destination -+rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" -+ -+make_testfile "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 4: initial transfer for backup test failed" -+ -+echo "backup update" >> "$srcbase/dir/file" -+sleep 1 -+touch "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --backup --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 4: update with --backup through directory symlink failed" -+ -+diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ -+ || test_fail "test 4: backup update content mismatch" -+ -+if [ ! -f "$HOME/real-dir/file~" ]; then -+ test_fail "test 4: backup file was not created" -+fi -+ -+###################################################################### -+# Test 5: --inplace with directory symlinks -+# -+# Exercises the updating_basis_or_equiv check which uses -+# "fnamecmp == fname". With --inplace, rsync writes directly to -+# the destination file instead of a temp file. -+###################################################################### -+ -+rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" -+ -+make_testfile "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 5: initial inplace transfer failed" -+ -+echo "inplace update" >> "$srcbase/dir/file" -+sleep 1 -+touch "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 5: inplace update through directory symlink failed" -+ -+diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ -+ || test_fail "test 5: inplace update content mismatch" -+ -+###################################################################### -+# Test 6: Top-level file (no dirname, no split needed) -+# -+# Ensures the dirname/basename split is not attempted for files -+# at the top level (file->dirname is NULL). -+###################################################################### -+ -+make_testfile "$srcbase/topfile" -+mkdir -p "$HOME" -+ -+(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ -+ || test_fail "test 6: initial top-level transfer failed" -+ -+echo "toplevel update" >> "$srcbase/topfile" -+sleep 1 -+touch "$srcbase/topfile" -+ -+(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ -+ || test_fail "test 6: top-level update failed" -+ -+diff "$srcbase/topfile" "$HOME/topfile" >/dev/null \ -+ || test_fail "test 6: top-level update content mismatch" -+ -+###################################################################### -+# Test 7: --partial-dir with protocol < 29 -+# -+# For protocol < 29, fnamecmp_type stays FNAMECMP_FNAME even when -+# fnamecmp is set to partialptr. The dirname/basename split must -+# NOT trigger in this case (guarded by "fnamecmp == fname"). -+###################################################################### -+ -+rm -f "$HOME/real-dir/file" -+make_testfile "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ -+ --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 7: initial proto28 partial-dir transfer failed" -+ -+echo "partial-dir update" >> "$srcbase/dir/file" -+sleep 1 -+touch "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ -+ --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 7: proto28 partial-dir update through dirlink failed" -+ -+diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ -+ || test_fail "test 7: proto28 partial-dir update content mismatch" -+ -+###################################################################### -+# Test 8: Protocol < 29 basic directory symlink update -+# -+# Exercises the protocol < 29 code path and its fallback logic -+# (clearing basedir on retry). -+###################################################################### -+ -+rm -f "$HOME/real-dir/file" -+make_testfile "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ -+ --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 8: initial proto28 transfer failed" -+ -+echo "proto28 update" >> "$srcbase/dir/file" -+sleep 1 -+touch "$srcbase/dir/file" -+ -+(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ -+ --rsync-path="$RSYNC" dir/file localhost:) \ -+ || test_fail "test 8: proto28 update through directory symlink failed" -+ -+diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ -+ || test_fail "test 8: proto28 update content mismatch" -+ -+# The script would have aborted on error, so getting here means we've won. -+exit 0 diff -Nru rsync-3.4.1+ds1/debian/tests/upstream-tests rsync-3.4.1+ds1/debian/tests/upstream-tests --- rsync-3.4.1+ds1/debian/tests/upstream-tests 2026-04-30 13:05:39.000000000 +0000 +++ rsync-3.4.1+ds1/debian/tests/upstream-tests 2026-05-18 18:33:38.000000000 +0000 @@ -5,7 +5,7 @@ debian/rules override_dh_auto_configure # Supress gcc warnings (autopkg treats them as failures) -make tls getgroups getfsdev trimslash t_unsafe wildtest testrun 2>/dev/null +make tls getgroups getfsdev t_chmod_secure t_secure_relpath trimslash t_unsafe wildtest testrun 2>/dev/null # Run tests rsync_bin="/usr/bin/rsync" ./runtests.sh diff -Nru rsync-3.4.1+ds1/debian/tests/upstream-tests-as-root rsync-3.4.1+ds1/debian/tests/upstream-tests-as-root --- rsync-3.4.1+ds1/debian/tests/upstream-tests-as-root 2026-04-30 13:05:39.000000000 +0000 +++ rsync-3.4.1+ds1/debian/tests/upstream-tests-as-root 2026-05-18 18:33:38.000000000 +0000 @@ -5,7 +5,7 @@ debian/rules override_dh_auto_configure # Supress gcc warnings (autopkg treats them as failures) -make tls getgroups getfsdev trimslash t_unsafe wildtest testrun 2>/dev/null +make tls getgroups getfsdev t_chmod_secure t_secure_relpath trimslash t_unsafe wildtest testrun 2>/dev/null # Run tests rsync_bin="/usr/bin/rsync" ./runtests.sh