From 3786926703fc592dec5a7fc88ca3bfbc40a27051 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 6 Jun 2026 14:52:02 +1000 Subject: [PATCH 01/25] build: add check-progs target for fleettest Build the test-helper programs without running the suite, so an external harness (fleettest.py) can invoke runtests.py with its own options. --- Makefile.in | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile.in b/Makefile.in index 699d99562..3eed94e50 100644 --- a/Makefile.in +++ b/Makefile.in @@ -319,6 +319,12 @@ test: check # catch Bash-isms earlier even if we're running on GNU. Of course, we # might lose in the future where POSIX diverges from old sh. +# Build the test-helper programs (CHECK_PROGS) without running the suite, so +# an external harness (e.g. fleettest.py) can invoke runtests.py with its own +# options. +.PHONY: check-progs +check-progs: all $(CHECK_PROGS) $(CHECK_SYMLINKS) + .PHONY: check check: all $(CHECK_PROGS) $(CHECK_SYMLINKS) $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) From 9e2e9f3362bbfd3f9d2097ca98c8b995022e056c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 24 May 2026 08:48:42 +1000 Subject: [PATCH 02/25] receiver: fix absolute --partial-dir delta resume (false verification) A delta (--no-whole-file) resume whose basis is an absolute --partial-dir looped forever on exit code 23 ("failed verification -- update put into partial-dir"), stranding the correct data in the partial-dir and never populating the destination. Cause: an absolute --partial-dir makes the basis path absolute, but the receiver opened it with secure_relative_open(NULL, fnamecmp, ...), which by design rejects an absolute relpath (EINVAL). The basis fd was then -1, so receive_data() mapped no basis and (because the matched-block sum_update() is guarded by "if (mapbuf)") computed the whole-file verification checksum over the literal data only -> a spurious mismatch every run. (The data itself was correct, since the in-place update leaves the matched basis bytes in place.) Under a non-chroot daemon the in-place write went through the same call and failed outright. Fix: add secure_basis_open(), which treats an operator-trusted absolute basis path as (trusted directory + confined leaf) -- the same way secure_relative_open already trusts an absolute basedir while keeping O_NOFOLLOW on the leaf -- and use it for both the basis read and the inplace-partial write. The strict "reject absolute relpath" contract of secure_relative_open is left intact. Defense-in-depth: receive_data() now treats a block-match token with no mapped basis as a protocol inconsistency (it can only arise from a basis that the generator opened but the receiver could not), failing cleanly instead of silently dropping those bytes from the verify checksum or the output. Thanks to @sylvain-ilm for the report (#724, #725). Co-Authored-By: Claude Opus 4.7 (1M context) --- receiver.c | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/receiver.c b/receiver.c index f49931bf1..7d429fe84 100644 --- a/receiver.c +++ b/receiver.c @@ -83,6 +83,44 @@ static int updating_basis_or_equiv; #define MAX_UNIQUE_NUMBER 999999 #define MAX_UNIQUE_LOOP 100 +/* Open a basis/output path that may legitimately be an operator-trusted + * ABSOLUTE path -- e.g. an absolute --partial-dir ("a directory reserved for + * partial-dir work") or --backup-dir. secure_relative_open() deliberately + * rejects an absolute relpath, so feeding it the whole absolute partialptr + * (with a NULL basedir) returns EINVAL: the basis fd is then -1, no basis is + * mapped, and receive_data() omits every matched block from the whole-file + * verification checksum -> a spurious "failed verification" that strands the + * (correct) data in the partial-dir forever. + * + * The operator's directory is trusted; only the leaf basename is peer-supplied. + * So when basedir is NULL and relpath is absolute, split it into its directory + * (trusted) and leaf and confine just the leaf -- exactly how secure_relative_ + * open already trusts an absolute basedir while O_NOFOLLOW-confining the leaf. + * Anything else is a straight pass-through that preserves the strict contract. */ +static int secure_basis_open(const char *basedir, const char *relpath, int flags, mode_t mode) +{ + if (!basedir && relpath && *relpath == '/') { + const char *slash = strrchr(relpath, '/'); + const char *leaf = slash + 1; + char dirbuf[MAXPATHLEN]; + const char *dir; + if (slash == relpath) + dir = "/"; + else { + size_t dlen = slash - relpath; + if (dlen >= sizeof dirbuf) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirbuf, relpath, dlen); + dirbuf[dlen] = '\0'; + dir = dirbuf; + } + return secure_relative_open(dir, leaf, flags, mode); + } + return secure_relative_open(basedir, relpath, flags, mode); +} + /* get_tmpname() - create a tmp filename for a given filename * * If a tmpdir is defined, use that as the directory to put it in. Otherwise, @@ -364,6 +402,18 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, stats.matched_data += len; + /* A block match can only be honored if we actually mapped the + * basis. If we didn't (basis open failed), the sender should + * never have been told a basis existed -- treat it as a protocol + * inconsistency rather than silently omitting these bytes from + * the verification checksum (which yields a spurious failure) or + * leaving a hole in the output. */ + if (!mapbuf) { + rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", + full_fname(fname), who_am_i()); + exit_cleanup(RERR_PROTOCOL); + } + if (DEBUG_GTE(DELTASUM, 3)) { rprintf(FINFO, "chunk[%d] of size %ld at %s offset=%s%s\n", @@ -793,8 +843,9 @@ int recv_files(int f_in, int f_out, char *local_name) fnamecmp = fname; } - /* open the file */ - fd1 = secure_relative_open(basedir, fnamecmp, O_RDONLY, 0); + /* open the file (secure_basis_open tolerates an operator-trusted + * absolute fnamecmp, e.g. an absolute --partial-dir basis) */ + fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); if (fd1 == -1 && protocol_version < 29) { if (fnamecmp != fname) { @@ -884,7 +935,7 @@ int recv_files(int f_in, int f_out, char *local_name) * 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); + fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600); else fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); #ifdef linux From f68facd22f13c0e32f4783d10c0543e39ed472ed Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Wed, 3 Jun 2026 20:47:56 +1000 Subject: [PATCH 03/25] flist: accept the missing-args mode-0 entry in recv_file_entry (#910) --delete-missing-args (missing_args==2) sends a missing --files-from arg as a mode-0 entry (IS_MISSING_FILE), the generator's delete signal. The mode-type validation in recv_file_entry() rejected mode 0 as an invalid file type, aborting the transfer with 'invalid file mode 00 ... code 2' before the generator could act (a regression from 3.4.1). Allow mode 0 through only when missing_args==2 (the delete mode -- not --ignore-missing-args, which never sends a mode-0 entry); all other modes are still rejected. Thanks to @mgkeeley for the report (#910). --- flist.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/flist.c b/flist.c index 2ec07f54a..7c0a279e9 100644 --- a/flist.c +++ b/flist.c @@ -865,13 +865,18 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x 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)) { + * the file-type checks below unpredictably. mode 0 is the one + * legitimate exception: --delete-missing-args (missing_args==2) + * sends a missing arg as a mode-0 entry (IS_MISSING_FILE), the + * generator's delete signal (#910). */ + if (mode != 0 || missing_args != 2) { + 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)) { From 51c5f05771c26376f64db3cf4808d0b33043d37c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Wed, 3 Jun 2026 20:48:10 +1000 Subject: [PATCH 04/25] sender: open a module-root-absolute path for a `path = /` module (#897) A daemon module with path=/ makes F_PATHNAME absolute, so the secure_path built for the content open starts with '/'. secure_relative_open() rejects an absolute relpath with EINVAL, so a use-chroot=no daemon with path=/ could not send any file ('failed to open ...: Invalid argument (22)') -- a regression from 3.4.2. Strip leading slashes to a module-relative path; resolution stays confined beneath module_dir. Thanks to @moonlitbugs for the report (#897). --- sender.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sender.c b/sender.c index 033f87e54..32913af0e 100644 --- a/sender.c +++ b/sender.c @@ -362,6 +362,7 @@ void send_files(int f_in, int f_out) * Reconstruct the full path relative to module_dir * from F_PATHNAME (path) and f_name (fname). */ char secure_path[MAXPATHLEN]; + const char *relp; int slen = snprintf(secure_path, sizeof secure_path, "%s%s%s", path, slash, fname); if (slen >= (int)sizeof secure_path) { io_error |= IOERR_GENERAL; @@ -371,7 +372,13 @@ void send_files(int f_in, int f_out) send_msg_int(MSG_NO_SEND, ndx); continue; } - fd = secure_relative_open(module_dir, secure_path, O_RDONLY, 0); + /* A module with `path = /` makes F_PATHNAME absolute, so the + * joined path starts with '/'; strip leading slashes to a + * module-relative path that secure_relative_open accepts (#897). */ + relp = secure_path; + while (*relp == '/') + relp++; + fd = secure_relative_open(module_dir, relp, O_RDONLY, 0); } else { fd = do_open_checklinks(fname); } From d8847ff7a81b8cfc59fb74c9dd0af6af0a35a37b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Wed, 3 Jun 2026 20:48:10 +1000 Subject: [PATCH 05/25] syscall/receiver: honour a relative alt-basis dir on a daemon receiver (#915) The symlink-race hardening routed the receiver's basis open through secure_relative_open(), which rejects any '..' -- so a sibling --link-dest=../01 on a use-chroot=no daemon was silently ignored and every file re-transferred (#915/#928, a regression from 3.4.1). Narrow the confinement to the sanitizing daemon (am_daemon && !am_chrooted) and re-anchor it at the module root, the real trust boundary: secure_relative_open() prefixes the cwd's module-relative path (from rsync's logical curr_dir[], a guaranteed lexical prefix of module_dir) and resolves beneath module_dir, so RESOLVE_BENEATH permits an in-module '..' climb while still rejecting one that escapes the module. secure_basis_open() opens with a bare do_open() in the non-sanitizing cases. t_stub.c gains weak curr_dir[]/curr_dir_len for the helpers (via #pragma weak on non-GNU compilers, where rsync.h erases __attribute__). Two tests: link-dest-relative-basis asserts the in-module '..' is honoured; link-dest-module-escape asserts a --link-dest=../../OUTSIDE climb that leaves the module is refused (not hard-linked to an outside file). See upstream PR #930. Thanks to @fufu65 (#915) and @JetAppsClark (#928) for the reports. --- receiver.c | 23 +++++++++++++- syscall.c | 91 +++++++++++++++++++++++++++++++++++++++++++++++++----- t_stub.c | 2 ++ util1.c | 4 +-- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/receiver.c b/receiver.c index 7d429fe84..cb7978419 100644 --- a/receiver.c +++ b/receiver.c @@ -99,6 +99,27 @@ static int updating_basis_or_equiv; * Anything else is a straight pass-through that preserves the strict contract. */ static int secure_basis_open(const char *basedir, const char *relpath, int flags, mode_t mode) { + extern int am_daemon, am_chrooted; + + /* The confined resolver is only needed for the sanitizing daemon + * (am_daemon && !am_chrooted, i.e. use_secure_symlinks). Local / + * remote-shell mode has no module boundary, and "use chroot = yes" makes + * the kernel root the boundary, so there an alt-dest basis like + * --link-dest=../01 must resolve against the cwd as a bare open did before + * the hardening (confining it would reject the legitimate sibling "..", + * #915). */ + if (!am_daemon || am_chrooted) { + if (basedir) { + char fullpath[MAXPATHLEN]; + if (pathjoin(fullpath, sizeof fullpath, basedir, relpath) >= sizeof fullpath) { + errno = ENAMETOOLONG; + return -1; + } + return do_open(fullpath, flags, mode); + } + return do_open(relpath, flags, mode); + } + if (!basedir && relpath && *relpath == '/') { const char *slash = strrchr(relpath, '/'); const char *leaf = slash + 1; @@ -859,7 +880,7 @@ int recv_files(int f_in, int f_out, char *local_name) basedir = basis_dir[0]; fnamecmp = fname; fnamecmp_type = FNAMECMP_BASIS_DIR_LOW; - fd1 = secure_relative_open(basedir, fnamecmp, O_RDONLY, 0); + fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); } } diff --git a/syscall.c b/syscall.c index e317bccc3..c10e7bed5 100644 --- a/syscall.c +++ b/syscall.c @@ -1766,13 +1766,68 @@ static int secure_relative_open_resolve_beneath(const char *basedir, const char } #endif +/* The logical current directory (maintained by change_dir() in util1.c). + * Defined here -- rather than in util1.c -- so the test helpers that link + * syscall.o but not util1.o (tls, trimslash) get the definition without a + * weak-symbol fallback, which is not portable to PE/COFF targets (Cygwin). */ +char curr_dir[MAXPATHLEN]; +unsigned int curr_dir_len; + int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) { + extern int am_daemon, am_chrooted; + extern char *module_dir; + extern unsigned int module_dirlen; + char modrel_buf[MAXPATHLEN]; + int reanchored = 0; + if (!relpath || relpath[0] == '/') { // must be a relative path errno = EINVAL; return -1; } + + /* Sanitizing daemon only (am_daemon && !am_chrooted). Here we have chdir'd + * into a sub-dir of the module (the transfer destination), so a relative + * alt-dest like "../01" may legitimately climb to a sibling that is still + * inside the module (#915). Confining beneath the cwd would reject that + * climb. Re-anchor at the module root -- the real trust boundary -- by + * prefixing the cwd's module-relative path (from rsync's logical curr_dir[], + * a guaranteed lexical prefix of module_dir, unlike getcwd()) and resolving + * beneath module_dir; RESOLVE_BENEATH then allows in-module climbs and still + * rejects escapes. Only for paths that contain "..". module_dirlen is 0 for + * a `path = /` module (clientserver.c), so we gate on module_dir, not its + * length, to cover that case too -- the prefix check below treats + * module_dirlen 0 as "module root is /". */ + if (am_daemon && !am_chrooted + && module_dir && module_dir[0] == '/' + && (basedir == NULL || basedir[0] != '/') + && (path_has_dotdot_component(relpath) + || (basedir && path_has_dotdot_component(basedir)))) { + const char *p; + int n; + if (curr_dir_len >= module_dirlen + && strncmp(curr_dir, module_dir, module_dirlen) == 0 + && (curr_dir[module_dirlen] == '\0' || curr_dir[module_dirlen] == '/')) { + for (p = curr_dir + module_dirlen; *p == '/'; p++) {} + if (basedir) + n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s/%s", + p, *p ? "/" : "", basedir, relpath); + else + n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s", + p, *p ? "/" : "", relpath); + if (n < 0 || n >= (int)sizeof modrel_buf) { + errno = ENAMETOOLONG; + return -1; + } + basedir = module_dir; /* absolute, operator-trusted anchor */ + relpath = modrel_buf; + reanchored = 1; + } + /* else: cwd not under module root as expected -- fall through to the + * front-door rejection below (fail safe). */ + } + /* Reject any path with a literal ".." component (bare "..", * "../foo", "foo/..", "foo/../bar", "subdir/.."). The previous * substring-based check caught only "../" prefix and "/../" @@ -1781,14 +1836,19 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo * 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; + * platforms so callers can rely on the validation. Skipped for a + * re-anchored path: its ".." is deliberate, stays within the module, + * and is adjudicated by RESOLVE_BENEATH below (the portable fallback + * re-rejects it -- see there). */ + if (!reanchored) { + if (path_has_dotdot_component(relpath)) { + errno = EINVAL; + return -1; + } + if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { + errno = EINVAL; + return -1; + } } #ifdef __linux__ @@ -1805,6 +1865,21 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo return secure_relative_open_resolve_beneath(basedir, relpath, flags, mode); #endif + /* Portable fallback only (no kernel RESOLVE_BENEATH): the per-component + * O_NOFOLLOW walk below can't adjudicate ".." safely, so reject it here -- + * even for a re-anchored path. This re-breaks --link-dest=../01 on + * openat2/O_RESOLVE_BENEATH-less platforms (NetBSD/OpenBSD/Solaris/Cygwin/ + * pre-5.6 Linux), trading function for safety; on the kernel paths above + * RESOLVE_BENEATH already allowed the in-module climb. */ + if (path_has_dotdot_component(relpath)) { + errno = EINVAL; + return -1; + } + if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { + errno = EINVAL; + return -1; + } + #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/t_stub.c b/t_stub.c index 63bc144c5..723875ed6 100644 --- a/t_stub.c +++ b/t_stub.c @@ -39,6 +39,8 @@ int open_noatime = 0; size_t max_alloc = 0; /* max_alloc is needed when combined with util2.o */ char *partial_dir; char *module_dir; +/* curr_dir[]/curr_dir_len (read by secure_relative_open) are defined in + * syscall.c, which every helper links -- no stub needed here. */ filter_rule_list daemon_filter_list; void rprintf(UNUSED(enum logcode code), const char *format, ...) diff --git a/util1.c b/util1.c index 36c1b68cd..12361057a 100644 --- a/util1.c +++ b/util1.c @@ -41,8 +41,8 @@ extern filter_rule_list daemon_filter_list; int sanitize_paths = 0; -char curr_dir[MAXPATHLEN]; -unsigned int curr_dir_len; +extern char curr_dir[MAXPATHLEN]; /* defined in syscall.c */ +extern unsigned int curr_dir_len; int curr_dir_depth; /* This is only set for a sanitizing daemon. */ /* Set a fd into nonblocking mode. */ From a8f80f5a12caf180a08c6231b0936acbea965241 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Thu, 4 Jun 2026 14:04:47 +1000 Subject: [PATCH 06/25] generator: cap block s2length at the negotiated checksum length sum_sizes_sqroot() capped the strong-sum length at SUM_LENGTH (16), the legacy MD4/MD5 digest size. Since 0902b52f the sum2 array elements are xfer_sum_len bytes and the sender rejects a sums header whose s2length exceeds xfer_sum_len. When the negotiated transfer checksum is shorter than 16 bytes -- xxh64 (8), used when the build's libxxhash lacks xxh128/xxh3 (e.g. Ubuntu 20.04) -- the generator still emitted s2length up to 16, so --append-verify and other full-checksum (redo) transfers died with "Invalid checksum length 16 [sender]" (protocol incompatibility). Cap s2length at MIN(SUM_LENGTH, xfer_sum_len): unchanged for any checksum >= 16 bytes (md5/xxh128/sha1), corrected for short ones. Also closes a latent over-read of the xfer_sum_len-sized digest buffer. --- generator.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/generator.c b/generator.c index 4d4ae72e3..83c4c128b 100644 --- a/generator.c +++ b/generator.c @@ -66,6 +66,7 @@ extern int inplace; extern int append_mode; extern int make_backups; extern int csum_length; +extern int xfer_sum_len; extern int ignore_times; extern int size_only; extern OFF_T max_size; @@ -697,6 +698,11 @@ static void sum_sizes_sqroot(struct sum_struct *sum, int64 len) { int32 blength; int s2length; + /* The strong sum can be no longer than the negotiated checksum digest: + * a short checksum (e.g. xxh64 = 8 bytes, when xxh128/xxh3 are absent) + * makes xfer_sum_len < SUM_LENGTH, and the sender rejects an s2length + * larger than xfer_sum_len (io.c). */ + int max_s2length = MIN(SUM_LENGTH, xfer_sum_len); int64 l; if (len < 0) { @@ -731,7 +737,7 @@ static void sum_sizes_sqroot(struct sum_struct *sum, int64 len) if (protocol_version < 27) { s2length = csum_length; } else if (csum_length == SUM_LENGTH) { - s2length = SUM_LENGTH; + s2length = max_s2length; } else { int32 c; int b = BLOCKSUM_BIAS; @@ -740,7 +746,7 @@ static void sum_sizes_sqroot(struct sum_struct *sum, int64 len) /* add a bit, subtract rollsum, round up. */ s2length = (b + 1 - 32 + 7) / 8; /* --optimize in compiler-- */ s2length = MAX(s2length, csum_length); - s2length = MIN(s2length, SUM_LENGTH); + s2length = MIN(s2length, max_s2length); } sum->flength = len; From 6c8295fd62f293fc3657379fe4f089e9ba227de8 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Thu, 4 Jun 2026 14:43:38 +1000 Subject: [PATCH 07/25] alloc: revert "zero all new memory from allocations" (#959) Commit d046525d made my_alloc() calloc every fresh allocation and made expand_item_list() memset the freshly grown tail, to hand out predictably zeroed memory. But that forces the kernel to back pages callers never touch: each per-directory file_list pre-allocates a FLIST_START-entry (32768) pointer array -- 256KB -- and calloc now zeroes the whole array even for an empty directory. With incremental recursion over many directories the resident set explodes; 80000 empty dirs went from ~336MB to ~10.8GB. Restore the pre-d046525d malloc/calloc split: fresh allocations use malloc (so untouched tails stay lazy) and only explicit do_calloc requests (new_array0) are zeroed. Callers that need zeroed memory already ask for it, and the full test suite passes. Thanks to @guilherme-puida for the report (#959). Fixes: #959 --- util1.c | 2 -- util2.c | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/util1.c b/util1.c index 12361057a..7a1f24b54 100644 --- a/util1.c +++ b/util1.c @@ -1788,8 +1788,6 @@ 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 ce6f7de14..b59bff0a0 100644 --- a/util2.c +++ b/util2.c @@ -79,7 +79,9 @@ 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 == do_calloc) + if (!ptr) + ptr = malloc(num * size); + else if (ptr == do_calloc) ptr = calloc(num, size); else ptr = realloc(ptr, num * size); From f3757a470ad3defdb2c4c6ad334e3839f3c7f1ae Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Thu, 4 Jun 2026 14:46:38 +1000 Subject: [PATCH 08/25] build: fall back to do_mknod() when mknodat() is unavailable (#896) do_mknod_at() (the symlink-race-safe variant used by a non-chrooted daemon receiver) calls mknodat()/mkfifoat(), but the at-variant was gated only on AT_FDCWD. Older Darwin declares AT_FDCWD without mknodat(), so the build failed with "mknodat undeclared". Probe mknodat()/mkfifoat() in configure and require HAVE_MKNODAT for the at-variant; without it do_mknod_at() falls back to do_mknod(), exactly as it already does where AT_FDCWD is missing. Linux keeps the mknodat path since HAVE_MKNODAT is defined there. Thanks to @debohman for the report (#896). Fixes: #896 --- configure.ac | 2 +- syscall.c | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/configure.ac b/configure.ac index 4062651df..ff7c9aeb7 100644 --- a/configure.ac +++ b/configure.ac @@ -888,7 +888,7 @@ AC_FUNC_UTIME_NULL AC_FUNC_ALLOCA AC_CHECK_FUNCS(waitpid wait4 getcwd chown chmod lchmod mknod mkfifo \ fchmod fstat ftruncate strchr readlink link utime utimes lutimes strftime \ - chflags getattrlist mktime innetgr linkat \ + chflags getattrlist mktime innetgr linkat mknodat mkfifoat \ memmove lchown vsnprintf snprintf vasprintf asprintf setsid strpbrk \ strlcat strlcpy stpcpy strtol mallinfo mallinfo2 getgroups setgroups geteuid getegid \ setlocale setmode open64 lseek64 mkstemp64 mtrace va_copy __va_copy \ diff --git a/syscall.c b/syscall.c index c10e7bed5..d7de6bd8c 100644 --- a/syscall.c +++ b/syscall.c @@ -535,7 +535,9 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) */ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) { -#ifdef AT_FDCWD + /* HAVE_MKNODAT: older Darwin declares AT_FDCWD but not mknodat(), so + * the at-variant won't build there; fall back to do_mknod() (#896). */ +#if defined(AT_FDCWD) && defined(HAVE_MKNODAT) extern int am_daemon, am_chrooted; char dirpath[MAXPATHLEN]; const char *bname; @@ -597,7 +599,7 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) return ret; } -#if !defined MKNOD_CREATES_FIFOS && defined HAVE_MKFIFO +#if !defined MKNOD_CREATES_FIFOS && defined HAVE_MKFIFO && defined HAVE_MKFIFOAT if (S_ISFIFO(mode)) ret = mkfifoat(dfd, bname, mode); else From 7811f2b1b92ff053b0042aa58b91baab96e1bfb1 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Thu, 4 Jun 2026 16:19:31 +1000 Subject: [PATCH 09/25] daemon: un-backslash escaped option args (#829) Without --secluded-args, the client's safe_arg() backslash-escapes shell and wildcard chars in option values before sending them to the server, so --chown's --usermap=*:user is transmitted as --usermap=\*:user. Over ssh a remote shell removes the backslashes before rsync parses the args, but a daemon has no shell and read_args() stored option args verbatim -- so the receiver saw the literal "\*", the usermap/groupmap wildcard never matched, and the module's configured uid/gid won instead. A regression from the secluded-args hardening; rsync 3.2.3 (protocol 31) worked. Un-backslash option args in read_args() on the daemon's first (non-protected) read, mirroring what the ssh-side shell does. File args after the dot are already handled by glob_expand(); the protected (NUL, already-unescaped) re-read and the server's stdin read pass unescape=0 so their raw args are left untouched. Thanks to @elcamlost for the report (#829). Fixes: #829 --- clientserver.c | 4 ++-- io.c | 20 +++++++++++++++++++- main.c | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/clientserver.c b/clientserver.c index 14daba3c0..cc59663ad 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1070,7 +1070,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char io_printf(f_out, "@RSYNCD: OK\n"); - read_args(f_in, name, line, sizeof line, rl_nulls, &argv, &argc, &request); + read_args(f_in, name, line, sizeof line, rl_nulls, 1, &argv, &argc, &request); orig_argv = argv; save_munge_symlinks = munge_symlinks; @@ -1080,7 +1080,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char if (protect_args && ret) { orig_early_argv = orig_argv; protect_args = 2; - read_args(f_in, name, line, sizeof line, 1, &argv, &argc, &request); + read_args(f_in, name, line, sizeof line, 1, 0, &argv, &argc, &request); orig_argv = argv; ret = parse_arguments(&argc, (const char ***) &argv); } else diff --git a/io.c b/io.c index 08e7e0aad..0b96c2709 100644 --- a/io.c +++ b/io.c @@ -1292,8 +1292,21 @@ int read_line(int fd, char *buf, size_t bufsiz, int flags) return s - buf; } +/* Reverse safe_arg()'s backslash escaping of a daemon option arg, the way a + * remote shell un-escapes args for the ssh transport. In place; \X -> X. */ +static void unbackslash_arg(char *s) +{ + char *f = s, *t = s; + while (*f) { + if (*f == '\\' && f[1]) + f++; + *t++ = *f++; + } + *t = '\0'; +} + void read_args(int f_in, char *mod_name, char *buf, size_t bufsiz, int rl_nulls, - char ***argv_p, int *argc_p, char **request_p) + int unescape, char ***argv_p, int *argc_p, char **request_p) { int maxargs = MAX_ARGS; int dot_pos = 0, argc = 0, request_len = 0; @@ -1335,6 +1348,11 @@ void read_args(int f_in, char *mod_name, char *buf, size_t bufsiz, int rl_nulls, glob_expand(buf, &argv, &argc, &maxargs); } else { p = strdup(buf); + /* An option arg the client escaped with safe_arg() (no + * remote shell un-escapes it for a daemon). File args + * after the dot are handled by glob_expand() below. */ + if (unescape) + unbackslash_arg(p); argv[argc++] = p; if (*p == '.' && p[1] == '\0') dot_pos = argc; diff --git a/main.c b/main.c index 78f0b8331..fd59e8771 100644 --- a/main.c +++ b/main.c @@ -1840,7 +1840,7 @@ int main(int argc,char *argv[]) if (am_server && protect_args) { char buf[MAXPATHLEN]; protect_args = 2; - read_args(STDIN_FILENO, NULL, buf, sizeof buf, 1, &argv, &argc, NULL); + read_args(STDIN_FILENO, NULL, buf, sizeof buf, 1, 0, &argv, &argc, NULL); if (!parse_arguments(&argc, (const char ***) &argv)) { option_error(); exit_cleanup(RERR_SYNTAX); From b29c1495297b46ef579ad68e6325bd285066ec19 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 4 Jun 2026 00:59:48 +1000 Subject: [PATCH 10/25] fix: install generated manpages out of tree --- Makefile.in | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Makefile.in b/Makefile.in index 3eed94e50..42d4319c5 100644 --- a/Makefile.in +++ b/Makefile.in @@ -83,12 +83,19 @@ install: all $(INSTALLCMD) -m 755 $(srcdir)/rsync-ssl $(DESTDIR)$(bindir) -$(MKDIR_P) $(DESTDIR)$(mandir)/man1 -$(MKDIR_P) $(DESTDIR)$(mandir)/man5 - if test -f rsync.1; then $(INSTALLMAN) -m 644 rsync.1 $(DESTDIR)$(mandir)/man1; fi - if test -f rsync-ssl.1; then $(INSTALLMAN) -m 644 rsync-ssl.1 $(DESTDIR)$(mandir)/man1; fi - if test -f rsyncd.conf.5; then $(INSTALLMAN) -m 644 rsyncd.conf.5 $(DESTDIR)$(mandir)/man5; fi + for fn in rsync.1 rsync-ssl.1; do \ + if test -f $$fn; then $(INSTALLMAN) -m 644 $$fn $(DESTDIR)$(mandir)/man1; \ + elif test -f $(srcdir)/$$fn; then $(INSTALLMAN) -m 644 $(srcdir)/$$fn $(DESTDIR)$(mandir)/man1; fi; \ + done + for fn in rsyncd.conf.5; do \ + if test -f $$fn; then $(INSTALLMAN) -m 644 $$fn $(DESTDIR)$(mandir)/man5; \ + elif test -f $(srcdir)/$$fn; then $(INSTALLMAN) -m 644 $(srcdir)/$$fn $(DESTDIR)$(mandir)/man5; fi; \ + done if test "$(with_rrsync)" = yes; then \ $(INSTALLCMD) -m 755 rrsync $(DESTDIR)$(bindir); \ - if test -f rrsync.1; then $(INSTALLMAN) -m 644 rrsync.1 $(DESTDIR)$(mandir)/man1; fi; \ + fn=rrsync.1; \ + if test -f $$fn; then $(INSTALLMAN) -m 644 $$fn $(DESTDIR)$(mandir)/man1; \ + elif test -f $(srcdir)/$$fn; then $(INSTALLMAN) -m 644 $(srcdir)/$$fn $(DESTDIR)$(mandir)/man1; fi; \ fi install-ssl-daemon: stunnel-rsyncd.conf From ee7c8a57839d1f44c689d6a27b91edf7e078ec1c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Thu, 4 Jun 2026 15:49:14 +1000 Subject: [PATCH 11/25] token: drain the matched-block insert deflate (#951) send_deflated_token() adds a matched block to the compressor history with deflate(Z_INSERT_ONLY). Our bundled zlib implements Z_INSERT_ONLY (it produces no output and consumes the input in one call), but a build against a system zlib lacks it and falls back to Z_SYNC_FLUSH (see the top of the file), which emits a flush block into obuf. For a large incompressible matched token that block exceeds AVAIL_OUT_SIZE(CHUNK_SIZE), so deflate returned with avail_in != 0 and the transfer aborted: "deflate on token returned 0 (N bytes left)" at token.c The insert output is never sent -- the receiver rebuilds the matching history itself in see_deflate_token() -- so loop, resetting the output buffer, and discard it. Drain with the same condition as the data loop above: until the input is consumed AND avail_out != 0. Stopping at avail_in == 0 alone can leave pending output in the deflate stream (a full output buffer with bytes still buffered), which would then be emitted by the next real deflate send and corrupt the stream. A bundled-zlib build still finishes in one iteration. Thanks to @brabalan for the report (#951). Fixes: #951 --- token.c | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/token.c b/token.c index 62ffae151..f910f74b3 100644 --- a/token.c +++ b/token.c @@ -481,14 +481,29 @@ send_deflated_token(int f, int32 token, struct map_struct *buf, OFF_T offset, in tx_strm.avail_in = n1; if (protocol_version >= 31) /* Newer protocols avoid a data-duplicating bug */ offset += n1; - tx_strm.next_out = (Bytef *) obuf; - tx_strm.avail_out = AVAIL_OUT_SIZE(CHUNK_SIZE); - r = deflate(&tx_strm, Z_INSERT_ONLY); - if (r != Z_OK || tx_strm.avail_in != 0) { - rprintf(FERROR, "deflate on token returned %d (%d bytes left)\n", - r, tx_strm.avail_in); - exit_cleanup(RERR_STREAMIO); - } + /* With our bundled zlib's Z_INSERT_ONLY this produces no + * output and consumes the input in one call. A build + * against a system zlib lacks Z_INSERT_ONLY and falls back + * to Z_SYNC_FLUSH (see top of file), which emits a flush + * block we discard -- and for an incompressible token that + * block can exceed obuf. Loop, resetting the output buffer, + * until all the input is consumed so a large token can't + * overflow obuf and abort the transfer (#951). Drain until + * avail_out != 0 too: a full output buffer can leave pending + * bytes that would otherwise leak into the next real deflate + * send and corrupt the stream (same condition as the data loop + * above). The discarded output is not sent: the receiver + * rebuilds the matching history itself in see_deflate_token(). */ + do { + tx_strm.next_out = (Bytef *) obuf; + tx_strm.avail_out = AVAIL_OUT_SIZE(CHUNK_SIZE); + r = deflate(&tx_strm, Z_INSERT_ONLY); + if (r != Z_OK) { + rprintf(FERROR, "deflate on token returned %d (%d bytes left)\n", + r, tx_strm.avail_in); + exit_cleanup(RERR_STREAMIO); + } + } while (tx_strm.avail_in != 0 || tx_strm.avail_out == 0); } while (toklen > 0); } } From f86309f230bcebc752e777a7e6146e4d96872a07 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 4 Jun 2026 00:34:27 +1000 Subject: [PATCH 12/25] fix: daemon upload delete stats --- generator.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/generator.c b/generator.c index 83c4c128b..17293521b 100644 --- a/generator.c +++ b/generator.c @@ -2390,7 +2390,7 @@ void generate_files(int f_out, const char *local_name) write_ndx(f_out, NDX_DONE); if (protocol_version >= 31 && EARLY_DELETE_DONE_MSG()) { - if ((INFO_GTE(STATS, 2) && (delete_mode || force_delete)) || read_batch) + if (delete_mode || force_delete || read_batch) write_del_stats(f_out); if (EARLY_DELAY_DONE_MSG()) /* Can't send this before delay */ write_ndx(f_out, NDX_DONE); @@ -2435,7 +2435,7 @@ void generate_files(int f_out, const char *local_name) if (protocol_version >= 31) { if (!EARLY_DELETE_DONE_MSG()) { - if (INFO_GTE(STATS, 2) || read_batch) + if (delete_mode || force_delete || read_batch) write_del_stats(f_out); write_ndx(f_out, NDX_DONE); } From 20cc8245924a0f0f15025ba8dd51bdddea5c9653 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 5 Jun 2026 11:29:18 +1000 Subject: [PATCH 13/25] main: fix --mkpath + --dry-run file-to-file copy (#880) A single-file --mkpath copy whose destination parent does not exist failed under --dry-run: make_path() only *reports* the directories it would create in a dry run, so change_dir#3 then tried to chdir into a parent that isn't there and aborted with "change_dir#3 ... failed". When the parent is genuinely missing in a dry run, skip the chdir and mark the destination as not-yet-present (dry_run++), exactly as the multi-file/dir-creation path already does, so the generator doesn't probe the missing tree. Gating it on the missing-parent case keeps an ordinary file-to-file dry run chdir'ing into and itemizing against an existing destination. Fixes: #880 Thanks to @pkzc for the report (#880). Co-authored-by: Stiliyan Tonev (Bark) --- main.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/main.c b/main.c index fd59e8771..229a88972 100644 --- a/main.c +++ b/main.c @@ -832,7 +832,16 @@ static char *get_local_name(struct file_list *flist, char *dest_path) dest_path = "/"; *cp = '\0'; - if (!change_dir(dest_path, CD_NORMAL)) { + if (dry_run && mkpath_dest_arg && do_stat(dest_path, &st) < 0) { + /* --mkpath would have created this parent dir, but a dry run did + * not, so don't chdir into it; flag the destination as not yet + * present (as the dir-creation path above does) so the generator + * doesn't try to compare against the missing tree (#880). Only + * the missing-parent case is touched, so an ordinary file-to-file + * dry run still itemizes against an existing destination. */ + dry_run++; + change_dir(dest_path, CD_SKIP_CHDIR); + } else if (!change_dir(dest_path, CD_NORMAL)) { rsyserr(FERROR, errno, "change_dir#3 %s failed", full_fname(dest_path)); exit_cleanup(RERR_FILESELECT); From c7ca5217a7abf9cb8756569c047c6e95414fceca Mon Sep 17 00:00:00 2001 From: Mike-Goutokuji <83477269+mike-goutokuji@users.noreply.github.com> Date: Sat, 30 May 2026 16:04:27 -0400 Subject: [PATCH 14/25] Always clear st out and validate nanoseconds before using it Otherwise we get errors. Fixes: https://github.com/RsyncProject/rsync/issues/927 --- flist.c | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/flist.c b/flist.c index 7c0a279e9..346540fbf 100644 --- a/flist.c +++ b/flist.c @@ -132,6 +132,18 @@ static int64 tmp_dev = -1, tmp_ino; #endif static char tmp_sum[MAX_DIGEST_LEN]; +#ifdef ST_MTIME_NSEC +/* Return st_mtim nsec if it is in the wire-valid range, else 0. */ +static inline uint32 wire_mtime_nsec_from_stat(const STRUCT_STAT *stp) +{ + unsigned long nsec = (unsigned long)stp->ST_MTIME_NSEC; + + if (nsec > MAX_WIRE_NSEC) + return 0; + return (uint32)nsec; +} +#endif + static char empty_sum[MAX_DIGEST_LEN]; static int flist_count_offset; /* for --delete --progress */ static int show_filelist_progress; @@ -1255,7 +1267,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist, int extra_len = file_extra_cnt * EXTRA_LEN; const char *basename; alloc_pool_t *pool; - STRUCT_STAT st; + STRUCT_STAT st = {0}; char *bp; if (strlcpy(thisname, fname, sizeof thisname) >= sizeof thisname) { @@ -1417,8 +1429,12 @@ struct file_struct *make_file(const char *fname, struct file_list *flist, } #ifdef ST_MTIME_NSEC - if (st.ST_MTIME_NSEC && protocol_version >= 31) - extra_len += EXTRA_LEN; + { + uint32 nsec = wire_mtime_nsec_from_stat(&st); + + if (nsec && protocol_version >= 31) + extra_len += EXTRA_LEN; + } #endif #if SIZEOF_CAPITAL_OFF_T >= 8 if (st.st_size > 0xFFFFFFFFu && S_ISREG(st.st_mode)) @@ -1473,9 +1489,13 @@ struct file_struct *make_file(const char *fname, struct file_list *flist, file->flags = flags; file->modtime = st.st_mtime; #ifdef ST_MTIME_NSEC - if (st.ST_MTIME_NSEC && protocol_version >= 31) { - file->flags |= FLAG_MOD_NSEC; - F_MOD_NSEC(file) = st.ST_MTIME_NSEC; + { + uint32 nsec = wire_mtime_nsec_from_stat(&st); + + if (nsec && protocol_version >= 31) { + file->flags |= FLAG_MOD_NSEC; + F_MOD_NSEC(file) = nsec; + } } #endif file->len32 = (uint32)st.st_size; @@ -2075,10 +2095,9 @@ static void send1extra(int f, struct file_struct *file, struct file_list *flist) } if (name_type != NORMAL_NAME) { - STRUCT_STAT st; - if (name_type == MISSING_NAME) - memset(&st, 0, sizeof st); - else if (link_stat(fbuf, &st, 1) != 0) { + STRUCT_STAT st = {0}; + + if (name_type != MISSING_NAME && link_stat(fbuf, &st, 1) != 0) { interpret_stat_error(fbuf, True); continue; } @@ -2210,7 +2229,7 @@ struct file_list *send_file_list(int f, int argc, char *argv[]) static const char *lastdir; static int lastdir_len = -1; int len, dirlen; - STRUCT_STAT st; + STRUCT_STAT st = {0}; char *p, *dir; struct file_list *flist; struct timeval start_tv, end_tv; From 499ed5e1aba8dce3a96eac8d9c245e029b38e30c Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 4 Jun 2026 01:16:50 +1000 Subject: [PATCH 15/25] fix: update skips different file type --- generator.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/generator.c b/generator.c index 17293521b..09e276d1f 100644 --- a/generator.c +++ b/generator.c @@ -1718,7 +1718,8 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, goto cleanup; } - if (update_only > 0 && statret == 0 && file->modtime - sx.st.st_mtime < modify_window) { + if (update_only > 0 && statret == 0 && stype == ftype + && file->modtime - sx.st.st_mtime < modify_window) { if (INFO_GTE(SKIP, 1)) rprintf(FINFO, "%s is newer\n", fname); #ifdef SUPPORT_HARD_LINKS From c14e2258b59a3f16a99c6e83a28660ba438a73e9 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 6 Jun 2026 15:37:25 +1000 Subject: [PATCH 16/25] build: openat2 autodetect + android probe (R1 #924/#905/#900, R10 #904) configure now probes for + SYS_openat2 and defines HAVE_OPENAT2 only when both are present; syscall.c gates the openat2 include and the openat2(RESOLVE_BENEATH) tier on HAVE_OPENAT2, so the build no longer fails on kernels/headers that lack the openat2 header (3.4.3 included it unconditionally on Linux). android.c probes openat2 usability behind a SIGSYS handler so the Android/Termux seccomp sandbox falls back to the portable resolver instead of killing the process. Backport combining c73e0063, 83a24c21, the syscall.c guards from 1d5b5ab8, and 4634b0ad; the --disable-openat2/gcov coverage knobs and test changes are omitted. Thanks to @mmayer (#924), @fda77 (#905), @darkshram (#900) and @ketas (#904) for the reports. --- Makefile.in | 12 ++++---- android.c | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ configure.ac | 22 ++++++++++++++ syscall.c | 23 +++++++++++---- 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 android.c diff --git a/Makefile.in b/Makefile.in index 42d4319c5..79060b3fc 100644 --- a/Makefile.in +++ b/Makefile.in @@ -44,7 +44,7 @@ LIBOBJ=lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \ zlib_OBJS=zlib/deflate.o zlib/inffast.o zlib/inflate.o zlib/inftrees.o \ zlib/trees.o zlib/zutil.o zlib/adler32.o zlib/compress.o zlib/crc32.o OBJS1=flist.o rsync.o generator.o receiver.o cleanup.o sender.o exclude.o \ - util1.o util2.o main.o checksum.o match.o syscall.o log.o backup.o delete.o + util1.o util2.o main.o checksum.o match.o syscall.o android.o log.o backup.o delete.o OBJS2=options.o io.o compat.o hlink.o token.o uidlist.o socket.o hashtable.o \ usage.o fileio.o batch.o clientname.o chmod.o acls.o xattrs.o OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ @@ -53,7 +53,7 @@ popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ -TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ +TLS_OBJ = tls.o syscall.o android.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ # Programs we must have to run the test cases CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ @@ -178,19 +178,19 @@ getgroups$(EXEEXT): getgroups.o getfsdev$(EXEEXT): getfsdev.o $(CC) $(CFLAGS) $(LDFLAGS) -o $@ getfsdev.o $(LIBS) -TRIMSLASH_OBJ = trimslash.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o +TRIMSLASH_OBJ = trimslash.o syscall.o android.o util2.o t_stub.o lib/compat.o lib/snprintf.o trimslash$(EXEEXT): $(TRIMSLASH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(TRIMSLASH_OBJ) $(LIBS) -T_UNSAFE_OBJ = t_unsafe.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o +T_UNSAFE_OBJ = t_unsafe.o syscall.o android.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o 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_OBJ = t_chmod_secure.o syscall.o android.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) -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_OBJ = t_secure_relpath.o syscall.o android.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) diff --git a/android.c b/android.c new file mode 100644 index 000000000..0094b61e5 --- /dev/null +++ b/android.c @@ -0,0 +1,82 @@ +/* + * Android-specific helpers. + * + * openat2() usability probe + * ------------------------- + * openat2(2) is invoked directly via syscall() because the C library lacked a + * wrapper for it for years. Under a seccomp filter that uses + * SECCOMP_RET_TRAP -- as the Android application sandbox does -- a disallowed + * syscall raises SIGSYS and *kills the process* rather than failing with + * ENOSYS, so inspecting errno after the call is too late. We therefore probe + * openat2() once, behind a temporary SIGSYS handler, so a trapped syscall is + * caught and secure_relative_open_linux() can fall back to the portable + * per-component O_NOFOLLOW resolver instead of the whole process dying. + * + * This is only needed on Android, so the probe body is compiled only there. + * __ANDROID__ is defined by Bionic's headers and reflects the *target*, not + * the build host: it is set both for NDK cross-compiles (from a Linux/macOS + * host) and for native Termux builds, and is unset on every other platform. + * That makes it a reliable compile-time switch for cross builds -- there is + * nothing to detect in configure. Everywhere else openat2() is never + * seccomp-trapped to SIGSYS (a missing syscall simply returns ENOSYS), so + * openat2_usable() collapses to a constant 1 with no run-time cost. + */ + +#include "rsync.h" + +#if defined(__ANDROID__) && defined(HAVE_OPENAT2) + +#include +#include +#include + +static sigjmp_buf openat2_probe_env; + +static void openat2_probe_handler(int signo) +{ + (void)signo; + siglongjmp(openat2_probe_env, 1); +} + +#endif + +int openat2_usable(void) +{ +#if defined(__ANDROID__) && defined(HAVE_OPENAT2) + static int cached = -1; + struct sigaction sa, old_sa; + + if (cached >= 0) + return cached; + + memset(&sa, 0, sizeof sa); + sa.sa_handler = openat2_probe_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGSYS, &sa, &old_sa) != 0) + return cached = 0; + + if (sigsetjmp(openat2_probe_env, 1) != 0) { + /* SIGSYS delivered: openat2 is blocked by a seccomp filter. */ + cached = 0; + } else { + struct open_how how; + int fd; + memset(&how, 0, sizeof how); + how.flags = O_RDONLY | O_DIRECTORY; + how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; + fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how); + if (fd >= 0) + close(fd); + /* Usable only if the probe actually succeeded. Any failure -- + * ENOSYS (kernel < 5.6), a seccomp SECCOMP_RET_ERRNO denial + * (EPERM/EACCES), or EINVAL (RESOLVE_BENEATH unsupported) -- + * means we must fall back to the portable O_NOFOLLOW walk. */ + cached = fd >= 0; + } + + sigaction(SIGSYS, &old_sa, NULL); + return cached; +#else + return 1; +#endif +} diff --git a/configure.ac b/configure.ac index ff7c9aeb7..015fe0390 100644 --- a/configure.ac +++ b/configure.ac @@ -331,6 +331,28 @@ AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[ ]], [[return 0;]])], CFLAGS="$OLD_CFLAGS" AC_SUBST(NOEXECSTACK) +dnl Only define HAVE_OPENAT2 when both the header and the +dnl SYS_openat2 syscall number are present. syscall.c uses openat2(RESOLVE_BENEATH) +dnl for the secure resolver on Linux 5.6+; defining it unconditionally broke the +dnl build on older kernels/headers that lack the header (#924, #905, #900). +AC_CACHE_CHECK([for openat2],rsync_cv_HAVE_OPENAT2,[ + AC_COMPILE_IFELSE([ + AC_LANG_PROGRAM([[ +#include +#include +]], [[ +struct open_how how; +how.resolve = RESOLVE_BENEATH; +return SYS_openat2 + (int)how.resolve; +]]) + ], + [rsync_cv_HAVE_OPENAT2=yes], [rsync_cv_HAVE_OPENAT2=no]) +]) +if test x"$rsync_cv_HAVE_OPENAT2" = x"yes"; then + AC_DEFINE([HAVE_OPENAT2], 1, + [Define to use Linux openat2(RESOLVE_BENEATH) in secure_relative_open where available.]) +fi + # arrgh. libc in some old debian version screwed up the largefile # stuff, getting byte range locking wrong AC_CACHE_CHECK([for broken largefile support],rsync_cv_HAVE_BROKEN_LARGEFILE,[ diff --git a/syscall.c b/syscall.c index d7de6bd8c..1c3d1c9a0 100644 --- a/syscall.c +++ b/syscall.c @@ -33,7 +33,7 @@ #include #endif -#ifdef __linux__ +#if defined(__linux__) && defined(HAVE_OPENAT2) #include #include #endif @@ -1693,7 +1693,20 @@ static int path_has_dotdot_component(const char *path) return 0; } -#ifdef __linux__ +#if defined(__linux__) && defined(HAVE_OPENAT2) +/* openat2(RESOLVE_BENEATH) via the raw syscall, gated on openat2_usable() so a + * seccomp filter that traps openat2 with SIGSYS (e.g. the Android sandbox) + * makes us report ENOSYS and fall back rather than killing the process. Only + * the openat2 call is gated here; a plain openat() is always safe to attempt. */ +static int openat2_beneath(int dirfd, const char *path, const struct open_how *how) +{ + if (!openat2_usable()) { + errno = ENOSYS; + return -1; + } + return syscall(SYS_openat2, dirfd, path, how, sizeof *how); +} + static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) { struct open_how how; @@ -1722,12 +1735,12 @@ static int secure_relative_open_linux(const char *basedir, const char *relpath, 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); + dirfd = openat2_beneath(AT_FDCWD, basedir, &bhow); if (dirfd == -1) return -1; } - retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how); + retfd = openat2_beneath(dirfd, relpath, &how); if (dirfd != AT_FDCWD) close(dirfd); @@ -1853,7 +1866,7 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo } } -#ifdef __linux__ +#if defined(__linux__) && defined(HAVE_OPENAT2) { int fd = secure_relative_open_linux(basedir, relpath, flags, mode); /* ENOSYS = kernel < 5.6 doesn't have the syscall even though From ee4f668f295774ddd9764886ddd65014a57906bc Mon Sep 17 00:00:00 2001 From: pterror Date: Fri, 5 Jun 2026 17:24:05 +1000 Subject: [PATCH 17/25] receiver: fix NULL deref on the delta discard path receive_data() crashed a receiver that was merely DISCARDING a file's delta stream. discard_receive_data() calls receive_data() with fname == NULL and fd == -1, so size_r == 0 and mapbuf == NULL. A normal block-MATCH token (against a block the basis and source share) then reaches the !mapbuf branch added in 31fbb17d ("receiver: fix absolute --partial-dir delta resume"), which calls full_fname(fname). full_fname() dereferences its argument unconditionally (util1.c: `if (*fn == '/')`), so fname == NULL faults there -> receiver SIGSEGV. This is a normal-operation crash with a stock cooperating sender, not an adversarial one. The generator hands the sender real block sums whenever the basis is readable and we're in delta mode; the receiver only decides to discard afterwards, when its output cannot be produced -- e.g. the destination directory is not writable (mkstemp fails), the basis turns out to be a directory, or a --partial-dir resume is skipped. A MATCH token arriving during that discard hit the NULL deref. The 31fbb17d branch is correct only for a REAL output transfer (fd != -1, fname valid): there, a block match with no mapped basis is a genuine protocol inconsistency (the generator promised a basis the receiver could not open), and honoring it would silently omit those bytes from the verification checksum or leave a hole, so hard-erroring -- and full_fname(fname) -- is right. It conflated that with the discard path. The discriminator is fd, not mapbuf: on the discard path fd == -1 always; on the real-output inconsistency fd != -1. Scope the "no basis file" protocol error to fd != -1 (where fname is non-NULL and full_fname is safe) and, on the discard path (fd == -1), absorb the matched bytes benignly (offset += len; continue) -- symmetric with the literal-token handling just above, and restoring the pre-31fbb17d behavior. The real-transfer inconsistency check is preserved unchanged. --- receiver.c | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/receiver.c b/receiver.c index cb7978419..30855e13d 100644 --- a/receiver.c +++ b/receiver.c @@ -423,16 +423,32 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, stats.matched_data += len; - /* A block match can only be honored if we actually mapped the - * basis. If we didn't (basis open failed), the sender should - * never have been told a basis existed -- treat it as a protocol - * inconsistency rather than silently omitting these bytes from - * the verification checksum (which yields a spurious failure) or - * leaving a hole in the output. */ + /* A block match with no mapped basis is a protocol inconsistency + * ONLY when we are actually producing output (fd != -1): the + * generator told the sender a basis existed but the receiver could + * not open it, so honoring the match would silently omit these + * bytes from the verification checksum (a spurious failure) or + * leave a hole in the output. Fail cleanly in that case. + * + * On the DISCARD path (fd == -1, fname == NULL) there is no output + * and no verification: discard_receive_data() deliberately drains a + * delta the receiver never intends to write (basis fstat failed, + * basis is a directory, output open failed, batch skip, ...). The + * sender does not know the data is being discarded and streams an + * ordinary delta, so a match token here is NORMAL protocol, not + * malformed. Absorb it benignly (advance the offset and continue), + * as the pre-existing "if (mapbuf)" guards did before this check was + * added in 31fbb17d -- erroring would wrongly break legitimate + * transfers, and full_fname(fname) with fname==NULL would + * dereference NULL (a receiver crash on a normal transfer). */ if (!mapbuf) { - rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", - full_fname(fname), who_am_i()); - exit_cleanup(RERR_PROTOCOL); + if (fd != -1) { + rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", + full_fname(fname), who_am_i()); + exit_cleanup(RERR_PROTOCOL); + } + offset += len; + continue; } if (DEBUG_GTE(DELTASUM, 3)) { From 517c35e2db1bf97d56e98386562d99f677a25e05 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 6 Jun 2026 19:12:51 +1000 Subject: [PATCH 18/25] ci: also run the build workflows on *-stable release branches The workflows triggered only on 'master', so PRs targeting a release branch (e.g. v3.4-stable for 3.4.4) got no CI. Add a '*-stable' branch wildcard to the push and pull_request filters. --- .github/workflows/almalinux-8-build.yml | 4 ++-- .github/workflows/cygwin-build.yml | 4 ++-- .github/workflows/freebsd-build.yml | 4 ++-- .github/workflows/macos-build.yml | 4 ++-- .github/workflows/netbsd-build.yml | 4 ++-- .github/workflows/openbsd-build.yml | 4 ++-- .github/workflows/solaris-build.yml | 4 ++-- .github/workflows/ubuntu-22.04-build.yml | 4 ++-- .github/workflows/ubuntu-build.yml | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index 9d7ea782e..788cbd3f8 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -8,12 +8,12 @@ name: Test rsync on AlmaLinux 8 on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/almalinux-8-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/almalinux-8-build.yml' diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 781e46953..1237e2ba6 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -2,12 +2,12 @@ name: Test rsync on Cygwin on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/cygwin-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/cygwin-build.yml' diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index 79633ad16..2bb2d7569 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -2,12 +2,12 @@ name: Test rsync on FreeBSD on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/freebsd-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/freebsd-build.yml' diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index a127526e6..ba2c04dd5 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -2,12 +2,12 @@ name: Test rsync on macOS on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/macos-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/macos-build.yml' diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml index 770d7124c..60d115536 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -2,12 +2,12 @@ name: Test rsync on NetBSD on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/netbsd-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/netbsd-build.yml' diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 749724cd6..ce0440cff 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -2,12 +2,12 @@ name: Test rsync on OpenBSD on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/openbsd-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/openbsd-build.yml' diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml index e41e002dc..7c274d514 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -2,12 +2,12 @@ name: Test rsync on Solaris on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/solaris-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/solaris-build.yml' diff --git a/.github/workflows/ubuntu-22.04-build.yml b/.github/workflows/ubuntu-22.04-build.yml index 0e608279e..c8e8f83a6 100644 --- a/.github/workflows/ubuntu-22.04-build.yml +++ b/.github/workflows/ubuntu-22.04-build.yml @@ -6,12 +6,12 @@ name: Test rsync on Ubuntu 22.04 on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/ubuntu-22.04-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/ubuntu-22.04-build.yml' diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 5efadce5b..d7f1c9eba 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -2,12 +2,12 @@ name: Test rsync on Ubuntu on: push: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/ubuntu-build.yml' pull_request: - branches: [ master ] + branches: [ master, '*-stable' ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/ubuntu-build.yml' From bb8d1c14c54124a2ef0367a3369e431879ab15e7 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 8 Jun 2026 12:32:49 +1000 Subject: [PATCH 19/25] NEWS: add the 3.4.4 release entry Add the NEWS entry for rsync 3.4.4 (8 June 2026): the backported regression fixes, the PORTABILITY note documenting the #915 alt-basis platform limitation, the openat2 autodetect/mknodat fallback build notes, the stable-testsuite CI addition, and a CREDITS section for the contributors, reporters, and the PR #980 review. --- NEWS.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/NEWS.md b/NEWS.md index da09538b4..2ce8063d5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,95 @@ +# NEWS for rsync 3.4.4 (8 June 2026) + +## Changes in this version: + +This is a conservative point release that backports regression fixes +on top of 3.4.3. No new features are included. + +### BUG FIXES: + +- Honour a relative alt-basis directory (e.g. `--link-dest=../sibling`, + `--copy-dest`, `--compare-dest`) on a daemon receiver running with + `use chroot = no`. Such a path is re-anchored at the module root but + was then rejected by the receiver's secure open; it now works where + kernel-enforced confinement is available. See the PORTABILITY note + below for the platform limitation. Fixes #915. + +- sender: open a module-root-absolute path for a `path = /` module so a + daemon serving the filesystem root can satisfy absolute request + paths again. Fixes #897. + +- flist: accept the missing-args mode-0 entry in recv_file_entry. + Fixes #910. + +- receiver: fix a false "failed verification -- update discarded" when + resuming a delta transfer with an absolute `--partial-dir`. + +- receiver: fix a NULL dereference on the delta discard path. + +- generator: cap the block s2length at the negotiated checksum length. + +- main: fix `--mkpath` with `--dry-run` for a file-to-file copy. + Fixes #880. + +- daemon: un-backslash escaped option args. Fixes #829. + +- token: drain the matched-block insert deflate. Fixes #951. + +- Fix the "update skips a file of a different type" case and the + daemon upload delete stats. + +- alloc: revert "zero all new memory from allocations". Fixes #959. + +- Always clear the stat buffer and validate nanoseconds before use. + +### PORTABILITY / BUILD: + +- The relative alt-basis fix for daemon receivers (#915) relies on + kernel "stay below dirfd" path resolution -- `openat2(RESOLVE_BENEATH)` + on Linux 5.6+, or `openat()` with `O_RESOLVE_BENEATH` on FreeBSD 13+ + and macOS 15+. On platforms that lack it (Solaris, OpenBSD, NetBSD, + Cygwin and older Linux) `secure_relative_open()` deliberately rejects + any path with a `..` component, so relative alt-basis directories + remain unavailable there -- function traded for safety, matching the + trade-off already documented for the #715 fix. Absolute alt-basis + paths are unaffected on every platform. + +- openat2 is now autodetected at configure time (HAVE_OPENAT2): the + `openat2(RESOLVE_BENEATH)` resolver is compiled in only when both + `` and the `SYS_openat2` syscall number are present, + fixing the build on older kernels/headers. Fixes #924, #905, #900, + #904. + +- Fall back to do_mknod() when mknodat() / mkfifoat() are unavailable. + Fixes #896. + +- Install generated manpages correctly in an out-of-tree build. + +### DEVELOPER RELATED: + +- Added a CI workflow that builds this stable branch and runs the + `v34-stable-testsuite` regression suite against the built binary, + giving regression coverage without importing the full master test + suite into the stable branch. + +- Added a check-progs target for fleettest and extended the build + workflows to run on `*-stable` release branches. + +### CREDITS: + +Thanks to everyone who helped with this release: + +- Code contributions from Zen Dodd (steadytao), Mike-Goutokuji, + pterror, and Stiliyan Tonev (Bark). + +- Zen Dodd (steadytao) also reviewed the 3.4.4 backport set (PR #980). + +- Bug reports from @mmayer (#924), @fda77 (#905), @darkshram (#900), + @ketas (#904), @pkzc (#880), @brabalan (#951), @elcamlost (#829), + @debohman (#896), @guilherme-puida (#959), @fufu65 (#915), + @JetAppsClark (#928), @moonlitbugs (#897), @mgkeeley (#910), and + @sylvain-ilm (#724, #725). + # NEWS for rsync 3.4.3 (20 May 2026) ## Changes in this version: From 5073e6a575609ef4749cb7455e184282bb894047 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 8 Jun 2026 08:50:22 +1000 Subject: [PATCH 20/25] ci: run the v34-stable-testsuite regression suite against this build The stable branch keeps the old shell test suite, so the modern Python suite lives on the v34-stable-testsuite branch. Build rsync here and run that suite against the built binary (helpers/config.h as tooldir from this build, test scripts via --srcdir), giving regression coverage for 3.4.x without importing the full master suite. Runs on ubuntu-latest and ubuntu-22.04 (older-LTS coverage for backports). Each does a pipe-transport pass (with the same RSYNC_EXPECT_SKIPPED list the v34-stable-testsuite ubuntu jobs use) and a --use-tcp pass for the daemon tests the pipe run skips. Addresses review on PR #980. --- .github/workflows/stable-testsuite.yml | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/stable-testsuite.yml diff --git a/.github/workflows/stable-testsuite.yml b/.github/workflows/stable-testsuite.yml new file mode 100644 index 000000000..1cc41d727 --- /dev/null +++ b/.github/workflows/stable-testsuite.yml @@ -0,0 +1,76 @@ +name: Stable testsuite + +# Regression coverage for the 3.4.x stable branch. The stable branch keeps the +# old shell test suite, so the modern Python suite is maintained separately on +# the v34-stable-testsuite branch. This job builds rsync from this branch and +# runs that suite against the freshly-built binary (the same "testsuite from one +# branch, code from another" split fleettest uses). Helper programs and +# config.h come from this branch's build (tooldir); the test scripts come from +# the stable-testsuite checkout (--srcdir). + +on: + push: + branches: [ v3.4, '*-stable' ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/stable-testsuite.yml' + pull_request: + branches: [ v3.4, '*-stable' ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/stable-testsuite.yml' + workflow_dispatch: + schedule: + - cron: '23 6 * * *' + +jobs: + stable-testsuite: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, ubuntu-22.04 ] + name: Stable testsuite on ${{ matrix.os }} + steps: + - name: checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: checkout stable testsuite + uses: actions/checkout@v4 + with: + repository: RsyncProject/rsync + ref: v34-stable-testsuite + path: stable-testsuite + fetch-depth: 1 + - name: prep + run: | + sudo apt-get update + sudo apt-get install -y gcc g++ gawk autoconf automake \ + acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev \ + python3-cmarkgfm openssl + echo "/usr/local/bin" >>$GITHUB_PATH + - name: configure + run: ./configure --with-rrsync + - name: make check-progs + run: make check-progs + - name: info + run: ./rsync --version + # Pipe transport (the secure stdio default). The TCP-only daemon tests + # (daemon-access-ip, proxy-response-line-too-long) skip here and are run in + # the --use-tcp pass below; crtimes/daemon-chroot-acl/recv-discard-nullderef + # skip on the runner's filesystem / under root. + - name: run stable testsuite (pipe) + run: | + sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef \ + ./stable-testsuite/runtests.py \ + --srcdir="$GITHUB_WORKSPACE/stable-testsuite" \ + --rsync-bin="$GITHUB_WORKSPACE/rsync" \ + -j16 + # TCP transport over loopback, exercising the daemon paths the pipe run skips. + - name: run stable testsuite (tcp) + run: | + sudo ./stable-testsuite/runtests.py \ + --srcdir="$GITHUB_WORKSPACE/stable-testsuite" \ + --rsync-bin="$GITHUB_WORKSPACE/rsync" \ + --use-tcp -j8 From 37d0080e9278ebddae411b6be6acb0e5830d1f8c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 8 Jun 2026 10:57:35 +1000 Subject: [PATCH 21/25] t_stub: give test helpers an unlimited max_alloc Helpers link util2.o but not options.c, so they used the stub's max_alloc = 0, which makes every my_alloc()/my_strdup() in util2.c abort with "exceeded --max-alloc=0". CI didn't catch it because the openat2 path avoids those allocations, but the secure_relative_open() fallback hits my_strdup() and aborts. Set max_alloc = (size_t)-1, matching the v34-stable-testsuite fix. Reported by steadytao on PR #980. --- t_stub.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/t_stub.c b/t_stub.c index 723875ed6..8f2f8ea6e 100644 --- a/t_stub.c +++ b/t_stub.c @@ -36,7 +36,10 @@ int preserve_perms = 0; int preserve_executability = 0; int omit_link_times = 0; int open_noatime = 0; -size_t max_alloc = 0; /* max_alloc is needed when combined with util2.o */ +size_t max_alloc = (size_t)-1; /* unlimited: helpers link util2.o, where 0 makes + * every my_alloc()/my_strdup() abort with + * "exceeded --max-alloc=0" (hit on the + * secure_relative_open() fallback path). */ char *partial_dir; char *module_dir; /* curr_dir[]/curr_dir_len (read by secure_relative_open) are defined in From ed2950f867eb5e2b69b1da60c4f2daa69d8a0ea3 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 8 Jun 2026 10:59:05 +1000 Subject: [PATCH 22/25] version.h: bump to 3.4.4 for the release --- version.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.h b/version.h index 0b696415b..7417b9c4f 100644 --- a/version.h +++ b/version.h @@ -1,2 +1,2 @@ -#define RSYNC_VERSION "3.4.3" +#define RSYNC_VERSION "3.4.4" #define MAINTAINER_TZ_OFFSET 10.0 From f26f747b8017b321d3776becc69f330dd889fa21 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 8 Jun 2026 13:42:13 +1000 Subject: [PATCH 23/25] Preparing for release of 3.4.4 [buildall] --- NEWS.md | 3 ++- packaging/lsb/rsync.spec | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2ce8063d5..969c77513 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,4 @@ -# NEWS for rsync 3.4.4 (8 June 2026) +# NEWS for rsync 3.4.4 (8 Jun 2026) ## Changes in this version: @@ -5261,6 +5261,7 @@ to develop and test fixes. | RELEASE DATE | VER. | DATE OF COMMIT\* | PROTOCOL | |--------------|--------|------------------|-------------| +| 08 Jun 2026 | 3.4.4 | | 32 | | 20 May 2026 | 3.4.3 | | 32 | | 28 Apr 2026 | 3.4.2 | | 32 | | 16 Jan 2025 | 3.4.1 | | 32 | diff --git a/packaging/lsb/rsync.spec b/packaging/lsb/rsync.spec index 388d43447..676e593f3 100644 --- a/packaging/lsb/rsync.spec +++ b/packaging/lsb/rsync.spec @@ -1,6 +1,6 @@ Summary: A fast, versatile, remote (and local) file-copying tool Name: rsync -Version: 3.4.3 +Version: 3.4.4 %define fullversion %{version} Release: 1 %define srcdir src @@ -79,5 +79,5 @@ rm -rf $RPM_BUILD_ROOT %dir /etc/rsync-ssl/certs %changelog -* Wed May 20 2026 Rsync Project -Released 3.4.3. +* Mon Jun 08 2026 Rsync Project +Released 3.4.4. From f7ca0a772b790f5d1126da7080b234f5c4e4cd21 Mon Sep 17 00:00:00 2001 From: Stein Haugan Date: Mon, 8 Jun 2026 16:04:11 +0200 Subject: [PATCH 24/25] [ gitignore ] Update --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a1f912b77..582344d33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.[oa] *~ +/.vscode dummy ID Makefile From 14d555d3b98f81d1e5bc6479158c771c1153ed89 Mon Sep 17 00:00:00 2001 From: Stein Haugan Date: Mon, 8 Jun 2026 16:12:55 +0200 Subject: [PATCH 25/25] [ generator.c, options.c, rsync.1.md ] Add time-only option --- generator.c | 3 ++- options.c | 12 ++++++++++++ rsync.1.md | 17 ++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/generator.c b/generator.c index 09e276d1f..c6e38150e 100644 --- a/generator.c +++ b/generator.c @@ -69,6 +69,7 @@ extern int csum_length; extern int xfer_sum_len; extern int ignore_times; extern int size_only; +extern int time_only; extern OFF_T max_size; extern OFF_T min_size; extern int io_error; @@ -625,7 +626,7 @@ int quick_check_ok(enum filetype ftype, const char *fn, struct file_struct *file { switch (ftype) { case FT_REG: - if (st->st_size != F_LENGTH(file)) + if (!time_only && st->st_size != F_LENGTH(file)) return 0; /* If always_checksum is set then we use the checksum instead diff --git a/options.c b/options.c index 3c2d23526..b562649da 100644 --- a/options.c +++ b/options.c @@ -129,6 +129,7 @@ int copy_unsafe_links = 0; int munge_symlinks = 0; int use_secure_symlinks = 0; int size_only = 0; +int time_only = 0; int daemon_bwlimit = 0; int bwlimit = 0; int fuzzy_basis = 0; @@ -699,6 +700,7 @@ static struct poptOption long_options[] = { {"chmod", 0, POPT_ARG_STRING, 0, OPT_CHMOD, 0, 0 }, {"ignore-times", 'I', POPT_ARG_NONE, &ignore_times, 0, 0, 0 }, {"size-only", 0, POPT_ARG_NONE, &size_only, 0, 0, 0 }, + {"time-only", 0, POPT_ARG_NONE, &time_only, 0, 0, 0 }, {"one-file-system", 'x', POPT_ARG_NONE, 0, 'x', 0, 0 }, {"no-one-file-system",0, POPT_ARG_VAL, &one_file_system, 0, 0, 0 }, {"no-x", 0, POPT_ARG_VAL, &one_file_system, 0, 0, 0 }, @@ -2853,6 +2855,16 @@ void server_options(char **args, int *argc_p) args[ac++] = "--super"; if (size_only) args[ac++] = "--size-only"; + /* We don't send --time-only to server because: + * 1. sensible use of the --time-only option requires + * post-processing not done by rsync: compressing transferred + * files and truncating the original while preserving the timestamp. + * 2. older servers would croak. + * It may be a good idea to throw an error with a better error + * message... + */ + // if (time_only) + // args[ac++] = "--time-only"; if (do_stats) args[ac++] = "--stats"; } else { diff --git a/rsync.1.md b/rsync.1.md index 2b4b75087..bc6704eb7 100644 --- a/rsync.1.md +++ b/rsync.1.md @@ -504,6 +504,7 @@ has its own detailed description later in this manpage. --contimeout=SECONDS set daemon connection timeout in seconds --ignore-times, -I don't skip files that match size and time --size-only skip files that match in size +--time-only skip files that match in time (PULL ONLY) --modify-window=NUM, -@ set the accuracy for mod-time comparisons --temp-dir=DIR, -T create temporary files in directory DIR --fuzzy, -y find similar file for basis if no dest file @@ -777,6 +778,19 @@ expand it. after using another mirroring system which may not preserve timestamps exactly. +0. `--time-only` + + This modifies rsync's "quick check" algorithm for finding files that need + to be transferred, changing it from the default of transferring files with + either a changed size or a changed last-modified time to just looking for + files that have a changed last-modified time, ignoring size changes. This + is useful when remote files are uncompressed but a local copy should be + stored compressed, together with a zero-size stub to prevent re-transfers. + This option is only useful when pulling files, as it requires post-transfer + compression and truncation of files whilst preserving the original + modification time of the stub. Thus the option is not transmitted to the + remote side, also preventing the server from croaking on an unknown option. + 0. `--modify-window=NUM`, `-@` When comparing two timestamps, rsync treats the timestamps as being equal @@ -1032,7 +1046,8 @@ expand it. This forces rsync to skip any files which exist on the destination and have a modified time that is newer than the source file. (If an existing destination file has a modification time equal to the source file's, it - will be updated if the sizes are different.) + will be updated if the sizes are different - unless the --time-only flag + is set.) Note that this does not affect the copying of dirs, symlinks, or other special files. Also, a difference of file format between the sender and