diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 8d30e4096..16a71cb9d 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -4,12 +4,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true -# Static-check the workflow YAML with rhysd/actionlint. Catches missing -# secrets, bad expressions, expression-type errors, unsupported runner -# images, and (via embedded shellcheck) common pitfalls in `run:` scripts. -# Trigger only on changes under .github/workflows/ so the rest of the -# matrix isn't billed when nothing here moves. - on: push: branches: [ master ] @@ -30,16 +24,20 @@ permissions: jobs: actionlint: runs-on: ubuntu-latest + timeout-minutes: 15 name: actionlint steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: install actionlint - # Pin a version so this job is reproducible; bump deliberately. - # The download script verifies a SHA256 of the release tarball. run: | - bash <(curl --proto '=https' --tlsv1.2 -fsSL \ - https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) \ - 1.7.12 + archive=actionlint_1.7.12_linux_amd64.tar.gz + curl --proto '=https' --tlsv1.2 -fsSLO \ + "https://github.com/rhysd/actionlint/releases/download/v1.7.12/$archive" + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $archive" \ + | sha256sum --check --strict + tar -xzf "$archive" actionlint echo "$PWD" >>"$GITHUB_PATH" - name: actionlint --version run: actionlint -version diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index 8eb4598c3..d7ec965cd 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -4,12 +4,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true -# Older-LTS coverage on the Fedora/RHEL family to help with backporting -# security fixes. AlmaLinux 8 is the RHEL 8 rebuild and is the oldest -# active LTS in this family (RHEL 8 full support runs to 2029). -# GitHub Actions has no native runner for this family, so the job runs -# inside an almalinux:8 container hosted on ubuntu-latest. - on: push: branches: [ master ] @@ -24,26 +18,26 @@ on: schedule: - cron: '42 8 * * *' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 container: image: almalinux:8 name: Test rsync on AlmaLinux 8 steps: - name: install git - # actions/checkout needs git in the container before the checkout step. run: dnf -y install git - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep - # PowerTools is needed for libzstd-devel etc; xxhash and lz4 dev - # headers live in EPEL on RHEL 8. The default python3 on RHEL 8 - # is 3.6, which is too old for runtests.py (uses capture_output= - # / text= introduced in 3.7), so install python39 and point - # /usr/bin/python3 at it. run: | + # RHEL 8's default Python is too old for the test runner dnf -y install epel-release dnf config-manager --set-enabled powertools dnf -y install gcc gcc-c++ make autoconf automake m4 \ @@ -63,18 +57,13 @@ jobs: - name: info run: ./rsync --version - name: check - # In the container we already run as root, so no sudo. The - # crtimes-not-supported skip matches the other Linux jobs; - # daemon-chroot-acl and proxy-response-line-too-long skip because - # the default (secure) transport opens no listening socket. - run: RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check + run: RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,linux,no-zstd-threads make check - name: check (TCP daemon transport) - # Second run exercising the real loopback-TCP daemon path. - run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 + run: RSYNC_TEST_PROFILES=non-asan,root,self-peer,linux,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only -j 8 - name: ssl file list run: ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: almalinux-8-bin diff --git a/.github/workflows/android-static-build.yml b/.github/workflows/android-static-build.yml index 14593a6dc..dc37aa60d 100644 --- a/.github/workflows/android-static-build.yml +++ b/.github/workflows/android-static-build.yml @@ -4,15 +4,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true -# Cross-compiles statically-linked rsync binaries with the Android NDK, -# suitable for dropping onto a phone (adb push / Termux) with no shared -# libraries. arm64-v8a covers all modern phones; armeabi-v7a covers older -# 32-bit devices. The binaries are uploaded as workflow artifacts. -# -# These are cross-compiled, so the test suite can't run here; we sanity -# check that each binary is the right architecture, is static, and that -# it executes (`--version`) under qemu-user. - on: push: branches: [ master ] @@ -28,14 +19,16 @@ on: - cron: '42 8 * * 1' workflow_dispatch: +permissions: + contents: read + env: - # Minimum supported API level. 24 (Android 7.0) runs on every modern - # phone while keeping broad reach; bump if you need newer Bionic APIs. ANDROID_API: 24 jobs: build: runs-on: ubuntu-latest + timeout-minutes: 45 name: ${{ matrix.abi }} strategy: fail-fast: false @@ -48,9 +41,10 @@ jobs: triple: armv7a-linux-androideabi qemu: qemu-arm-static steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: Install build prerequisites run: sudo apt-get update && sudo apt-get install -y autoconf automake gawk qemu-user-static @@ -65,19 +59,12 @@ jobs: export AR="$TC/llvm-ar" RANLIB="$TC/llvm-ranlib" STRIP="$TC/llvm-strip" export CFLAGS="-O2" LDFLAGS="-static" - # Bionic doesn't declare lchmod()/lutimes() until API 36, but the - # symbols link, so configure mis-detects them -- force them off so - # rsync uses its fallbacks. The other cache vars restore values - # that configure can't probe when cross-compiling (Android runs a - # normal Linux kernel, so these match the native Linux result). + # Bionic hides lchmod and lutimes before API 36; cross-builds cannot run probes export ac_cv_func_lchmod=no ac_cv_func_lutimes=no \ rsync_cv_HAVE_SOCKETPAIR=yes \ rsync_cv_MKNOD_CREATES_FIFOS=yes \ rsync_cv_MKNOD_CREATES_SOCKETS=yes - # Self-contained build: drop optional external libraries so the - # static binary needs nothing at runtime. rsync keeps md5/md4 - # checksums and its bundled zlib. ./configure --host=${{ matrix.triple }} --build=x86_64-pc-linux-gnu \ --enable-ipv6 \ --disable-zstd --disable-lz4 --disable-xxhash --disable-openssl --disable-idn \ @@ -86,8 +73,7 @@ jobs: --disable-md2man --disable-roll-simd \ --with-included-popt --with-included-zlib - # Generate the awk-built headers serially first so the parallel - # build can't race on proto.h <- daemon-parm.h. + # Generate shared headers before the parallel build make proto.h make -j"$(nproc)" rsync "$STRIP" rsync @@ -97,12 +83,10 @@ jobs: run: | set -euo pipefail file rsync - # Gate: must be a statically-linked executable (no interpreter). file rsync | grep -q "statically linked" if file rsync | grep -q "dynamically linked"; then echo "ERROR: binary is not static" >&2; exit 1 fi - # Best-effort: confirm it actually runs under qemu-user. ${{ matrix.qemu }} ./rsync --version | head -3 || \ echo "WARNING: qemu smoke test did not run cleanly (check on a real device)" @@ -118,7 +102,7 @@ jobs: echo "ARTIFACT_NAME=rsync-android-${{ matrix.abi }}" >>"$GITHUB_ENV" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: ${{ env.ARTIFACT_NAME }} diff --git a/.github/workflows/asan-build.yml b/.github/workflows/asan-build.yml index 246a31f9d..acde5752d 100644 --- a/.github/workflows/asan-build.yml +++ b/.github/workflows/asan-build.yml @@ -16,61 +16,50 @@ on: - '.github/workflows/*.yml' - '!.github/workflows/asan-build.yml' schedule: - # Weekly (Mon 09:42 UTC): catch breakage from a moving ubuntu-latest/clang - # toolchain (a new clang can add a UBSan check, or change ASan behaviour) - # that no code push would otherwise trigger. Push/PR already gate every - # code change, so daily would just re-run an unchanged tree. - cron: '42 9 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: asan: runs-on: ubuntu-latest + timeout-minutes: 45 name: rsync ASan+UBSan (clang) env: - # rsync intentionally leaks small allocations at process exit, so leak - # detection would be all noise; chase only memory-safety errors. + # Process-exit leaks are intentional; memory errors remain fatal ASAN_OPTIONS: detect_leaks=0:abort_on_error=1 - # UBSan is a gate: -fno-sanitize-recover=undefined (below) aborts on the - # first finding and halt_on_error=1 makes that fatal, so any undefined - # behaviour fails the run. This needs the tree to be UBSan-clean: the - # remaining findings are fixed in code (hashtable/mdfour shifts, xattrs, - # and log.c's file_struct, kept aligned via rounding.h); only byteorder.h's - # intentional unaligned accessors are suppressed, with no_sanitize. UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | sudo apt-get update sudo apt-get install -y clang acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev openssl echo "/usr/local/bin" >>"$GITHUB_PATH" - name: configure - # -DNDEBUG builds as a shipped release does (assert() compiled out), so - # AddressSanitizer catches the over-reads/over-writes that an "assert() - # instead of a real bounds check" bug would cause in a production build. - # UBSan rides along on the same build; -fno-sanitize-recover=undefined - # makes any undefined behaviour abort (and thus fail the run) instead of - # merely printing it. + # Exercise release behaviour with assertions disabled run: | CC=clang \ CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1 -DNDEBUG" \ LDFLAGS="-fsanitize=address,undefined" \ ./configure --with-rrsync --disable-md2man - name: make - # check-progs builds rsync plus the test helper programs (tls, trimslash, - # t_unsafe, ...) that runtests.py requires; plain "make" builds only rsync - # and runtests aborts on the missing helpers. run: make check-progs - name: info run: ./rsync --version - name: check (stdio-pipe transport) - # ASan+UBSan-instrumented coverage of the transfer, daemon, sender, - # receiver and metadata paths over the default stdio-pipe transport. - run: ./runtests.py --rsync-bin="$PWD/rsync" -j8 + run: RSYNC_TEST_PROFILES=pipe,nonroot,self-peer,linux ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --receipt=test-results/asan-pipe.json -j8 - name: check (TCP daemon transport) - # --use-tcp also exercises the loopback rsyncd listener and the client's - # TCP connection path. - run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j8 + run: RSYNC_TEST_PROFILES=nonroot,self-peer,linux ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only --receipt=test-results/asan-tcp.json -j8 + - name: upload receipts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + retention-days: 45 + name: asan-receipts + path: test-results/*.json diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6e0eccaff..313657641 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,14 +19,19 @@ on: - cron: '42 9 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: coverage: runs-on: ubuntu-latest + timeout-minutes: 45 name: gcov coverage steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | sudo apt-get update @@ -38,11 +43,6 @@ jobs: run: make - name: info run: rsync --version - # Two coverage runs: the default pipe transport, then a second pass over a - # real loopback rsyncd (--use-tcp) which also exercises the require_tcp-only - # tests. gcovr's --print-summary line/branch/decision totals go to the step - # log (and the job summary below), so the numbers are visible in CI. - # `make coverage` exits with the suite's status, so a regression fails CI. - name: coverage (pipe transport) run: | set -o pipefail @@ -67,7 +67,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: upload HTML reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: coverage-html diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index b82e18b12..c9490bcca 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -18,14 +18,19 @@ on: schedule: - cron: '42 8 * * *' +permissions: + contents: read + jobs: test: runs-on: windows-2022 + timeout-minutes: 60 name: Test rsync on Cygwin steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: cygwin run: | $setup = Join-Path $Env:RUNNER_TEMP 'setup-x86_64.exe' @@ -81,21 +86,13 @@ jobs: - name: info run: bash -c '/usr/local/bin/rsync --version' - name: check - # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on Cygwin - # (rsyncfns.py drives xattrs via getfattr/setfattr from the `attr` - # package installed above), verified on a real Cygwin host. The real - # chown/devices tests still skip (need root/mknod), as do the - # RESOLVE_BENEATH symlink-race tests. Cygwin runs non-root, so the - # namecvt empty-response regression can run despite its common root skip. - run: bash -c 'RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/cygwin.txt,-daemon-namecvt-empty-response make check' + run: bash -c 'RSYNC_TEST_PROFILES=pipe,non-asan,nonroot,self-peer,cygwin make check' - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd; the default - # 'make check' above uses the secure stdio-pipe transport. - run: bash -c './runtests.py --rsync-bin=`pwd`/rsync.exe --use-tcp -j 8' + run: bash -c 'RSYNC_TEST_PROFILES=non-asan,nonroot,self-peer,cygwin ./testsuite/runtests.py --rsync-bin=`pwd`/rsync.exe --use-tcp --daemon-tests-only -j 8' - name: ssl file list run: bash -c 'PATH="/usr/local/bin:$PATH" rsync-ssl --no-motd download.samba.org::rsyncftp/ || true' - name: save artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: cygwin-bin diff --git a/.github/workflows/fleettest.yml b/.github/workflows/fleettest.yml deleted file mode 100644 index 40f6f8fc6..000000000 --- a/.github/workflows/fleettest.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Test fleettest harness - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Bitrot check for testsuite/fleettest.py (the developer fleet CI harness). -# fleettest is meant to be run by developers on a modern Ubuntu box, so this -# job runs only on ubuntu-latest: it stands up a one-host "fleet" of two -# targets that both ssh to localhost and runs a real fleettest pass against it. -# It does not run on the BSD/Solaris/macOS/Cygwin matrix. - -on: - push: - branches: [ master ] - paths: - - 'testsuite/fleettest.py' - - '.github/workflows/fleettest.yml' - - 'runtests.py' - - 'testsuite/skiplist/**' - - 'testsuite/skiplist-spec_test.py' - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'testsuite/fleettest.py' - - '.github/workflows/fleettest.yml' - - 'runtests.py' - - 'testsuite/skiplist/**' - - 'testsuite/skiplist-spec_test.py' - workflow_dispatch: - schedule: - - cron: '17 7 * * 1' - -jobs: - fleettest: - runs-on: ubuntu-latest - name: fleettest against localhost - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install -y gcc g++ gawk autoconf automake \ - acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev \ - python3-cmarkgfm openssl rsync openssh-server - - name: set up ssh to localhost - run: | - mkdir -p ~/.ssh && chmod 700 ~/.ssh - ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 - cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys - chmod 600 ~/.ssh/authorized_keys - sudo systemctl start ssh || sudo service ssh start - # fleettest connects with `ssh -o BatchMode=yes localhost`, which won't - # answer a host-key prompt -- so pre-trust localhost in known_hosts. - ssh-keyscan -H localhost 127.0.0.1 >> ~/.ssh/known_hosts 2>/dev/null - ssh -o BatchMode=yes -o ConnectTimeout=15 localhost 'echo ssh-to-localhost-ok' - - name: write localhost fleet config - run: | - cat > fleettest-ci.json <<'EOF' - { "targets": [ - { "name": "local-a", "ssh_host": "localhost", "workflow": "none.yml", - "configure_flags": [], "builddir": "rsync-citest-a", "privilege": "sudo" }, - { "name": "local-b", "ssh_host": "localhost", "workflow": "none.yml", - "configure_flags": [], "builddir": "rsync-citest-b", "privilege": "sudo" } - ] } - EOF - - name: fleettest --list (config sanity) - run: python3 testsuite/fleettest.py --fleet fleettest-ci.json --list - - name: run fleettest against localhost - # Two targets both on localhost exercise the parallel multi-target path - # and the per-run dir / port isolation; exit 0 iff every cell is OK. - run: python3 testsuite/fleettest.py --fleet fleettest-ci.json --timing diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index fe87913f5..89f288918 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -18,19 +18,32 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on FreeBSD steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false + - name: prepare VM host + timeout-minutes: 10 + run: | + packages=(qemu-utils qemu-system-x86 ovmf) + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" + } - name: Test in FreeBSD VM id: test - uses: vmactions/freebsd-vm@v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: - usesh: true + cache-after-prepare: true prepare: | pkg install -y bash autotools m4 devel/xxhash zstd liblz4 python3 archivers/liblz4 git run: | @@ -38,13 +51,14 @@ jobs: ./configure --with-rrsync \ -disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn \ --enable-roll-simd --enable-roll-asm --enable-md5-asm - make + make check-progs ./rsync --version - make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 + RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,freebsd,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --receipt=test-results/freebsd-pipe.json -j 8 + RSYNC_TEST_PROFILES=non-asan,root,self-peer,freebsd,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only --receipt=test-results/freebsd-tcp.json -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact - uses: actions/upload-artifact@v4 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: freebsd-bin @@ -56,3 +70,4 @@ jobs: rsyncd.conf.5 rrsync.1 rrsync + test-results/*.json diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index b20638a8f..8d0662392 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -18,14 +18,19 @@ on: schedule: - cron: '42 8 * * *' +permissions: + contents: read + jobs: test: runs-on: macos-latest + timeout-minutes: 45 name: Test rsync on macOS steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | brew install automake openssl xxhash zstd lz4 libidn2 @@ -45,18 +50,13 @@ jobs: - name: info run: rsync --version - name: check - # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS - # (rsyncfns.py drives xattrs via the `xattr` command), verified on a - # real macOS host, so they're no longer in the skip set. - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/macos.txt make check + run: sudo RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,macos make check - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd; the default - # 'make check' above uses the secure stdio-pipe transport. - run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 + run: sudo RSYNC_TEST_PROFILES=non-asan,root,self-peer,macos ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only -j 8 - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: macos-bin diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml index 4d2463b78..a53ec95e6 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -18,32 +18,46 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on NetBSD steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false + - name: prepare VM host + timeout-minutes: 10 + run: | + packages=(qemu-utils qemu-system-x86 ovmf) + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" + } - name: Test in NetBSD VM id: test - uses: vmactions/netbsd-vm@v1 + uses: vmactions/netbsd-vm@c8a0d7ddb84619a7a8cc4e652efa7950b3fd9f92 # v1.5.2 with: - usesh: true + cache-after-prepare: true prepare: | PATH=/usr/sbin:$PATH pkg_add autoconf automake python312 ln -sf /usr/pkg/bin/python3.12 /usr/pkg/bin/python3 run: | uname -a ./configure --with-rrsync --disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn - make + make check-progs ./rsync --version - make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 + RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,netbsd,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --receipt=test-results/netbsd-pipe.json -j 8 + RSYNC_TEST_PROFILES=non-asan,root,self-peer,netbsd,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only --receipt=test-results/netbsd-tcp.json -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact - uses: actions/upload-artifact@v4 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: netbsd-bin @@ -55,3 +69,4 @@ jobs: rsyncd.conf.5 rrsync.1 rrsync + test-results/*.json diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 6218b9a24..c9a4d550e 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -18,19 +18,32 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on OpenBSD steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false + - name: prepare VM host + timeout-minutes: 10 + run: | + packages=(qemu-utils qemu-system-x86 ovmf) + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" + } - name: Test in OpenBSD VM id: test - uses: vmactions/openbsd-vm@v1 + uses: vmactions/openbsd-vm@5f7b2c933b8846a138361d6843c52af4bef5d945 # v1.4.8 with: - usesh: true + cache-after-prepare: true prepare: | pkg_add -I bash autoconf%2.71 automake%1.16 run: | @@ -38,28 +51,17 @@ jobs: export AUTOCONF_VERSION=2.71 export AUTOMAKE_VERSION=1.16 ./configure --with-rrsync --disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn - make + make check-progs ./rsync --version - # The flipper (symlink-race) tests are excluded on OpenBSD, as on the - # fleet's OpenBSD box: this kernel has a connect()-under-rename-load - # lost-wakeup and an FFS rename-storm corruption that hang them to - # the 300s timeout for non-rsync reasons (see - # dev-notes/openbsd-connect-lost-wakeup-report.txt); the protections - # they exercise are verified on the Linux/BSD boxes. + # OpenBSD rename storms can hang these races outside rsync export RSYNC_EXCLUDE=acl-symlink-race,sender-readlink-atfd,sender-remove-source-secure - make check - # The --use-tcp daemon tests run at -j2 here (vs -j8 elsewhere): this - # job runs inside a nested VM, and at -j8 the many concurrent loopback - # daemons occasionally lose a connection-handshake timing race under - # that resource pressure, hanging one test to the 300s timeout. It is - # an environment artifact, not an rsync bug (the handshake is - # deadlock-free and unreproducible elsewhere, even pinned to 1 CPU at - # -j8); -j2 keeps the VM from over-subscribing. The pipe `make check` - # above stays at the default parallelism. - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 2 + RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,openbsd,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --receipt=test-results/openbsd-pipe.json -j 8 + # Keep nested-VM loopback daemons below the observed lost-wakeup threshold + RSYNC_TEST_PROFILES=non-asan,root,self-peer,openbsd,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only --receipt=test-results/openbsd-tcp.json -j 2 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact - uses: actions/upload-artifact@v4 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: openbsd-bin @@ -71,3 +73,4 @@ jobs: rsyncd.conf.5 rrsync.1 rrsync + test-results/*.json diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index 6aafaa12e..60bd0bcf5 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -17,31 +17,27 @@ on: - '!.github/workflows/scan-build.yml' workflow_dispatch: +permissions: + contents: read + jobs: - # GATING run: pinned clang-18 on a pinned runner so the checker set -- and - # thus the expected zero -- is deterministic. The tree is kept clean for - # clang-18, so --status-bugs (non-zero exit on any report) fails the build - # when a new finding appears. Pin both the analyzer (clang-18/clang-tools-18) - # and the runner (ubuntu-24.04, whose apt repos carry those packages). + # Pinned analyser and runner form the deterministic gate gate-clang18: runs-on: ubuntu-24.04 + timeout-minutes: 45 name: scan-build gate (clang-18, pinned) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | sudo apt-get update sudo apt-get install -y clang-18 clang-tools-18 acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev libpopt-dev openssl - name: configure (under scan-build) - # Run configure under scan-build so its analyzer compiler-wrapper is baked - # into the Makefile's $(CC); --disable-md2man avoids the doc toolchain. run: scan-build-18 ./configure --with-rrsync --disable-md2man - name: scan-build (gating) - # --status-bugs makes scan-build exit non-zero if it finds ANY report. - # pipefail + 'exit $status' propagate that through the tee so the job goes - # red while still printing the summary; the report uploads for triage. run: | set -o pipefail status=0 @@ -51,27 +47,23 @@ jobs: exit $status - name: upload report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: scan-build-report-clang18 path: scan-report if-no-files-found: ignore - # INFORMATIONAL run: whatever clang ubuntu-latest currently ships. Newer - # clang releases enable extra, FP-heavy checkers (e.g. unix.Chroot - # "no chdir after chroot", alpha.unix.Stream) that the gate deliberately - # avoids, so this is NOT a gate (no --status-bugs). It surfaces what the - # newest analyzer sees -- useful for spotting genuine new findings before a - # gate bump -- without blocking merges. continue-on-error keeps a noisy or - # broken run from affecting the workflow's required status. + # The moving analyser is informational until its checker set is pinned informational-latest: runs-on: ubuntu-latest + timeout-minutes: 45 name: scan-build (latest clang, informational) continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | sudo apt-get update @@ -85,7 +77,7 @@ jobs: grep -E 'scan-build: .* bugs? found|scan-build: No bugs found' scan-build.out >>"$GITHUB_STEP_SUMMARY" || true - name: upload report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: scan-build-report-latest path: scan-report diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml index 1b328e4a1..55fdfba97 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -18,31 +18,45 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on Solaris steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false + - name: prepare VM host + timeout-minutes: 10 + run: | + packages=(qemu-utils qemu-system-x86 ovmf) + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "${packages[@]}" + } - name: Test in Solaris VM id: test - uses: vmactions/solaris-vm@v1 + uses: vmactions/solaris-vm@87d3c436511cef802cd1637f86f73b1a97715c8c # v1.4.1 with: - usesh: true + cache-after-prepare: true prepare: | pkg install bash automake gnu-m4 pkg://solaris/runtime/python-35 autoconf gcc git run: | uname -a ./configure --with-rrsync -disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn - make + make check-progs ./rsync --version - make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 + RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,solaris,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --receipt=test-results/solaris-pipe.json -j 8 + RSYNC_TEST_PROFILES=non-asan,root,self-peer,solaris,no-idn,no-xxhash,no-zstd-threads ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only --receipt=test-results/solaris-tcp.json -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact - uses: actions/upload-artifact@v4 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 name: solaris-bin @@ -54,3 +68,4 @@ jobs: rsyncd.conf.5 rrsync.1 rrsync + test-results/*.json diff --git a/.github/workflows/ubuntu-22.04-build.yml b/.github/workflows/ubuntu-22.04-build.yml deleted file mode 100644 index ce05cf1a2..000000000 --- a/.github/workflows/ubuntu-22.04-build.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Test rsync on Ubuntu 22.04 - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Older-LTS coverage to help with backporting security fixes. ubuntu-22.04 -# is currently the oldest GitHub Actions runner image (20.04 was retired -# in April 2025). - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-22.04-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-22.04-build.yml' - schedule: - - cron: '42 8 * * *' - -jobs: - test: - runs-on: ubuntu-22.04 - name: Test rsync on Ubuntu 22.04 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl - echo "/usr/local/bin" >>"$GITHUB_PATH" - - name: configure - run: ./configure --with-rrsync - - name: make - run: make - - name: install - run: sudo make install - - name: info - run: rsync --version - - name: check - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check - - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto30.txt make check30 - - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt make check29 - - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd; the default - # 'make check' above uses the secure stdio-pipe transport. - run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 - - name: ssl file list - run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: ubuntu-22.04-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 3adab91e6..2eda24033 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -11,21 +11,42 @@ on: - '.github/workflows/*.yml' - '!.github/workflows/ubuntu-build.yml' pull_request: - types: [opened, synchronize, reopened] + branches: [ master ] paths-ignore: - '.github/workflows/*.yml' - '!.github/workflows/ubuntu-build.yml' schedule: - - cron: '42 8 * * *' + - cron: '42 8 * * 1' + +permissions: + contents: read jobs: test: - runs-on: ubuntu-latest - name: Test rsync on Ubuntu + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + name: Test rsync on Ubuntu latest + artifact: ubuntu-bin + nonroot: true + install_smoke: true + profiles: non-asan,root,self-peer,linux + - runner: ubuntu-22.04 + name: Test rsync on Ubuntu 22.04 + artifact: ubuntu-22.04-bin + nonroot: false + install_smoke: false + profiles: non-asan,root,self-peer,linux,no-zstd-threads + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + name: ${{ matrix.name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | sudo apt-get update @@ -35,45 +56,61 @@ jobs: run: ./configure --with-rrsync - name: make run: make + - name: install/uninstall DESTDIR smoke test + if: matrix.install_smoke + run: | + set -e + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + make install-all DESTDIR="$tmp" + + for path in \ + /usr/local/bin/rsync \ + /usr/local/bin/rsync-ssl \ + /usr/local/bin/rrsync \ + /usr/local/share/man/man1/rsync.1 \ + /usr/local/share/man/man1/rsync-ssl.1 \ + /usr/local/share/man/man1/rrsync.1 \ + /usr/local/share/man/man5/rsyncd.conf.5 \ + /etc/stunnel/rsyncd.conf + do + test -e "$tmp$path" + done + + make uninstall-all DESTDIR="$tmp" + + leftover="$(find "$tmp" -type f -print)" + if [ -n "$leftover" ]; then + printf '%s\n' "$leftover" + exit 1 + fi - name: install run: sudo make install - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check + run: sudo RSYNC_TEST_PROFILES=pipe,${{ matrix.profiles }} make check - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto30.txt make check30 + run: sudo RSYNC_TEST_PROFILES=pipe,${{ matrix.profiles }},protocol-30 make check30 - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt make check29 + run: sudo RSYNC_TEST_PROFILES=pipe,${{ matrix.profiles }},protocol-29 make check29 - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd. The default - # 'make check' above uses the secure stdio-pipe transport (no listening - # sockets); this run exercises the real TCP accept/auth path. Skip-set - # is env-dependent here (chroot-acl), so leave RSYNC_EXPECT_SKIPPED unset. - run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 + run: sudo RSYNC_TEST_PROFILES=${{ matrix.profiles }} ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp --daemon-tests-only -j 8 - name: check (non-root, targeted) - # Every run above is root (sudo), so privilege-sensitive tests never hit - # their non-root path. Run those here as the unprivileged 'runner' user - # (NO sudo). Explicit test names make runtests.py full_run False, so - # RSYNC_EXPECT_SKIPPED is bypassed -- no per-platform skip list needed. - # daemon-namecvt-empty-response -- REQUIRES non-root (skips as root by - # design); the only test with no other CI coverage (Benjamin #2). - # ownership-depth -- non-root takes the group-only remap path. - # daemon -- non-root takes the default-config path. - # CONVENTION: a new test that requires/meaningfully exercises a non-root - # path must be added to the list below (kept in sync with the fleet - # harness's nonroot_tests). + if: matrix.nonroot run: | - sudo rm -rf testtmp # prior root steps left it root-owned - ./runtests.py --rsync-bin="$PWD/rsync" \ - daemon-namecvt-empty-response ownership-depth daemon + # Prior matrix steps leave root-owned scratch + sudo rm -rf testtmp + ./testsuite/runtests.py --rsync-bin="$PWD/rsync" \ + daemon-namecvt-empty-response ownership-depth daemon-basics - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 45 - name: ubuntu-bin + name: ${{ matrix.artifact }} path: | rsync rsync-ssl diff --git a/.github/workflows/ubuntu-version-mix.yml b/.github/workflows/ubuntu-version-mix.yml index d16dd09dd..704bd4263 100644 --- a/.github/workflows/ubuntu-version-mix.yml +++ b/.github/workflows/ubuntu-version-mix.yml @@ -4,27 +4,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true -# 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 ] @@ -39,14 +18,19 @@ on: schedule: - cron: '52 8 * * 1' +permissions: + contents: read + jobs: version-mix: runs-on: ubuntu-latest + timeout-minutes: 45 name: rsync version-mix steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | sudo apt-get update @@ -55,24 +39,26 @@ jobs: - 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: validate profiles + run: ./testsuite/runtests.py profile --rsync-bin="$PWD/rsync" - name: version mixing (all peers, pipe + TCP transports) run: | + mkdir -p test-results/version-mix rc=0 for peer in old_versions/rsync_*; do chmod +x "$peer" name=$(basename "$peer") - expect="testsuite/expect/$name.expect" + profile="peer-${name#rsync_}" for transport in pipe tcp; do tcp=() - [ "$transport" = tcp ] && tcp=(--use-tcp) + [ "$transport" = tcp ] && tcp=(--use-tcp --daemon-tests-only) 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 + if ! ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --rsync-bin2="$PWD/$peer" \ + --profiles="linux,pre-3.4.3,$profile" --receipt="test-results/version-mix/$name-$transport.json" \ + "${tcp[@]}" -j 8; then echo "::error::version-mix failed: $name ($transport)" rc=1 fi @@ -80,3 +66,9 @@ jobs: done done exit $rc + - name: upload receipts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: version-mix-receipts + path: test-results/version-mix diff --git a/.github/workflows/valgrind.yml b/.github/workflows/valgrind.yml index 33be5f102..78f6df5f5 100644 --- a/.github/workflows/valgrind.yml +++ b/.github/workflows/valgrind.yml @@ -19,20 +19,37 @@ on: - cron: '17 4 * * *' workflow_dispatch: +permissions: + contents: read + jobs: memcheck: runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 60 strategy: fail-fast: false matrix: - privilege: [ user, root ] - transport: [ pipe, tcp ] + include: + - privilege: user + transport: pipe + profiles: pipe,non-asan,nonroot,self-peer,linux,valgrind + - privilege: user + transport: tcp + profiles: non-asan,nonroot,self-peer,linux,valgrind + - privilege: root + transport: pipe + profiles: pipe,non-asan,root,self-peer,linux,valgrind + - privilege: root + transport: tcp + profiles: non-asan,root,self-peer,linux,valgrind name: memcheck (${{ matrix.privilege }}, ${{ matrix.transport }}) + env: + VALGRIND_SCRATCH: /tmp/rsync-valgrind-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.privilege }}-${{ matrix.transport }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: prep run: | sudo apt-get update @@ -46,33 +63,36 @@ jobs: - name: info run: ./rsync --version - # Run the whole suite under valgrind. We gate on memory *errors* (uninit - # reads, invalid read/write, bad frees, uninit syscall params), not leaks: - # rsync deliberately leaves file-list/socket/option memory unfreed at exit - # (short-lived process; the OS reclaims), so --leak-check=no avoids a sea of - # by-design "definitely lost" reports. Functional pass/fail is covered by - # the other workflows, so the suite is allowed to finish regardless of - # per-test results; the scan step below is the gate. --error-exitcode=0 - # keeps valgrind from perturbing test exit codes; the bundled - # testsuite/valgrind.supp silences known-benign reports. + # Gate memory errors rather than intentional process-exit leaks - name: run testsuite under valgrind run: | - SUDO= - [ "${{ matrix.privilege }}" = root ] && SUDO="sudo -E" - TCP= - [ "${{ matrix.transport }}" = tcp ] && TCP="--use-tcp" - $SUDO ./runtests.py --valgrind \ + mkdir -p test-results/valgrind-logs + SUDO=() + [ "${{ matrix.privilege }}" = root ] && SUDO=(sudo -E) + TCP=() + [ "${{ matrix.transport }}" = tcp ] && TCP=(--use-tcp --daemon-tests-only) + "${SUDO[@]}" env RSYNC_TEST_PROFILES=${{ matrix.profiles }} scratchbase="$VALGRIND_SCRATCH" \ + ./testsuite/runtests.py --valgrind \ --valgrind-opts="--leak-check=no --error-exitcode=0" \ - $TCP -j8 --preserve-scratch || true + "${TCP[@]}" -j2 --timeout=600 --preserve-scratch \ + --receipt=test-results/valgrind-${{ matrix.privilege }}-${{ matrix.transport }}.json - name: scan for unsuppressed valgrind errors + if: always() run: | - sudo chown -R "$USER" testtmp 2>/dev/null || true - mapfile -t logs < <(find testtmp -name 'valgrind.*.log' 2>/dev/null) + log_dir="$VALGRIND_SCRATCH/testtmp/valgrind-logs" + if [ -d "$log_dir" ]; then + sudo chown -R "$USER" "$log_dir" + fi + shopt -s nullglob + logs=("$log_dir"/valgrind.*.log) if [ "${#logs[@]}" -eq 0 ]; then echo "::error::no valgrind logs were produced -- the suite did not run" exit 1 fi + evidence_dir=test-results/valgrind-logs + mkdir -p "$evidence_dir" + cp -- "${logs[@]}" "$evidence_dir/" echo "scanned ${#logs[@]} valgrind log(s)" bad=() for f in "${logs[@]}"; do @@ -90,11 +110,13 @@ jobs: fi echo "valgrind clean: no unsuppressed errors" - - name: upload valgrind logs on failure - if: failure() - uses: actions/upload-artifact@v4 + - name: upload valgrind evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: valgrind-logs-${{ matrix.privilege }}-${{ matrix.transport }} - path: testtmp/**/valgrind.*.log + path: | + test-results/valgrind-logs/*.log + test-results/*.json if-no-files-found: ignore - retention-days: 7 + retention-days: 45 diff --git a/.gitignore b/.gitignore index f73d65f27..babf937b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.[oa] *~ +__pycache__/ dummy ID Makefile @@ -63,8 +64,6 @@ aclocal.m4 /testsuite/chown-fake.test /testsuite/devices-fake.test /testsuite/xattrs-hlink.test -/testsuite/fleettest.json -/fleettest-logs /patches /patches.gen /build diff --git a/Makefile.in b/Makefile.in index 716171db1..891f4a8c0 100644 --- a/Makefile.in +++ b/Makefile.in @@ -62,8 +62,10 @@ CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) -CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ - testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py +CHECK_SYMLINKS = testsuite/tests/metadata/chown-fake.py \ + testsuite/tests/metadata/devices-fake.py \ + testsuite/tests/metadata/xattrs-hlink.py \ + testsuite/tests/transfer/exclude-lsh.py # Objects for CHECK_PROGS to clean CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o @@ -436,22 +438,22 @@ COVERAGE_EXCLUDE = -e '(^|/)zlib/' -e '(^|/)popt/' \ -e '(^|/)lib/(md5|snprintf)\.c$$' # 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). +# WITHOUT running it. Used by CI jobs that invoke testsuite/runtests.py +# directly with options such as --rsync-bin2 and --profiles. .PHONY: check-progs check-progs: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) .PHONY: check check: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) .PHONY: check29 check29: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=29 + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=29 .PHONY: check30 check30: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=30 + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=30 # Whole-suite gcov coverage report (HTML, with branch + decision coverage). # Requires a build configured with --enable-coverage and the `gcovr` tool @@ -473,13 +475,13 @@ coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS) @# world-writable so any uid can create the sibling .gcda, and set a @# default ACL of o::rw so the .gcda are world-mergeable regardless of @# the creator's umask (every test process resets umask to 022 via - @# rsyncfns.py, so a Makefile-level `umask 0` would not survive). + @# harness/rsync.py, so a Makefile-level `umask 0` would not survive). @find . -name '*.gcno' -printf '%h\n' 2>/dev/null | sort -u | \ while read d; do \ chmod a+rwx "$$d"; \ setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \ done - @rc=0; "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) || rc=$$?; \ + @rc=0; "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) || rc=$$?; \ rm -rf $(COVERAGE_DIR) && mkdir -p $(COVERAGE_DIR); \ gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \ --gcov-ignore-parse-errors=negative_hits.warn_once_per_file \ @@ -517,9 +519,9 @@ coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS) setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \ done @rc=0; \ - for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp'; do \ + for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp --daemon-tests-only'; do \ echo "===== coverage-all: runtests.py $$cfg ====="; \ - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $$cfg || rc=$$?; \ + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $$cfg || rc=$$?; \ done; \ rm -rf coverage-all && mkdir -p coverage-all; \ gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \ @@ -550,17 +552,17 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS) touch $@; \ fi -testsuite/chown-fake_test.py: - ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py +testsuite/tests/metadata/chown-fake.py: + ln -s chown.py $(srcdir)/testsuite/tests/metadata/chown-fake.py -testsuite/devices-fake_test.py: - ln -s devices_test.py $(srcdir)/testsuite/devices-fake_test.py +testsuite/tests/metadata/devices-fake.py: + ln -s devices.py $(srcdir)/testsuite/tests/metadata/devices-fake.py -testsuite/xattrs-hlink_test.py: - ln -s xattrs_test.py $(srcdir)/testsuite/xattrs-hlink_test.py +testsuite/tests/metadata/xattrs-hlink.py: + ln -s xattrs.py $(srcdir)/testsuite/tests/metadata/xattrs-hlink.py -testsuite/exclude-lsh_test.py: - ln -s exclude_test.py $(srcdir)/testsuite/exclude-lsh_test.py +testsuite/tests/transfer/exclude-lsh.py: + ln -s exclude.py $(srcdir)/testsuite/tests/transfer/exclude-lsh.py # This does *not* depend on building or installing: you can use it to # check a version installed from a binary or some other source tree, @@ -568,7 +570,7 @@ testsuite/exclude-lsh_test.py: .PHONY: installcheck installcheck: $(CHECK_PROGS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir="`pwd`" -j $(CHECK_J) + "$(srcdir)/testsuite/runtests.py" --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir="`pwd`" -j $(CHECK_J) # TODO: Add 'dist' target; need to know which files will be included diff --git a/NEWS.md b/NEWS.md index f792911cd..f8ea78e63 100644 --- a/NEWS.md +++ b/NEWS.md @@ -45,7 +45,7 @@ test improvements. ### BUILD AND TESTS: - `install-strip` now honours `STRIP` including during cross-compilation. -- Updated platform tests and fleet-test coverage for the 3.5.0 fixes. +- Updated platform tests for the 3.5.0 fixes. ------------------------------------------------------------------------------ # NEWS for rsync 3.5.0 (13 Aug 2026) diff --git a/SECURITY.md b/SECURITY.md index b77e1473c..65e1c7ff6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -385,8 +385,8 @@ The following are documented as out of scope for this release: can still slip past confinement that is anchored at the process CWD (e.g. the sender's content open), because the descriptor is not bound to the original inode. The parent-component symlink-race tests are therefore not enforced on - Cygwin (see `RSYNC_EXPECT_SKIPPED` in `.github/workflows/cygwin-build.yml` and - the Cygwin-only xfail in `symlink-race-source_test.py`). Cygwin is a + Cygwin (see the `cygwin` test profile and the Cygwin-only xfail in + `symlink-race-source.py`). Cygwin is a development/interoperability target, not a privilege boundary host, so this is accepted for this release. diff --git a/old_versions/README.md b/old_versions/README.md index a5ce3a5b6..30c19e55e 100644 --- a/old_versions/README.md +++ b/old_versions/README.md @@ -1,87 +1,27 @@ -# 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 uses a temporary worktree, builds the requested tag, verifies the binary and removes the worktree. + +# Old rsync versions + +These statically linked binaries support cross-version regression checks and the version-mixing workflow. Peer expectations live in `testsuite/profiles/` + +Binary | Version | Protocol +--- | --- | --- +rsync_2.6.0 | 2.6.0 | 27 +rsync_3.0.0 | 3.0.0 | 30 +rsync_3.1.0 | 3.1.0 | 31 +rsync_3.1.3 | 3.1.3 | 31 +rsync_3.2.0 | 3.2.0 | 31 +rsync_3.2.7 | 3.2.7 | 31 +rsync_3.3.0 | 3.3.0 | 31 +rsync_3.4.0 | 3.4.0 | 32 +rsync_3.4.1 | 3.4.1 | 32 + +The archive starts at 2.6.0 because older releases require broader source changes to build with modern toolchains. + +## Rebuilding +```sh +./build_static.sh 3.2.7 +./build_static.sh 3.0.9 v3.0.9 ``` -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. +Set `RSYNC_REPO` to override the source repository. Compatibility patches cover old configure inputs, K&R prototypes, current libc declarations and compiler diagnostics. OpenSSL and `_FORTIFY_SOURCE` are disabled so the archived binaries retain their release-era checksum and memory behaviour when used as peers. diff --git a/runtests.py b/runtests.py deleted file mode 100755 index 4f62c12f6..000000000 --- a/runtests.py +++ /dev/null @@ -1,1024 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright (C) 2001, 2002 by Martin Pool -# Copyright (C) 2003-2022 Wayne Davison -# Copyright (C) 2026 Andrew Tridgell -# -# Rewrite of runtests.sh in Python (runtests.sh is now deprecated). -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version -# 2 as published by the Free Software Foundation. - -"""rsync test runner. - -Invokes test scripts from testsuite/ and reports results. -Can be called by 'make check' or directly. - -Usage: - ./runtests.py [options] [TEST ...] - -Each TEST is a test name (e.g. 'delete') or glob pattern (e.g. 'xattr*'). -If no tests are specified, all tests are run. -""" - -import argparse -import concurrent.futures -import fnmatch -import glob -import math -import os -import signal -import subprocess -import sys -import threading -import time - -# Share the test exit-code enum with the test helpers. exitcodes.py lives in -# testsuite/ (next to this script); it has no import-time side effects. -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testsuite')) -from exitcodes import Exit - - -def _race_seconds(text): - """argparse type for --race-timeout: a finite, strictly positive number. - - A race test loops `while monotonic() < deadline`, so a budget of 0 (or a - negative, or a NaN, which fails every comparison) runs the body ZERO times - and the test reports PASS without ever exercising its oracle -- a silently - disarmed security test, which is worse than a slow one. Infinity would run - until the unrelated per-test timeout. The old max(RACE_TIMEOUT, 10.0) floor - used to make this unreachable; validating here restores that guarantee.""" - try: - secs = float(text) - except ValueError: - raise argparse.ArgumentTypeError(f'not a number: {text!r}') - if not math.isfinite(secs) or secs <= 0: - raise argparse.ArgumentTypeError( - f'must be a finite positive number of seconds, got {text!r}; ' - 'a zero/negative/NaN budget would make every race test pass ' - 'without running its race') - return secs - - -def parse_args(): - p = argparse.ArgumentParser(description='Run rsync test suite') - p.add_argument('tests', nargs='*', metavar='TEST', - help='Test names or patterns to run (default: all)') - p.add_argument('--exclude', default=None, metavar='LIST', - help='Comma-separated test names/globs to skip entirely: ' - 'they are not run and not reported as skipped. Useful ' - 'for tests that cannot work in a given build/CI ' - 'environment (e.g. a restricted buildd chroot). ' - 'Falls back to the RSYNC_EXCLUDE environment variable.') - p.add_argument('-j', '--parallel', type=int, default=1, metavar='N', - help='Run up to N tests in parallel (default: 1)') - p.add_argument('--valgrind', action='store_true', - help='Run rsync under valgrind (logs to per-process files)') - p.add_argument('--valgrind-opts', default='', metavar='OPTS', - help='Extra valgrind options (e.g. "--leak-check=full")') - p.add_argument('--preserve-scratch', action='store_true', - help='Keep scratch directories after tests complete') - p.add_argument('--log-level', type=int, default=1, metavar='N', - help='Verbosity level 1-10 (default: 1)') - p.add_argument('--always-log', action='store_true', - help='Show test logs even for passing tests') - p.add_argument('--stop-on-fail', action='store_true', - help='Stop after first test failure') - p.add_argument('--timing', action='store_true', - help='After the run, report each test\'s wall-clock time, ' - 'slowest first. With -j N the report also shows how ' - 'much of the run the slowest test alone accounts for.') - p.add_argument('--timeout', type=int, default=300, metavar='SECS', - help='Per-test timeout in seconds (default: 300)') - p.add_argument('--race-timeout', type=_race_seconds, default=None, metavar='SECS', - help='Budget (seconds) a TOCTOU symlink-race test may spend ' - 'trying to win its race before concluding. Overrides ' - 'every such test\'s own default (5-15s, the suite\'s ' - 'slowest tests: a race test always spends its whole ' - 'budget). Lowering it speeds the suite up but weakens ' - 'the oracle. Unset: each test keeps its default.') - 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', - help='Source directory (default: script directory)') - p.add_argument('--protocol', type=int, default=None, metavar='VER', - 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. An ' - '@FILE entry reads a skip list (one test per line, ' - '"#" comments); relative paths resolve against srcdir ' - 'and several may be composed, e.g. ' - '@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt. ' - 'A -NAME entry removes a name the rest of the spec ' - 'added, for a host that can really run a test its ' - 'platform list expects to skip.') - 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('--daemon-tests-only', action='store_true', - help='Run only the tests that can reach the daemon ' - 'transport. Intended for a --use-tcp pass that follows ' - 'a full default-transport run: the tests this drops ' - 'never call start_test_daemon(), so they cannot observe ' - '--use-tcp and would just repeat themselves. Disables ' - 'the expected-skip oracle (it describes a full run).') - 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 ' - 'stdio-pipe transport, which opens no listening ' - 'socket; --use-tcp exposes a loopback port for the ' - 'duration of each daemon test.') - return p.parse_args() - - -def find_setfacl_nodef(scratchbase): - """Determine the setfacl command to remove default ACLs.""" - for cmd in [ - ['setacl', '-k', 'u::7,g::5,o:5', scratchbase], - ['setfacl', '-k', scratchbase], - ['setfacl', '-s', 'u::7,g::5,o:5', scratchbase], - ]: - try: - subprocess.run(cmd, capture_output=True, timeout=5) - return cmd[:2] if cmd[0] == 'setacl' else cmd[:2] - except (FileNotFoundError, subprocess.TimeoutExpired): - continue - try: - r = subprocess.run(['setfacl', '--help'], capture_output=True, text=True, timeout=5) - if '-k,' in r.stdout or '-k,' in r.stderr: - return ['setfacl', '-k'] - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - return None - - -def get_tls_args(config_h): - """Determine TLS_ARGS from config.h.""" - args = '' - try: - with open(config_h) as f: - text = f.read() - if '#define HAVE_LUTIMES 1' in text: - args += ' -l' - if '#undef CHOWN_MODIFIES_SYMLINK' in text: - args += ' -L' - except FileNotFoundError: - pass - return args.strip() - - -def read_shconfig(path): - """Read shell config variables from shconfig.""" - env = {} - try: - with open(path) as f: - for line in f: - line = line.strip() - if line.startswith('#') or line.startswith('export') or not line: - continue - if '=' in line: - k, _, v = line.partition('=') - env[k.strip()] = v.strip().strip('"') - except FileNotFoundError: - pass - return env - - -def get_testuser(): - """Determine the current test user.""" - for cmd in ['/usr/bin/whoami', '/usr/ucb/whoami', '/bin/whoami']: - if os.path.isfile(cmd): - try: - return subprocess.check_output([cmd], text=True).strip() - except subprocess.CalledProcessError: - pass - try: - return subprocess.check_output(['id', '-un'], text=True).strip() - except (FileNotFoundError, subprocess.CalledProcessError): - return os.environ.get('LOGNAME', os.environ.get('USER', 'UNKNOWN')) - - -def _move_aside(path): - """Rename an un-removable directory to a unique sibling so its name is free. - - A rename-storm symlink-race test can corrupt a directory on some filesystems - (OpenBSD FFS soft-updates can leave an "empty" dir that still reports - ENOTEMPTY/EPERM and only fsck clears). `rm -rf` then can't remove it, but - renaming the top dir aside succeeds even with a corrupted descendant, freeing - the original name for a clean scratchdir.""" - n = 0 - while os.path.exists(f"{path}.corrupt.{os.getpid()}.{n}"): - n += 1 - try: - os.rename(path, f"{path}.corrupt.{os.getpid()}.{n}") - except OSError: - pass - - -def prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef): - """Prepare a scratch directory for a test.""" - if os.path.isdir(scratchdir): - subprocess.run(['chmod', '-R', 'u+rwX', scratchdir], capture_output=True) - subprocess.run(['rm', '-rf', scratchdir], capture_output=True) - if os.path.isdir(scratchdir): - _move_aside(scratchdir) # rm -rf left corrupted debris; don't inherit it - os.makedirs(scratchdir, exist_ok=True) - if setfacl_nodef: - subprocess.run(setfacl_nodef + [scratchdir], capture_output=True) - try: - os.chmod(scratchdir, os.stat(scratchdir).st_mode & ~0o2000) # clear setgid - except OSError: - pass - src_link = os.path.join(scratchdir, 'src') - if not os.path.exists(src_link): - if os.path.isabs(srcdir): - os.symlink(srcdir, src_link) - else: - os.symlink(os.path.join(tooldir, srcdir), src_link) - - -# Python tests are identified by a positive "_test.py" suffix so that -# helper modules (e.g. rsyncfns.py) sit in testsuite/ without being mistaken -# for tests. -_PY_TEST_SUFFIX = '_test.py' - - -def _is_test_path(path): - return os.path.basename(path).endswith(_PY_TEST_SUFFIX) - - -def _testbase(path): - """Strip the test extension to get the canonical test name.""" - base = os.path.basename(path) - if base.endswith(_PY_TEST_SUFFIX): - return base[:-len(_PY_TEST_SUFFIX)] - return base - - -def collect_tests(suitedir, patterns): - """Collect test scripts (_test.py) matching the given patterns.""" - if not patterns: - candidates = glob.glob(os.path.join(suitedir, '*' + _PY_TEST_SUFFIX)) - tests = sorted(p for p in candidates if _is_test_path(p)) - else: - seen = set() - tests = [] - for pat in patterns: - # Accept either bare name ("mkpath"), explicit extension, or glob. - if pat.endswith('.py'): - pats = [pat] - else: - pats = [pat + _PY_TEST_SUFFIX] - for p in pats: - for m in sorted(glob.glob(os.path.join(suitedir, p))): - if _is_test_path(m) and m not in seen: - seen.add(m) - tests.append(m) - return tests - - -# Tokens through which a test can reach the daemon transport. --use-tcp works by -# setting RSYNC_TEST_USE_TCP, which is read in exactly one place (rsyncfns -# USE_TCP) and acted on in exactly one function (start_test_daemon): a test whose -# source mentions none of these never gets there, so it behaves identically with -# and without --use-tcp and running it a second time under TCP buys no coverage. -# -# The list is the closure of every rsyncfns helper that reaches USE_TCP, -# start_rsyncd or claim_ports, plus the helper modules that open a daemon -# connection themselves and the bare literals a test might use directly. It is -# deliberately over-broad: a false positive only costs runtime, while a false -# negative would silently drop real coverage. -_DAEMON_API = ( - 'USE_TCP', 'require_tcp', 'start_test_daemon', 'start_rsyncd', - 'claim_ports', 'claim_free_port', 'setup_chroot_inner', - 'stdio_daemon', 'rsync_proto', 'DaemonClient', - 'rsync://', '--daemon', 'rsyncd', -) - - -def select_daemon_tests(tests): - """Split `tests` into (daemon-transport tests, the rest). - - Used by --daemon-tests-only so a TCP pass need not re-run the whole suite. - A test we cannot read is kept, not dropped -- the failure mode of this - filter must always be "ran too much".""" - keep, dropped = [], [] - for path in tests: - try: - with open(path, errors='replace') as f: - text = f.read() - except OSError: - keep.append(path) - continue - (keep if any(tok in text for tok in _DAEMON_API) else dropped).append(path) - return keep, dropped - - -_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(Exit.ERROR) - expect[fields[0]] = fields[1] - return expect - - -def expand_skip_spec(spec, srcdir, suitedir): - """Expand an RSYNC_EXPECT_SKIPPED spec into a normalised csv. - - The spec is a comma-separated list of test names, '@FILE' skip-list - references, and '-name' removals. A skip-list file holds one test name per line ('#' starts a - comment; blank lines are ignored), which is what keeps two branches from - colliding: adding a test edits one line of one file rather than a shared - 3 KB csv. Several may be composed, e.g. - RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/linux.txt,@.../proto29.txt - Relative paths resolve against srcdir (not the cwd) so out-of-tree builds - and `make installcheck` work. A '-name' entry removes a name the rest of - the spec added, for a host that can genuinely run a test its platform list - expects to skip; it is applied last, and must actually remove something. - - Entries must name a real test, and each file must be non-empty, sorted and - free of duplicates: unsorted files defeat the point (everyone appends to - the same last line), and a stale name would otherwise fail as a skip - mismatch far from its cause. Exits 2 on any of those -- nothing malformed - may quietly shrink the expected set, which would disarm the oracle. - """ - def die(msg): - sys.stderr.write(msg + '\n') - sys.exit(Exit.ERROR) - - # An entirely empty spec is the legitimate "expect no skips at all". An - # empty entry *within* a spec is not: it is what an unset shell variable - # expands to, and silently dropping it would quietly shrink the expected - # set. - if not spec.strip(): - return '' - - names = [] - drop = [] - for tok in (t.strip() for t in spec.split(',')): - if not tok: - die('RSYNC_EXPECT_SKIPPED: empty entry (an unset variable?): ' - f'{spec!r}') - if tok.startswith('-'): - # '-name' removes a name a composed list added, for a host that - # really can run a test its platform list expects to skip (e.g. a - # scratch dir on a second filesystem makes a cross-device copy - # work). Subtraction cannot be done by whoever composes the spec, - # because the name lives inside an @FILE that is only expanded - # here. Applied after every addition, so order does not matter. - drop.append((tok[1:], tok)) - continue - if not tok.startswith('@'): - names.append((tok, 'RSYNC_EXPECT_SKIPPED')) - continue - path = tok[1:] - if not os.path.isabs(path): - path = os.path.join(srcdir, path) - try: - with open(path) as f: - lines = f.readlines() - except (OSError, UnicodeDecodeError) as e: - die(f'{tok}: cannot read skip list: {e}') - prev = None - found = 0 - for lineno, raw in enumerate(lines, 1): - name = raw.split('#', 1)[0].strip() - if not name: - continue - where = f'{path}:{lineno}' - if len(name.split()) != 1: - die(f'{where}: expected one test name per line, got: {raw.rstrip()}') - if prev is not None and name <= prev: - die(f'{where}: skip lists must be sorted and duplicate-free ' - f'({name!r} follows {prev!r})') - prev = name - found += 1 - names.append((name, where)) - # A truncated or emptied list must not read as "expect no skips". - if not found: - die(f'{path}: skip list contains no test names') - - seen = {} - for name, where in names: - if name in seen: - continue - seen[name] = where - # A plain test name: no path, and no comma (which the expanded csv, - # and the summary the fleet parses back, use as the separator). - if ',' in name or '/' in name or os.sep in name or name in ('.', '..'): - die(f'{where}: not a test name: {name!r}') - if not os.path.isfile(os.path.join(suitedir, name + '_test.py')): - die(f'{where}: no such test: {name}') - for name, tok in drop: - # Every removal must remove something. A name the spec never added is - # stale (the list stopped expecting that skip, or it is misspelled), and - # a repeated removal is the same no-op written twice. Shrinking the - # expected set is precisely what must not happen quietly, so neither is - # allowed to sit in a config unnoticed. - if name not in seen: - die(f'RSYNC_EXPECT_SKIPPED: {tok!r} removes a name that nothing ' - f'added: {name}') - del seen[name] - return ','.join(sorted(seen)) - - -def read_backport_exclude(tooldir, suitedir): - """Test names from the BUILD tree's testsuite/skiplist/backport.txt. - - A stable-backport branch runs a newer suite than its own code, and this - file names the tests that base cannot run. It lives in the tree being - built, not the suite tree, so it cannot be an @FILE in the expect-skipped - spec -- those resolve against srcdir. - """ - path = os.path.join(tooldir, 'testsuite', 'skiplist', 'backport.txt') - if not os.path.isfile(path): - return set() - names = set() - with open(path) as f: - for lineno, raw in enumerate(f, 1): - name = raw.split('#', 1)[0].strip() - if not name: - continue - where = f'{path}:{lineno}' - if len(name.split()) != 1: - sys.stderr.write(f'{where}: expected one test name per line\n') - sys.exit(Exit.ERROR) - # A stale name here would silently exclude nothing, which is the - # failure this file exists to prevent. - if not os.path.isfile(os.path.join(suitedir, name + '_test.py')): - sys.stderr.write(f'{where}: no such test: {name}\n') - sys.exit(Exit.ERROR) - names.add(name) - return names - - -_TIMING_TOP = 25 - - -def print_timing_report(durations, outcomes, run_wall, parallel): - """Report per-test wall-clock, slowest first (--timing). - - With -j N the run cannot finish sooner than its slowest single test, so the - tail matters as much as the total: a 300s test pins the whole suite to 300s - no matter how many workers there are. The footer gives both bounds -- the - serial sum (what one worker would take) and that floor -- so it is obvious - whether a slow run wants more parallelism or a faster individual test.""" - if not durations: - return - ranked = sorted(durations.items(), key=lambda kv: kv[1], reverse=True) - total = sum(durations.values()) - print(f'----- slowest tests (of {len(ranked)}, wall-clock each):') - for name, secs in ranked[:_TIMING_TOP]: - print(f' {secs:7.1f}s {name:<32} {outcomes.get(name, "?")}') - print(f' serial sum {total:.0f}s over {len(ranked)} tests; ' - f'run took {run_wall:.0f}s with -j{parallel}') - slowest, slowest_secs = ranked[0] - if parallel > 1: - # The floor: even with unlimited workers the suite cannot beat its - # longest single test. - print(f' floor {slowest_secs:.0f}s ({slowest}) = ' - f'{100.0 * slowest_secs / run_wall:.0f}% of this run; ' - f'ideal at -j{parallel} is {total / parallel:.0f}s') - - -def outcome_of(result): - """Map a per-test exit code to an outcome string.""" - if result == Exit.PASS: - return 'pass' - if result == Exit.SKIP: - return 'skip' - if result == Exit.XFAIL: - return 'xfail' - return 'fail' - - -def build_rsync_cmd(rsync_bin, args, scratchbase): - """Build the RSYNC command string for tests.""" - parts = [] - if args.valgrind: - # Logs go in a world-writable+sticky subdir so that rsync children - # which drop privileges (the setpriv cap-drop in partial_nowrite, a - # daemon dropping to the module's uid) can still create their log file - # even when scratchbase itself is root-owned. - vgdir = os.path.join(scratchbase, 'valgrind-logs') - os.makedirs(vgdir, exist_ok=True) - os.chmod(vgdir, 0o1777) - vlog = os.path.join(vgdir, 'valgrind.%p.log') - vopts = f'--log-file={vlog}' - supp = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'testsuite', 'valgrind.supp') - if os.path.exists(supp): - vopts += f' --suppressions={supp}' - if args.valgrind_opts: - vopts += ' ' + args.valgrind_opts - parts.append(f'valgrind {vopts}') - parts.append(rsync_bin) - if args.protocol is not None: - parts.append(f'--protocol={args.protocol}') - return ' '.join(parts) - - -class TestResult: - """Result of a single test execution.""" - __slots__ = ('testbase', 'result', 'output', 'skipped_reason', 'duration') - - def __init__(self, testbase, result, output='', skipped_reason='', - duration=0.0): - self.testbase = testbase - self.result = result - self.output = output - self.skipped_reason = skipped_reason - self.duration = duration - - -def run_one_test(testscript, testbase, scratchdir, base_env, timeout, - srcdir, tooldir, setfacl_nodef, always_log): - """Run a single test. Returns a TestResult. - - This function is safe to call from multiple threads — it uses only - per-test state (unique scratchdir, copy of env). - """ - started = time.monotonic() - prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef) - - env = base_env.copy() - env['scratchdir'] = scratchdir - - # Dispatch by extension: shell tests via /bin/sh -e, Python tests via - # the same python3 that's running this runner. - if testscript.endswith('.py'): - cmd = [sys.executable, testscript] - else: - cmd = ['sh', '-e', testscript] - - logfile = os.path.join(scratchdir, 'test.log') - with open(logfile, 'w') as log: - # start_new_session: run the test driver as its own session/group leader - # so the daemon, clients and flipper it spawns inherit that group. A - # timeout then killpg's the whole tree (not just the driver), and the - # lock-file sweep can reap a SIGKILLed run's stranded group the same way. - proc = subprocess.Popen( - cmd, - stdout=log, stderr=subprocess.STDOUT, - env=env, cwd=env.get('TOOLDIR', '.'), - start_new_session=True, - ) - try: - result = proc.wait(timeout=timeout) - except subprocess.TimeoutExpired: - # Reap the whole session group, but only if the driver really is its - # own group leader (start_new_session took) and that group isn't ours - # -- killpg of our own group would take down the runner. - try: - pgid = os.getpgid(proc.pid) - except OSError: - pgid = -1 - if pgid == proc.pid and pgid != os.getpgrp(): - try: - os.killpg(pgid, signal.SIGKILL) - except OSError: - proc.kill() - else: - proc.kill() - proc.wait() - result = 1 - log.write(f"\nTIMEOUT: test took over {timeout} seconds\n") - - # Build output text - output_parts = [] - - show_log = always_log or (result not in (Exit.PASS, Exit.SKIP, Exit.XFAIL)) - if show_log: - output_parts.append(f'----- {testbase} log follows') - try: - with open(logfile) as f: - output_parts.append(f.read().rstrip()) - except FileNotFoundError: - pass - output_parts.append(f'----- {testbase} log ends') - rsyncd_log = os.path.join(scratchdir, 'rsyncd.log') - if os.path.isfile(rsyncd_log): - output_parts.append(f'----- {testbase} rsyncd.log follows') - with open(rsyncd_log) as f: - output_parts.append(f.read().rstrip()) - output_parts.append(f'----- {testbase} rsyncd.log ends') - - skipped_reason = '' - if result == Exit.PASS: - output_parts.append(f'PASS {testbase}') - elif result == Exit.SKIP: - whyfile = os.path.join(scratchdir, 'whyskipped') - try: - with open(whyfile) as f: - skipped_reason = f.read().strip() - except FileNotFoundError: - pass - output_parts.append(f'SKIP {testbase} ({skipped_reason})') - elif result == Exit.XFAIL: - output_parts.append(f'XFAIL {testbase}') - else: - output_parts.append(f'FAIL {testbase}') - - return TestResult(testbase, result, '\n'.join(output_parts), skipped_reason, - time.monotonic() - started) - - -# Lock for serializing output in parallel mode -_print_lock = threading.Lock() - - -def main(): - args = parse_args() - - # Also accept legacy environment variables - if args.preserve_scratch or os.environ.get('preserve_scratch') == 'yes': - args.preserve_scratch = True - if args.log_level == 1: - args.log_level = int(os.environ.get('loglevel', '1')) - if args.expect_skipped is None: - args.expect_skipped = os.environ.get('RSYNC_EXPECT_SKIPPED', 'IGNORE') - if args.exclude is None: - args.exclude = os.environ.get('RSYNC_EXCLUDE', '') - if os.environ.get('whichtests'): - args.tests = [os.environ['whichtests']] - - # Determine directories - tooldir = args.tooldir or os.environ.get('TOOLDIR') or os.getcwd() - script_path = os.path.dirname(os.path.abspath(__file__)) - srcdir = args.srcdir or script_path - if not srcdir or srcdir == '.': - srcdir = tooldir - rsync_bin = args.rsync_bin or os.environ.get('rsync_bin') or os.path.join(tooldir, 'rsync') - # Absolutize: tests run with subprocess(cwd=TOOLDIR) below, so a relative - # argv[0] would re-resolve against TOOLDIR rather than the runner's - # invocation cwd, breaking --rsync-bin=../foo/rsync forms. abspath() - # captures os.getcwd() now, which is what the operator intended. - 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') - # A backport tree excludes what its base cannot run. Those tests never - # run, so they must also drop out of the expected-skip set -- otherwise the - # oracle demands a skip from a test that was never started. - backport_excl = read_backport_exclude(tooldir, suitedir) - if backport_excl: - args.exclude = ','.join(x for x in (args.exclude, - ','.join(sorted(backport_excl))) if x) - if args.expect_skipped != 'IGNORE': - args.expect_skipped = expand_skip_spec(args.expect_skipped, srcdir, suitedir) - if backport_excl: - args.expect_skipped = ','.join(n for n in args.expect_skipped.split(',') - if n and n not in backport_excl) - scratchbase = os.path.join(os.environ.get('scratchbase', tooldir), 'testtmp') - os.makedirs(scratchbase, exist_ok=True) - - shconfig = read_shconfig(os.path.join(tooldir, 'shconfig')) - 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(Exit.ERROR) - if not os.path.isfile(rsync_bin2): - sys.stderr.write(f"rsync_bin2 {rsync_bin2} is not a file\n") - sys.exit(Exit.ERROR) - if not os.path.isdir(srcdir): - sys.stderr.write(f"srcdir {srcdir} is not a directory\n") - sys.exit(Exit.ERROR) - - # Helper programs the test scripts invoke directly. Missing any of these - # would cause many tests to fail with confusing "not found" errors, so - # check up front and point the user at the make target that builds them. - required_helpers = ['tls', 'trimslash', 't_unsafe', 't_chmod_secure', - 't_secure_relpath', - 'wildtest', 'getgroups', 'getfsdev'] - missing = [h for h in required_helpers - if not os.path.isfile(os.path.join(tooldir, h))] - if missing: - sys.stderr.write( - f"runtests.py: missing test helper program(s) in {tooldir}: " - f"{', '.join(missing)}\n" - f"Build them with: make {' '.join(missing)}\n" - f"or run the full test target: make check\n" - ) - sys.exit(Exit.ERROR) - - testuser = get_testuser() - - # Print header - 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}') - print(f' os={subprocess.check_output(["uname", "-a"], text=True).strip()}') - print(f' preserve_scratch={"yes" if args.preserve_scratch else "no"}') - if args.valgrind: - print(f' valgrind=enabled (logs in valgrind-logs/valgrind.*.log)') - if args.parallel > 1: - print(f' parallel={args.parallel}') - print(f' daemon_transport={"tcp (loopback)" if args.use_tcp else "pipe (secure default)"}') - print(f' scratchbase={scratchbase}') - - # Build base environment for test scripts - path = os.environ.get('PATH', '') - if os.path.isdir('/usr/xpg4/bin'): - path = '/usr/xpg4/bin:' + path - - # Make the testsuite/ directory importable so Python tests can `import rsyncfns`. - pythonpath = suitedir - if os.environ.get('PYTHONPATH'): - pythonpath = suitedir + os.pathsep + os.environ['PYTHONPATH'] - - base_env = os.environ.copy() - base_env.update({ - 'PATH': path, - 'POSIXLY_CORRECT': '1', - 'TOOLDIR': tooldir, - 'srcdir': srcdir, - 'RSYNC': rsync_cmd, - 'RSYNC_PEER': rsync_peer_cmd, - 'TLS_ARGS': tls_args, - 'RUNSHFLAGS': '-e', - 'scratchbase': scratchbase, - 'suitedir': suitedir, - 'TESTRUN_TIMEOUT': str(args.timeout), - 'HOME': scratchbase, - 'PYTHONPATH': pythonpath, - }) - if args.use_tcp: - # Opt-in: daemon tests start a real rsyncd on a claimed loopback port. - # Default (unset) keeps the secure stdio-pipe transport. - base_env['RSYNC_TEST_USE_TCP'] = '1' - if args.race_timeout is not None: - # Only exported when the operator actually passed --race-timeout: its - # mere presence is what tells a race test to override its own default. - base_env['race_timeout'] = str(args.race_timeout) - else: - # A stale value inherited from the environment would silently override - # every test's default; the flag is the only way to set this. - base_env.pop('race_timeout', None) - for k, v in shconfig.items(): - if v: - base_env[k] = v - if setfacl_nodef: - base_env['setfacl_nodef'] = ' '.join(setfacl_nodef) - else: - base_env['setfacl_nodef'] = 'true' - if args.log_level > 8: - base_env['RUNSHFLAGS'] = '-e -x' - - # Collect tests - tests = collect_tests(suitedir, args.tests) - full_run = len(args.tests) == 0 - - # Drop excluded tests entirely (matched by basename against name/glob). - excl = [e.strip() for e in args.exclude.split(',') if e.strip()] - if excl: - before = len(tests) - tests = [t for t in tests - if not any(fnmatch.fnmatch(_testbase(t), pat) for pat in excl)] - if before != len(tests): - print(f"Excluding {before - len(tests)} test(s) matching: " - f"{', '.join(excl)}") - - # Narrow to the daemon-transport tests. The dropped count is always printed: - # a pass that silently ran a third of the suite would read in the report as - # if it had run all of it. - if args.daemon_tests_only: - tests, dropped = select_daemon_tests(tests) - print(f"Daemon-transport tests only: running {len(tests)}, skipping " - f"{len(dropped)} test(s) that cannot observe the transport") - # The expected-skip list describes a full run, so it cannot be enforced - # against a subset -- same rule as naming tests explicitly. - full_run = False - - # 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)} - - passed = 0 - failed = 0 - skipped = 0 - xfailed = 0 - skipped_list = [] - outcomes = {} # testbase -> actual outcome string ('pass'/'skip'/'fail'/'xfail') - durations = {} # testbase -> wall-clock seconds (for --timing) - - def process_result(tr): - """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, xfailed - 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 - durations[tr.testbase] = tr.duration - if tr.result == Exit.PASS: - passed += 1 - elif tr.result == Exit.SKIP: - skipped_list.append(tr.testbase) - skipped += 1 - elif tr.result == Exit.XFAIL: - # XFAIL: an expected failure (a known, documented residual the test - # asserts against). Reported distinctly but does NOT fail the suite; - # when the underlying issue is fixed the test returns 0 instead. - xfailed += 1 - else: - failed += 1 - if tr.result in (Exit.PASS, Exit.SKIP, Exit.XFAIL) 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/non-xfail result is a failure. - if expect is not None: - return mismatch(tr.testbase, oc) - return tr.result not in (Exit.PASS, Exit.SKIP, Exit.XFAIL) - - run_started = time.monotonic() - - if args.parallel > 1: - # Parallel execution - with concurrent.futures.ThreadPoolExecutor(max_workers=args.parallel) as executor: - futures = {} - for testscript in tests: - testbase = _testbase(testscript) - scratchdir = os.path.join(scratchbase, testbase) - timeout = 600 if ('hardlinks' in testbase or testbase == 'variety') else args.timeout - f = executor.submit( - run_one_test, testscript, testbase, scratchdir, - base_env, timeout, srcdir, tooldir, setfacl_nodef, - args.always_log - ) - futures[f] = testbase - - for f in concurrent.futures.as_completed(futures): - tr = f.result() - is_fail = process_result(tr) - if is_fail and args.stop_on_fail: - # Cancel pending futures - for pending in futures: - pending.cancel() - break - else: - # Sequential execution - for testscript in tests: - testbase = _testbase(testscript) - scratchdir = os.path.join(scratchbase, testbase) - timeout = 600 if ('hardlinks' in testbase or testbase == 'variety') else args.timeout - tr = run_one_test( - testscript, testbase, scratchdir, - base_env, timeout, srcdir, tooldir, setfacl_nodef, - args.always_log - ) - is_fail = process_result(tr) - if is_fail and args.stop_on_fail: - break - - run_wall = time.monotonic() - run_started - - # Check valgrind logs for errors - vg_errors = 0 - if args.valgrind: - for vlog in sorted(glob.glob(os.path.join(scratchbase, 'valgrind-logs', 'valgrind.*.log'))): - try: - with open(vlog) as f: - content = f.read() - for line in content.splitlines(): - if 'ERROR SUMMARY:' in line and 'ERROR SUMMARY: 0 errors' not in line: - vg_errors += 1 - print(f'----- valgrind errors in {os.path.basename(vlog)}:') - print(content) - break - except FileNotFoundError: - pass - - # Summary - print('-' * 60) - print('----- overall results:') - print(f' {passed} passed') - if failed > 0: - print(f' {failed} failed') - if xfailed > 0: - print(f' {xfailed} xfailed (expected)') - if skipped > 0: - print(f' {skipped} skipped') - if vg_errors > 0: - print(f' {vg_errors} valgrind error(s) found (see logs in {os.path.join(scratchbase, "valgrind-logs")})') - - if args.timing: - print_timing_report(durations, outcomes, run_wall, args.parallel) - - 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:') - print(f' expected: {args.expect_skipped}') - print(f' got: {skipped_str}') - else: - skipped_str = '' - args.expect_skipped = '' - - print('-' * 60) - - exit_code = failed + vg_errors - if exit_code == 0: - # Compare the skipped set order-insensitively: which tests skipped is - # what matters, not the order runtests happened to collect them in - # (that order is just sorted filenames -- an easy thing to get subtly - # wrong when maintaining the per-platform expected lists). - got = set(s for s in skipped_str.split(',') if s) - want = set(s for s in args.expect_skipped.split(',') if s) - if got != want: - exit_code = 1 - - print(f'overall result is {exit_code}') - sys.exit(exit_code) - - -if __name__ == '__main__': - main() diff --git a/support/rrsh.sh b/support/rrsh.sh index 83025f2f5..fc598cf12 100755 --- a/support/rrsh.sh +++ b/support/rrsh.sh @@ -1,12 +1,5 @@ #!/bin/sh -# abdiff helper: a "remote shell" that emulates an sshd forced-command of -# `rrsync DIR`. rsync invokes a remote shell as: -# [ssh-opts] -# so when used as -e "sh rrsh.sh " rsync calls us as: -# sh rrsh.sh [opts] lh rsync --server ... -# We hand the server command to rrsync via SSH_ORIGINAL_COMMAND (exactly as -# sshd would) and exec the restricted wrapper, so abdiff can A/B the rrsync -# path itself. Only the pretend hosts "lh"/"localhost" are accepted. +# Emulate an sshd forced command for the version comparison's rrsync transport. RRSYNC="$1"; DIR="$2"; shift 2 while [ $# -gt 0 ]; do case "$1" in diff --git a/testsuite/00-hello_test.py b/testsuite/00-hello_test.py deleted file mode 100644 index cdc153798..000000000 --- a/testsuite/00-hello_test.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -# Python rewrite of testsuite/00-hello.test. -# -# Foundational smoke test: --version / --info=help / --debug=help all -# work, plus a round-trip transfer of a directory whose name contains -# shell-special characters via the lsh.sh remote-shell stand-in. - -import os - -from rsyncfns import ( - FROMDIR, RSYNC, RSYNC_PEER, SRCDIR, TODIR, - checkit, run_rsync, test_fail, rsync_path_arg, rsh_cmd, -) - - -# Set RSYNC_RSH so rsync picks up lsh.sh for the "lh:" hosts below. -os.environ['RSYNC_RSH'] = rsh_cmd() - -# Basic help dumps must not crash. -if run_rsync('--version', check=False).returncode != 0: - test_fail('--version output failed') -if run_rsync('--info=help', check=False).returncode != 0: - test_fail('--info=help output failed') -if run_rsync('--debug=help', check=False).returncode != 0: - test_fail('--debug=help output failed') - -weird_name = "A weird)name" - -FROMDIR.mkdir(parents=True, exist_ok=True) -weird_dir = FROMDIR / weird_name -weird_dir.mkdir() - - -def append_line(line: str) -> None: - print(line) - with open(weird_dir / 'file', 'a') as f: - f.write(line + '\n') - - -def copy_weird(args: list, src_host: str, dst_host: str) -> None: - checkit( - [*args, f'--rsync-path={rsync_path_arg()}', - f'{src_host}{weird_dir}/', - f'{dst_host}{TODIR / weird_name}'], - FROMDIR, TODIR, - ) - - -append_line('test1') -checkit(['-ai', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) - -append_line('test2') -copy_weird(['-ai'], 'lh:', '') - -append_line('test3') -copy_weird(['-ai'], '', 'lh:') - -append_line('test4') -copy_weird(['-ais'], 'lh:', '') - -append_line('test5') -copy_weird(['-ais'], '', 'lh:') - -# test6: --old-args lets two whitespace-separated names go through as a -# single "one two" remote argument to be re-split by the remote shell. -print('test6') -(FROMDIR / 'one').touch() -(FROMDIR / 'two').touch() - -saved = os.getcwd() -os.chdir(FROMDIR) -try: - run_rsync('-ai', '--old-args', f'--rsync-path={rsync_path_arg()}', - 'lh:one two', f'{TODIR}/') -finally: - os.chdir(saved) - -if not (TODIR / 'one').is_file() or not (TODIR / 'two').is_file(): - test_fail("old-args copy of 'one two' failed") - -# test7: the RSYNC_OLD_ARGS=1 env var should be equivalent to --old-args. -print('test7') -(TODIR / 'one').unlink() -(TODIR / 'two').unlink() - -env = os.environ.copy() -env['RSYNC_OLD_ARGS'] = '1' -import subprocess -from rsyncfns import rsync_argv - -os.chdir(FROMDIR) -try: - subprocess.run( - rsync_argv('-ai', f'--rsync-path={rsync_path_arg()}', - 'lh:one two', f'{TODIR}/'), - env=env, check=True, - ) -finally: - os.chdir(saved) - -# check=True only proves a zero exit; confirm the env-var path actually copied -# both files (as the explicit --old-args case above does). -if not (TODIR / 'one').is_file() or not (TODIR / 'two').is_file(): - test_fail("RSYNC_OLD_ARGS=1 copy of 'one two' failed") diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 470d87a09..4f9165764 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -1,204 +1,199 @@ -# rsync option / daemon-parameter test coverage matrix - -Living checklist for the test-coverage effort that precedes the path-handling -restructure of rsync's path resolution. The restructure rewrites parent-directory -resolution for essentially every option, so the goal here is a regression net -that exercises each option **at directory depth** (≥3 levels) and, where the -option spans trees, **across directory boundaries**, asserting the *specific -property* the option controls — not just `dest == src`. - -How to read the columns: - -* **test(s)** — the `testsuite/*_test.py` that exercise the option. Tests added - by this effort are marked `*new*`. -* **depth** — Y = asserted on entries ≥3 levels deep; `~` = exercised only at/near - the tree root; `n/a` = not a path-resolution option. -* **x-dir** — Y = exercised with the relevant aux tree (temp/backup/dest/partial) - **outside** the main tree; `—` = not a cross-directory option. -* **gap** — what is still missing. - -Status legend: ✓ property asserted · `~` shallow / by an existing ported test · -✗ no coverage. - ---- - -## Command-line options - -### Recursion / structure -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -a, --archive | (all) | Y | — | ✓ ubiquitous | -| -r, --recursive | hands, delete-deep*new* | Y | — | ✓ | -| -R, --relative | relative, relative-implied*new* | Y | — | ✓ implied-dir attrs at depth | -| --no-implied-dirs | relative-implied*new* | Y | — | ✓ (proto 30+; proto 29 rejects multi-component path) | -| --inc-recursive / --no-inc-recursive | hardlinks | Y | — | `~` exercised, not isolated | -| -d, --dirs | dirs*new* | Y | — | ✓ no-recurse top layer | -| --old-dirs / --old-d | — | — | — | ✗ | -| -m, --prune-empty-dirs | prune-empty-dirs*new* | Y | — | ✓ incl. filter-emptied chains | - -### Links -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -l, --links | links*new*, symlink-ignore | Y | — | ✓ | -| -L, --copy-links | links*new* | Y | — | ✓ deref file+dir | -| -k, --copy-dirlinks | links*new* | Y | — | ✓ follow dir-symlink | -| -K, --keep-dirlinks | symlink-dirlink-basis | Y | — | ✓ #715; skips on no-RESOLVE_BENEATH / --disable-openat2 | -| -H, --hard-links | hardlinks, hardlinks-deep*new* | Y | Y | ✓ cross-directory hardlink | -| --copy-unsafe-links | unsafe-links | `~` | — | `~` | -| --safe-links | safe-links | `~` | — | `~` | -| --munge-links | (daemon-munge*new* covers the daemon param) | — | — | `~` client option not isolated; local mode is a near no-op | - -### Metadata / permissions / ownership -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -p, --perms | metadata-depth*new* | Y | — | ✓ exact modes per entry | -| -E, --executability | executability | `~` | — | `~` | -| --chmod | metadata-depth*new*, chmod-option | Y | — | ✓ | -| -A, --acls | acls, acls-depth*new* | Y | — | ✓ | -| -X, --xattrs | xattrs, xattrs-depth*new* | Y | — | ✓ | -| -t, --times | metadata-depth*new* | Y | — | ✓ | -| -U, --atimes | atimes | `~` | — | `~` (same set path as -t, covered deep) | -| --open-noatime | open-noatime | `~` | — | `~` | -| -N, --crtimes | crtimes | `~` | — | `~` (skips without crtimes support) | -| -O, --omit-dir-times | omit-times*new* | Y | — | ✓ | -| -J, --omit-link-times | omit-times*new* | Y | — | ✓ | -| -o, --owner | chown, ownership-depth*new* | Y | — | ✓ uid map root-gated | -| -g, --group | chgrp, ownership-depth*new* | Y | — | ✓ group remap non-root | -| --super / --fake-super | chown, chown-fake | `~` | — | `~` | -| --numeric-ids | — | — | — | ✗ client; daemon `numeric ids` also ✗ | -| --usermap / --groupmap | ownership-depth*new* | Y | — | ✓ groupmap non-root; usermap root-gated | -| --chown | ownership-depth*new* | Y | — | ✓ group half | -| -D / --devices / --specials | devices, devices-fake | `~` | — | `~` root/device-gated | -| --copy-devices / --write-devices | — | — | — | ✗ device-gated | -| -S, --sparse | sparse*new* | Y | — | ✓ hole preserved at depth | - -### Delta / temp / backup / dest (highest restructure risk) -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -T, --temp-dir | temp-dir*new*, chmod-temp-dir | Y | Y | ✓ cross-dir rename | -| --partial | partial*new* | Y | — | ✓ partial kept in dest file | -| --partial-dir | partial*new*, symlink-dirlink-basis | Y | Y | ✓ relative (in-tree) + absolute (outside), incl. delta resume from an absolute outside-tree partial | -| --delay-updates | delay-updates, delay-updates-deep*new* | Y | — | ✓ per-dir staging | -| --inplace | inplace*new*, alt-dest | Y | — | ✓ inode preserved | -| --append / --append-verify | append*new* | Y | — | ✓ verify split is proto 30+ | -| -b, --backup / --backup-dir / --suffix | backup, backup-deep*new* | Y | Y | ✓ | -| --compare-dest / --copy-dest / --link-dest | alt-dest, alt-dest-deep*new* | Y | Y | ✓ link=hardlink, copy=copy, compare=skip | -| -y, --fuzzy | fuzzy | `~` | — | `~` | -| -u, --update | update*new* | Y | — | ✓ keeps newer dest, updates older | -| -W, --whole-file | (used widely; --no-whole-file ubiquitous) | n/a | — | `~` | -| --mkpath | mkpath | `~` | — | `~` | -| -x, --one-file-system | — | — | — | ✗ (needs a mount boundary) | -| --preallocate / --fsync | — | — | — | ✗ | -| -B, --block-size | — | — | — | ✗ | -| --max-alloc | max-alloc-zero | — | — | ✓ zero resolves to each peer's supported ceiling; values above the limit are rejected | - -### Filtering -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -f, --filter / -F | filter-depth*new*, merge | Y | — | ✓ deep per-dir merge | -| --exclude / --include | filter-depth*new*, exclude, exclude-lsh | Y | — | ✓ | -| --exclude-from / --include-from | files-from-depth*new* | Y | — | ✓ | -| -C, --cvs-exclude | cvs-exclude*new* | Y | — | ✓ incl. deep .cvsignore | -| --files-from | files-from-depth*new* | Y | — | ✓ | -| -0, --from0 | files-from-depth*new* | Y | — | ✓ | -| --max-size / --min-size | size-filter*new* | Y | — | ✓ | -| --existing / --ignore-existing | delete-deep*new* | Y | — | ✓ | -| --ignore-missing-args / --delete-missing-args | — | — | — | ✗ | - -### Deletion -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| --delete / --del | delete, delete-deep*new* | Y | — | ✓ deep subtree | -| --delete-before/during/delay/after | delete-deep*new* | Y | — | ✓ all four agree | -| --delete-excluded | delete | `~` | — | `~` | -| --max-delete | delete-deep*new* | Y | — | ✓ caps deletions | -| --remove-source-files | delete | `~` | — | `~` | -| --force | update*new* | Y | — | ✓ replaces a non-empty dir with a file | -| --ignore-errors | — | — | — | ✗ (client; daemon `ignore errors` also ✗) | - -### Comparison / checksum / compression -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -c, --checksum | compare*new* | Y | — | ✓ catches stealth change | -| -I, --ignore-times | compare*new* | Y | — | ✓ | -| --size-only | compare*new* | Y | — | ✓ | -| -@, --modify-window | compare*new* | Y | — | ✓ | -| --checksum-choice / --checksum-seed | compress-options*new* | Y | — | ✓ every advertised algo | -| -z, --compress | daemon-gzip-{up,down}load, daemon-refuse-compress | `~` | — | `~` | -| --compress-choice / --compress-level / --skip-compress | compress-options*new* | Y | — | ✓ | - -### Output / reporting (path-irrelevant — checked for output shape) -| option | test(s) | notes / gap | -|---|---|---| -| -i, --itemize-changes | output-options*new*, itemize | ✓ | -| -n, --dry-run | output-options*new* | ✓ | -| --stats | output-options*new* | ✓ | -| --out-format | output-options*new* | ✓ | -| --list-only | output-options*new* | ✓ | -| -q, --quiet | output-options*new* | ✓ | -| --progress / -P | output-options*new* | ✓ (--progress) | -| -h, --human-readable / -8, --8-bit-output | output-options*new* | ✓ smoke | -| --version / --help | output-options*new* | ✓ | -| --info / --debug / --stderr / --no-motd / --outbuf | — | ✗ | -| -M, --remote-option / --log-file / --log-file-format | — | ✗ (daemon `log file` covered) | - -### Batch / connection / misc -| option | test(s) | notes / gap | -|---|---|---| -| --write-batch / --only-write-batch / --read-batch | batch-mode | `~` | -| -e, --rsh / --rsync-path | ssh-basic, many | `~` | -| --protocol | check29 / check30 (whole suite) | ✓ | -| --address / --port | daemon tests under --use-tcp | `~` | -| --password-file | daemon-auth*new* | ✓ | -| --early-input / daemon `early exec` | — | ✗ | -| --sockopts / --blocking-io / --timeout / --contimeout | — | ✗ | -| -4/-6, --ipv4/--ipv6 | — | ✗ | -| --stop-after / --stop-at | — | ✗ | -| --bwlimit | partial*new* (used, not asserted) | `~` | -| --copy-as | — | ✗ root-gated | -| --iconv | — | ✗ | -| -s/--secluded-args, --old-args, --trust-sender | (default arg-protection exercised) | `~` | - ---- - -## Daemon (rsyncd.conf) parameters - -| parameter | test(s) | notes / gap | -|---|---|---| -| path | daemon-access*new*, all daemon tests | ✓ incl. deep sub-path | -| read only | daemon-access*new*, daemon | ✓ | -| write only | daemon-access*new* | ✓ | -| list | daemon-access*new*, daemon | ✓ hidden-but-usable | -| use chroot | sender-flist-symlink-leak, daemon-chroot-acl | `~` (no=most tests; yes needs root) | -| munge symlinks | daemon-munge*new* | ✓ /rsyncd-munged/ add+strip | -| exclude / include | daemon-filter*new*, daemon | ✓ exclude | -| filter / exclude from / include from | — | ✗ (exclude covers the mechanism) | -| incoming chmod | daemon-filter*new*, chmod-option | ✓ | -| outgoing chmod | daemon-filter*new* | ✓ | -| auth users / secrets file | daemon-auth*new* | ✓ accept/reject/unauth | -| strict modes | daemon-auth*new* | ✓ rejects world-readable secrets | -| refuse options | daemon-refuse*new*, daemon-refuse-compress | ✓ named/wildcard/allow-list | -| pre-xfer exec / post-xfer exec | daemon-exec*new* | ✓ env + abort | -| early exec | — | ✗ (needs --early-input) | -| hosts allow / hosts deny | daemon (allow), daemon-chroot-acl (deny) | `~` (needs --use-tcp for real peer) | -| reverse lookup / forward lookup | daemon-chroot-acl | `~` reverse only | -| log file / transfer logging / log format | daemon | `~` set, not asserted | -| max verbosity | daemon | `~` | -| comment | daemon, daemon-access*new* | ✓ | -| numeric ids | — | ✗ (hard to observe non-root) | -| fake super | chown-fake (client side) | ✗ as daemon param | -| timeout / max connections / lock file | — | ✗ (need --use-tcp + concurrency) | -| temp dir / open noatime / ignore errors / ignore nonreadable | — | ✗ | -| charset / name converter / dont compress | — | ✗ | -| uid / gid / daemon uid / daemon gid / daemon chroot | build_rsyncd_conf (uid/gid when root), daemon-chroot-acl | `~` root-gated | -| motd file / pid file / port / address / socket options / listen backlog / proxy protocol / syslog facility / syslog tag | — | ✗ (server-startup/connection params) | - ---- - -## Known gaps worth a future pass -* Connection/timeout params (`--timeout`, `--contimeout`, daemon `timeout`, - `max connections`) need a real socket + concurrency (run under `--use-tcp`). -* Root-only behaviours (`-o`/`--usermap` uid remap, real devices, `use chroot - = yes`, daemon uid/gid) skip as non-root; run the suite as root to cover. -* `--ignore-errors`, `-x/--one-file-system`, `--numeric-ids` have no dedicated - test yet (lower restructure risk). +Covered means a test directly checks the behaviour. Partial means the option is exercised without covering its whole contract. Missing identifies work still to do. + +Path depth is deep, shallow, untested or N/A. Auxiliary tree is outside, inside only, untested or N/A depending on where the relevant backup, basis, partial or temporary tree is exercised. + +## Recursion and structure + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-a, --archive | many | deep | N/A | Covered throughout the suite +-r, --recursive | basic-transfer, delete-deep | deep | N/A | Covered +-R, --relative | relative-paths, relative-implied | deep | N/A | Covered with implied-directory attributes +--no-implied-dirs | relative-implied | deep | N/A | Covered for protocol 30 and later; protocol 29 rejects the multi-component case +--inc-recursive, --no-inc-recursive, --no-i-r | hardlinks | deep | N/A | Partial: exercised but not isolated +-d, --dirs | nonrecursive-directories | deep | N/A | Covered without recursion +--old-dirs, --old-d | old-dirs | shallow | N/A | Covered in both mixed-version remote directions +-m, --prune-empty-dirs | prune-empty-dirs | deep | N/A | Covered with filter-emptied chains + +## Links + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-l, --links | links, symlink-ignore | deep | N/A | Covered +-L, --copy-links | links | deep | N/A | Covered for file and directory links +-k, --copy-dirlinks | links | deep | N/A | Covered +-K, --keep-dirlinks | symlink-dirlink-basis | deep | N/A | Covered for issue 715; unavailable without the secure path resolver +-H, --hard-links | hardlinks, hardlinks-deep | deep | outside | Covered across directories +--copy-unsafe-links | safe-links | shallow | N/A | Partial +--safe-links | safe-links | shallow | N/A | Partial +--insecure-links | operator-path tests, insecure-links-admin-optout | deep | N/A | Covered for the local opt-out and daemon refusal +--confine-root | filter-file-confinement, relative-source-ancestor, rrsync-merge-file-confine | deep | N/A | Covered for direct arguments, files-from and restricted shells +--munge-links | daemon-munge | N/A | N/A | Partial: daemon behaviour is covered but the client option is not isolated + +## Metadata, permissions and ownership + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-p, --perms | metadata-depth | deep | N/A | Covered with exact per-entry modes +-E, --executability | executability | shallow | N/A | Partial +--chmod | metadata-depth, chmod-option | deep | N/A | Covered +-A, --acls | acls, acls-depth | deep | N/A | Covered when ACL support is available +-X, --xattrs | xattrs, xattrs-depth | deep | N/A | Covered when extended attributes are available +-t, --times | metadata-depth | deep | N/A | Covered +-U, --atimes | atimes | shallow | N/A | Partial +--open-noatime | open-noatime | shallow | N/A | Partial +-N, --crtimes | crtimes | shallow | N/A | Partial and platform-dependent +-O, --omit-dir-times | omit-times | deep | N/A | Covered +-J, --omit-link-times | omit-times | deep | N/A | Covered +-o, --owner | chown, ownership-depth | deep | N/A | Covered with root-gated UID mapping +-g, --group | chgrp, ownership-depth | deep | N/A | Covered with non-root group remapping +--super, --fake-super | chown, chown-fake | shallow | N/A | Partial +--numeric-ids | ownership-depth | deep | N/A | Covered for client UID and GID mapping +--usermap, --groupmap | ownership-depth | deep | N/A | Covered; user mapping needs root +--chown | ownership-depth | deep | N/A | Partial: group handling is covered +-D, --devices, --specials | devices, devices-fake | shallow | N/A | Partial and privilege-dependent +--drop-D | rrsync-specials-denied | N/A | N/A | Covered for restricted receivers +--copy-devices, --write-devices | none | untested | N/A | Missing +-S, --sparse | sparse | deep | N/A | Covered with a hole at depth + +## Delta, temporary, backup and basis paths + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-T, --temp-dir | temp-dir, chmod-temp-dir | deep | outside | Covered across filesystems +--partial | partial | deep | inside only | Covered with a retained destination partial +--partial-dir | partial, symlink-dirlink-basis | deep | outside | Covered for relative and absolute partial directories with delta resume +--delay-updates | delay-updates, delay-updates-deep | deep | inside only | Covered for per-directory staging +--inplace | inplace, alt-dest | deep | inside only | Covered with inode preservation +--append, --append-verify | append | deep | inside only | Covered; the verification split needs protocol 30 or later +-b, --backup, --backup-dir, --suffix | backup, backup-deep | deep | outside | Covered +--compare-dest, --copy-dest, --link-dest | alt-dest, alt-dest-deep, alt-dest-module-escape, operator-path-alt-dest | deep | outside | Covered for skip, copy, hard-link and confinement behaviour +-y, --fuzzy | fuzzy | shallow | N/A | Partial +-u, --update | update | deep | N/A | Covered for newer and older destinations +-W, --whole-file, --no-whole-file | many | N/A | N/A | Partial: widely exercised but not isolated +--mkpath | mkpath | shallow | N/A | Partial +-x, --one-file-system | none | untested | untested | Missing; needs a mount boundary +--preallocate | preallocate | deep | N/A | Covered for allocation and delta updates +--fsync | none | untested | N/A | Missing +-B, --block-size | hashsearch-chain, compress-zlib-insert, preallocate | deep | N/A | Covered +--max-alloc | max-alloc-zero | N/A | N/A | Covered for zero and values above the peer limit + +## Filtering + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-f, --filter, -F | filter-depth, multiple-sources | deep | N/A | Covered for deep per-directory merges +--exclude, --include | filter-depth, exclude, exclude-lsh | deep | N/A | Covered +--exclude-from, --include-from | files-from-depth | deep | N/A | Covered +-C, --cvs-exclude | cvs-exclude | deep | N/A | Covered with nested .cvsignore +--files-from | files-from-depth | deep | N/A | Covered +-0, --from0 | files-from-depth | deep | N/A | Covered +--max-size, --min-size | size-filter | deep | N/A | Covered +--existing, --ignore-non-existing, --ignore-existing | delete-deep | deep | N/A | Covered +--ignore-missing-args | ignore-missing-args | deep | N/A | Covered for direct, remote-shell and files-from inputs +--delete-missing-args | delete-missing-args-files-from | shallow | N/A | Covered with files-from + +## Deletion + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +--delete, --del | delete, delete-deep | deep | N/A | Covered for deep subtrees +--delete-before, --delete-during, --delete-delay, --delete-after | delete-deep | deep | N/A | Covered +--delete-excluded | delete | shallow | N/A | Partial +--max-delete | delete-deep | deep | N/A | Covered +--remove-source-files | delete | shallow | N/A | Partial +--force | update | deep | N/A | Covered when replacing a non-empty directory with a file +--ignore-errors | iconv | N/A | N/A | Covered for deletion after sender input failure + +## Comparison, checksum and compression + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-c, --checksum | comparison-options | deep | N/A | Covered for same-metadata content changes +-I, --ignore-times | comparison-options | deep | N/A | Covered +--size-only | comparison-options | deep | N/A | Covered +-@, --modify-window | comparison-options | deep | N/A | Covered +--checksum-choice, --checksum-seed | compress-options | deep | N/A | Covered for advertised algorithms +-z, --compress | daemon-gzip, daemon-refuse-compress | shallow | N/A | Partial +--compress-choice, --compress-level, --skip-compress | compress-options | deep | N/A | Covered +--compress-threads | daemon-refuse-compress-threads-alias, daemon-zstd-thread-exhaustion | N/A | N/A | Partial: refusal and the daemon worker limit are covered + +## Output and reporting + +Option | Tests | Coverage +--- | --- | --- +-v, --verbose | many | Partial: used widely but verbosity levels are not isolated +-i, --itemize-changes | output-options, itemize | Covered +-n, --dry-run | output-options | Covered +--stats | output-options | Covered +--out-format | output-options | Covered +--list-only | output-options | Covered +-q, --quiet | output-options | Covered +--progress, -P | output-options | Covered for progress output +-h, --human-readable, -8, --8-bit-output | output-options | Covered with format assertions +--version, --help | output-options | Covered +--info, --debug, --stderr, --no-motd, --outbuf | output-options, daemon-module-options | Covered for selection, routing, MOTD suppression and line buffering +-M, --remote-option, --log-file, --log-file-format | remote-logging | Covered for client and remote-shell sender or receiver logs + +## Batch, connection and miscellaneous options + +Option | Tests | Coverage +--- | --- | --- +--write-batch, --only-write-batch, --read-batch | batch-mode | Partial +-e, --rsh, --rsync-path | ssh-basic and many others | Partial +--protocol | check29 and check30 | Covered across the selected suite +--daemon, --config, --detach, --no-detach | daemon-standalone-detach, daemon-stdin tests | Covered for detached, foreground and inherited-socket startup +--dparam | none | Missing +--address, --port | daemon-address-family, daemon-standalone-detach | Covered for IPv4, IPv6 and configured binding +--password-file | daemon-auth | Covered +--early-input | daemon-early-exec-nameconv, early-input-symlink | Covered for data delivery and confined input paths +--sockopts | daemon-module-options | Covered for client and daemon configuration +--blocking-io | none | Missing +--timeout, --contimeout | daemon-handshake-timeout, msg-io-timeout-zero, msg-io-timeout-overflow, contimeout-rsh | Covered for precedence, bounds and remote-shell daemon paths +-4, -6, --ipv4, --ipv6 | daemon-address-family | Covered for daemon binding and client connection +--stop-after, --stop-at | stop-time | Covered for future, past and duration parsing +--bwlimit | partial | Partial: used but not asserted directly +--copy-as | none | Missing and root-gated +--iconv | iconv | Covered for names, arguments, file lists, protocol 30 link targets and invalid input; the raw-byte fixture is unavailable on macOS +-s, --secluded-args, --old-args, --trust-sender | iconv, smoke | Partial: secluded and legacy argument handling are asserted; trust-sender is not isolated + +## Daemon parameters + +Parameter | Tests | Coverage +--- | --- | --- +path | daemon-access and daemon-basics | Covered with nested paths +read only | daemon-access, daemon-basics | Covered +write only | daemon-access | Covered +list | daemon-access, daemon-basics | Covered for hidden but usable modules +use chroot | sender-flist-symlink-leak, daemon-chroot-acl | Partial: the enabled case needs root +insecure links | insecure-links-admin-optout, daemon-symlink-escape-matrix | Covered for the administrative opt-out +munge symlinks | daemon-munge | Covered for prefix addition and removal +exclude, include | daemon-filter, daemon-basics | Covered for exclusion +filter, exclude from, include from | none | Missing as daemon parameters +incoming chmod | daemon-filter, chmod-option | Covered +outgoing chmod | daemon-filter | Covered +auth users, secrets file | daemon-auth | Covered for acceptance and rejection +auth digest | daemon-auth-digest-floor | Covered for minimum strength, old peers and invalid configuration +strict modes | daemon-auth | Covered for unsafe secrets-file modes +refuse options | daemon-refuse, daemon-refuse-compress | Covered for names, wildcards and allow lists +pre-xfer exec, post-xfer exec | daemon-exec | Covered for environment and abort behaviour +early exec | daemon-early-exec-nameconv | Covered for environment and early input +hosts allow, hosts deny | daemon-basics, daemon-chroot-acl | Partial: a real TCP peer is required +reverse lookup, forward lookup | daemon-chroot-acl | Partial: reverse lookup only +log file, transfer logging, log format | daemon-basics | Partial: configured but not asserted +max verbosity | daemon-basics | Partial +comment | daemon-basics, daemon-access | Covered +numeric ids | daemon-early-exec-nameconv, daemon-namecvt tests | Covered for numeric ids = no +fake super | chown-fake, daemon-namecvt-empty-response | Covered +timeout | daemon-handshake-timeout | Covered with zero and precedence +max connections, lock file | daemon-connection-limits | Covered for refusal and slot reuse +temp dir, open noatime, ignore errors, ignore nonreadable | iconv | Partial: ignore errors is asserted +charset | iconv | Covered for module override of the remote charset +name converter | daemon-early-exec-nameconv, daemon-namecvt tests | Covered for success, empty and malformed responses +dont compress | daemon-module-options | Partial: configured but compression choice is not asserted +uid, gid, daemon uid, daemon gid, daemon chroot | build_rsyncd_conf, daemon-chroot-acl | Partial and root-gated +motd file | daemon-module-options | Covered for banner content +socket options | daemon-module-options | Partial: the live socket path runs but kernel effects are not inspected +pid file, port, address | daemon-standalone-detach, daemon-address-family | Covered for configuration and CLI paths +proxy protocol, proxy protocol hosts | daemon-proxy-protocol, proxy-protocol-trusted-peer | Covered for enabled, disabled and trusted-peer policy +listen backlog, syslog facility, syslog tag | none | Missing diff --git a/testsuite/README.md b/testsuite/README.md index 3536a3660..ad51147ef 100644 --- a/testsuite/README.md +++ b/testsuite/README.md @@ -1,304 +1,91 @@ # rsync testsuite -This directory holds rsync's automated regression tests. Ideally every code -change or bug fix comes with a test that would have caught the problem. +Rsync's automated tests live here. Bug fixes **should** include a regression test when practical. -The tests are Python scripts named `testsuite/*_test.py`, driven by the -`runtests.py` harness at the top of the tree (the old shell-based `runtests.sh` -is gone). Shared helpers live in `testsuite/rsyncfns.py`. A handful of C helper -programs (`tls`, `getgroups`, `trimslash`, …) are built alongside `rsync` and -used by some tests. Coverage notes are in [COVERAGE.md](COVERAGE.md). +## Layout +- `testsuite/tests/` groups test scripts by the subsystem they exercise +- `testsuite/runtests.py` is the test runner entry point +- `testsuite/harness/` contains the runner plus shared fixtures, assertions and protocol support +- `testsuite/profiles/` records platform capabilities and peer deviations +- `testsuite/tools/` contains standalone fixture and comparison tools +- [COVERAGE.md](COVERAGE.md) records option and daemon-parameter coverage -## Writing tests - -Favour readability — a test is also documentation of the behaviour it pins, so -prefer clarity over cleverness: - -* When a test writes an `rsyncd.conf`, write it as a triple-quoted f-string so - the actual config is readable top-to-bottom, with module parameters indented - with plain spaces. Don't build it from adjacent string literals full of `\n` - (and `\t`) escapes. The daemon's parser accepts space-indented parameters. -* Better still, use the structured helpers in `rsyncfns.py` when a stock config - will do: `write_daemon_conf(modules, globals)` (per-test modules/params) or - `build_rsyncd_conf()` (the four standard modules). They also handle the - root-only `uid`/`gid` lines for you (needed so a `use chroot = no` daemon run - as root can read a root-owned module). -* For config that varies (e.g. those root-only `uid`/`gid` lines), interpolate a - single optional block that expands when needed and is an empty string - otherwise, rather than splicing pieces together: - - ```python - root = get_testuid() == get_rootuid() - ids = f"uid = {get_rootuid()}\ngid = {get_rootgid()}" if root else "" - conf.write_text(f"""\ - pid file = {base}/rsyncd.pid - use chroot = no - {ids} - log file = {base}/rsyncd.log +Some tests also use C helpers built with rsync. - [m] - path = {mod} - read only = yes - """) - ``` +The test groups are build, daemon, harness, metadata, path, protocol, rrsync and transfer. The group does not form part of a test name. Selectors, profiles and receipts use the filename without a `.py` file ending. Security, compatibility and cost remain test metadata because they apply across those groups. -## Running the tests - -### Via make +## Writing tests -Run from the build directory: +A regression test should assert the behaviour being fixed. A final source and destination comparison can miss the actual bug. -- **`make check`** — build the helper programs and run the whole suite in - parallel (`CHECK_J`, default 8) against the just-built `./rsync`. You do **not** - need `make install` first; indeed you generally should not install before - testing. Use `make check CHECK_J=1` to run serially. -- **`make check29`** / **`make check30`** — the same, forcing protocol version 29 - or 30. -- **`make installcheck`** — run the suite against the *installed* binary (e.g. - `/usr/local/bin/rsync`). Per the GNU standards this does not search `$PATH`. - Handy for testing a distribution build. -- **`make check-progs`** — (re)build just the C helper programs the tests need, - without running anything. -- **`make coverage`** / **`coverage-tcp`** / **`coverage-all`** — generate an HTML - coverage report (needs `./configure --enable-coverage` and `gcovr`); - `coverage-all` merges runs across protocol versions and the tcp transport. +Function-based tests use one `@requires(...)` decorator. Module tests use one top-level `metadata(...)` call. Declare any capability passed to `test_skipped()` and use `require_tcp()` or `require_asan()` for those checks. -### Via runtests.py directly +Use `TestContext` or the paths in `harness.rsync` for scratch data. Do not write into the source tree or use sleeps for synchronisation and timestamp changes. Tests run in parallel by default and must clean up their processes, sockets and temporary files. Use `write_daemon_conf()` or `build_rsyncd_conf()` for ordinary daemon configurations. -`make check` just drives `runtests.py`; run it directly for finer control. It -defaults `--rsync-bin` to `./rsync`, so run it from the build directory (or pass -`--rsync-bin` / `--tooldir`): +## Running tests +Run the standard suite from a configured build directory: ```sh -./runtests.py # all tests -./runtests.py chmod-temp-dir # a single test by name -./runtests.py 'xattr*' # a glob of test names +make check ``` -Useful options: - -- `-j N`, `--parallel N` — run up to N tests at once -- `--use-tcp` — run daemon tests against a real `rsyncd` on `127.0.0.1` (the - default runs them over a stdio pipe). **Read the security warning below before - using this on a shared machine.** -- `--protocol VER` — force a protocol version -- `--preserve-scratch` — keep each test's scratch dir afterwards -- `--log-level N`, `--always-log` — more verbose output / show logs for passing tests too -- `--stop-on-fail` — stop after the first failure -- `--timeout SECS` — per-test timeout (default 300) -- `--timing` — after the run, list the tests by wall-clock, slowest first, with - the serial sum and the floor set by the single longest test -- `--race-timeout SECS` — budget a TOCTOU race test may spend trying to win its - race. These are the suite's slowest tests: a race test is a negative oracle, - so it passes by spending its *whole* budget (5–15s each by default). Lowering - this speeds the suite up and weakens the oracle in equal measure. -- `--valgrind`, `--valgrind-opts OPTS` — run rsync under valgrind -- `--rsync-bin PATH`, `--tooldir DIR`, `--srcdir DIR` — locate the binary / build / source dirs -- `--expect-skipped LIST` — see skip enforcement below - -### Security warning: `--use-tcp` - -> **⚠️ Do not use `--use-tcp` on a machine with untrusted local users.** -> -> `--use-tcp` starts a real `rsync` daemon listening on a loopback TCP port -> (`127.0.0.1` / `::1`) and **deliberately configures insecure test scenarios** -> (daemon modules without authentication, unsafe options enabled, etc.). Loopback -> addresses are reachable by *every* local user, so for as long as the tests run, -> any other user on the machine can connect to that daemon and exploit those -> deliberately-insecure modules — potentially reading or writing files with the -> privileges of the user running the tests (which is **root** if you run the suite -> as root). -> -> Only run `--use-tcp` where there are **no possible local users who might try to -> exploit it** — a single-user workstation or a dedicated, isolated CI machine. -> The default stdio-pipe transport carries no such risk: it talks to the daemon -> over a private pipe with nothing listening on the network, so prefer it on any -> shared or multi-user host. - -### Results and exit codes - -Each test prints one result line — `PASS`, `FAIL`, `ERROR`, `SKIP` (with a -reason), or `XFAIL` (an expected failure) — and the run ends with a -`passed / failed / skipped` summary. Per-test exit-code convention: - -| code | meaning | -|------|---------| -| 0 | pass | -| 1 | fail | -| 2 | error | -| 77 | skip | -| 78 | xfail | - -`runtests.py` exits non-zero if any test fails. Some tests need root or another -precondition and otherwise `SKIP` — read the individual test scripts for details. - -**Skip enforcement:** on a full run, set `RSYNC_EXPECT_SKIPPED=a,b,c` (or -`--expect-skipped a,b,c`) and the run fails if the set of skipped tests does not -match. This is how the CI workflows pin each platform's expected skip set. An -`@FILE` entry reads a skip list (one test per line) instead, and several may be -composed: the workflows use -`@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt`. Keeping the -lists one-name-per-line is what stops two branches that each add a skipping test -from conflicting -- see `testsuite/skiplist/README.md`. - -### Scratch dirs and debugging - -Each test runs in `testtmp//`. On failure the scratch directory is left in -place (also `--preserve-scratch`); including its logs in a bug report is helpful. - -### Preconditions - -You need `python3`, `/bin/sh`, and the normal build toolchain. The ACL/xattr -tests need the `acl` and `attr` tools (`getfacl`/`setfacl`, -`getfattr`/`setfattr`) and skip if they are absent. Some tests need root. - -These tests also run in CI via GitHub Actions (see `.github/workflows/`). - -## Fleet testing (fleettest.py) - -`testsuite/fleettest.py` builds the committed HEAD of an rsync checkout on a -fleet of remote machines over ssh and runs the suite under both transports -(stdio-pipe and `--use-tcp`) in parallel, reporting only the *unexpected* -results. It is a fast local pre-flight for the GitHub CI matrix: each target -mirrors a `.github/workflows/*.yml` job — its configure flags, and the -`RSYNC_EXPECT_SKIPPED` list parsed straight from the workflow. - -Because every run includes a `--use-tcp` pass, the fleet stands up the insecure -loopback test daemon on each target — so only point it at machines with **no -untrusted local users** (see the [security warning](#security-warning---use-tcp) -above). - -The fleet — which machines, and how to reach and build on each — is described in -a JSON file. Copy the bundled example (it is git-ignored) and edit it for your -hosts: - +Other make targets: ```sh -cp testsuite/fleettest.json.example testsuite/fleettest.json # then edit -# (or symlink it, or point elsewhere with --fleet PATH) +make check CHECK_J=1 +make check29 +make check30 +make check-progs +make installcheck +make coverage-all ``` -The config is looked up in order: `~/.fleettest.json` first, then -`testsuite/fleettest.json`, unless overridden with `--fleet PATH`. - -Each entry names an ssh host (`null` to run locally), the workflow it mirrors, -and its configure flags, plus optional per-target settings (`make`, `privilege`, -`env_prefix`, …). See the comments in `fleettest.json.example`. +The runner accepts names and shell patterns: +```sh +./testsuite/runtests.py +./testsuite/runtests.py chmod-temp-dir +./testsuite/runtests.py 'xattr*' +``` -A target with `"nonroot": true` does an extra pass, after the main (root) run, -that reruns the privilege-sensitive tests as the unprivileged ssh user. Which -tests those are is **not** listed in the fleet config — a test opts in by -setting a module-level `fleet_nonroot = True`, so the set is maintained in the -test files and new privilege-sensitive tests join automatically with no -fleet-config change. +The main controls are `-j`, `--rsync-bin`, `--rsync-bin2`, `--protocol`, `--profiles`, `--use-tcp`, `--daemon-tests-only`, `--race-timeout`, `--receipt`, `--describe-tests` and `--valgrind`. Run `./testsuite/runtests.py --help` for the full list. -A target with `"protocols": [30, 29]` runs one extra stdio-pipe pass per listed -version, each forcing that older wire version with `runtests --protocol=N` — the -fleet analogue of a workflow's `check30`/`check29` steps. Each pass takes the -`RSYNC_EXPECT_SKIPPED` spec from the workflow's own `check30`/`check29` step, so -a lane with extra protocol-gated skips (`check29` adds -`@testsuite/skiplist/proto29.txt`) is enforced correctly. They show up as -`protoNN` columns in the report (and `--timing` breakdown); targets that don't -set `protocols` show `-` there. +## TCP daemon mode -Run it from inside a checkout (it builds the current directory's HEAD; use -`--repo PATH` for another tree): +`--use-tcp` starts unauthenticated test daemons on loopback addresses and some fixtures enable unsafe daemon options. Other local users can reach those listeners, so use the default pipe transport on shared hosts. -```sh -python3 testsuite/fleettest.py # whole fleet, both transports -python3 testsuite/fleettest.py --list # list configured targets -python3 testsuite/fleettest.py --targets NAME[,NAME] -python3 testsuite/fleettest.py --fleet other.json --transport pipe -python3 testsuite/fleettest.py --timing # per-target wall-clock breakdown -python3 testsuite/fleettest.py --keep-on-fail # keep logs + tree where it broke -python3 testsuite/fleettest.py --full-tcp # whole suite in the tcp pass too -``` +`--daemon-tests-only` is for a TCP pass that follows a full pipe pass. It omits tests that cannot observe the transport choice. -`--timing` adds a per-target breakdown after the report — total wall-clock plus -the push / build / pipe / tcp / protoNN / nonroot phases, sorted slowest-first. Targets -run in parallel, so the whole run is gated by the slowest one; the phase columns -show whether that target's hold-up is the push, the build, or a test pass. It -also passes `--timing` down to each target's `runtests.py`, so the captured -output attributes a slow pass to individual tests. +## Results and profiles -The `tcp` pass runs **only the tests that can reach the daemon transport**, since -it follows a full pipe pass over the very same build. `--use-tcp` is observable -through exactly one path — `RSYNC_TEST_USE_TCP` is read once in `rsyncfns` -(`USE_TCP`) and acted on once, in `start_test_daemon()` — so a test that never -gets there produces an identical result twice. That drops 186 of the 340 tests -and roughly a third of the pass's work; the count skipped is always printed. -Pass `--full-tcp` to sweep the whole suite there anyway. The narrowing applies -only when both transports run: under `--transport tcp` that pass is the only -one, so it runs the whole suite regardless. +Result | Meaning +--- | --- +PASS | The assertion passed +FAIL | The assertion failed +ERROR | The test environment or harness failed +SKIP | The test did not run +UNSUPPORTED | A declared capability was unavailable +XFAIL | A known defect reproduced +XPASS | A known defect no longer reproduced +PROFILE_ERROR | The result disagreed with the active profile -`--keep-on-fail [DIR]` makes a failure inspectable without repeating the run. -For every target that came back with anything unexpected it writes the full -build and per-transport output to `DIR///` (default -`./fleettest-logs`) and keeps that target's remote run dir, with the scratch -trees its failing tests left behind. Targets that came back clean are swept as -usual. This matters most for the race tests, which may not fail the same way -twice — and because a re-run costs a full configure + build on every machine. +Exit codes are 0 for pass, 1 for fail, 2 for error, 77 for skip and 78 for expected failure. -Each run gets its own randomly-named build dir on every target -(`-`), so two or three runs can share the same fleet without -interfering. The dir is removed when the run ends — on success or failure, and -best-effort on Ctrl-C/kill; pass `--keep` to retain it for inspection. A hard -kill (`SIGKILL`), or a signal arriving mid-push, can leave a stray -`-` behind; sweep leftovers with -`python3 testsuite/fleettest.py --cleanup` (scope it with `--targets`, and only -run it when no other fleet runs are active, since it removes *all* matching run -dirs on the selected targets). +Profiles compose by name. For example `--profiles=linux,peer-3.4.1` combines the Linux capabilities with the known deviations for that peer. Tests tagged `version-mix` are selected from metadata. -Each target must be provisioned with the build toolchain its workflow installs -(autoconf, automake, a C compiler, perl, a python3 markdown module such as -cmarkgfm or commonmark unless the flags pass `--disable-md2man`, and the dev -libraries its configure flags enable). A missing piece shows up as `BUILD-FAIL`. +An unsupported result is accepted only when the test declares the capability and the active profile permits its absence. A generic skip is a profile error. Receipts retain the raw outcome and the profile verdict. -## Differential regression hunting (abdiff.py) +## Scratch data and requirements -`testsuite/abdiff.py` is a developer tool — **not** a `*_test.py`, so `runtests.py` -ignores it. It hunts *regressions* by running the **same benign transfer** with -two rsync binaries (`A` = the build under test, `B` = a baseline) and comparing -the OUTCOME. The oracle is: for a benign input, a correctness/behaviour change -between the builds must be **invisible**, so A and B must produce an identical -result. Any divergence is a regression candidate to investigate and, if real, -minimize into a `*_test.py`. +Tests use `testtmp//`; failed scratch directories remain for inspection. The suite needs Python 3, `/bin/sh` and the normal build toolchain. ACL and extended-attribute tests also need the platform ACL and attr tools. -It compares exit code, stderr (error markers + normalised text), `--stats` -"Literal data", the destination tree (content + full metadata: mode/uid/gid/ -mtime/size/symlink target/xattrs/ACLs/hardlink grouping), the `--itemize` list, -and — with `--cost` — peak process-group RSS (a resource-regression oracle that -functional comparison misses). A **stability gate** runs each binary several -times and escalates on a candidate diff; nondeterministic scenarios are -quarantined `FLAKY`, never reported as regressions. +## Differential testing -Run it from the build directory (so `./rsync` and `old_versions/` resolve): +The version comparison tool runs the same transfer with two rsync binaries and compares the result, diagnostics, file data, metadata and optional peak memory. +Examples: ```sh -testsuite/abdiff.py # default: ./rsync vs old_versions/rsync_3.4.1 -testsuite/abdiff.py --sweep all -j5 # broad single pass, 5-way parallel -testsuite/abdiff.py --loop --timelimit 3600 --cost # hunt for an hour, resource oracle on -testsuite/abdiff.py --list --sweep all # list scenarios without running +python3 -m testsuite.tools.compare_versions +python3 -m testsuite.tools.compare_versions --sweep all -j5 +python3 -m testsuite.tools.compare_versions --loop --timelimit 3600 --cost ``` -Each finding is classed `DIFF` (regression candidate), `ALLOW` (an intentional, -documented behaviour change listed in the tool's allowlist), `BETTER` (A succeeds -where B fails), `FLAKY`, or `TIMEOUT`. Findings are printed and appended to a -per-run `abdiff-log_