Andrew Tridgell [Fri, 5 Jun 2026 01:29:18 +0000 (11:29 +1000)]
testsuite: regression for #880 --mkpath --dry-run file-to-file
Covers both halves: a --mkpath file-to-file --dry-run must succeed and
match the real run (the #880 abort), and a plain file-to-file --dry-run
onto an existing differing destination must still itemize the real change
rather than report it as brand new. Both compare "--dry-run -i" output
against the real run.
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.
Andrew Tridgell [Fri, 5 Jun 2026 00:51:06 +0000 (10:51 +1000)]
Remove obsolete DocBook manual
doc/rsync.sgml is a 1996-2002 DocBook user manual (with README-SGML
describing the docbook-utils build) that was long ago superseded by the
markdown man pages. It is unmaintained and referenced by nothing in the
build. This empties doc/.
Andrew Tridgell [Fri, 5 Jun 2026 00:49:44 +0000 (10:49 +1000)]
Remove obsolete design notes
rsync3.txt and rsyncsh.txt are Martin Pool's 2001 design proposals
("notes towards a new version of rsync", an interactive rsync shell),
neither of which reflects the current implementation. doc/profile.txt is
stale profiling notes. None are referenced by the build, tests, or docs.
Andrew Tridgell [Thu, 4 Jun 2026 23:02:32 +0000 (09:02 +1000)]
Remove obsolete testhelp/maketree.py
This Python 2 test-tree generator (print statements, string.letters,
.next()) has been broken on modern Python for years and is referenced
nowhere in the build, tests, or any script. Drop it.
Andrew Tridgell [Thu, 4 Jun 2026 05:49:14 +0000 (15:49 +1000)]
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.
Andrew Tridgell [Thu, 4 Jun 2026 22:15:58 +0000 (08:15 +1000)]
ci: add ubuntu-latest fleettest workflow against a localhost fleet
fleettest is a developer tool meant to run on a modern Ubuntu box, so a
bitrot check belongs in its own ubuntu-latest job rather than in the
testsuite (which runs on the BSD/Solaris/macOS/Cygwin matrix, whose
older Pythons may not even parse it).
The job sets up passwordless ssh to localhost, writes a two-target
fleet config that both ssh to localhost (distinct build dirs), and runs
a real fleettest pass. Two targets exercise the parallel multi-target
path and the per-run dir / port isolation; the run exits 0 only if
every cell is OK. Triggered on changes to fleettest.py or this
workflow, manually, and weekly.
Andrew Tridgell [Thu, 4 Jun 2026 21:52:49 +0000 (07:52 +1000)]
fleettest: add --timing to show per-target wall-clock
Records wall-clock per phase (push, build, each test transport, nonroot)
plus a total in TargetResult, and with --timing prints a breakdown after
the report, sorted slowest-target-first. Targets run in parallel, so the
run is gated by the slowest one; the phase columns show whether that
hold-up is the push, the build, or a test pass. A target that failed
early (no total) falls back to the sum of the phases it reached.
Andrew Tridgell [Thu, 4 Jun 2026 21:48:03 +0000 (07:48 +1000)]
fleettest: tighten --cleanup sweep scope and rm hardening
Address review findings on the cleanup paths:
- --cleanup no longer removes a bare <builddir>, only the suffixed
<builddir>-* run dirs it created. This keeps the sweep within its
documented scope and avoids clobbering an unrelated tree.
- Add _unsafe_builddir(): reject empty/root/$HOME and any absolute path
directly under / (e.g. a misconfigured builddir of "/tmp") before
building a destructive command, in both cleanup paths.
- Use `rm -rf --` so a path with a leading dash can't be read as options.
- Soften the docs: run-dir removal on Ctrl-C/kill is best-effort (a
signal arriving mid-push can still leave a remnant for --cleanup).
Andrew Tridgell [Thu, 4 Jun 2026 21:39:31 +0000 (07:39 +1000)]
fleettest: isolate concurrent runs and add config/cleanup options
Each run now builds in its own randomly-named dir on every target
(<builddir>-<run_id>), so two or three fleettest runs can share the same
fleet without colliding on the pushed tree, the build, or the testtmp
scratch. Port collisions were already handled by claim_ports() locks.
The run dir is removed when the run ends -- on success, failure, or
Ctrl-C/kill (atexit + SIGINT/SIGTERM handlers); --keep retains it. A new
--cleanup mode sweeps stray <builddir>-* dirs left by a SIGKILL.
Incremental builds are dropped (every run is a fresh dir + full build):
--no-push removed, --clean removed.
Also look for the fleet config at ~/.fleettest.json first, then
testsuite/fleettest.json (still overridable with --fleet PATH).
Andrew Tridgell [Thu, 4 Jun 2026 06:19:31 +0000 (16:19 +1000)]
testsuite: regression for the #829 daemon --chown/--groupmap wildcard
Maps every source group to a second group the test user belongs to via a
daemon upload (--groupmap='*:GID') and checks the wildcard took effect.
Runs both arg modes: the default path (the '*' is safe_arg-escaped and the
daemon must un-backslash it -- the regression) and --secluded-args (the '*'
is sent raw over the protected channel, a guard that the fix left that path
alone). Needs no root -- a non-root receiver can chgrp to a member group --
and was verified RED on a pre-fix binary (the escaped '\*' is ignored, gid
unchanged) and GREEN after the fix.
Andrew Tridgell [Thu, 4 Jun 2026 06:19:31 +0000 (16:19 +1000)]
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.
Andrew Tridgell [Thu, 4 Jun 2026 04:46:38 +0000 (14:46 +1000)]
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.
Andrew Tridgell [Thu, 4 Jun 2026 04:43:38 +0000 (14:43 +1000)]
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.
Andrew Tridgell [Thu, 4 Jun 2026 04:17:12 +0000 (14:17 +1000)]
testsuite: regression for short-checksum --append-verify s2length
Forces --checksum-choice=xxh64 (an 8-byte transfer checksum) with a
corrupted-prefix --append-verify so the full-checksum redo path runs.
Before the generator capped s2length at MIN(SUM_LENGTH, xfer_sum_len)
this died with "Invalid checksum length 16 [sender]"; the test is RED on
the prior generator and GREEN with the cap. Reproduces on any build that
has xxhash, so it guards the fix without an old-libxxhash host; skips when
xxh64 is absent (a build without xxhash).
Andrew Tridgell [Thu, 4 Jun 2026 04:04:47 +0000 (14:04 +1000)]
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.
Andrew Tridgell [Wed, 3 Jun 2026 23:14:52 +0000 (09:14 +1000)]
android: probe openat2 usability behind a SIGSYS handler
Android's seccomp sandbox traps openat2() with SECCOMP_RET_TRAP, which
raises SIGSYS and kills the process instead of returning ENOSYS, so the
secure resolver cannot simply try openat2() and inspect errno. Add
openat2_usable() in a new android.c: it probes openat2() once behind a
temporary SIGSYS handler and caches the result.
Gate every SYS_openat2 call on openat2_usable(): in the resolver via an
openat2_beneath() wrapper, and in t_chmod_secure's kernel probe directly,
so a blocked openat2 reports ENOSYS and the caller falls back to the
portable O_NOFOLLOW resolver. Only openat2 is gated -- a plain openat()
(e.g. opening an operator-trusted absolute basedir) is left free.
The probe body compiles only on Android -- __ANDROID__ is a Bionic target
macro, so it is set for NDK cross-builds and native Termux alike and unset
everywhere else, where openat2_usable() collapses to a constant 1. Link
android.o into the secure-resolver test helpers too so their self-tests
survive on Termux.
Andrew Tridgell [Wed, 3 Jun 2026 22:50:49 +0000 (08:50 +1000)]
configure: require <linux/openat2.h>, not just SYS_openat2
The openat2 secure resolver in syscall.c needs struct open_how and
RESOLVE_BENEATH from <linux/openat2.h>, not only the SYS_openat2 syscall
number. Some setups expose the syscall number via glibc without the
kernel header present, so probing SYS_openat2 alone still left the build
broken (#905). Exercise the header and struct in the configure check so
HAVE_OPENAT2 is defined only when both are actually usable.
Markus Mayer [Fri, 29 May 2026 17:15:13 +0000 (10:15 -0700)]
t_chmod_secure: use HAVE_OPENAT2 to check for openat2() support
To prevent using openat2() in situations where it is not supported, use
#if defined(__linux__) && defined(HAVE_OPENAT2)
in t_chmod_secure.c, just like it was already being done in syscall.c.
Markus Mayer [Thu, 28 May 2026 00:44:37 +0000 (17:44 -0700)]
build: auto-detect the presence of the openat2() syscall
Let configure detect if the openat2() syscall is supported by the kernel
headers we are building against. Do not attempt to use openat2() if
support is not present.
Users can still disable using the openat2() syscall manually if so
desired.
Andrew Tridgell [Wed, 3 Jun 2026 23:35:33 +0000 (09:35 +1000)]
testsuite: add fleettest.py fleet CI harness
fleettest.py builds the committed HEAD of a checkout on a fleet of remote machines over ssh and runs the test suite under both the stdio-pipe and --use-tcp transports in parallel, reporting only the unexpected results. Each target mirrors a .github/workflows/*.yml job: its configure flags, and the RSYNC_EXPECT_SKIPPED list parsed from the workflow.
The fleet is described by a JSON file (testsuite/fleettest.json, git-ignored); fleettest.json.example is a worked template. Use --fleet to point at another config and --repo to build a tree other than the current directory.
A target with nonroot:true reruns, as the unprivileged ssh user, the tests that declare a module-level fleet_nonroot=True (here ownership-depth and daemon). The set lives in the test files, so new privilege-sensitive tests join the non-root pass with no fleet-config change.
Also rename testsuite/README.testsuite to README.md and rewrite it as markdown documenting the current testsuite: runtests.py, the make check/check29/check30/installcheck/coverage targets, the result/exit-code conventions, and fleettest.py.
Andrew Tridgell [Wed, 3 Jun 2026 10:48:10 +0000 (20:48 +1000)]
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.
Andrew Tridgell [Wed, 3 Jun 2026 10:48:10 +0000 (20:48 +1000)]
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.
Andrew Tridgell [Wed, 3 Jun 2026 10:47:56 +0000 (20:47 +1000)]
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.
Andrew Tridgell [Wed, 3 Jun 2026 11:36:25 +0000 (21:36 +1000)]
testsuite/runtests: count XFAIL (exit 78) as expected, not a failure
The regression tests use test_xfail() (exit 78) to assert a known, documented
residual on platforms where the fix can't apply -- e.g. link-dest-relative-basis
XFAILs where the receiver has no openat2/O_RESOLVE_BENEATH and the portable
resolver rejects the '..' for safety. runtests.py counted exit 78 in the
generic else->failed branch, so a bare XFAIL failed the whole suite; tally it
separately ('N xfailed (expected)') and exclude it from the failure exit code.
Also add --race-timeout plumbing (race_timeout env) for race tests.
Adds .github/workflows/ubuntu-version-mix.yml (ubuntu-latest) and a
per-release manifest testsuite/expect/rsync_<ver>.expect for each of the
nine peers. The workflow builds the current rsync, then runs the two-
sided suite against every old binary over both the pipe and --use-tcp
daemon transports. All peers run in a SINGLE looped job (not a matrix)
so the PR shows one check line; each peer/transport is a foldable log
group and a failure annotates which one broke.
A new phony `check-progs` target builds rsync plus the test helper
programs and check symlinks without running the suite -- the build half
of `make check` -- so the workflow's direct runtests.py invocation has
the helpers it needs.
Notable expected results encoded in the manifests:
- The four May-2026 security tests xfail against every released peer:
the suite demonstrates each release is vulnerable to those findings
while current master is fixed.
- symlink-dirlink-basis xfails on 3.4.0/3.4.1 (issue #715: their
secure_relative_open O_NOFOLLOW-confines the basedir, breaking a -K
dir-symlink update; current master fixes it with secure_basis_open).
- Older peers carry more xfails for options/negotiation they lack;
2.6.0 (protocol 27) fails most daemon tests. reverse-daemon-delta
passes against all peers, confirming backward compat down to 2004.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 31 May 2026 11:01:09 +0000 (21:01 +1000)]
old_versions: commit static binaries of old rsync releases
Nine statically-linked, stripped binaries for the version-mixing test
suite (and ad-hoc cross-version behaviour checks): every x.y.0 release
from 2.6.0 (2004, protocol 27) through 3.4.0, plus the 3.1.3/3.2.7/3.4.1
point releases. 2.6.0 is the practical floor; older tags need more
porting to build on a current toolchain.
build_static.sh rebuilds any release from its git tag, applying the
minimal patches needed to compile old sources on a modern toolchain:
K&R lseek64 redecl, gettimeofday, -std=gnu11, --disable-openssl, and
_FORTIFY_SOURCE disabled (modern FORTIFY=3 turns latent benign over-reads
in old rsync into aborts when it runs as a server). Pre-3.0 trees ship
configure.in, so it regenerates configure (autoheader/autoconf) after
neutralizing the dead AC_LIBOBJ replacement fallbacks, generates proto.h,
and stubs the dropped vendored lib/addrinfo.h -- all guarded to no-op on
newer versions.
.gitattributes marks the binaries binary (so the text=auto rule can't
corrupt them) and export-ignore (kept out of the release tarball).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 31 May 2026 11:01:09 +0000 (21:01 +1000)]
testsuite: reverse-direction smoke test (old client -> current daemon)
Every other two-sided test drives with the current binary, covering
new-client -> old-server. This adds the backward-compat direction that
matters most for a project shipping new servers to a world of old
clients: a current daemon must keep serving the installed base of old
rsync clients.
reverse-daemon-delta_test.py starts the daemon with the current build
(via start_test_daemon's rsync_cmd override) and drives it with the old
binary. It does a push and a pull, each with and without -z, with the
receiving side pre-seeded with an older version of the file so the delta
algorithm actually runs -- exercising delta encoding both ways (old->new
on push, new->old on pull) and compression negotiation both ways. It
asserts the bytes crossing the wire are far smaller than the file, so a
silent fallback to a whole-file copy is caught, and accepts both the
modern "sent/received" and the old "wrote/read" summary wording so an
old client's output parses.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 31 May 2026 11:00:51 +0000 (21:00 +1000)]
runtests: add --rsync-bin2 / --expect-result for version-mixing tests
Let the suite run with two rsync binaries so the current build can be
tested against the actual old code of a previous release, rather than
only forcing the current binary to speak an old protocol (check29/30).
--rsync-bin2 PATH exports RSYNC_PEER, the binary used for the SERVER
side of two-sided transfers (the daemon process and
the remote-shell --rsync-path target). Defaults to
RSYNC, so single-binary runs are byte-for-byte
unchanged.
--expect-result F the manifest's listed tests ARE the run set; each
test's actual outcome (pass/skip/fail/xfail) is
compared to its expected one and any mismatch --
including an unexpected pass (xpass) -- fails the
run. --expect-skipped and the default exit logic
are untouched.
rsyncfns gains the RSYNC_PEER global and launches the daemon with it
(start_rsyncd / start_test_daemon, the latter with an optional rsync_cmd
override used by the reverse-direction test); the remote-shell tests
pass --rsync-path={RSYNC_PEER}. All no-ops when no peer is selected.
Direction is fixed: the current binary always drives (only it
understands the new test scripts); the old binary is only ever the
server/daemon side.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Mon, 1 Jun 2026 05:54:41 +0000 (15:54 +1000)]
runtests: add --exclude / RSYNC_EXCLUDE to skip tests entirely
Some tests cannot run in certain build/CI environments. In particular the
protected-regular test self-re-execs under "unshare --map-users" to exercise
fs.protected_regular handling, and that user-namespace path hangs in a
restricted buildd chroot (e.g. Launchpad/sbuild), tripping the per-test
timeout and failing the whole "make check".
Add an --exclude option (comma-separated test names/globs), with an
RSYNC_EXCLUDE environment fallback so it can be set without touching the
make/check command line. Excluded tests are dropped before running -- they
are neither executed nor reported as skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Mon, 1 Jun 2026 05:03:05 +0000 (15:03 +1000)]
docs: document the rsync-latest snapshot PPA
Add the new ppa:rsyncproject/rsync-latest (development snapshots rebuilt
from git master) alongside the existing stable PPA in INSTALL.md and the
download page. Notes that snapshot versions (3.5.0~git...) sort below the
matching stable release, so the two PPAs can coexist without a stable
release being silently replaced by a snapshot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Thu, 28 May 2026 19:26:30 +0000 (05:26 +1000)]
ci: halve CI artifact retention from 90 to 45 days
GitHub Actions artifact storage is approaching our quota. Each `make`/build
job uploads its rsync binary + manpages, the coverage job uploads its full
HTML tree, and Android uploads its dist/ -- 11 jobs producing artifacts per
PR/push, all kept for the repo default of 90 days.
Set retention-days: 45 explicitly on every upload-artifact step so they
expire at half the previous lifetime; older artifacts can still be re-built
from the commit if needed. No other workflow behaviour changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Tue, 26 May 2026 21:07:58 +0000 (07:07 +1000)]
runtests.py: accept a relative --rsync-bin
Tests are launched with subprocess.run(..., cwd=TOOLDIR) so the
subprocess's argv[0] resolves against TOOLDIR, not the runner's
invocation cwd. A user-supplied --rsync-bin=../foo/rsync therefore
worked when invoked from inside TOOLDIR but silently failed (or
ENOENT'd inside individual tests) when invoked from a sibling
directory.
Fix: absolutize rsync_bin via os.path.abspath() at parse time, before
it propagates into build_rsync_cmd()/RSYNC. abspath() captures
os.getcwd() now, which is the operator's invocation cwd -- exactly
what the --rsync-bin=../path form expresses.
Andrew Tridgell [Tue, 26 May 2026 10:02:52 +0000 (20:02 +1000)]
ci: add actionlint workflow to lint GitHub Actions YAML
Adds .github/workflows/actionlint.yml which runs rhysd/actionlint over
.github/workflows/*.yml on push and PR to master. Triggers only when
something in .github/workflows/ (or the actionlint config) changes, so
the rest of the platform matrix isn't billed when nothing here moves.
The job downloads a pinned actionlint binary (1.7.12) via the upstream
download script (which verifies a SHA256) -- no third-party Action
dependency, matching the inline-install style of the existing
ubuntu/macos/cygwin workflows. Bump the pinned version deliberately.
actionlint catches a) GitHub Actions expression / type errors, b)
unsupported runner images, c) missing secrets / inputs, and d) the
embedded shellcheck class of issues in 'run:' scripts that the previous
commit cleaned up. Keeping it in CI prevents regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Tue, 26 May 2026 09:59:16 +0000 (19:59 +1000)]
ci: clean up workflow shellcheck nits
actionlint (rhysd/actionlint) reported a handful of shellcheck-class issues
across the GitHub Actions workflows. All are 1-line mechanical fixes:
* Replace legacy backticks in --rsync-bin=`pwd`/rsync with
--rsync-bin="$PWD/rsync" (SC2006 + SC2046; almalinux-8-build,
macos-build, ubuntu-22.04-build, ubuntu-build).
* Quote >>$GITHUB_PATH redirects as >>"$GITHUB_PATH"
(SC2086; coverage, macos-build, ubuntu-22.04-build, ubuntu-build).
After this commit `actionlint .github/workflows/*.yml` exits 0.
(Also cleaned up 6 editor backup *.yml~ files from the local working
tree; those weren't tracked -- *~ is gitignored -- so the cleanup is
local-only and not part of this commit.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:23:11 +0000 (08:23 +1000)]
testsuite: close minor assertion gaps
symlink-dirlink-basis assert the --backup file holds the pre-update content,
not merely that the backup file exists.
acls-default check that clearing the inherited default ACL actually
succeeded, so the no-default-ACL cases can't silently
test against the scratch dir's seeded default ACL.
alt-dest assert --copy-dest produces a distinct inode from the
alt-dir candidate (a copy, not a hard link) -- the
property that distinguishes it from --link-dest, which
checkit's tree comparison alone doesn't capture.
(crtimes' "independently pin the historical create time" gap is left as-is: the
touch-trick pinning is APFS-specific and not locally verifiable, and a mistuned
probe would make the test skip on macOS and break its expected-skip set.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:20:04 +0000 (08:20 +1000)]
testsuite: tighten metadata-precision and symlink-target assertions
Replace loose/partial oracles with exact ones:
omit-times under -O, require EVERY directory mtime to be omitted, not
just one (the old "at least one differs" missed partial bugs).
dir-sgid assert the created dirs' actual gid: a setgid parent makes
them inherit its group (set to a secondary group to be
discriminating), while the non-setgid case gets the process's.
relative-implied pin a deterministic umask and assert the exact default mode
(0o755) for --no-implied-dirs, not merely "not the source's".
safe-links / compare the preserved symlink TARGET strings via readlink,
unsafe-links not just that a symlink exists.
preallocate verify do_punch_hole via st_blocks on the --inplace --sparse
case (guarded by a sparse-capability probe).
Note: --preallocate --sparse leaves the file fully allocated on a fresh write
(the zero run is not punched), so that case stays content-only rather than
asserting hole-punching -- see the test comment; rsync.1's claim that the
combination yields sparse blocks does not hold for the fresh-write path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:14:04 +0000 (08:14 +1000)]
testsuite: add content and return-code assertions
Several tests proved only that rsync exited cleanly (or that a file merely
exists), so a no-op/short transfer would pass:
protected-regular compare the dst bytes to the source after --inplace.
00-hello re-assert one/two were copied on the RSYNC_OLD_ARGS=1
env-var path (the explicit --old-args case already did).
missing check the dry-run's exit status in test 1.
mkpath compare transferred bytes (not just existence) and add a
negative control: a transfer WITHOUT --mkpath must fail
and create no intermediate path.
size-filter compare each kept file's content to its source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:12:03 +0000 (08:12 +1000)]
testsuite: verify destination content/listings in daemon tests
These daemon tests confirmed refusals/exclusions but accepted the allowed
transfers on exit status alone, so a transfer that exited cleanly while moving
nothing would pass:
daemon-refuse allowed() imported verify_dirs but never called it; now it
confirms the allowed push/pull actually populated the dest.
daemon-filter pull()/the incoming push ignored their exit status, and the
outgoing-chmod loop iterated only files that exist -- a
zero-file pull passed vacuously. Check the codes and require
at least one file to have been mode-checked.
daemon run_and_check's unused `expected` param is dropped; the
hidden-module and glob listings now compare the exact set of
listed paths (catching a leaked extra path), replacing the
per-path containment check and the dead normalise() helper
whose regex never matched the -r listing format anyway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:08:42 +0000 (08:08 +1000)]
testsuite: add positive controls to the symlink-race security tests
The symlink-race tests only asserted that an outside sentinel was unchanged or
unlisted while ignoring rsync's exit status, so an attack transfer/listing that
failed before reaching the vulnerable receiver/sender path would pass without
the security property ever being exercised. Add a positive control to each --
an ordinary in-module write (bare-do-open, chdir) or an in-module listing
(sender-flist-leak) that must succeed -- so a globally broken/refusing daemon
can no longer make the sentinel checks vacuous, and assert the attack run did
not die from a signal.
clean-fname-underflow now also enforces a non-zero exit: clean_fname()
collapses "a/../test" to "test", whose merge file is absent, so rsync must
reject it; accepting it (rc 0) would mean the crafted name was mis-collapsed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:04:56 +0000 (08:04 +1000)]
testsuite: harden output-options checks
Several subcases ran rsync without checking the exit status, so a silent
failure could pass as the expected (often empty) output -- most notably -q,
which only asserted empty stdout. Route every expected-success run through a
helper that asserts the exit status, and verify -q actually transferred the
tree. Replace the "-h/-8 didn't break the transfer" check with positive format
assertions: -h must render byte counts with a K/M/G suffix (and the default
must not), and -8 must leave a high-bit filename byte unescaped (\#371 absent)
where the default escapes it -- best-effort, self-skipping where the platform
can't store the raw byte.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:02:41 +0000 (08:02 +1000)]
testsuite: verify the negotiated compressor/checksum selection
compress-options only checked that each requested algorithm yielded
byte-identical output, which proves parsing/non-corruption but not that the
advertised algorithm was actually used -- the test would pass if the choice
were silently ignored. Capture --debug=NSTR (compat.c / checksum.c) and assert
the selected compressor, compress level, and checksum match the request
(anchored so zlib != zlibx). --skip-compress / --checksum-seed stay content
checks: they have no comparable negotiation-string signal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:01:11 +0000 (08:01 +1000)]
testsuite: verify --fuzzy actually selects a basis
Both fuzzy tests asserted only that the final file content matched, which a
full transfer that ignored --fuzzy would also satisfy -- so a broken fuzzy
basis selection would pass undetected. Drive rsync directly with --debug=FUZZY
and assert the generator reports the expected basis ("fuzzy basis selected
for <f>: <basis>", generator.c find_fuzzy): rsync2.c for fuzzy, and the
closest-named candidate archive-v1.tar for fuzzy-basis. fuzzy switches from
checkit() to a manual run plus verify_dirs() so the output can be captured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 22:51:34 +0000 (08:51 +1000)]
Fix --preallocate --sparse to actually produce sparse files
rsync.1 says combining --preallocate with --sparse yields sparse blocks
wherever the filesystem can punch holes, but since 2019 (commit c2da3809,
"keep file-size 0 when possible") it has silently left the file fully
allocated. Two problems, both rooted in that commit switching --preallocate /
--inplace to fallocate(FALLOC_FL_KEEP_SIZE):
* do_fallocate() then returned 0 instead of the reserved length, so the
receiver's preallocated_len was 0 and write_sparse() always lseek'd over
null runs instead of punching them (and the over-preallocation trim in
receiver.c never fired either).
* more fundamentally, KEEP_SIZE leaves the file size at 0 while data is
written incrementally, so the FALLOC_FL_PUNCH_HOLE call lands on blocks
beyond EOF and is a silent no-op -- the reserved blocks are never freed.
Fix both: don't request KEEP_SIZE when --sparse is also active, so the file is
preallocated at full size and the punch lands within it; and return the
reserved length from do_fallocate() so preallocated_len drives the punch
decision and the over-allocation trim. --preallocate without --sparse keeps
the KEEP_SIZE (file-size-0) behaviour. t_stub.c gains a sparse_files stub since
do_fallocate now references it and the test helpers link syscall.o.
preallocate_test.py now asserts via st_blocks (where the filesystem can punch
holes) that --preallocate --sparse ends up sparse, guarding the regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 20:40:42 +0000 (06:40 +1000)]
ci: run the OpenBSD --use-tcp test step at -j2
The OpenBSD job runs inside a nested VM. At -j8 the --use-tcp run starts
many concurrent loopback daemons, and under that resource pressure the
daemon connection handshake occasionally loses a timing race and one test
hangs to the 300s runner timeout. It is an environment artifact, not an
rsync defect: the daemon handshake writes-then-reads with unbuffered early
I/O (no flush/mutual-wait deadlock), the indefinite wait is the documented
no-timeout daemon behaviour, and it does not reproduce off OpenBSD even with
the full suite pinned to a single CPU at -j8.
Drop just this job's --use-tcp parallelism to -j2 so the nested VM stops
over-subscribing; the pipe `make check` and every other platform are
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 03:36:32 +0000 (13:36 +1000)]
testsuite: cover more path/file-operation code (syscall.c, util1.c, delete.c)
Target previously-uncovered functions in the path/file-operation files the
resolver restructure touches, confirmed hit under coverage:
preallocate --preallocate (syscall.c do_fallocate) and sparse hole-punching
via --preallocate --sparse and --inplace --sparse (do_punch_hole),
on a file several levels deep.
fuzzy-basis --fuzzy basis selection with similar-named candidates and no
exact match, so the generator scores them (util1.c fuzzy_distance).
delete-deep add a --backup --delete case so removing an extraneous
backup-suffixed file consults delete.c is_backup_file.
preallocate probes --preallocate support up front and skips where it is
unavailable: macOS, the *BSDs and Solaris build without fallocate/posix_fallocate
(and FALLOC_FL_PUNCH_HOLE is Linux-only), and reject the option outright. It runs
on Linux and Cygwin. fuzzy-basis and delete-deep are plain local transfers with
no skips. All green on master and under --protocol=29/30.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The --expect-skipped check compared the skip list as an ordered string, so the
per-platform RSYNC_EXPECT_SKIPPED lists had to match runtests' collection order
(sorted filenames) exactly -- a subtle, easy-to-break ordering dependency.
Compare the skipped SET instead; which tests skipped is what matters.
Register the new require_tcp test daemon-access-ip in the per-platform
expected-skipped lists (it skips in the pipe-transport make check, like
daemon-chroot-acl and proxy-response-line-too-long).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sun, 24 May 2026 02:39:19 +0000 (12:39 +1000)]
build: scope gcov report to rsync's own source; add coverage-all
The coverage report counted bundled third-party code (zlib/, popt/, and the
PostgreSQL/ISC lib/ imports getaddrinfo/getpass/inet_ntop/inet_pton) that rsync
ships but does not own, muddying the percentages. Add a COVERAGE_EXCLUDE gcovr
filter (shared by all coverage targets) so the report reflects rsync's own code:
on the same data, lines 63.9%->65.5%, functions 81.4%->85.0%, branches
55.0%->56.5% (rsync's own md5/mdfour/wildmatch/etc. stay in the report).
Add 'make coverage-all': run the suite under pipe + --protocol=30 + --protocol=29
+ --use-tcp, accumulating into the shared .gcda (not cleared between runs), then
one merged scoped report -- covers the daemon/TCP and protocol-compat paths a
single pipe run misses (lines 67.6%, functions 87.6%, branches 58.6%). Also add
'make coverage-fallback' for a separate --disable-openat2 build (different .gcno,
so it can't merge with the openat2 report). CI is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 23:44:49 +0000 (09:44 +1000)]
ci: declare new metadata-coverage test skips for macOS and Cygwin
acls-depth skips where ACLs/setfacl are unavailable (macOS, Cygwin) like the
existing acls tests, and sparse skips on APFS (macOS), where a seek-written
hole isn't allocated sparsely. Add them to the per-platform RSYNC_EXPECT_SKIPPED
lists so the skip-set assertion stays accurate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 23:23:54 +0000 (09:23 +1000)]
ci: add an Ubuntu gcov coverage job
Builds with --enable-coverage and runs the suite under both transports
(make coverage, then make coverage-tcp). gcovr's line/branch/decision totals
are printed to the step log and also written to the GitHub step summary, so the
coverage numbers are visible directly in the CI output; the HTML reports are
uploaded as an artifact. make coverage exits with the suite's status, so a test
regression fails the job.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 23:23:54 +0000 (09:23 +1000)]
build: add 'make coverage-tcp' and drop deprecated gcovr --branches
coverage-tcp reuses the coverage recipe with --use-tcp (daemon tests over a real
loopback rsyncd, which also runs the require_tcp-only tests) and a separate
report directory, via COVERAGE_RUNFLAGS / COVERAGE_DIR. Verified end to end:
pipe run reports 63.9% lines, the TCP run 64.5% (it exercises more code).
Also drop gcovr's --branches flag: it is deprecated in gcovr 8 and branch +
decision coverage still appear in --print-summary and the HTML without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 22:48:42 +0000 (08:48 +1000)]
testsuite: assert absolute --partial-dir delta resume now works
partial_test.py sub-test 5 deterministically asserts a delta (--no-whole-file)
resume from an absolute, outside-tree --partial-dir reproduces the source and
consumes the basis -- the regression guard for the receiver fix. Sub-test 4
keeps asserting the cross-directory partial WRITE on interrupt. Drop the
--whole-file workaround and the 'broken on master' notes in the docstring and
COVERAGE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 22:14:39 +0000 (08:14 +1000)]
testsuite: add COVERAGE.md matrix and -u/--force coverage
COVERAGE.md is the living checklist mapping every CLI option (~142) and daemon
parameter (~54) to its test(s), with depth / cross-dir status and remaining
gaps, so the path-resolution restructure can see exactly what is guarded.
update_test.py closes two of the documented gaps: -u/--update (keep a newer
destination, update an older one) and --force (replace a non-empty destination
directory with a file), both at depth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 22:12:39 +0000 (08:12 +1000)]
build: add gcov coverage and --disable-openat2 knobs for the test suite
Two test-coverage build knobs (both behaviour-neutral by default):
--enable-coverage appends '--coverage -fprofile-update=atomic -O0' and adds
a 'make coverage' target (whole suite, run serially, then
gcovr HTML with branch + decision coverage). rsync forks
and its children exit without running the gcov atexit
flush -- the generator via its SIGUSR1 handler
(_exit_cleanup) and the receiver via the SIGUSR2 handler
-- so under GCOV_COVERAGE we call __gcov_dump() at both, or
receiver.c/generator.c record no coverage at all.
--disable-openat2 gates the Linux openat2(RESOLVE_BENEATH) sites in syscall.c
on HAVE_OPENAT2 (defined by default), so disabling it forces
the portable per-component O_NOFOLLOW resolver to run as the
primary on ordinary Linux -- exercising and
coverage-counting that fallback tier without a pre-5.6
kernel. NOTE: coordinate with the parallel syscall.c
path-resolution restructure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 22:12:39 +0000 (08:12 +1000)]
testsuite: probe RESOLVE_BENEATH support functionally for the #715 test
Add resolve_beneath_supported() to rsyncfns: it functionally probes whether the
rsync binary can follow an in-tree directory symlink under its secure resolver
(an initial transfer plus a delta update through a dir-symlink, the operation
issue #715 is about). This tracks the actual binary instead of a platform name.
Use it in symlink-dirlink-basis_test.py in place of the SunOS/OpenBSD/NetBSD/
Cygwin name check: it skips on those platforms too, and additionally on
Linux < 5.6, a seccomp-blocked openat2, and the new --disable-openat2 build,
where the portable O_NOFOLLOW fallback rejects the in-tree symlink.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 22:01:52 +0000 (08:01 +1000)]
testsuite: output, comparison and algorithm-selection option coverage
Breadth pass for options not yet exercised:
output-options output shape of --version/--help/-i/-n/--stats/
--out-format/--list-only/-q/--progress/-h/-8 (these control
output, not path handling, so they're checked for shape).
compare -c and -I catch a stealth change (same size+mtime, new
content) deep in the tree; --size-only skips a same-size
change; --modify-window absorbs a 1s mtime difference.
compress-options --compress-choice for every advertised compressor,
--compress-level, --skip-compress, --checksum-choice for
every advertised checksum, and --checksum-seed -- each a
clean byte-identical transfer at depth.
Green on master and under --protocol=29/30.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 21:59:23 +0000 (07:59 +1000)]
testsuite: daemon parameter coverage (loopback)
Drive a loopback daemon (secure stdio-pipe transport by default, also green
under --use-tcp) via the new write_daemon_conf helper and assert the behaviour
of the security-relevant rsyncd.conf parameters, transferring >=3-deep trees:
daemon-access path / read only / write only / list, incl. a deep sub-path
pull and that a list=no module is hidden yet usable by name.
daemon-filter daemon exclude hides matching files everywhere; incoming /
outgoing chmod rewrite modes of every transferred file.
daemon-auth auth users + secrets file accept the right password, reject a
wrong one and an unauthenticated request; strict modes rejects
a world-readable secrets file.
daemon-exec pre-/post-xfer exec run with RSYNC_MODULE_NAME /
RSYNC_EXIT_STATUS; a failing pre-xfer exec aborts the transfer
(marker files polled for, since post-xfer exec runs after the
client disconnects under TCP).
daemon-munge munge symlinks stores incoming links with the /rsyncd-munged/
prefix and strips it on the way out.
daemon-refuse refuse options: a named option, a wildcard, and the '* !a !v'
allow-list idiom.
Green on master under pipe and --use-tcp transports and under --protocol=29.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 21:55:45 +0000 (07:55 +1000)]
testsuite: filtering coverage at depth
Assert exactly which entries are/aren't transferred, deep in the tree:
filter-depth --exclude/--include precedence on files at every level, and
a -F per-directory .rsync-filter loaded from a deep dir that
applies to that subtree only (not above it).
cvs-exclude -C built-in cruft patterns (*.o, *~) at every level plus a
deep per-directory .cvsignore scoped to its subtree.
size-filter --max-size / --min-size select the right files all the way
down.
files-from-depth --files-from selects only the listed deep paths (implied
parents created); --from0 NUL-delimited; --exclude-from /
--include-from filter at depth.
(--existing / --ignore-existing are covered in delete-deep_test.py.)
Green on master and under --protocol=29/30.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 21:54:28 +0000 (07:54 +1000)]
testsuite: metadata preservation coverage at depth
Set each attribute distinctively on a file AND a directory at every level of a
>=3-deep tree and verify it per entry after transfer (metadata is applied as a
single-component op on an entry whose parent chain the resolver restructure
rewrites):
metadata-depth -p preserves exact file/dir modes; -t preserves file
mtimes; --chmod=D710,F600 rewrites them.
omit-times -O omits directory times (files still preserved); -J omits
symlink times.
sparse -S preserves a deep file's hole (allocated << size);
--no-sparse fills it.
xattrs-depth -X reproduces a user xattr on every entry (gated on xattr
support).
acls-depth -A reproduces a POSIX ACL on every entry (gated on ACL
support + setfacl/getfacl).
ownership-depth --groupmap and --chown=:GROUP remap the group of every
entry (non-root, to a secondary group); -o/--usermap gated
on root.
All green on master and under --protocol=29/30.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 21:51:19 +0000 (07:51 +1000)]
testsuite: structure / recursion / link coverage at depth
Cover the structure and link options at >=3 levels and across directories,
asserting each option's specific effect:
links -l keeps a symlink, -L dereferences it, -k follows a
directory symlink -- all on a symlink several levels deep.
dirs -d copies the top layer (file + empty dir) without recursing.
prune-empty-dirs -m drops empty chains and chains emptied by an exclude,
keeps populated ones.
hardlinks-deep -H preserves a hard link whose names live in different
directories at depth; without -H they become separate inodes.
delete-deep --delete removes a deep extraneous file/subtree; the four
delete-timing variants agree; --max-delete caps deletions;
--existing / --ignore-existing select/skip correctly.
relative-implied -R mirrors an implied directory's mode at depth;
--no-implied-dirs does not (proto 30+).
Green on master and under --protocol=29/30 (the --no-implied-dirs sub-case is
gated to protocol >= 30, where multi-component sender paths are accepted).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 21:47:10 +0000 (07:47 +1000)]
testsuite: cross-directory/temp/backup/dest coverage at depth
Fill the highest-restructure-risk gap: options that do two-directory / rename /
outside-tree work, asserted at >=3 levels deep with the aux tree kept outside
the main tree, and asserting the option's specific property rather than just
tree equality (which the ported tests already cover).
alt-dest-deep --link-dest hardlinks unchanged files (same inode), --copy-dest
copies (never links), --compare-dest omits unchanged files;
ref tree outside both src and dest.
temp-dir cross-dir temp->final rename at depth; temp dir left clean; a
missing --temp-dir fails (so the option is proven consulted).
partial --partial keeps the partial in the dest file; relative
--partial-dir stages per-directory at depth (pre-seed +
interrupt/resume); absolute --partial-dir writes the partial
outside the tree.
inplace --inplace keeps the destination inode across a delta update;
the default temp+rename path replaces it.
append --append completes truncated files tail-only; --append-verify
repairs a corrupted prefix (protocol >= 30).
backup-deep --suffix saves <name>S beside the new file; --backup-dir
relocates old files to a parallel deep tree outside the dest
and captures deletions under --delete.
All green on master and under --protocol=29/30.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Sat, 23 May 2026 21:47:10 +0000 (07:47 +1000)]
testsuite: add depth/cross-dir/daemon coverage helpers to rsyncfns.py
Add helpers for the option-coverage expansion (the path-handling restructure
changes parent-component resolution, so options must be exercised at depth and
across directory boundaries):
* make_tree() builds a tree with a regular file at every level so a property
can be checked at the tree root and >=3 levels deep;
* walk_files()/walk_dirs() iterate entries for per-level assertions;
* assert_same/assert_mode/assert_mtime_close/assert_is_symlink/
assert_hardlinked/assert_not_hardlinked/assert_exists/assert_not_exists
assert the concrete property an option controls (not just dest == src);
* write_daemon_conf() writes an arbitrary rsyncd.conf (globals + modules)
for daemon-parameter tests, beyond build_rsyncd_conf's fixed four modules;
* forced_protocol() lets protocol-sensitive tests gate sub-cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Fri, 22 May 2026 04:52:46 +0000 (14:52 +1000)]
testsuite: read xattrs natively instead of shelling out to getfattr
xattr_set() sets attributes with the native os.setxattr(), but
xattr_dump() read them back by running "getfattr -d". That asymmetry
breaks "make check" on any system where rsync is built with xattr
support (libattr headers present) but the attr package's CLI tools are
not installed -- common on Android/Termux and minimal CI images: setting
succeeds via os.setxattr, then xattr_dump's getfattr raises
FileNotFoundError, which crashes the test (reported FAIL) instead of
running or skipping it. That's why "make check" was failing here on
xattrs / xattrs-hlink.
Read the xattrs natively with os.listxattr()/os.getxattr() on Linux,
symmetric with xattr_set(), so the suite needs no external getfattr; the
output still mimics "getfattr -d" and only has to be self-consistent
between the source and destination dumps. Cygwin keeps the CLI path
(Python there lacks os.*xattr). make check now passes with no attr
package installed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Python rewrite of the suite carried over the shell habit of
populating the test tree by capturing "ls -l /etc" / "ls -l /bin"
(falling back to "ls /"): hands_setup() built etc-ltr-list / bin-lt-list
that way, and longdir_test.py did the same for its leaf files. That ties
the fixtures to the host filesystem layout -- those directories are
absent or unreadable on Android/Termux and other minimal environments,
where "ls /" fails outright -- and the captured content was never
reproducible from run to run.
Add a deterministic make_text_file() helper to rsyncfns.py and use it for
hands_setup()'s two fixture files and longdir's leaf files. The names
etc-ltr-list / bin-lt-list are unchanged (chmod, chmod-temp-dir and
alt-dest reference them by name); only the content source changes, so the
fixtures are now self-contained and identical on every platform. This
also drops longdir_test.py's date(1) and ls(1) subprocess calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Python rewrite had gated the xattr / fake-super tests (xattrs,
xattrs-hlink, chown-fake, devices-fake) to Linux because it used the
Linux-only os.*xattr. Restore them on macOS, FreeBSD, Cygwin and Solaris
via a per-OS xattr surface in rsyncfns.py (xattrs_supported / xattr_set /
xattr_dump):
* Linux -- os.*xattr
* macOS -- xattr
* FreeBSD -- setextattr / lsextattr / getextattr
* Cygwin -- getfattr / setfattr (from the `attr` package; CPython on
Cygwin has no os.*xattr)
* Solaris -- runat(1), with the script on stdin and the attr name/value
passed via the environment (the runat -c form mangles args)
Test attribute names are logical; the "user." namespace prefix is added
only on the Linux-style platforms (Linux, Cygwin). RSYNC_PREFIX/RUSR vary
per OS (macOS and Solaris use rsync.nonuser to avoid rsync's reserved
rsync.* space). The macOS and Cygwin workflows no longer skip these tests;
the FreeBSD/Solaris jobs use IGNORE skip-checking so need no change.
Verified on real Linux, macOS, FreeBSD, Cygwin and Solaris hosts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Thu, 21 May 2026 04:14:13 +0000 (14:14 +1000)]
testsuite: post-review fixes and lock-file hardening
* chmod-option: pin umask to the suite-wide 022 baseline (mirroring the
old rsync.fns) so rsync's --chmod `D+w` is computed and applied under
the same umask -- fixes failures under a different ambient umask (077).
* daemon module-list test: assert the `list = no` module does NOT leak
into the listing (the substring check alone missed regressions).
* claim_ports() lock file: open with O_NOFOLLOW and only fchmod a file we
O_EXCL-created, rejecting a symlink OR hard link planted at the
well-known /tmp path -- which, with the TCP tests running under sudo in
CI, could otherwise chmod an arbitrary root-owned target. Require a
pristine (regular, nlink==1) file.
* CI: extend the Linux/Cygwin expected-skip lists for the gated tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
socketpair_tcp() fakes a connected socket pair via a loopback TCP
self-connect (socket -> bind 127.0.0.1:0 -> listen -> connect ->
accept), used by sock_exec() for RSYNC_CONNECT_PROG. Its comment has
long promised that "nobody else can attach to the socket, or if they
do that this function fails", but nothing actually verified it: the
code accept()ed whatever connection arrived first without checking it
was the one our own connect() made.
Between listen() and accept() the ephemeral loopback port is
connectable by any local user. With backlog 1 a same-host attacker who
races a connection in before our connect() lands could have their
socket returned by accept(), handing them one end of the rsync
protocol stream. The exposure is small (loopback only, random
ephemeral port, sub-millisecond window, local users only), but the
promised guarantee was simply not enforced.
Enforce it: after the connection is established, require that the peer
address of the accepted end (fd[0]) equals the local address of our
connecting end (fd[1]), and that both are 127.0.0.1. A hijacked
connection has a different source port and is rejected (errno EPERM,
fail closed). The legitimate self-connect always matches, so there is
no behaviour change for the normal path.
Verified: rebuilds clean with -Wall -W; the full testsuite still
passes in both transports (pipe `make check` 57/3, `runtests.py
--use-tcp` 59/1) -- the pipe transport exercises this code path on
every daemon test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Thu, 21 May 2026 04:14:13 +0000 (14:14 +1000)]
testsuite: secure stdio-pipe daemon transport by default, opt-in TCP
Daemon-mode tests default to the stdio-pipe transport (RSYNC_CONNECT_PROG),
which opens no listening socket -- so `make check` never exposes a network
service. Real TCP is opt-in via `runtests.py --use-tcp`, with the daemon
bound to loopback (127.0.0.1) on a claim_ports()-reserved port; CI runs the
suite both ways.
start_test_daemon() is the single seam every daemon test uses: the secure
pipe by default, a real rsyncd on a claimed loopback port under --use-tcp.
Tests with no pipe equivalent (the fake-proxy listener and the reverse-DNS
hostname-ACL daemon test) are gated behind require_tcp().
`make check` also now runs the suite in parallel by default (CHECK_J=8);
the claim_ports() byte-range locks make that safe across concurrent runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Thu, 21 May 2026 01:47:44 +0000 (11:47 +1000)]
testsuite: add claim_ports() for parallel-safe TCP-port coordination
rsyncfns.claim_ports(*ports) takes exclusive POSIX byte-range locks on
/tmp/rsync_test.lck (offset = port number) so any number of test
processes can run concurrently without colliding on a TCP port: a test
asking for a port already held blocks until the holder exits. The
kernel drops the locks automatically when the holding process dies, so
a crashed test releases its ports with no manual cleanup.
Ports are claimed in sorted order so two callers requesting the same
set in different orders can't deadlock. The lock file is forced to
mode 0o666 after creation (the umask would otherwise trim it and lock
out a second user on a shared CI runner; EPERM when we're not the
owner is fine).
proxy-response-line-too-long is the first user: it switches from an
ephemeral port to a claimed fixed port (12873).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Thu, 21 May 2026 01:47:34 +0000 (11:47 +1000)]
testsuite: rewrite the shell testsuite in Python
Replace the entire shell-based testsuite with Python. runtests.py
already drove the suite (it had replaced runtests.sh earlier); this
converts all 60 test scripts from *.test shell to *_test.py and adds
testsuite/rsyncfns.py as the shared helper module -- the Python
counterpart of the now-removed rsync.fns.
runtests.py:
* Discovers and runs both *.test and *_test.py; dispatches the
Python tests via the same python3 that runs the harness.
* Extends PYTHONPATH so tests can `import rsyncfns`.
testsuite/rsyncfns.py provides everything the ports need:
* environment wiring (scratchdir / srcdir / TOOLDIR / RSYNC /
TLS_ARGS, and HOME pointed at the per-test scratch dir);
* result reporting -- test_fail / test_skipped / test_xfail mapping
to the 0 / 1 / 77 / 78 exit-code convention;
* the transfer-and-verify helpers checkit, checkdiff, verify_dirs,
rsync_ls_lR, check_perms and the v_filt output filter;
* fixture builders hands_setup, build_symlinks, build_rsyncd_conf,
make_data_file, cp_p / cp_touch, makepath / rmtree.
All 60 tests are converted, including the four split-variant tests
that share one source via a Makefile-built symlink (chown/chown-fake,
devices/devices-fake, xattrs/xattrs-hlink, exclude/exclude-lsh);
Makefile.in's CHECK_SYMLINKS now points at the *_test.py names.
The dead rsync.fns shell library is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Fri, 22 May 2026 02:40:17 +0000 (12:40 +1000)]
ci: add static Android NDK build workflow
Cross-compiles statically-linked rsync binaries with the Android NDK for
arm64-v8a (all modern phones) and armeabi-v7a (older 32-bit devices), and
uploads them as workflow artifacts for adb push / Termux use.
The build is self-contained (optional external libraries disabled; keeps
md5/md4 and the bundled zlib) and forces a few configure cache values
that can't be probed when cross-compiling: lchmod()/lutimes() off (Bionic
doesn't declare them until API 36 though the symbols link), and
socketpair / mknod-FIFO / mknod-socket on (Android runs a Linux kernel,
so these match the native result). IPv6 is enabled explicitly.
Since the binaries are cross-compiled the test suite can't run; the job
instead asserts each binary is static and the correct architecture, and
smoke-tests `--version` under qemu-user.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Andrew Tridgell [Wed, 20 May 2026 21:28:03 +0000 (07:28 +1000)]
testsuite: portable make_data_file helper; drop hard /dev/urandom dependency
symlink-dirlink-basis.test and chdir-symlink-race.test both
require a multi-kilobyte non-trivial-content source file for the
rsync delta algorithm to exercise. Both used dd / head against
/dev/urandom directly, which fails on platforms that don't ship
/dev/urandom (e.g. HPE NonStop). The dd error gets swallowed by
'2>/dev/null' and the test then fails with a misleading 'failed
to create test file' that hides the real cause.
Add make_data_file <path> <size> to testsuite/rsync.fns. Prefers
/dev/urandom when readable (kernel-provided randomness, fast),
falling back to a deterministic awk LCG seeded from PID and a
POSIX cksum of the destination path. Output is constrained to
printable ASCII (33..126) so the helper survives two awk-portability
quirks:
- printf '%c', 0 terminates the string in some awks, emitting
fewer than sz bytes;
- gawk in UTF-8 locales encodes printf '%c', N for N > 127 as
a 2-byte UTF-8 sequence, emitting more than sz bytes.
The tests don't need 8-bit binary entropy -- they just need
non-trivial bytes for rsync's block-matching algorithm.
Update both call sites to use the helper. Linux/FreeBSD/macOS
still take the /dev/urandom fast path; NonStop and any other
platform missing the device get the awk fallback transparently.
Both paths verified locally with the symlink-dirlink-basis test.
Andrew Tridgell [Wed, 20 May 2026 21:13:36 +0000 (07:13 +1000)]
t_chmod_secure: probe kernel RESOLVE_BENEATH at runtime; drop test skip
The chmod-symlink-race test was previously a no-op on Solaris,
OpenBSD, NetBSD, and Cygwin via a case 'uname -s' skip. The skip
was too broad: of the four scenarios the helper exercises, only
the 'legitimate within-tree dir-symlink' one actually needs
RESOLVE_BENEATH-equivalent kernel support. The other three
(attack rejection, plain relative path, top-level file) behave
identically on the per-component O_NOFOLLOW fallback and would
have caught the t_stub.c max_alloc=0 bug fixed in the previous
commit if the test had been allowed to run.
Make the helper probe the running kernel for either
openat2(RESOLVE_BENEATH) on Linux 5.6+ or openat(O_RESOLVE_BENEATH)
on FreeBSD 13+ / macOS 15+ by opening '.' under the requested
confinement. Honour the result:
- If RESOLVE_BENEATH-equivalent confinement is available, the
within-tree symlink scenario must succeed (status quo).
- If not, the per-component O_NOFOLLOW fallback rejects every
symlink including legitimate ones; expect the within-tree
symlink scenario to be rejected (rc != 0) and the file mode
to remain unchanged.
The attack-rejection, plain-path and top-level scenarios are
unchanged: they expect the same outcome on both code paths.
Drop the case-based skip from chmod-symlink-race.test so the test
runs everywhere and the per-component fallback gets the CI
coverage that the SunOS/OpenBSD/NetBSD/Cygwin runners can
provide. HPE NonStop -- which lacks RESOLVE_BENEATH but isn't in
the existing skip list -- is also covered by this change.
Andrew Tridgell [Wed, 20 May 2026 21:11:30 +0000 (07:11 +1000)]
t_stub.c: raise max_alloc default so test helpers can allocate
The t_stub.c shim defined max_alloc = 0 as a placeholder to satisfy
the link against util2.o. This was harmless when the test helpers
made no allocations, but the secure_relative_open() implementation
in 3.4.0+ calls my_strdup() in its per-component O_NOFOLLOW
fallback (syscall.c around line 1857), and the 3.4.3 do_*_at()
hardening series added more such calls. With max_alloc=0, every
allocation in that path trips the 'exceeded --max-alloc=0' check in
util2.c's my_alloc(), and t_chmod_secure (which exercises
do_chmod_at via secure_relative_open) fails on the very first
my_strdup.
The failure is invisible on Linux 5.6+ / FreeBSD 13+ / macOS 15+ /
recent Cygwin because those platforms take the kernel-enforced
openat2(RESOLVE_BENEATH) or openat(O_RESOLVE_BENEATH) branch and
never reach the per-component fallback. It also goes unobserved
on the SunOS/OpenBSD/NetBSD/CYGWIN* CI runners because the
chmod-symlink-race.test script case-skips on those platforms (the
legitimate dir-symlink scenario the test exercises can't pass on
the per-component fallback). HPE NonStop is the first platform
that lacks RESOLVE_BENEATH support AND isn't in the skip list AND
has someone actually running the test suite, so it surfaced the
latent bug.
Raise max_alloc to SIZE_MAX so the helpers can allocate freely.
A follow-up patch makes t_chmod_secure adapt at runtime so the
skip list can be removed and the per-component fallback gets real
CI coverage.
Andrew Tridgell [Wed, 20 May 2026 05:18:25 +0000 (15:18 +1000)]
packaging: add ftp.filt, the FTP mirror filter file
The .filt file in /home/ftp/pub/rsync on samba.org controls which
subtrees release.py's FTP mirror excludes (currently /binaries/
and /generated-files/). Without it, step-10-push-ftp's
'rsync --del' would propagate local deletions to the server even
for those archive subtrees.
Until now the only copy of this two-line file lived on the server.
Bundle it in source at packaging/ftp.filt so it survives a disaster
on samba.org, and have step_1_fetch seed FTP_DIR/.filt from the
bundled copy on every run (with --exclude=/.filt on the rsync pull,
so the server's copy can't silently drift the bundled one).
step-10-push-ftp then propagates any in-source updates to the
filter back to the server.
Andrew Tridgell [Wed, 20 May 2026 05:02:32 +0000 (15:02 +1000)]
packaging: remove obsolete samba-rsync and send-news scripts
Both scripts were pre-release.py legacy helpers:
* samba-rsync rsync'd ~/samba-rsync-{ftp,html}/ to the samba.org
server. release.py step-10-push-ftp and step-11-push-html now
do exactly this, using ../release/rsync-{ftp,html}/ as the
local mirrors.
* send-news copied README/INSTALL/NEWS .md + .html files into
~/samba-rsync-ftp/ and rsync'd them to samba.org.
release.py step-8-update-ftp already does this
(./md-convert --dest=FTP_DIR README.md NEWS.md INSTALL.md and
the surrounding rsync of html files into FTP_DIR), and
step-10-push-ftp pushes the result.
Update the trailing instructions printed at the end of
step-12-push-git to drop the now-obsolete 'run packaging/send-news'
suggestion, and tighten the comment in step_1_fetch that referred
to samba-rsync as a current sibling tool.
Andrew Tridgell [Wed, 20 May 2026 04:58:35 +0000 (14:58 +1000)]
packaging/release.py: rsync-web is now an in-tree subdirectory
Track the move of rsync-web from sibling git checkout to a regular
subdirectory of the rsync source tree:
* HTML_SRC: '../rsync-web' -> 'rsync-web'.
* step_1_fetch: drop the .git-presence probe and the 'make sure
it's up to date' reminder. Both made sense when rsync-web was
a separate repo the maintainer had to clone and pull, but the
directory is now part of the same checkout as this script.
* rsync invocation no longer needs --exclude=/.git: there is no
.git inside rsync-web/ (it is just a subdir of the parent
rsync-git checkout).
* Header comment block and step-1 help text rewritten to describe
the new layout.
Andrew Tridgell [Wed, 20 May 2026 04:57:50 +0000 (14:57 +1000)]
import rsync-web website content as a subdirectory
Fold the standalone rsync-web repo into the rsync source tree as
rsync-web/, eliminating the sibling-checkout convention and the
drift it causes between the release-time HTML snapshot in
../release/rsync-html and the source of truth in ../rsync-web.
Flat-copy import (no git history merge). The standalone repo at
github.com/RsyncProject/rsync-web is retained for historical
reference and will be archived once the in-tree copy proves itself.
Add /rsync-web/ to .gitattributes with export-ignore so the
website content does not bloat the release source tarball
produced by 'git archive' in packaging/release.py step_7_tarball.
A follow-up commit repoints HTML_SRC in packaging/release.py at
the new in-tree location.
Andrew Tridgell [Wed, 20 May 2026 04:36:02 +0000 (14:36 +1000)]
INSTALL.md: point Ubuntu users at the ppa:rsyncproject/rsync PPA
Most Ubuntu users landing on INSTALL.md want to install rsync, not
build it. Add a short section near the top that offers the
Launchpad PPA as the one-line path for the four currently supported
series (jammy 22.04 LTS, noble 24.04 LTS, questing 25.10,
resolute 26.04 LTS), and clarify that the rest of the file is about
building from source.
Andrew Tridgell [Thu, 7 May 2026 21:01:39 +0000 (07:01 +1000)]
NEWS: prepare 3.4.3 release entry with six CVEs
Set the date to 20 May 2026, add a SECURITY FIXES section listing
all six May 2026 CVEs (CVE-2026-29518, -43617, -43618, -43619,
-43620, -45232) with reach, root cause, fix and reporter for each,
plus a note on the defence-in-depth hardening that goes with them.
Also list the new symlink-race regression tests under DEVELOPER
RELATED.
Andrew Tridgell [Wed, 13 May 2026 10:35:35 +0000 (20:35 +1000)]
socket: reject over-long proxy response line
fixes a one byte stack overflow when using RSYNC_PROXY with a
malicious proxy.
Reach: only when RSYNC_PROXY is set and a malicious or MITM'd
proxy returns the pathological response. The byte written is
always '\0' and the attacker doesn't choose the offset, so impact
is corruption of one adjacent stack byte and possible later
misbehaviour or crash -- no information disclosure beyond the
existing rprintf of buffer contents.
With the previous MAX_WIRE_DEL_STAT = 2^30 (1.07 GB) the worst-case
sum is 5 * 2^30 = 5.37 GB; three maximal values already exceed
INT32_MAX = 2.15 GB on the third "+=", triggering signed integer
overflow (C99 6.5/5 -- undefined behaviour, the compiler may assume
it cannot happen and elide subsequent checks).
The bound was introduced in f0155902 ("defence-in-depth: bound
wire-supplied counts and lengths") with a commit message claiming
"per-summand cap so the total can't overflow", but 2^30 * 5 does
overflow. Lower the per-summand cap to 2^28 (= 268M) so the worst
case is 5 * 2^28 = 1.34 GB < INT32_MAX with margin. 2^28 deletions
per category is still vastly above any plausible real transfer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>