rsync 3.5.0 security release - #1042
Merged
Merged
Conversation
The core symlink-race (TOCTOU) defense for the CVE-2026-29518 class: a portable secure resolver that walks a path one component at a time holding an O_NOFOLLOW dir fd per level (the dirfd-stack), plus the do_*_at() filesystem wrappers, the held-directory fd cache, secure_relative_open[_at](), the operator-path ownership walk (owner_walk_parent / open_no_attacker_symlinks, follow a uid0/euid symlink, refuse a foreign one) and its module-root confinement (abspath_excluded_by_module). util1.c routes change_dir / robust_rename / make_path / handle_partial_dir through it; the resolver bounds its deep-path fd use against RLIMIT_NOFILE. Also drops the obsolete android.c openat2 path and confines delete.c via the held dirfd.
Route every operator-supplied directory path (--temp-dir / --partial-dir / --backup-dir / alt-dest basis) and the transfer engine's own dest/source opens through the secure resolver + ownership walk, so a symlink owned by another uid can no longer redirect a read, a backup, a staging open, a rename, an unlink or a new-destination create outside the module. Covers backup.c, generator.c (alt/ link/in-place basis), receiver.c (basis open), sender.c (remove-source / source open / copy-links leaf), rsync.c (held-fd attr stat), clientserver.c (pid-file parent pin), and main.c (relative-basis make-absolute, mkpath dest-arg guard). --insecure-links / "insecure links = yes" is the local opt-out (a daemon never honors a peer-forwarded one).
The daemon exclude/filter chain is a name-based visibility/tamper filter, as in stock rsync (verified against 3.2.7): a symlink whose own name is not excluded is followed to an excluded target, and the documented symlink defense is `munge symlinks`, not the filter. Collapse ".." (via sanitize_path) before the daemon dest / temp-dir / backup-dir / partial-dir / basis filter checks so a "../excluded" path is matched by name like 3.2.7 (clean_fname's CFN_COLLAPSE_DOT_DOT_DIRS does not collapse "a/b/c/../../../secret"), and keep a leading "/" for a "path = /" module so an absolute filter rule still matches. The module-ROOT confinement of operator paths is unchanged (previous commit); only the in-module name match is restored to its 3.2.7 behaviour.
Apply ACLs and xattrs through a held file descriptor instead of by path, closing the symlink-race where an attacker swaps the leaf between the transfer and the metadata set. lib/acl.c provides fd/at POSIX-ACL primitives (the system libacl *_at where available, else a /proc/self/fd compat that never follows on the fallback); acls.c routes through them and stays functional (path-based) where the OS lacks a race-safe primitive; xattrs.c routes copy_xattrs through a held fd; -VV (usage.c) reports the runtime race-safe-ACL capability.
Refuse malformed/hostile wire input that could crash or corrupt the receiver: io.c (out-of-range file index, count*blength OFF_T overflow, read_args NUL room, deferred in_multiplexed), flist.c (sub-flist after the final flist is freed, FLAG_HLINKED on dirs / gated on preserve_hard_links, parent_ndx bound, cleared- slot ndx in the transfer phase), hlink.c (undeclared cross-flist gnum -> error not assert), match.c (clamp peer flength, re-check len before want_i), log.c (drop peer-reachable asserts and F_SUM deref), exclude.c (merge-file recursion cap, trailing-backslash heap fix), lib/pool_alloc.c (ASan redzone for pool underflow).
authenticate.c: seed gen_challenge() from /dev/urandom, add an "auth digest" floor to refuse weak negotiated digests, and fstat the opened --password-file fd rather than re-stat the pathname; checksum.c carries auth_digest_rank(). socket.c: reject control bytes in the daemon host before a proxy CONNECT and bind the stunnel server cert to the requested hostname. clientserver/access: warn when proxy-protocol fail-closes. loadparm + daemon-parm: only shell-quote %RSYNC_*% for shell-executed hooks, and add the auth-digest / proxy-protocol-hosts module parameters.
batch.c: single-quote every --write-batch replay-script argument, quote a "--opt=" prefix unless it is a plain option token, and refuse a newline in a filter rule written to the replay script. rsync-ssl: bind the server certificate to the requested hostname in stunnel mode.
…lies Pin each validated path component (and a receiver-side new destination's parent) with O_RDONLY|O_NOFOLLOW and pass /proc/self/fd/N to the exec'd rsync so the child cannot re-resolve the path; probe the /proc/self/fd magic-symlink at runtime (not just isdir); fail closed on a readlink anomaly; and don't abort when flock() is unavailable (Solaris).
The t_rename_secure / t_symlink_secure / t_acl unit harnesses (C), the rsyncfns.py helper library (daemon fixtures, symlink matrix, tree compare, xattr/ACL drivers), runtests.py, and the rsync_proto/xrsync/cmptree/mkvariety helper scripts that the security tests build on.
The TOCTOU / symlink-race suite for the secure resolver and operator-supplied paths: chdir/chmod/rename/mknod/source/dest symlink races, relative make_path and symlinked-parent cases, the operator-path matrix (--temp/partial/backup-dir, alt-dest basis, files-from, log-file, insecure-links), and the admin-file opens (--password-file / daemon secrets / config / log-file / early-input symlinks), plus the daemon module-confinement and chroot inner-module cases. Co-authored-by: Omar Elsayed <omarelsayed161@gmail.com>
The exclude/filter is a name filter, not a symlink boundary (3.2.7-equivalent): symlink-exclude family, daemon-exclude-namebased, the operator-path exclude / traversal / dir-daemon cases, filter-merge and implied-trailing-backslash. Co-authored-by: Omar Elsayed <omarelsayed161@gmail.com>
acl-symlink-race, acls-unpinnable, xattrs-hlink, and the chown/devices fake-super metadata tests.
The malformed-peer fuzz/crash regression tests: io flood/argv, proto cleared-flist/ ndx/hlink/selftest, match nullmap, max-alloc / size-arg / argv limits, sum-blocklen, nested-socket specials, malicious-sender delete scope, xattr-wire-cap.
auth-digest floor, namecvt empty/newline, proxy-protocol (CRLF, trusted-peer, over-long lines), connect-prog/exec/remote-shell quoting, unix-socket atfd, and the daemon scan cwd/dir-escape tests.
rrsync restriction tests, rsync-ssl hostname/CA validation, write-batch quoting/ filter-injection, backup-incremental, delay-updates, variety and xrsync.
Run the security test suite (pipe + real-TCP daemon transports, proto30/29, and a targeted non-root pass) across Ubuntu/macOS/Cygwin/AlmaLinux, with per-platform expected-skip baselines for the tests that legitimately skip there.
Gate the build on a pinned clang-18 analyzer run (deterministic checker set, --status-bugs fails on any new report) and run the latest clang informationally.
testsuite/fleettest.py builds the branch and runs the suite across a fleet of remote VMs (BSDs/Solaris/Ubuntu/macOS/Cygwin) over multiple transports, with a --cleanup that reaps orphaned daemons; the expect/*.expect files are the version-mixing manifests (current rsync vs old static peers).
NEWS.md: the 3.5.0 security-update section. SECURITY.md: the platform-residuals policy, symlink-race-safe path resolution, operator-path symlink defense, the name-based daemon exclude/filter clarification, and the known residuals.
--insecure-links and the SECURITY sections in rsync(1)/rsyncd.conf(5), the name-based exclude/filter + munge-symlinks clarification, the rsync-ssl hostname note, and the README (incl. the thank-you to Wayne Davison, 2004-2024).
The race-safe resolver's dirstack grew its fd array with realloc() inside the recursive ds_descend() walk and freed it via a passed pointer. clang's unix.Malloc analyzer cannot model that ownership and reported a false "Potential leak of ds.fds", failing the pinned-clang-18 scan-build gate. The walk holds one open fd per path component, so its depth is already bounded by RLIMIT_NOFILE; use a fixed inline array (DS_MAXDEPTH, mirroring DPC_MAXDEPTH) and drop the malloc/realloc/free entirely. ds_push() fails with ENOMEM past the cap. No behaviour change -- 1024 levels exceeds any reachable depth.
variety is the heaviest test in the suite; on slow platforms (Cygwin) the per-component O_NOFOLLOW resolver pushes it past the default 300s per-test timeout. Give it the same 600s budget the hardlinks test already gets.
Add a per-target `xfail` field (merged with the global --xfail) so a known platform/version-specific failure can be tolerated persistently without a command-line flag -- the test still runs, and if it passes the entry is a no-op. Mark crtimes xfail on mac2: older backport binaries (3.4.x/3.2.7) drive APFS birthtime via setattrlist differently than current rsync, so the 3.5.0 testsuite's crtimes check fails there; it passes for a current binary.
Adds the coverage/regression tests from the code-scanner run and the
gcov plumbing they rely on:
- scanner-argv-bounds, scanner-batch-flag-mismatch,
scanner-delete-delay-overread, scanner-daemon-log-checksum:
regression tests for the argv/-v/--info/--skip-compress bounds, the
batch metadata-ndx corruption, the read_delay_line off-by-one, and
the daemon -c/%C checksum-slot leak.
- daemon-proxy-protocol, daemon-early-exec-nameconv, daemon-auth-group,
daemon-standalone-detach, misc-coverage, nonroot-restrictive-perms,
backup-acl-xattr-cache: daemon and path coverage tests.
- rsyncfns.py: CAP_MKNOD probe in devices_supported().
- gcov_flush() macro (rsync.h) + calls in the daemon fork/_exit paths
(clientserver.c, socket.c); no-op without --enable-coverage. Makefile.in
COVERAGE_EXCLUDE / gcovr / setuid .gcda refinements.
- CI: list the new TCP/root/ACL tests in the per-platform
RSYNC_EXPECT_SKIPPED sets.
Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
recv_file_entry (normal and XMIT_HLINKED abbrev branches) and make_file left F_RDEV_P / the symlink-name slot uninitialized when the matching preserve option was off, so a later read walked into adjacent pool memory. Empty the symlink name when !preserve_links, zero F_RDEV_P when !preserve_devices, and mirror both in the abbreviated branch. receiver.c saves/restores the --write-devices S_IFBLK mode flip around receive_data so dest_mode() never sees the mutated mode. Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
hlink.c must confirm S_ISREG before quick_check_ok(FT_REG,...) reads F_SUM, and start_server() must set sender_keeps_checksum when a daemon sender runs -c with a %C log format so make_file() allocates SUM_EXTRA_CNT. Without these, F_SUM() reads past the pool slot and (for %C) hex-encodes adjacent heap into the transfer log. Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
A '!'-prefixed delete-delay entry computed one byte short, dropping the final character of the name. Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
Move the uid/gid/acls/xattrs *_ndx assignments past check_batch_flags(): a mismatched-flag batch otherwise wrote F_XATTR(file) at offset 0 of every file_struct, clobbering file->dirname. parse_negotiate_str() no longer short-circuits on am_server, so each side picks its own #1 mutual digest/checksum/compress choice rather than deferring to the peer's order; man pages updated to match. Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
Cap -v repetition so the argstr[64] global can't overflow, clamp a negative --info/--debug level out of counts[], and cap a --skip-compress suffix token at 32 bytes. Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
…efusal io.c only treats a short read as the EOF sentinel when the fd is still open; xattrs.c never stores a -1 from find_matching_xattr() and guards ndx < 0 in set_xattr; rsync-ssl refuses the gnutls backend without RSYNC_SSL_CA_CERT. Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
The tcp pass re-ran the whole suite over the same build the pipe pass had just swept, but --use-tcp is observable through exactly one code path: RSYNC_TEST_USE_TCP is read once (rsyncfns USE_TCP) and acted on once (in start_test_daemon). A test that never reaches there cannot tell the two passes apart, so 186 of the 340 tests were producing the same result twice. runtests.py --daemon-tests-only keeps the tests that can reach the daemon transport, matched against the closure of every rsyncfns helper leading to USE_TCP/start_rsyncd/claim_ports plus the modules that open a daemon connection themselves. The token list is deliberately over-broad and an unreadable test is kept, so the filter can only ever run too much; audited against the tests it drops, none of which reach the transport (their "daemon" hits are the unix username, a macOS ACL principal, mount --bind, and docstrings declaring the test local-only). The dropped count is always printed rather than left implicit. The narrowing is only sound as the second half of a pipe+tcp pair, so it is gated on the pipe pass having run: under --transport tcp that pass is the only one there is, and narrowing it would drop the other 186 tests from the run altogether. --full-tcp forces the full sweep either way. Measured on the full suite: serial work 558s -> 367s.
A daemon test could leave an orphaned rsyncd squatting its port even when every test PASSED, so nothing in the results pointed at it. On Cygwin the orphan then wedged the whole fleet: it kept the ssh session from closing, so fleettest's run_on() blocked until its 2400s timeout and unrelated tests failed with 300s timeouts as collateral. One such wedge cost a fleet run 21 minutes. Cause: rsyncd forks a child per connection, but _stop_rsyncd only killed the parent -- the one pid the Popen handle knows. A child still winding up or down when the test ended survived, inherited the listening socket, and was reparented to init. Cygwin turned that from untidy into unrecoverable: its signals are cooperative, delivered by a helper thread inside the target, so a process sitting in a Windows call ignores even SIGKILL. kill, killpg and pkill all failed against it, which also defeated the orphan reapers and fleettest --cleanup. Snapshot the daemon's children before killing it (once the parent is gone they are reparented and no longer identifiable as ours) and kill them too, re-checking with _pid_is_rsync before each signal so a pid recycled in the meantime is never signalled. Where signals cannot win, fall back to terminating the winpid via taskkill; fleettest --cleanup gets the same fallback, so it can no longer report SURVIVED and leave the port squatted. _reap_group() reports success only once the daemon is confirmed gone rather than when a signal was merely accepted -- on Cygwin a signal is routinely accepted by a process that then ignores it -- and confirms with a bounded poll, because SIGKILL is asynchronous and calling a still-terminating process "alive" would make _probe_bindable() skip its retry and fail a test for a port that was about to free itself. _cleanup_rsyncd() keeps the port's pid record only while it still names a live rsync. Keying that on the port being busy instead looks safer but is worse: a port sits in TIME_WAIT after a passing test, so a record naming an already-dead pid would be retained forever, and nothing clears such a record -- yet no reaper can use it either, since they all reject it at the _pid_is_rsync guard, leaving only the hazard that its pid is recycled onto an unrelated rsync. The daemon stays in the TEST's process group on purpose: runtests.py killpg's that group on a per-test timeout, and that is what keeps a timed-out test from stranding its daemon. An earlier version of this fix gave the daemon its own group so one killpg would catch the children -- which silently broke that sweep, and a full Cygwin pass then stranded two parent daemons when variety hit its timeout. Two residual limitations are documented in the code rather than left to be rediscovered: _kill_pid's check-then-signal is inherently a TOCTOU (narrowed to microseconds, not closed; closing it needs pidfd or retained Windows handles across seven platforms), and _stop_rsyncd cannot collect children when the parent has already exited on its own, because the parent-child link it relies on is gone by then. Measured on a Cygwin VM, 4 proxy/daemon tests x 8 runs at -j4: before 5/8 runs left an orphan (one left two), after 0/10. All tests passed in every run, before and after -- which is the point: the leak was invisible to the suite. A test killed by the runner's timeout still leaves no daemon behind.
A filter rule that fails to parse was printed back verbatim. When the rule came from a file rather than an argument, that text is file CONTENT, and the peer picks which file gets merged: a per-directory merge rule travels over the protocol, so no argument of ours ever names it and nothing a wrapper can see mentions it either. Any line that is not valid filter syntax therefore came straight back to the peer -- a read-any-line oracle over an rrsync restricted account or a daemon module, neither of which confines the merge open. The syntax errors turned out to be the smaller half. The MATCH trace names the pattern that acted, and report_filter_result() logs at level 1 for a sender or generator, so plain -vv -- no --debug, nothing a stock client cannot send -- returns a server-side merge file's rules: [generator] protecting file X because of pattern <the file's text> So provenance is carried on the rule itself (FILTRULE_FROM_FILE), not just in the parser: a deferred ":" merge is processed long after the file that named it was read, and its own name is file content too. TEXT_FROM_FILE() consults the parse-time context and the rule, so both the immediate and the deferred paths redact. Rather than test the provenance at each message -- which is how the last few of these were found, one at a time, after the ones before them were fixed -- every string that is or is built from a rule's own text goes through rule_text(). It returns the text for an argument-supplied rule and a description of where it came from otherwise, so a message added later cannot reintroduce the leak by forgetting to check, and there is one place to audit. rule_detail() does the same for the extra detail a message adds ABOUT the text: a character of it, an offset into it, the [not found] bit. Thirteen sites now route through them: the syntax errors; the modifier character (one byte of the file, a slower oracle but still one); the failed-open and merge-depth messages, whose pathname is file content whenever a rule named it -- and errno with them, since it answers "does this path exist"; both over-long messages, the deferred one of which needed no verbosity at all; both merge-name overflows; the long-named directory error; the [not found] openability bit; the match trace; the add_rule, parse_filter_file and daemon-hidden traces; and the per-dir mergelist label, which had the name baked in. rule_detail() covers more than it first looks: the trailing-whitespace CAUTION is computed from the rule's last byte, and "hidden by daemon filter" distinguishes a daemon-filter rejection from an ordinary open, so both would answer questions about text the peer cannot see. The regression proves the chokepoint rather than the sites: making rule_text() return its input unconditionally fails the test. It also pins what must NOT change for the user's own rules -- the whitespace warning still fires, and an over-long argument rule is still reported at full length (the helper buffers at BIGPATHBUFLEN, as rprintf does, so redaction does not quietly truncate what the user typed). Bounded and left alone: the numeric rflags in the FILTER2 trace and the in/exclude wording still describe a file-derived rule without quoting it, and the daemon's own FLOG line records the name it filtered -- that one goes to the operator's log, not the peer. Rules given AS arguments are still echoed in full -- that text is the user's own, and hiding it would only make ordinary typos harder to fix. Where a rule did come from a file, the diagnostic names the file and line instead, which is more useful anyway. Two things the location itself needed: fname can point into parse_merge_name()'s static buffer, which a merge rule inside the same file overwrites while we are still reading it, so a rule after a nested merge was blamed on the nested file -- keep our own copy. And a CRLF pair was counted as two line endings while word-split mode counted tokens rather than lines, so the number pointed at nothing; consume the LF of a CRLF (preserving the byte for the next rule if pushback ever fails), and report word-split sources without a line number. Not covered, deliberately: a rule's provenance is not serialized by send_filter_list(), so it does not survive to the far side. That is right -- only the client sends that list, and the server already knows the patterns the peer gave it.
The --debug=FILTER traces print rule text and merge-file names that came
out of a file's contents -- and a word-split per-dir merge (":w- FILE")
turns every word of a file into a merge-file name, so the trace echoes
what the syntax errors no longer do, with nothing failing to parse.
Redacting every trace would mean carrying provenance on each rule, which
a deferred ":" merge does not currently keep. For a restricted account
the cheaper answer is to deny the peer the switch: server_options() only
ever forwards --info, so no stock client sends --debug to a server and
the only way it arrives is a deliberate -M--debug=. An operator
debugging their own server is unaffected.
Disabled rather than deleted from the table, because that table is
generated by the cull-options script and a regeneration would put the
line back; the test would then catch it.
The exclude-self rule that a ":e" merge synthesizes is built by hand with
new0(), so it inherited no flags. While the merge file was still being
parsed the global parse state masked that, but once parsing finished the
stored rule looked argument-origin, and report_filter_result() printed its
pattern -- a merge file's own text -- verbatim:
[sender] hiding file PAT-x9 because of pattern PAT-[x]9 [per-dir ...]
Plain -vv reaches this on a stock client; no --debug is involved. That is
the fifth site of this shape, and the first to get there by constructing a
rule rather than by printing one, so the redaction helper could not catch it.
Also fix the location a per-directory merge reports. Its fname points into
dirbuf, which is cut back to the directory before the name was saved, so the
error said "<rule from .../src/ line 1>" instead of naming .rsync-filter --
no leak, but it breaks the "redact what, keep where" bargain the rest of this
work depends on. Save the name before the truncation.
The rrsync test's claim to close "the rest of the FILTER trace family" was
too strong and is corrected: options.c maps verbosity onto the debug flags,
so -vvv still raises a restricted server to FILTER2 and its trace metadata
comes back. Rule text stays redacted at every verbosity, which is the
property that matters; -vvv is added to the unaffected-transfer cases.
The ownership walk that resolves operator-supplied paths asks who planted a symlink, not where the path came out, so a symlink owned by uid 0 or the euid is followed wherever it points. A daemon already narrows that with the served module root; nothing else has a root to narrow it with. That leaves a wrapper serving a restricted directory over a remote shell with no way to bound the resolution. rrsync can vet the argv it is handed, but filter rules travel over the protocol instead: a dir-merge rule can name a merge file outside the restricted dir and the server reads it in as rules. Redacting the resulting diagnostics does not close it, because an exclude-only merge produces none -- every line becomes a pattern, so nothing fails to parse, and the client reads the file's contents off which of its own names went missing from the file list. --confine-root gives that wrapper the root the daemon has. The existing module check becomes a root check that takes its root from module_dir when we are a daemon and from the option otherwise, so daemon behaviour is unchanged; a daemon ignores the option outright, since it arrives in a peer-supplied argv and could only widen the module. The tracker is seeded from getcwd() rather than curr_dir, which is only the lexical name change_dir() was given: descend into a source argument through a trusted symlink and the two sit at different depths, so a ".." that really escapes looks like it landed inside. When the cwd cannot be read there is nothing to measure against and the open is refused -- an empty tracker does not deny by itself, because a leading ".." pops nothing from it and an empty path reads as an ancestor of the root. An fd pin (/proc/self/fd/N, which rrsync uses so no later symlink can redirect a validated option path) is spelled outside the root by construction, so the walk transits the pin namespace and the pin is judged by what it points at. Only a bare ".../fd/<digits>" is resolved that way, and one that will not readlink to an absolute path is refused; rrsync's ".../fd/N/<leaf>" spelling resolves through the magic link and has its remaining components checked normally. --insecure-links is refused alongside it: that opt-out returns the legacy open before the walk that enforces the root runs, so the pair would have quietly meant no confinement at all.
Filter rules arrive over the protocol, long after the wrapper has exec'd rsync, so no argv-level check can see them. A client can name a merge file outside the restricted dir in a dir-merge rule and have the server read it in as filter rules; on a pull that needs neither --delete nor any verbosity. Pass --confine-root so the server bounds the open itself, which is the only end that can. Both directions: a dir-merge is read by whichever side its rule applies to, so unlike --drop-D this is not receiver-only. Skipped for a "/" restricted dir, where there is nothing to confine.
Uses the exclude-only merge form, which leaves no diagnostic to assert on: the escape shows up as a file silently missing from the transfer, so the test reads the oracle the same way an attacker would. Pull mode, so no --delete is involved. Each case requires the transfer to have succeeded as well, since refusing outright would hand the peer a denial of service. A second escape reaches the source through a symlink, which is what makes rsync's tracked cwd and the real one disagree -- the shape that catches a lexical seed. That one drives --confine-root directly: rrsync rejects the argument spellings that would carry it, so routing it through the wrapper would pass either way and prove nothing. Both controls repeat their escape with an in-tree merge target and require it to be read AND obeyed, since "the transfer failed" and "every merge file is refused" would otherwise satisfy the escape assertions on their own.
CAN_HARDLINK_SYMLINK and CAN_HARDLINK_SPECIAL are decided by configure running linkat() on whatever filesystem the build tree happened to sit on. The destination is free to disagree, and one host can hold both answers: macOS builds on APFS, which can hard-link a symlink, and backs up to HFS+, which returns ENOTSUP. A build that said yes had no fallback left. try_dests_non() reported the refusal as a transfer error and returned a matched basis, so the caller created the entry anyway -- correctly -- and the run still exited 23. Every neighbouring case copes: a regular file whose link() fails goes to try_a_copy, and a build compiled without either macro resorts to --copy-dest behaviour. This was the same situation, discovered a little later, and the only one treated as fatal. Take the existing fallback on any refusal. Singling out the "cannot" errnos is not possible: link(2) documents EPERM both for a filesystem with no hard-link support and for an ordinary permission refusal, and FUSE reports ENOSYS for the same thing. It is also what the regular-file path next door has always done (try_dests_reg -> hard_link_one -> try_a_copy), and consistency between the two was the point. Where the errno does matter the surrounding transfer says so anyway: ENOSPC, EDQUOT and EROFS fail the creation independently, EMLINK and EXDEV mean the link was never possible. EIO alone goes unremarked; reporting it would put a line into --link-dest's itemised output, so it is left out on purpose. Returning -3 rather than -2 keeps the caller out of the "already up to date, skip it" arm, which under --link-dest would drop the entry entirely. Both callers give -3 the treatment the compile-time fallback already gets -- clearing itemizing and code -- because try_dests_non() has itemised the match itself and would otherwise report the entry twice. The fallback is silent, matching a build that cannot link these at compile time; documented under --link-dest instead.
…ystem An LD_PRELOAD hook refuses linkat() for a symlink source only, so the arm is reachable on a filesystem that hard-links symlinks perfectly well. Three controls keep it from proving less than it looks: - the regular file in the same transfer must still be hard-linked, or "it fell back" would also be satisfied by --link-dest having been abandoned; - the itemised run must emit exactly one "cL... sym -> some-target" line. A plain -a run cannot see a duplicated itemisation, which is how that defect reached an HFS+ target before this was added; - EPERM and ENOSYS must fall back too, since errno does not separate "cannot" from "may not". itemize picked its expected change-type letter from the build capability, which is the wrong question -- the link happens on whichever filesystem holds the test data. Ask that one too, and drop the XFAIL the old mismatch needed. The hook is Linux-only, so the test joins the macOS and Cygwin skip lists, which are required to be sorted.
The loop is software-pipelined: each iteration folds in the 64 bytes it preloaded last time and preloads the next 64. Nothing stopped the final iteration doing that preload, so it always read the 64 bytes after the region it was asked to checksum. Not an edge case. The assembly processes len&~63 and leaves the remainder to the caller, so the remainder is by construction under 64 bytes and the preload passed buf+len on every call, by 64 minus the remainder. It normally landed in slack inside the map_ptr() window and nothing noticed. Where the buffer ended near an unmapped page it was a SIGSEGV in the middle of a transfer -- reported on macOS x86-64 by Roland Kletzing, whose `partial` run died with "connection unexpectedly closed" because the generator had crashed. A guard page reproduces it on Linux too, so it was latent there, not absent. Run the pipelined loop one block short and finish the last block in .last, which does the same arithmetic without the preload. No per-iteration cost, and checksums are bit-identical -- simdtest compares every implementation against the C reference. The earlier fix for that report, "lib: use .balign in md5 x86-64 asm", was to the md5 assembly. It addressed the linker alignment warning that appeared alongside, not this.
simdtest allocated 64 spare bytes so it could test an unaligned buffer, which is exactly the slack that hid a 64-byte over-read in the AVX2 assembly for as long as it existed. Add a pass that places the buffer flush against a PROT_NONE page, so a read past the end faults in the test rather than in somebody's transfer. Every length from 128 to 4096, so each remainder mod 64 and both alignments are covered, and all four implementations are checked -- the assembly was the one at fault here, but the intrinsic paths preload too. It fails closed. A guard this test cannot set up means it is not testing what the caller thinks, so a failed sysconf/mmap/mprotect is a failure rather than a pass that looks identical to a real one. And because the dispatcher falls back on a CPU without AVX2 -- where the guard loop proves nothing about the code under test -- it says which of the two happened rather than letting a fallback run read as coverage. Without the fix this segfaults; the suite's simd-checksum test reports the non-zero exit.
Nothing in CI or the fleet has ever set --enable-roll-simd, --enable-roll-asm
or --enable-md5-asm, which is why the over-read above sat behind a "fixed"
label for two months, and why the fix applied for it went to the wrong
assembly file.
mac-x86-asm is the same host and OS as mac-x86 with all three on. Mach-O is
the interesting part -- both problems reported against these flags were
macOS-x86-64 -- and it is the only machine in the fleet that can build the
x86-64 assembly at all.
It needs MacPorts clang 19 through CC/CXX, because Apple clang 10 (the ceiling
on macOS 10.13) rejects configure's target("default") multiversioning probe.
mac-x86 keeps the stock Apple compiler, which is what caught #161, so the two
cover different ground rather than one replacing the other.
simd-checksum is a macOS-wide expected skip, since simdtest is only built when
SIMD is enabled; this target subtracts it, because running it is the point.
Also corrects mac-x86's comment, which claimed the probe "cannot compile here
with any clang". It is a compiler-version limit: clang 19 on that same box
compiles, links and runs it.
On OpenBSD a blocking accept() can sleep forever on a connection the kernel has already completed: the 4-tuple is ESTABLISHED at both ends, the connection is queued on the listener, and the accept()ing process is still asleep in netacc. Nothing bounds that wait, so rsync hangs for good. Poll the listener instead, with a non-blocking accept(), so a missed wakeup costs another pass rather than the process. The accepted fd is put back into blocking mode explicitly because BSD accept() gives it the listener's non-blocking flag. A time(NULL) deadline bounds the whole wait the way io.c bounds its own, rather than counting passes -- a signal on every pass must not extend it and a poll() that returns at once must not consume it. A listener that reports ready without yielding a connection (the peer can reset first) pauses rather than spinning. Measured on an OpenBSD 7.8 VM, driving the real binary through RSYNC_CONNECT_PROG with 8 concurrent workers, alternating stock/patched rounds: 111 hangs in 120,000 invocations before, 0 in 120,000 after, with no change in throughput. Every daemon test reaches socketpair_tcp() through RSYNC_CONNECT_PROG in the default transport, so the hang landed on whichever daemon test happened to be connecting. See dev-notes/openbsd-socketpair-accept-wedge.txt.
The same kernel-side missed wakeup on the other side of the connection: a blocking connect() can sleep forever on a connection that is already established, with the 4-tuple ESTABLISHED at both ends and the listener's greeting queued unread. Without --contimeout nothing breaks it. Wait for the connect with poll() in slices rather than blocking in the kernel, re-checking the socket on each pass, and take the result from SO_ERROR. A finished slice is not a failure -- looping is what re-examines the socket and recovers a missed wakeup. --contimeout is unchanged: the alarm still fires and the caller still reports RERR_CONTIMEOUT. The per-address errno is now stashed before close()/alarm() can overwrite it. Measured the same way, against a real loopback daemon: 20 hangs in 48,000 connects before, 0 in 48,000 after, with equal wall clock. This is the half of the OpenBSD flakiness that the socketpair_tcp() fix does not cover: the --use-tcp pass talks to a real rsyncd over a port, so it hangs here rather than in accept().
The daemon sets its deadline with time(NULL) (set_daemon_handshake_timeout, io.c), and this test measured the elapsed time with CLOCK_MONOTONIC. Those agree on a quiet machine and diverge on a stalled one: a virtualised guest resyncs its wall clock after the host deschedules it, while monotonic keeps its own count. The daemon then closes exactly when it meant to and the test reports it closed early. That is what a NetBSD CI run showed -- "closed after 39.55s, before the expected timeout window (58.75s)" -- and it is the same shape as the macOS failure that turned out to be the machine sleeping mid-test. Measure the bound on the clock the daemon decides with. Monotonic still drives the poll budget, where the job is only "do not hang forever". On its own that would trade a false failure for a false pass, which is worse: a refusal or a crash arriving just as the guest's wall clock caught up would read as a clean timeout, and no diagnostic would fire because the test would be green. So when the two clocks disagree -- wall says on time, monotonic says early -- neither settles it, and the daemon has to have recorded its own deadline firing. The offset of its log is taken before each observation, so a timeout it logged earlier cannot vouch for this one. Failures now carry both clocks and that window of the daemon's log, bounded and with control bytes escaped. The clock note states the discrepancy without concluding from it: a stalled host produces it, but so does an NTP step, and either can accompany a real failure.
The fix shipped in the test10 snapshot but was never written up: safe_arg()'s filename-mode buffer sizing disagreed with the writer, leaving an uninitialized heap byte in the argument handed to the remote shell when --protect-args is off.
Under inc_recurse the first flist (ndx_start == 1) has no parent entry of its own, so recv_file_list() trusts the peer's "." entry to be the transfer root and leaves parent_ndx at the flist_new() default of 0 -- dir_flist->files[0]. Only S_ISDIR entries are appended to dir_flist, so a peer that sends "." with a NON-directory mode keeps dir_flist->used at 0 while the basename strcmp still passes: parent_ndx stays 0 and the consumers index a never-written slot. Drives a real daemon with the pure-Python sender: an inc_recurse push whose only flist is a regular file "." plus a regular file "a" (no directory anywhere, and "." sorts lowest; "a" keeps file_total != 1 so the receiver doesn't divert into recv_additional_file_list). The file list alone is what does it -- the generator crashes in generate_files() before any transfer phase -- reproduced on released 3.2.7, 3.4.0 and 3.4.1. The oracle needs both halves: a positive control that the daemon logged "receiving file list", and the condition-specific refusal. Accepting any "rsync error:" line is not enough -- with "." sent as a valid directory and a bogus file index, that form passes on "File-list index 1000000 not in 0 - 2" without the crafted transfer root ever reaching the parser. A fixed daemon has already refused the list and exited by the time the ndx-0 token is sent, so that send and the drain can hit a closed socket; Linux and FreeBSD swallow it, Solaris, the other BSDs, macOS x86 and Cygwin raise EPIPE/ECONNRESET. Treat it as an expected outcome, not a result. The header records what this does not prove: it gates the attack shape rather than the parent_ndx clause (only the parse-time transfer-root check fires on a current build), it does not exercise the receiver-side consumer, and the dereferenced slot is not guaranteed NULL since dir_flist->files[] comes from realloc(), not calloc().
Running the 3.5.0 suite against an older branch (--repo BACKPORT --testsuite-repo .) reports a wall of failures that are not regressions: tests for fixes the branch does not carry, and tests whose unit-test helpers its Makefile cannot build. Both backport branches came back 3 OK / 36 not OK with every distinct failure explained that way, which makes the run useless as an oracle -- a real regression would not stand out. A backport now declares those in its own testsuite/skiplist/backport.txt, read from the tree being BUILT rather than the one providing the suite: only the built tree knows what it lacks. The names go to runtests.py as RSYNC_EXCLUDE rather than as an expected-skip declaration, because some of them fail rather than skip and an expected-skip list cannot describe a failure. The overlay that puts a newer testsuite/ onto an older tree is a merge with no delete, so a file that exists only on the backport survives it. skiplist-spec exempts the name from its every-list-must-be-referenced rule, since nothing references this one by design.
A test named in a backport's backport.txt never runs, so it cannot skip either. If the suite's expected-skip list also names it -- the two --compress-threads tests are declared as expected skips because they need --use-tcp -- the oracle waits for a skip that can no longer happen and every pipe cell reports a skip mismatch. Emit a '-name' removal for those, as the per-target expect_skip_omit already does. Only for names the spec actually contains: runtests rejects a '-name' that removes a name nothing added, and most of a backport's exclusions (a test for a feature it lacks) are not expected skips at all. Deciding that needs the @file references expanded locally, which is what _expand_spec_names does. v3.4.1 with this: 5/5 cells OK on ubuntu-2404, from 3 OK / 36 not OK across the fleet before the mechanism existed.
Also note that backport CI, once it exists, has to consume these lists the same way fleettest does -- read backport.txt from the branch being built and pass it as RSYNC_EXCLUDE -- or it will fail on every entry and get switched off.
A stable-backport branch runs a newer suite than its own code. fleettest already reads testsuite/skiplist/backport.txt from the tree being built and excludes those tests; runtests.py did not, so running the suite directly -- which is what the backport branches' CI job does -- tried to run tests that base cannot support. Read the same file from tooldir and drop its names from both the run and the expected-skip set: an excluded test never runs, so leaving it in the expected set would make the oracle demand a skip that cannot happen. A stale name is an error rather than a silent no-op.
Date the release and bring the security section up to the full set: it described 20 CVEs, and the release fixes 33. The thirteen later items are added in three groups -- the peer-triggerable memory-corruption findings from the daemon-protocol fuzzing pass, the daemon availability and access-control issues, and the two client-side ones. Several changes were previously described here as carrying no CVE and now do, so those claims are removed rather than left to contradict the advisories: rsync-ssl's unverified TLS is CVE-2026-70454, the non-positive MSG_IO_TIMEOUT is part of CVE-2026-70462, and the early-protocol argument-count bound is part of CVE-2026-70464. What is left under "no CVE assigned" is only the proxy header bounds and the xattr expansion cap.
Drops the "dev" suffix on RSYNC_VERSION ahead of the 2026-08-13 00:00 UTC public release.
ready for security release
fixed some typos and add in old 3.4.4 release info
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This release incorporates the changes for 33 CVEs, see NEWS.md and the GHSA links for details