diff --git a/.gitattributes b/.gitattributes index dd440b78c..17c3856c4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,12 @@ # packaging/release.py step_7_tarball does not bloat with HTML the # tarball doesn't need. /rsync-web/ export-ignore + +# old_versions/ holds static binaries of historical rsync releases, used by the +# version-mixing test suite (.github/workflows/ubuntu-version-mix.yml) to run +# the current code against a real old peer over the daemon / remote-shell. +# Mark the binaries as binary so the `text=auto eol=lf` rule above can't try to +# normalise line endings and corrupt them; export-ignore keeps them out of the +# release source tarball. +/old_versions/rsync_* binary +/old_versions/rsync_* export-ignore diff --git a/.github/workflows/ubuntu-version-mix.yml b/.github/workflows/ubuntu-version-mix.yml new file mode 100644 index 000000000..d122a39b1 --- /dev/null +++ b/.github/workflows/ubuntu-version-mix.yml @@ -0,0 +1,77 @@ +name: Test rsync version mixing on Ubuntu + +# Runs the CURRENT test suite with two different rsync binaries: the freshly +# built ./rsync as the client/driver, and a committed OLD static binary +# (old_versions/rsync_) as the daemon / remote-shell peer. This exercises +# real version mixing over the wire -- more convincing than --protocol forcing, +# which only makes the current binary speak an old protocol. +# +# 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. The +# reverse (old client driving new scripts) is not possible -- but one test, +# reverse-daemon-delta, swaps the roles internally (current build as the daemon, +# old binary as the client) to cover the backward-compat direction: a current +# daemon serving the installed base of old clients. +# +# The per-version manifest testsuite/expect/rsync_.expect lists exactly +# which tests run and each one's expected outcome (pass/skip/fail/xfail), so an +# old peer's known feature gaps are recorded rather than treated as breakage. +# +# All peers run in a SINGLE job (looped, not a matrix) so the PR shows one check +# line rather than one per version. Each peer/transport is a foldable ::group:: +# in the log, and a failure annotates which peer/transport broke. + +on: + push: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/ubuntu-version-mix.yml' + pull_request: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/ubuntu-version-mix.yml' + schedule: + - cron: '52 8 * * *' + +jobs: + version-mix: + runs-on: ubuntu-latest + name: rsync version-mix + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: prep + run: | + sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl + echo "/usr/local/bin" >>"$GITHUB_PATH" + - name: configure + run: ./configure --with-rrsync + - name: make + # check-progs builds rsync AND the test helper programs (tls, trimslash, + # t_unsafe, ...) that runtests.py requires; plain `make` does not. + run: make check-progs + - name: info + run: ./rsync --version | head -1 + - name: version mixing (all peers, pipe + TCP transports) + run: | + rc=0 + for peer in old_versions/rsync_*; do + chmod +x "$peer" + name=$(basename "$peer") + expect="testsuite/expect/$name.expect" + for transport in pipe tcp; do + tcp=() + [ "$transport" = tcp ] && tcp=(--use-tcp) + echo "::group::$name ($transport): $("$peer" --version | head -1)" + if ! ./runtests.py --rsync-bin="$PWD/rsync" --rsync-bin2="$PWD/$peer" \ + --expect-result "$expect" "${tcp[@]}" -j 8; then + echo "::error::version-mix failed: $name ($transport)" + rc=1 + fi + echo "::endgroup::" + done + done + exit $rc diff --git a/Makefile.in b/Makefile.in index bde2c5897..60160c307 100644 --- a/Makefile.in +++ b/Makefile.in @@ -351,6 +351,12 @@ COVERAGE_RUNFLAGS = COVERAGE_EXCLUDE = -e '(^|/)zlib/' -e '(^|/)popt/' \ -e '(^|/)lib/(getaddrinfo|getpass|inet_ntop|inet_pton)\.' +# Build everything the test suite needs (rsync + helper programs + symlinks) +# WITHOUT running it. Used by CI jobs that invoke runtests.py directly with +# custom options (e.g. the version-mix workflow's --rsync-bin2/--expect-result). +.PHONY: check-progs +check-progs: all $(CHECK_PROGS) $(CHECK_SYMLINKS) + .PHONY: check check: all $(CHECK_PROGS) $(CHECK_SYMLINKS) $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) diff --git a/old_versions/README.md b/old_versions/README.md new file mode 100644 index 000000000..a5ce3a5b6 --- /dev/null +++ b/old_versions/README.md @@ -0,0 +1,87 @@ +# Old rsync version archive + +Static rsync binaries built from historical release tags. Two uses: + +1. **Cross-version behaviour checks** — confirming whether a behaviour a user + reported on an old release is version-specific or option-driven. +2. **The version-mixing test suite** — `runtests.py --rsync-bin2=...` runs the + current code against one of these as the daemon / remote-shell peer; CI + (`.github/workflows/ubuntu-version-mix.yml`) does this for every binary + here against the per-version manifests in `testsuite/expect/`. + +Binaries are **statically linked** so they run regardless of the host's +shared libraries, and named `rsync_`: + +| Binary | Version | Protocol | Notes | +|----------------|---------|----------|-----------------------------------------| +| `rsync_2.6.0` | 2.6.0 | 27 | 2004; needs autoconf regen (see below) | +| `rsync_3.0.0` | 3.0.0 | 30 | 2008 | +| `rsync_3.1.0` | 3.1.0 | 31 | 2013 | +| `rsync_3.1.3` | 3.1.3 | 31 | Ubuntu 18.04 / Debian buster era (2018) | +| `rsync_3.2.0` | 3.2.0 | 31 | 2020 (zstd/lz4/xxhash negotiation added)| +| `rsync_3.2.7` | 3.2.7 | 31 | 2022 | +| `rsync_3.3.0` | 3.3.0 | 31 | 2024 | +| `rsync_3.4.0` | 3.4.0 | 32 | 2025 | +| `rsync_3.4.1` | 3.4.1 | 32 | 2025 | + +These are every `x.y.0` release from 2.6.0 (2004) onward plus a few point +releases. 2.6.0 is the practical floor: older tags need progressively more +porting to build on a current toolchain. + +All built `--disable-openssl` and with `_FORTIFY_SOURCE` disabled (see below); +xxhash/zstd/lz4 are compiled in where the version supports them. + +## Adding a version + +```bash +./build_static.sh 3.2.7 # uses git tag v3.2.7 +./build_static.sh 3.0.9 v3.0.9 # explicit tag if naming differs +``` + +The script checks out the tag into a throwaway `git worktree`, applies the +minimal patches needed to compile old sources on a modern toolchain, links +statically, verifies the result is static and reports the requested version, +then installs `rsync_` here and removes the worktree. + +Override the source repo with `RSYNC_REPO=/path/to/rsync ./build_static.sh ...` +(defaults to `../rsync.4`). + +## Why the patches? + +Modern GCC (>= 14, C23 default) and glibc reject things old rsync relied on. +`build_static.sh` handles these, each guarded so it's a no-op when not needed: + +1. **K&R `lseek64()` redeclaration** in `syscall.c` clashes with glibc's real + prototype — removed. +2. **`gettimeofday()`** — glibc only has the 2-arg form; configure misdetects + the 1-arg form, so `HAVE_GETTIMEOFDAY_TZ` is forced on in `config.h`. +3. **C23 `()` == `(void)`** breaks K&R prototypes called with arguments + (`qsort` comparator, `pool->bomb`, etc.) — built with `-std=gnu11`. +4. Assorted modern `-Werror` promotions (incompatible pointer types, implicit + declarations) downgraded to warnings; bundled zlib/popt used to keep the + static link self-contained. + +5. **OpenSSL (3.2+)** is disabled with `--disable-openssl`: linking + `libcrypto.a` statically drags in jitterentropy (`jent_*`) and zlib's + `uncompress` (OpenSSL's COMP module), which don't resolve here. OpenSSL only + provided optional MD4/MD5, which rsync implements natively, so checksum + behaviour is unaffected. + +6. **`_FORTIFY_SOURCE` disabled** (`-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0`): + modern Ubuntu defaults it to `=3`, whose stricter object-size checks turn + latent (historically benign) over-reads in OLD rsync into hard + `*** buffer overflow detected ***` aborts when the binary runs as a + server/daemon — which made e.g. 3.1.3 and 3.2.7 unusable as peers. Disabling + it makes the archival binaries behave as the released versions did. + +7. **Pre-3.0 tags (e.g. 2.6.0)** ship `configure.in`, not a generated + `configure`. The script runs `autoheader`/`autoconf` to generate it, after + neutralizing the `AC_CHECK_FUNCS(fn,,AC_LIBOBJ(lib/...))` fallbacks for + `inet_ntop`/`inet_pton`/`getaddrinfo`/`getnameinfo` — modern autoconf emits + broken shell for those never-taken branches (the funcs exist in glibc). It + also generates `proto.h` (no make rule in that era) and stubs the vendored + `lib/addrinfo.h` the tag dropped (modern glibc supplies `struct addrinfo`). + All guarded so they no-op on 3.x. + +Newer versions may need fewer or different tweaks; if a build fails, the +script prints the first compiler errors from its log. diff --git a/old_versions/build_static.sh b/old_versions/build_static.sh new file mode 100755 index 000000000..7cabcc0cf --- /dev/null +++ b/old_versions/build_static.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Build a static rsync binary from a historical git tag, for cross-version +# behaviour testing. Produces ./rsync_ in this directory. +# +# Usage: ./build_static.sh [git-tag] +# Example: ./build_static.sh 3.1.3 # uses tag v3.1.3 +# ./build_static.sh 3.2.7 v3.2.7 +# +# Old rsync releases don't compile cleanly on a modern toolchain (GCC >= 14 +# defaults to C23, where an empty () prototype means (void); glibc dropped the +# 1-arg gettimeofday; lseek64 K&R redeclarations clash). This script applies +# the minimal, best-effort workarounds and links statically so the result is +# self-contained and reproducible regardless of the host's shared libraries. +# +# Each workaround is guarded so it's a no-op on versions that don't need it. +set -euo pipefail + +VERSION="${1:?usage: build_static.sh [git-tag]}" +TAG="${2:-v$VERSION}" + +ARCHIVE_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO="${RSYNC_REPO:-/home/tridge/project/rsync/rsync.4}" # any rsync worktree +WORKTREE="$(mktemp -d /tmp/rsync-build-XXXXXX)" +OUT="$ARCHIVE_DIR/rsync_$VERSION" + +# C standard restores K&R () semantics; permissive flags downgrade the pile of +# modern -Werror promotions (incompatible pointers, implicit decls) to warnings. +# _FORTIFY_SOURCE is forced OFF: modern Ubuntu defaults it to =3, whose stricter +# object-size checks turn latent (historically benign) over-reads in OLD rsync +# into hard "*** buffer overflow detected ***" aborts when the binary acts as a +# server/daemon. Disabling it makes these archival binaries behave the way the +# released versions did, which is the whole point of the archive. +CFLAGS_OLD="-I. -I./zlib -O2 -g -std=gnu11 -fcommon -DHAVE_CONFIG_H -Wno-error \ +-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \ +-Wno-incompatible-pointer-types -Wno-implicit-function-declaration -Wno-int-conversion" + +cleanup() { + cd "$REPO" + git worktree remove --force "$WORKTREE" 2>/dev/null || true + git worktree prune 2>/dev/null || true +} +trap cleanup EXIT + +echo ">>> checking out $TAG into $WORKTREE" +# prefer an exact tag to avoid ambiguity with similarly-named branches +REF="$TAG" +if git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + REF="refs/tags/$TAG" +fi +git -C "$REPO" worktree add --detach "$WORKTREE" "$REF" +cd "$WORKTREE" + +# --- workaround 1: K&R lseek64 redeclaration clashes with glibc's prototype --- +if grep -q 'off64_t lseek64();' syscall.c 2>/dev/null; then + echo ">>> patching syscall.c lseek64 redeclaration" + perl -0pi -e 's/#ifdef HAVE_LSEEK64\n#if !SIZEOF_OFF64_T\n\tOFF_T lseek64\(\);\n#else\n\toff64_t lseek64\(\);\n#endif\n\treturn lseek64/#ifdef HAVE_LSEEK64\n\treturn lseek64/' syscall.c +fi + +# --- workaround 0: pre-3.0 tags ship configure.in, not a generated configure. +# Generate it. Modern autoconf emits broken shell for their +# AC_CHECK_FUNCS(fn,,AC_LIBOBJ(lib/...)) fallbacks -- but those branches are +# dead on a modern host (glibc has inet_ntop/inet_pton/getaddrinfo/getnameinfo), +# so neutralize the AC_LIBOBJ replacements before regenerating. +OLD_TREE=0 +if [ ! -f ./configure ] && { [ -f configure.in ] || [ -f configure.ac ]; }; then + OLD_TREE=1 + acsrc=configure.ac; [ -f configure.in ] && acsrc=configure.in + echo ">>> generating configure for an old tag (autoheader/autoconf)" + sed -i 's#AC_LIBOBJ(lib/[a-zA-Z_]*)#:#g' "$acsrc" + autoheader 2>/dev/null || true + autoconf 2>/dev/null || { echo "autoconf failed"; exit 1; } +fi + +CONF_ARGS=(--disable-md2man --with-included-zlib=yes --with-included-popt=yes) +# OpenSSL (3.2+) only adds optional MD4/MD5 that rsync already implements, but +# linking libcrypto.a statically drags in jitterentropy + zlib's uncompress, +# which aren't resolvable here. Drop it when the flag exists. +if ./configure --help 2>/dev/null | grep -q -- '--disable-openssl'; then + echo ">>> disabling openssl for self-contained static link" + CONF_ARGS+=(--disable-openssl) +fi + +echo ">>> configure (bundled zlib + popt, static-friendly)" +./configure "${CONF_ARGS[@]}" \ + >"$WORKTREE/conf.log" 2>&1 || { tail -20 "$WORKTREE/conf.log"; exit 1; } + +# --- workaround 2: modern glibc only has the 2-arg gettimeofday --------------- +if grep -q '/\* #undef HAVE_GETTIMEOFDAY_TZ \*/' config.h; then + echo ">>> forcing HAVE_GETTIMEOFDAY_TZ (configure misdetects it)" + sed -i 's|/\* #undef HAVE_GETTIMEOFDAY_TZ \*/|#define HAVE_GETTIMEOFDAY_TZ 1|' config.h +fi + +# --- workaround 4 (old trees only): generate proto.h if the tree has no make +# rule for it, and stub a vendored lib/addrinfo.h that the git tag dropped +# (modern glibc supplies struct addrinfo / sockaddr_storage, so empty is right). +if [ "$OLD_TREE" = 1 ]; then + if [ ! -f proto.h ] && [ -f mkproto.awk ]; then + echo ">>> generating proto.h" + cat ./*.c ./lib/compat.c 2>/dev/null | awk -f ./mkproto.awk > proto.h + fi + if grep -q 'include "lib/addrinfo.h"' rsync.h 2>/dev/null && [ ! -f lib/addrinfo.h ]; then + echo ">>> stubbing lib/addrinfo.h" + echo '/* emptied: modern glibc provides struct addrinfo */' > lib/addrinfo.h + fi +fi + +echo ">>> building (static)" +make -j"$(nproc)" CFLAGS="$CFLAGS_OLD" LDFLAGS="-static" \ + >"$WORKTREE/make.log" 2>&1 || { grep -E 'error:|\*\*\*' "$WORKTREE/make.log" | head; exit 1; } + +# verify it's actually static before we keep it +if ldd ./rsync 2>&1 | grep -qv 'not a dynamic executable'; then + echo "ERROR: binary is not statically linked:" >&2 + ldd ./rsync >&2 + exit 1 +fi + +GOT="$(./rsync --version | head -1 | awk '{print $3}')" +if [ "$GOT" != "$VERSION" ]; then + echo "ERROR: built version '$GOT' != requested '$VERSION'" >&2 + exit 1 +fi + +cp ./rsync "$OUT" +strip "$OUT" +echo ">>> installed $OUT" +"$OUT" --version | head -1 +file "$OUT" diff --git a/old_versions/rsync_2.6.0 b/old_versions/rsync_2.6.0 new file mode 100755 index 000000000..face7a023 Binary files /dev/null and b/old_versions/rsync_2.6.0 differ diff --git a/old_versions/rsync_3.0.0 b/old_versions/rsync_3.0.0 new file mode 100755 index 000000000..c844826ca Binary files /dev/null and b/old_versions/rsync_3.0.0 differ diff --git a/old_versions/rsync_3.1.0 b/old_versions/rsync_3.1.0 new file mode 100755 index 000000000..ebb876077 Binary files /dev/null and b/old_versions/rsync_3.1.0 differ diff --git a/old_versions/rsync_3.1.3 b/old_versions/rsync_3.1.3 new file mode 100755 index 000000000..3cd1ac4ae Binary files /dev/null and b/old_versions/rsync_3.1.3 differ diff --git a/old_versions/rsync_3.2.0 b/old_versions/rsync_3.2.0 new file mode 100755 index 000000000..a28c4058a Binary files /dev/null and b/old_versions/rsync_3.2.0 differ diff --git a/old_versions/rsync_3.2.7 b/old_versions/rsync_3.2.7 new file mode 100755 index 000000000..7ca168206 Binary files /dev/null and b/old_versions/rsync_3.2.7 differ diff --git a/old_versions/rsync_3.3.0 b/old_versions/rsync_3.3.0 new file mode 100755 index 000000000..1169447b2 Binary files /dev/null and b/old_versions/rsync_3.3.0 differ diff --git a/old_versions/rsync_3.4.0 b/old_versions/rsync_3.4.0 new file mode 100755 index 000000000..7432dbd4c Binary files /dev/null and b/old_versions/rsync_3.4.0 differ diff --git a/old_versions/rsync_3.4.1 b/old_versions/rsync_3.4.1 new file mode 100755 index 000000000..4c66adddd Binary files /dev/null and b/old_versions/rsync_3.4.1 differ diff --git a/runtests.py b/runtests.py index 1bdf43988..dc19d8783 100755 --- a/runtests.py +++ b/runtests.py @@ -60,6 +60,11 @@ def parse_args(): help='Per-test timeout in seconds (default: 300)') p.add_argument('--rsync-bin', default=None, metavar='PATH', help='Path to rsync binary (default: ./rsync)') + p.add_argument('--rsync-bin2', default=None, metavar='PATH', + help='Path to a second ("peer") rsync binary used for the ' + 'daemon side and remote-shell --rsync-path. Lets the ' + 'suite mix two rsync versions over the wire. Default: ' + 'same as --rsync-bin (no version mixing).') p.add_argument('--tooldir', default=None, metavar='DIR', help='Tool/build directory (default: cwd)') p.add_argument('--srcdir', default=None, metavar='DIR', @@ -68,6 +73,13 @@ def parse_args(): help='Force protocol version (adds --protocol=VER to rsync)') p.add_argument('--expect-skipped', default=None, metavar='LIST', help='Comma-separated list of expected-skipped tests') + p.add_argument('--expect-result', default=None, metavar='FILE', + help='Path to an expected-outcome manifest (one ' + '" " per line). When ' + 'set, ONLY the tests listed in FILE are run, and each ' + "test's actual outcome is compared against its " + 'expected one; any mismatch (including an unexpected ' + 'pass) fails the run. Used for version-mixing CI.') p.add_argument('--use-tcp', action='store_true', help='Run daemon tests against a real rsyncd bound to ' '127.0.0.1 (non-default). The default is the secure ' @@ -208,6 +220,44 @@ def collect_tests(suitedir, patterns): return tests +_VALID_OUTCOMES = ('pass', 'skip', 'fail', 'xfail') + + +def parse_expect_result(path): + """Parse an expected-outcome manifest into {testbase: outcome}. + + One " " entry per line; '#' comments and blank lines + are ignored. outcome is one of pass|skip|fail|xfail. The set of listed + tests doubles as the run set (see main()). Exits 2 on a malformed file. + """ + expect = {} + with open(path) as f: + for lineno, raw in enumerate(f, 1): + line = raw.split('#', 1)[0].strip() + if not line: + continue + fields = line.split() + if len(fields) != 2 or fields[1] not in _VALID_OUTCOMES: + sys.stderr.write( + f"{path}:{lineno}: expected ' " + f"<{'|'.join(_VALID_OUTCOMES)}>', got: {raw.rstrip()}\n" + ) + sys.exit(2) + expect[fields[0]] = fields[1] + return expect + + +def outcome_of(result): + """Map a per-test exit code to an outcome string.""" + if result == 0: + return 'pass' + if result == 77: + return 'skip' + if result == 78: + return 'xfail' + return 'fail' + + def build_rsync_cmd(rsync_bin, args, scratchbase): """Build the RSYNC command string for tests.""" parts = [] @@ -339,6 +389,12 @@ def main(): if rsync_bin and not os.path.isabs(rsync_bin): rsync_bin = os.path.abspath(rsync_bin) + # Optional second ("peer") binary for the daemon / remote-shell side, so a + # run can mix two rsync versions. Defaults to rsync_bin -> no mixing. + rsync_bin2 = args.rsync_bin2 or os.environ.get('rsync_bin2') or rsync_bin + if rsync_bin2 and not os.path.isabs(rsync_bin2): + rsync_bin2 = os.path.abspath(rsync_bin2) + suitedir = os.path.join(srcdir, 'testsuite') scratchbase = os.path.join(os.environ.get('scratchbase', tooldir), 'testtmp') os.makedirs(scratchbase, exist_ok=True) @@ -347,10 +403,14 @@ def main(): tls_args = get_tls_args(os.path.join(tooldir, 'config.h')) setfacl_nodef = find_setfacl_nodef(scratchbase) rsync_cmd = build_rsync_cmd(rsync_bin, args, scratchbase) + rsync_peer_cmd = build_rsync_cmd(rsync_bin2, args, scratchbase) if not os.path.isfile(rsync_bin): sys.stderr.write(f"rsync_bin {rsync_bin} is not a file\n") sys.exit(2) + if not os.path.isfile(rsync_bin2): + sys.stderr.write(f"rsync_bin2 {rsync_bin2} is not a file\n") + sys.exit(2) if not os.path.isdir(srcdir): sys.stderr.write(f"srcdir {srcdir} is not a directory\n") sys.exit(2) @@ -378,6 +438,8 @@ def main(): print('=' * 60) print(f'{sys.argv[0]} running in {tooldir}') print(f' rsync_bin={rsync_cmd}') + if rsync_peer_cmd != rsync_cmd: + print(f' rsync_peer={rsync_peer_cmd}') print(f' srcdir={srcdir}') print(f' TLS_ARGS={tls_args}') print(f' testuser={testuser}') @@ -407,6 +469,7 @@ def main(): 'TOOLDIR': tooldir, 'srcdir': srcdir, 'RSYNC': rsync_cmd, + 'RSYNC_PEER': rsync_peer_cmd, 'TLS_ARGS': tls_args, 'RUNSHFLAGS': '-e', 'scratchbase': scratchbase, @@ -443,6 +506,29 @@ def main(): print(f"Excluding {before - len(tests)} test(s) matching: " f"{', '.join(excl)}") + # An expected-result manifest defines BOTH the run set (its keys) and the + # expected per-test outcome (its values). Used for version-mixing runs. + expect = parse_expect_result(args.expect_result) if args.expect_result else None + if expect is not None: + have = {_testbase(t) for t in tests} + unknown = sorted(k for k in expect if k not in have) + if unknown: + sys.stderr.write( + "runtests.py: --expect-result lists test(s) with no matching " + f"test file (ignored): {', '.join(unknown)}\n" + ) + tests = [t for t in tests if _testbase(t) in expect] + full_run = False + + def _cls(outcome): + """Equivalence class for outcome comparison: fail and xfail both just + mean 'broke', so a manifest 'fail' matches an actual fail OR xfail.""" + return 'broken' if outcome in ('fail', 'xfail') else outcome + + def mismatch(testbase, actual): + """True if actual outcome disagrees with the manifest expectation.""" + return expect is not None and _cls(expect[testbase]) != _cls(actual) + # Record test order for consistent skipped-list output test_order = {_testbase(t): i for i, t in enumerate(tests)} @@ -450,31 +536,33 @@ def main(): failed = 0 skipped = 0 skipped_list = [] + outcomes = {} # testbase -> actual outcome string ('pass'/'skip'/'fail'/'xfail') def process_result(tr): - """Process a TestResult and update counters. Returns True if test failed.""" + """Process a TestResult and update counters. Returns True if the test + should count as a failure for --stop-on-fail purposes.""" nonlocal passed, failed, skipped with _print_lock: if tr.output: print(tr.output) scratchdir = os.path.join(scratchbase, tr.testbase) + oc = outcome_of(tr.result) + outcomes[tr.testbase] = oc if tr.result == 0: passed += 1 - if not args.preserve_scratch and os.path.isdir(scratchdir): - subprocess.run(['rm', '-rf', scratchdir], capture_output=True) - return False elif tr.result == 77: skipped_list.append(tr.testbase) skipped += 1 - if not args.preserve_scratch and os.path.isdir(scratchdir): - subprocess.run(['rm', '-rf', scratchdir], capture_output=True) - return False - elif tr.result == 78: - failed += 1 - return True else: failed += 1 - return True + if tr.result in (0, 77) and not args.preserve_scratch \ + and os.path.isdir(scratchdir): + subprocess.run(['rm', '-rf', scratchdir], capture_output=True) + # With a manifest, only a mismatch is a "failure" (an expected fail is + # fine); without one, any non-pass/non-skip result is a failure. + if expect is not None: + return mismatch(tr.testbase, oc) + return tr.result not in (0, 77) if args.parallel > 1: # Parallel execution @@ -541,6 +629,25 @@ def process_result(tr): if vg_errors > 0: print(f' {vg_errors} valgrind error(s) found (see logs in {scratchbase})') + if expect is not None: + # Version-mixing mode: the run is judged purely on whether each test's + # actual outcome matched its manifest expectation. An expected 'fail' + # is fine; an UNEXPECTED pass (xpass) or any other divergence is not. + mismatches = [] + for tb in sorted(expect, key=lambda x: test_order.get(x, 1 << 30)): + actual = outcomes.get(tb, 'notrun') + if actual == 'notrun' or mismatch(tb, actual): + mismatches.append((tb, expect[tb], actual)) + if mismatches: + print('----- expected-result mismatches:') + for tb, want, got in mismatches: + tag = ' (xpass)' if _cls(want) == 'broken' and got == 'pass' else '' + print(f' {tb}: expected {want}, got {got}{tag}') + print('-' * 60) + exit_code = len(mismatches) + vg_errors + print(f'overall result is {exit_code}') + sys.exit(exit_code) + skipped_str = ','.join(sorted(skipped_list, key=lambda x: test_order.get(x, 0))) if full_run and args.expect_skipped != 'IGNORE': print('----- skipped results:') diff --git a/testsuite/00-hello_test.py b/testsuite/00-hello_test.py index 312f35394..9077ab977 100644 --- a/testsuite/00-hello_test.py +++ b/testsuite/00-hello_test.py @@ -8,7 +8,7 @@ import os from rsyncfns import ( - FROMDIR, RSYNC, SRCDIR, TODIR, + FROMDIR, RSYNC, RSYNC_PEER, SRCDIR, TODIR, checkit, run_rsync, test_fail, ) @@ -39,7 +39,7 @@ def append_line(line: str) -> None: def copy_weird(args: list, src_host: str, dst_host: str) -> None: checkit( - [*args, f'--rsync-path={RSYNC}', + [*args, f'--rsync-path={RSYNC_PEER}', f'{src_host}{weird_dir}/', f'{dst_host}{TODIR / weird_name}'], FROMDIR, TODIR, @@ -70,7 +70,7 @@ def copy_weird(args: list, src_host: str, dst_host: str) -> None: saved = os.getcwd() os.chdir(FROMDIR) try: - run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC}', + run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC_PEER}', 'lh:one two', f'{TODIR}/') finally: os.chdir(saved) @@ -91,7 +91,7 @@ def copy_weird(args: list, src_host: str, dst_host: str) -> None: os.chdir(FROMDIR) try: subprocess.run( - rsync_argv('-ai', f'--rsync-path={RSYNC}', + rsync_argv('-ai', f'--rsync-path={RSYNC_PEER}', 'lh:one two', f'{TODIR}/'), env=env, check=True, ) diff --git a/testsuite/alt-dest_test.py b/testsuite/alt-dest_test.py index 07389d456..d5bb37b2a 100644 --- a/testsuite/alt-dest_test.py +++ b/testsuite/alt-dest_test.py @@ -11,7 +11,7 @@ import time from rsyncfns import ( - CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, + CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, checkit, hands_setup, rmtree, run_rsync, test_fail, ) @@ -73,7 +73,7 @@ for srchost in ('', 'localhost:'): desthost = 'localhost:' if not srchost else '' rmtree(TODIR) - checkit(['-ave', SSH, f'--rsync-path={RSYNC}', *maybe_inplace, + checkit(['-ave', SSH, f'--rsync-path={RSYNC_PEER}', *maybe_inplace, f'--copy-dest={alt3dir}', f'{srchost}{FROMDIR}/', f'{desthost}{TODIR}/'], FROMDIR, TODIR) diff --git a/testsuite/daemon_test.py b/testsuite/daemon_test.py index 4e022fa5c..8d9ab4b50 100644 --- a/testsuite/daemon_test.py +++ b/testsuite/daemon_test.py @@ -10,7 +10,7 @@ import subprocess from rsyncfns import ( - CHKFILE, FROMDIR, OUTFILE, RSYNC, SCRATCHDIR, SRCDIR, TODIR, + CHKFILE, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR, build_rsyncd_conf, get_rootuid, get_testuid, makepath, rsync_argv, run_rsync, start_test_daemon, test_fail, ) @@ -78,7 +78,7 @@ def run_and_check(args, label, capture_stderr=False): # Module list via the lsh.sh stand-in. -rsync_path = f"{RSYNC}{(' ' + ' '.join(confopt)) if confopt else ''}" +rsync_path = f"{RSYNC_PEER}{(' ' + ' '.join(confopt)) if confopt else ''}" out = run_and_check( ['-ve', SSH, f'--rsync-path={rsync_path}', 'localhost::'], "module list via lsh.sh", diff --git a/testsuite/exclude_test.py b/testsuite/exclude_test.py index d4886bfc6..bd528f618 100644 --- a/testsuite/exclude_test.py +++ b/testsuite/exclude_test.py @@ -15,7 +15,7 @@ import sys from rsyncfns import ( - CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, + CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, all_plus, allspace, dots, checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail, ) @@ -26,7 +26,7 @@ script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__) if 'lsh' in script_name: os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh') - rpath = [f'--rsync-path={RSYNC}'] + rpath = [f'--rsync-path={RSYNC_PEER}'] host = 'lh:' else: rpath = [] @@ -116,7 +116,7 @@ # --- main checks ------------------------------------------------------------ # Start with a check of --prune-empty-dirs. -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '-f', '-_foo/too/', '-f', '-_foo/down/', '-f', '-_foo/and/', '-f', '-_new/', f'{host}{FROMDIR}/', f'{CHKDIR}/') @@ -162,7 +162,7 @@ (up2 / 'dst-newness').touch() # Un-tweak the directory times in our first (weak) exclude test. -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--existing', '--include=*/', '--exclude=*', f'{host}{FROMDIR}/', f'{CHKDIR}/') @@ -181,7 +181,7 @@ (CHKDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').unlink() (CHKDIR / 'mid' / 'one-in-one-out').unlink() -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--existing', '--filter=exclude,! */', f'{host}{FROMDIR}/', f'{CHKDIR}/') @@ -205,7 +205,7 @@ cp_touch(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to', CHKDIR / 'bar' / 'down' / 'to' / 'foo') -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--existing', '-f', 'show .filt*', '-f', 'hide,! */', '--del', f'{host}{FROMDIR}/', f'{TODIR}/') @@ -213,7 +213,7 @@ cp_touch(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep', CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz') -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--existing', '--filter=-! */', f'{host}{FROMDIR}/', f'{CHKDIR}/') @@ -255,7 +255,7 @@ def run_with_stdin_filter(args, label="merge"): (CHKDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').unlink() (CHKDIR / 'mid' / '.filt').unlink() -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--existing', '--include=*/', '--exclude=*', f'{host}{FROMDIR}/', f'{CHKDIR}/') @@ -275,15 +275,15 @@ def run_with_stdin_filter(args, label="merge"): "+ file3\n*.bak\n" ) -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--del', f'{host}{FROMDIR}/', f'{CHKDIR}/') (CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').unlink() (CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').unlink() (CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').unlink() -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--existing', '--filter=-! */', f'{host}{FROMDIR}/', f'{CHKDIR}/') -run_rsync('-av', f'--rsync-path={RSYNC}', +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', '--delete-excluded', '--exclude=*', f'{host}{FROMDIR}/', f'{TODIR}/') @@ -293,7 +293,7 @@ def run_with_stdin_filter(args, label="merge"): # Combine with --relative. relative_opts = ['--relative', '--chmod=Du+w', '--copy-unsafe-links'] -run_rsync('-av', f'--rsync-path={RSYNC}', *relative_opts, +run_rsync('-av', f'--rsync-path={RSYNC_PEER}', *relative_opts, f'{host}{FROMDIR}/foo', f'{CHKDIR}/') shutil.rmtree(str(CHKDIR) + str(FROMDIR) + '/foo/down', ignore_errors=True) run_rsync('-av', *relative_opts, '--existing', '--filter=-! */', diff --git a/testsuite/expect/rsync_2.6.0.expect b/testsuite/expect/rsync_2.6.0.expect new file mode 100644 index 000000000..706c78b37 --- /dev/null +++ b/testsuite/expect/rsync_2.6.0.expect @@ -0,0 +1,33 @@ +# Expected outcomes for current rsync <-> rsync 2.6.0 peer (protocol 27). +# See rsync_3.1.3.expect for format/semantics; listed tests are the run set. +# 2.6.0 predates much of the suite; many daemon/feature tests are expected +# to fail (xfail) -- a documented old-peer gap, not a current-side bug. +# The four *-symlink-race / sender-flist-symlink-leak xfails show the peer +# is vulnerable to the May-2026 findings that current master fixes. + +00-hello xfail # feature/negotiation absent in 2.6.0 (proto 27) +alt-dest xfail # peer vulnerable to May-2026 finding (fixed in current) +alt-dest-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) +bare-do-open-symlink-race pass +batch-mode xfail # feature/negotiation absent in 2.6.0 (proto 27) +chdir-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) +chmod-option xfail # feature/negotiation absent in 2.6.0 (proto 27) +copy-dest-source-symlink xfail # peer vulnerable to May-2026 finding (fixed in current) +daemon xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-access xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-auth pass +daemon-config xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-exec xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-filter xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-gzip-download xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-gzip-upload xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-munge xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-refuse xfail # feature/negotiation absent in 2.6.0 (proto 27) +daemon-refuse-compress xfail # feature/negotiation absent in 2.6.0 (proto 27) +exclude pass +files-from xfail # feature/negotiation absent in 2.6.0 (proto 27) +hardlinks pass +reverse-daemon-delta pass +sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) +ssh-basic pass +symlink-dirlink-basis xfail # feature/negotiation absent in 2.6.0 (proto 27) diff --git a/testsuite/expect/rsync_3.0.0.expect b/testsuite/expect/rsync_3.0.0.expect new file mode 100644 index 000000000..392890317 --- /dev/null +++ b/testsuite/expect/rsync_3.0.0.expect @@ -0,0 +1,33 @@ +# Expected outcomes for current rsync <-> rsync 3.0.0 peer (protocol 30). +# See rsync_3.1.3.expect for format/semantics; listed tests are the run set. +# 3.0.0 predates much of the suite; many daemon/feature tests are expected +# to fail (xfail) -- a documented old-peer gap, not a current-side bug. +# The four *-symlink-race / sender-flist-symlink-leak xfails show the peer +# is vulnerable to the May-2026 findings that current master fixes. + +00-hello pass +alt-dest pass +alt-dest-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) +bare-do-open-symlink-race pass +batch-mode xfail # feature/negotiation absent in 3.0.0 (proto 30) +chdir-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) +chmod-option xfail # feature/negotiation absent in 3.0.0 (proto 30) +copy-dest-source-symlink xfail # peer vulnerable to May-2026 finding (fixed in current) +daemon xfail # feature/negotiation absent in 3.0.0 (proto 30) +daemon-access pass +daemon-auth pass +daemon-config xfail # feature/negotiation absent in 3.0.0 (proto 30) +daemon-exec pass +daemon-filter xfail # feature/negotiation absent in 3.0.0 (proto 30) +daemon-gzip-download xfail # feature/negotiation absent in 3.0.0 (proto 30) +daemon-gzip-upload xfail # feature/negotiation absent in 3.0.0 (proto 30) +daemon-munge pass +daemon-refuse xfail # feature/negotiation absent in 3.0.0 (proto 30) +daemon-refuse-compress pass +exclude pass +files-from pass +hardlinks pass +reverse-daemon-delta pass +sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) +ssh-basic pass +symlink-dirlink-basis pass diff --git a/testsuite/expect/rsync_3.1.0.expect b/testsuite/expect/rsync_3.1.0.expect new file mode 100644 index 000000000..cbb1900d2 --- /dev/null +++ b/testsuite/expect/rsync_3.1.0.expect @@ -0,0 +1,33 @@ +# Expected outcomes for current rsync <-> rsync 3.1.0 peer (protocol 31). +# See rsync_3.1.3.expect for format/semantics; listed tests are the run set. +# 3.1.0 predates much of the suite; many daemon/feature tests are expected +# to fail (xfail) -- a documented old-peer gap, not a current-side bug. +# The four *-symlink-race / sender-flist-symlink-leak xfails show the peer +# is vulnerable to the May-2026 findings that current master fixes. + +00-hello pass +alt-dest pass +alt-dest-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) +bare-do-open-symlink-race pass +batch-mode pass +chdir-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) +chmod-option pass +copy-dest-source-symlink xfail # peer vulnerable to May-2026 finding (fixed in current) +daemon xfail # feature/negotiation absent in 3.1.0 (proto 31) +daemon-access pass +daemon-auth pass +daemon-config pass +daemon-exec pass +daemon-filter pass +daemon-gzip-download xfail # feature/negotiation absent in 3.1.0 (proto 31) +daemon-gzip-upload xfail # feature/negotiation absent in 3.1.0 (proto 31) +daemon-munge pass +daemon-refuse xfail # feature/negotiation absent in 3.1.0 (proto 31) +daemon-refuse-compress xfail # feature/negotiation absent in 3.1.0 (proto 31) +exclude pass +files-from pass +hardlinks pass +reverse-daemon-delta pass +sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) +ssh-basic pass +symlink-dirlink-basis pass diff --git a/testsuite/expect/rsync_3.1.3.expect b/testsuite/expect/rsync_3.1.3.expect new file mode 100644 index 000000000..2b5a283ef --- /dev/null +++ b/testsuite/expect/rsync_3.1.3.expect @@ -0,0 +1,49 @@ +# Expected outcomes for current rsync (client/driver) <-> rsync 3.1.3 peer +# (daemon / remote-shell server), used by runtests.py --expect-result. +# +# Format: " ", '#' comments ignored. The listed +# tests ARE the run set. outcome meanings: +# pass - must succeed +# skip - must skip (exit 77) +# xfail - expected to fail: a known, benign old-peer limitation or an +# unpatched-old-peer vulnerability (documented per line below) +# fail - expected to fail, flagged for investigation (not understood / may +# be a current-side bug) +# fail and xfail are equivalent for pass/fail accounting; the label documents +# why. An UNEXPECTED pass (xpass) of an xfail/fail line fails the run, which is +# the signal to revisit this manifest (e.g. an old-peer gap got closed). +# +# daemon-access-ip and daemon-chroot-acl are intentionally omitted: they need a +# real TCP peer and would skip/run differently by transport. + +# --- two-sided tests that pass against a 3.1.3 peer --- +00-hello pass +reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) +alt-dest pass +bare-do-open-symlink-race pass +batch-mode pass +chmod-option pass +daemon-access pass +daemon-auth pass +daemon-config pass +daemon-exec pass +daemon-filter pass +daemon-gzip-download pass +daemon-gzip-upload pass +daemon-munge pass +exclude pass +files-from pass +hardlinks pass +ssh-basic pass +symlink-dirlink-basis pass + +# --- version feature/option gaps in 3.1.3 --- +daemon xfail # exercises -U/--atimes etc. unknown to 3.1.3 (server errors) +daemon-refuse xfail # daemon refuse/option semantics differ (connection reset) +daemon-refuse-compress xfail # pre-zstd compress negotiation; no --compress refuse error + +# --- May-2026 security fixes absent in 3.1.3 (peer is vulnerable) --- +alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) +chdir-symlink-race xfail # chmod escape via cd symlink during chdir +copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) +sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.2.0.expect b/testsuite/expect/rsync_3.2.0.expect new file mode 100644 index 000000000..ed73f48eb --- /dev/null +++ b/testsuite/expect/rsync_3.2.0.expect @@ -0,0 +1,34 @@ +# Expected outcomes for current rsync <-> rsync 3.2.0 peer (see +# rsync_3.1.3.expect for the format/semantics; listed tests are the run set; +# fail/xfail both keep CI green, an unexpected pass fails the run). + +00-hello pass +reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) +alt-dest pass +bare-do-open-symlink-race pass +batch-mode pass +daemon-access pass +daemon-auth pass +daemon-config pass +daemon-exec pass +daemon-filter pass +daemon-gzip-download pass +daemon-gzip-upload pass +daemon-munge pass +daemon-refuse pass +exclude pass +files-from pass +hardlinks pass +ssh-basic pass +symlink-dirlink-basis pass + +# --- version feature/option gaps in 3.2.0 --- +chmod-option xfail # client-server protocol start error (code 5) +daemon xfail # daemon listing/option path errors (code 5) +daemon-refuse-compress xfail # --compress refuse semantics differ from current + +# --- May-2026 security fixes absent in 3.2.0 (peer is vulnerable) --- +alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) +chdir-symlink-race xfail # chmod escape via cd symlink during chdir +copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) +sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.2.7.expect b/testsuite/expect/rsync_3.2.7.expect new file mode 100644 index 000000000..c67530db0 --- /dev/null +++ b/testsuite/expect/rsync_3.2.7.expect @@ -0,0 +1,31 @@ +# Expected outcomes for current rsync <-> rsync 3.2.7 peer (see +# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). + +00-hello pass +reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) +alt-dest pass +bare-do-open-symlink-race pass +batch-mode pass +chmod-option pass +daemon pass +daemon-access pass +daemon-auth pass +daemon-config pass +daemon-exec pass +daemon-filter pass +daemon-gzip-download pass +daemon-gzip-upload pass +daemon-munge pass +daemon-refuse pass +daemon-refuse-compress pass +exclude pass +files-from pass +hardlinks pass +ssh-basic pass +symlink-dirlink-basis pass + +# --- May-2026 security fixes absent in 3.2.7 (peer is vulnerable) --- +alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) +chdir-symlink-race xfail # chmod escape via cd symlink during chdir +copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) +sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.3.0.expect b/testsuite/expect/rsync_3.3.0.expect new file mode 100644 index 000000000..dd4a29b33 --- /dev/null +++ b/testsuite/expect/rsync_3.3.0.expect @@ -0,0 +1,31 @@ +# Expected outcomes for current rsync <-> rsync 3.3.0 peer (see +# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). + +00-hello pass +reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) +alt-dest pass +bare-do-open-symlink-race pass +batch-mode pass +chmod-option pass +daemon pass +daemon-access pass +daemon-auth pass +daemon-config pass +daemon-exec pass +daemon-filter pass +daemon-gzip-download pass +daemon-gzip-upload pass +daemon-munge pass +daemon-refuse pass +daemon-refuse-compress pass +exclude pass +files-from pass +hardlinks pass +ssh-basic pass +symlink-dirlink-basis pass + +# --- May-2026 security fixes absent in 3.3.0 (peer is vulnerable) --- +alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) +chdir-symlink-race xfail # chmod escape via cd symlink during chdir +copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) +sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.4.0.expect b/testsuite/expect/rsync_3.4.0.expect new file mode 100644 index 000000000..53abb4bfb --- /dev/null +++ b/testsuite/expect/rsync_3.4.0.expect @@ -0,0 +1,40 @@ +# Expected outcomes for current rsync <-> rsync 3.4.0 peer (see +# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). + +00-hello pass +reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) +alt-dest pass +bare-do-open-symlink-race pass +batch-mode pass +chmod-option pass +daemon pass +daemon-access pass +daemon-auth pass +daemon-config pass +daemon-exec pass +daemon-filter pass +daemon-gzip-download pass +daemon-gzip-upload pass +daemon-munge pass +daemon-refuse pass +daemon-refuse-compress pass +exclude pass +files-from pass +hardlinks pass +ssh-basic pass + +# issue #715, present in 3.4.0: updating a file through a -K (--copy-dirlinks) +# directory symlink fails on the receiver because its secure_relative_open() +# O_NOFOLLOW-confines every path component, including the basedir reached via +# the symlink, so the basis open is rejected and the update is discarded +# ("failed verification"). Current master fixes this with secure_basis_open() +# (receiver.c), which splits the path so the basedir may follow symlinks while +# only the leaf is O_NOFOLLOW'd. Verified peer-version-, not protocol-, driven +# (forcing this peer to --protocol=31 still fails; 3.3.0 at proto 31 passes). +symlink-dirlink-basis xfail + +# --- May-2026 security fixes absent in 3.4.0 (peer is vulnerable) --- +alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) +chdir-symlink-race xfail # chmod escape via cd symlink during chdir +copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) +sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.4.1.expect b/testsuite/expect/rsync_3.4.1.expect new file mode 100644 index 000000000..4d34226fc --- /dev/null +++ b/testsuite/expect/rsync_3.4.1.expect @@ -0,0 +1,40 @@ +# Expected outcomes for current rsync <-> rsync 3.4.1 peer (see +# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). + +00-hello pass +reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) +alt-dest pass +bare-do-open-symlink-race pass +batch-mode pass +chmod-option pass +daemon pass +daemon-access pass +daemon-auth pass +daemon-config pass +daemon-exec pass +daemon-filter pass +daemon-gzip-download pass +daemon-gzip-upload pass +daemon-munge pass +daemon-refuse pass +daemon-refuse-compress pass +exclude pass +files-from pass +hardlinks pass +ssh-basic pass + +# issue #715, present in 3.4.1: updating a file through a -K (--copy-dirlinks) +# directory symlink fails on the receiver because its secure_relative_open() +# O_NOFOLLOW-confines every path component, including the basedir reached via +# the symlink, so the basis open is rejected and the update is discarded +# ("failed verification"). Current master fixes this with secure_basis_open() +# (receiver.c), which splits the path so the basedir may follow symlinks while +# only the leaf is O_NOFOLLOW'd. Verified peer-version-, not protocol-, driven +# (forcing this peer to --protocol=31 still fails; 3.3.0 at proto 31 passes). +symlink-dirlink-basis xfail + +# --- May-2026 security fixes absent in 3.4.1 (peer is vulnerable) --- +alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) +chdir-symlink-race xfail # chmod escape via cd symlink during chdir +copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) +sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/files-from_test.py b/testsuite/files-from_test.py index 855007e57..0d10d2d52 100644 --- a/testsuite/files-from_test.py +++ b/testsuite/files-from_test.py @@ -6,7 +6,7 @@ # files-host / src-host / dest-host placement combinations. from rsyncfns import ( - CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TODIR, + CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR, checkit, hands_setup, rmtree, run_rsync, ) @@ -44,7 +44,7 @@ rmtree(TODIR) checkit( - ['-avse', SSH, f'--rsync-path={RSYNC}', + ['-avse', SSH, f'--rsync-path={RSYNC_PEER}', f'--files-from={filehost}{filelist}', f'{srchost}{SCRATCHDIR}', f'{desthost}{TODIR}/'], CHKDIR, TODIR, diff --git a/testsuite/hardlinks_test.py b/testsuite/hardlinks_test.py index 9084899d7..a578d63ce 100644 --- a/testsuite/hardlinks_test.py +++ b/testsuite/hardlinks_test.py @@ -12,7 +12,7 @@ import subprocess from rsyncfns import ( - CHKDIR, FROMDIR, OUTFILE, RSYNC, SRCDIR, TODIR, + CHKDIR, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SRCDIR, TODIR, checkit, makepath, rsync_argv, test_fail, test_skipped, ) @@ -64,7 +64,7 @@ os.link(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file') (TODIR / 'text').unlink() -checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC}', +checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC_PEER}', f'{FROMDIR}/', f'localhost:{TODIR}/'], FROMDIR, TODIR) # --link-dest and --copy-dest should also keep hard-linked entries. diff --git a/testsuite/reverse-daemon-delta_test.py b/testsuite/reverse-daemon-delta_test.py new file mode 100644 index 000000000..707977ddc --- /dev/null +++ b/testsuite/reverse-daemon-delta_test.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# Reverse-direction version-mixing smoke test: OLD client <-> CURRENT daemon. +# +# Every other two-sided test drives with the current binary and uses the old +# binary only as the server/daemon. That covers new-client -> old-server but +# NOT the more important backward-compat direction: a current daemon/server +# must keep working for the large installed base of OLD clients. This test +# fills that gap by starting the daemon with the CURRENT build (RSYNC) and +# running the OLD binary (RSYNC_PEER) as the client. +# +# It exercises, in BOTH transfer directions and with and without compression: +# * push (old client = sender, current daemon = receiver) -> old->new delta +# * pull (current daemon = sender, old client = receiver) -> new->old delta +# In each case the receiving side already holds an older version of the file, +# so the rsync delta algorithm actually runs (block matching + token stream) +# rather than a whole-file copy -- verified by asserting the bytes moved over +# the wire are far smaller than the file (a whole-file transfer of this random, +# incompressible data would be ~filesize even with -z). +# +# When no second binary was selected (RSYNC_PEER == RSYNC) this still runs as a +# current<->current smoke test of delta + compression over a daemon. + +import filecmp +import os +import re +import shlex +import subprocess + +from rsyncfns import ( + FROMDIR, RSYNC, RSYNC_PEER, TMPDIR, + build_rsyncd_conf, makepath, make_data_file, start_test_daemon, test_fail, +) + +DAEMON_PORT = 12894 +FILESIZE = 512 * 1024 # big enough that delta savings are unambiguous +# Old rsync (2.6.x era) prints "wrote N bytes read M bytes"; 3.0+ prints +# "sent N bytes received M bytes". Accept both so old clients parse too. +_SUMMARY = re.compile(r'(?:sent|wrote) ([\d,]+) bytes\s+(?:received|read) ([\d,]+) bytes') + +TODIR = TMPDIR / 'to' + + +def make_versions(path_old, path_new): + """Write an 'old' file and a 'new' file derived from it: same head, a + changed middle block, and an appended tail. The shared blocks give the + delta algorithm something to match; the changes give it real literal data + to send.""" + make_data_file(path_old, FILESIZE) + data = bytearray(open(path_old, 'rb').read()) + data[100000:100050] = bytes(((b + 1) & 0xFF) for b in data[100000:100050]) + data += b'reverse-delta appended tail\n' * 64 + with open(path_new, 'wb') as f: + f.write(data) + + +def peer_client(args, label): + """Run the OLD client (RSYNC_PEER) and return (sent, received) wire bytes + parsed from rsync's summary line. Fails the test on non-zero exit.""" + argv = shlex.split(RSYNC_PEER) + args + proc = subprocess.run(argv, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True) + print(proc.stdout, end='') + if proc.returncode != 0: + test_fail(f"{label}: old client exited {proc.returncode}") + m = _SUMMARY.search(proc.stdout) + if not m: + test_fail(f"{label}: could not parse sent/received from client output") + return int(m.group(1).replace(',', '')), int(m.group(2).replace(',', '')) + + +def assert_delta(label, moved): + """A delta transfer of FILESIZE moves far less than the whole file; a + whole-file copy (delta failed to engage) would move ~FILESIZE.""" + if moved >= FILESIZE // 2: + test_fail(f"{label}: {moved} bytes crossed the wire -- delta did not " + f"engage (file is {FILESIZE} bytes)") + + +def run_push(compress): + """old client (sender) -> current daemon (receiver), receiver holds the old + version as the basis. Exercises old->new delta encoding.""" + tag = "push+z" if compress else "push" + basis = TODIR / f'{tag}.dat' # daemon-side basis (old) + source = src / f'{tag}.dat' # client source (new) + make_versions(basis, source) + opts = ['-a', '-v'] + (['-z'] if compress else []) + sent, _ = peer_client(opts + [str(source), f'{url}test-to/'], tag) + if not filecmp.cmp(source, basis, shallow=False): + test_fail(f"{tag}: daemon-side file does not match source after push") + assert_delta(tag, sent) + print(f"{tag}: OK (sent {sent} bytes for a {FILESIZE}-byte file)") + + +def run_pull(compress): + """current daemon (sender) -> old client (receiver), client holds the old + version as the basis. Exercises new->old delta encoding.""" + tag = "pull+z" if compress else "pull" + served = FROMDIR / f'{tag}.dat' # daemon module file (new) + local = dst / f'{tag}.dat' # client basis (old) + make_versions(local, served) + opts = ['-a', '-v'] + (['-z'] if compress else []) + _, received = peer_client( + opts + [f'{url}test-from/{tag}.dat', str(dst) + '/'], tag) + if not filecmp.cmp(served, local, shallow=False): + test_fail(f"{tag}: client file does not match daemon source after pull") + assert_delta(tag, received) + print(f"{tag}: OK (received {received} bytes for a {FILESIZE}-byte file)") + + +os.chdir(TMPDIR) +makepath(FROMDIR, TODIR) + +# Current build is the daemon; old binary is the client. +conf = build_rsyncd_conf() +url = start_test_daemon(conf, DAEMON_PORT, rsync_cmd=RSYNC) + +src = TMPDIR / 'client-src' +dst = TMPDIR / 'client-dst' +makepath(src, dst) + +run_push(compress=False) +run_push(compress=True) +run_pull(compress=False) +run_pull(compress=True) diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index 3a3b37b19..d4c066060 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -64,6 +64,15 @@ def _required(name: str) -> str: os.environ['HOME'] = str(SCRATCHDIR) RSYNC = _required('RSYNC') # full command line, possibly with valgrind/protocol +# The "peer" rsync command -- used for the SERVER side of two-sided transfers +# (the daemon process; the remote-shell --rsync-path target). The runner sets +# RSYNC_PEER to a second binary when invoked with --rsync-bin2, letting a run +# mix two rsync versions over the wire. When no second binary was selected, +# RSYNC_PEER == RSYNC, so every consumer below behaves exactly as before and +# single-binary runs are unchanged. Use .get (not _required) so a test invoked +# by hand without the runner still works. +RSYNC_PEER = os.environ.get('RSYNC_PEER', RSYNC) + # TLS_ARGS controls how the 'tls' helper formats listings (e.g. --atimes, # -l, -L). Tests that exercise non-default rsync features (atimes, etc.) # assign to rsyncfns.TLS_ARGS before calling checkit / rsync_ls_lR. @@ -232,7 +241,7 @@ def _stop_rsyncd(proc) -> 'None': pass -def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen': +def start_rsyncd(conf_path, port: int, rsync_cmd: str = None) -> 'subprocess.Popen': """Spawn `rsync --daemon --no-detach --address=127.0.0.1 --port=N --config=conf` and return the Popen handle after the port is accepting connections. @@ -245,10 +254,16 @@ def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen': the test process doesn't strand the daemon either. The caller is expected to have already claim_ports()'d `port`. + rsync_cmd selects the binary to run as the daemon; it defaults to + RSYNC_PEER (the peer side of a two-sided run), so ordinary daemon tests + get current-client <-> peer-daemon. The reverse-direction test passes + rsync_cmd=RSYNC to put the current build on the daemon side and drive with + the old client. + This is only ever reached from start_test_daemon() in --use-tcp mode; the default (pipe) mode never starts a listening daemon. """ - argv = shlex.split(RSYNC) + [ + argv = shlex.split(rsync_cmd or RSYNC_PEER) + [ '--daemon', '--no-detach', '--address=127.0.0.1', f'--port={port}', @@ -282,9 +297,12 @@ def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen': test_fail(f"rsyncd never listened on 127.0.0.1:{port}: {last_err}") -def start_test_daemon(conf_path, port: int) -> str: +def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: """Bring up the test daemon and return a URL prefix for client commands. + rsync_cmd selects the daemon-side binary (default RSYNC_PEER); pass + rsync_cmd=RSYNC for the reverse-direction test (current daemon, old client). + This is the single seam every daemon test uses. The transport depends on the mode the runner selected: @@ -302,11 +320,12 @@ def start_test_daemon(conf_path, port: int) -> str: Build URLs as f"{prefix}module/path". `port` is only used (and claimed) in --use-tcp mode. """ + daemon_cmd = rsync_cmd or RSYNC_PEER if USE_TCP: claim_ports(port) - start_rsyncd(conf_path, port) + start_rsyncd(conf_path, port, daemon_cmd) return f'rsync://localhost:{port}/' - os.environ['RSYNC_CONNECT_PROG'] = f'{RSYNC} --config={conf_path} --daemon' + os.environ['RSYNC_CONNECT_PROG'] = f'{daemon_cmd} --config={conf_path} --daemon' return 'rsync://localhost/' diff --git a/testsuite/ssh-basic_test.py b/testsuite/ssh-basic_test.py index dbb9957a5..eb6581184 100644 --- a/testsuite/ssh-basic_test.py +++ b/testsuite/ssh-basic_test.py @@ -38,17 +38,17 @@ hands_setup() # RSYNC may be a multi-word command line; pass it through --rsync-path. -from rsyncfns import RSYNC +from rsyncfns import RSYNC, RSYNC_PEER def _basic(): - checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC}', + checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}', f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR) def _delete_after_rename(): shutil.move(str(TODIR / 'text'), str(TODIR / 'ThisShouldGo')) - checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC}', + checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}', f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR) diff --git a/testsuite/symlink-dirlink-basis_test.py b/testsuite/symlink-dirlink-basis_test.py index 8c52e13f9..cf0268153 100644 --- a/testsuite/symlink-dirlink-basis_test.py +++ b/testsuite/symlink-dirlink-basis_test.py @@ -18,7 +18,7 @@ import time from rsyncfns import ( - RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, + RSYNC, SCRATCHDIR, RSYNC_PEER, SRCDIR, TMPDIR, make_data_file, resolve_beneath_supported, rsync_argv, test_fail, test_skipped, ) @@ -59,7 +59,7 @@ def push(*args, label: str) -> None: os.chdir(srcbase) try: proc = subprocess.run( - rsync_argv(f'--rsync-path={RSYNC}', *args), + rsync_argv(f'--rsync-path={RSYNC_PEER}', *args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) print(proc.stdout, end='')