From 89f23932a366438332cc2ed187698eb06402149e Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 15 Aug 2026 17:10:50 +1000 Subject: [PATCH 01/27] ci: consolidate Ubuntu workflows --- .github/workflows/ubuntu-22.04-build.yml | 70 ------------------------ .github/workflows/ubuntu-build.yml | 54 ++++++++++++++++-- testsuite/fleettest.json.example | 10 ++-- testsuite/fleettest.py | 10 ++-- 4 files changed, 59 insertions(+), 85 deletions(-) delete mode 100644 .github/workflows/ubuntu-22.04-build.yml 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..30c1ccee0 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -11,17 +11,31 @@ 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' 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 + - runner: ubuntu-22.04 + name: Test rsync on Ubuntu 22.04 + artifact: ubuntu-22.04-bin + nonroot: false + install_smoke: false + runs-on: ${{ matrix.runner }} + name: ${{ matrix.name }} steps: - uses: actions/checkout@v4 with: @@ -35,6 +49,35 @@ 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 @@ -52,6 +95,7 @@ jobs: # is env-dependent here (chroot-acl), so leave RSYNC_EXPECT_SKIPPED unset. run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 - name: check (non-root, targeted) + if: matrix.nonroot # 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 @@ -73,7 +117,7 @@ jobs: uses: actions/upload-artifact@v4 with: retention-days: 45 - name: ubuntu-bin + name: ${{ matrix.artifact }} path: | rsync rsync-ssl diff --git a/testsuite/fleettest.json.example b/testsuite/fleettest.json.example index d8b3b299b..e697a630c 100644 --- a/testsuite/fleettest.json.example +++ b/testsuite/fleettest.json.example @@ -95,10 +95,10 @@ ] }, { - "_comment": "Ubuntu 18.04 older-LTS backport coverage on a root@ box; no 18.04 runner image exists so it mirrors the 22.04 workflow.", + "_comment": "Ubuntu 18.04 older-LTS backport coverage on a root@ box; no 18.04 runner image exists so it mirrors the 22.04 matrix lane.", "name": "ubuntu-1804", "ssh_host": "root@ubuntu-1804", - "workflow": "ubuntu-22.04-build.yml", + "workflow": "ubuntu-build.yml", "_python_comment": "18.04's default python3 is 3.6, but runtests.py uses subprocess capture_output (3.7+); run the suite under the 3.10 that's also installed.", "python": "python3.10", "_configure_flags_comment": "18.04's libzstd 1.3.3 lacks ZSTD_minCLevel (configure aborts), so disable zstd; also disable lz4 so the default -z compressor is zlib -- with lz4 as the default, compress-options' --compress-level=9 check fails since lz4 has no levels. xxhash is fine.", @@ -113,10 +113,10 @@ ] }, { - "_comment": "Ubuntu 20.04 older-LTS backport coverage on a root@ box; no 20.04 runner image exists so it mirrors the 22.04 workflow.", + "_comment": "Ubuntu 20.04 older-LTS backport coverage on a root@ box; no 20.04 runner image exists so it mirrors the 22.04 matrix lane.", "name": "ubuntu-2004", "ssh_host": "root@ubuntu-2004", - "workflow": "ubuntu-22.04-build.yml", + "workflow": "ubuntu-build.yml", "configure_flags": [ "--with-rrsync" ] @@ -125,7 +125,7 @@ "_comment": "Builds unprivileged (like a CI runner) and runs the suite via sudo; the nonroot pass reruns the privilege-sensitive tests as the ssh user. protocols: [30, 29] runs the check30/check29 passes on this NO-xattrat kernel: the CI's proto-29 step runs here, but the only other proto-29 fleet box (ubuntu-2604) is xattrat, so without this the (no-xattrat x proto-29) cell is uncovered.", "name": "ubuntu-2204", "ssh_host": "runner@ubuntu-2204", - "workflow": "ubuntu-22.04-build.yml", + "workflow": "ubuntu-build.yml", "privilege": "sudo", "nonroot": true, "protocols": [ diff --git a/testsuite/fleettest.py b/testsuite/fleettest.py index 154514c89..9ccc7db3c 100755 --- a/testsuite/fleettest.py +++ b/testsuite/fleettest.py @@ -6,10 +6,10 @@ --use-tcp) in parallel, and prints one report of only the UNEXPECTED results -- a fast local pre-flight for the GitHub CI matrix. -Each target maps 1:1 to a .github/workflows/*.yml job: the per-target configure -flags mirror that workflow, and the pipe-run RSYNC_EXPECT_SKIPPED list is PARSED -from the workflow (not hardcoded). The --use-tcp run never sets an expected-skip -list (matching the workflows), so only test FAILs matter there. +Each target maps to a .github/workflows/*.yml job or matrix lane: the per-target +configure flags mirror that lane, and the pipe-run RSYNC_EXPECT_SKIPPED list is +PARSED from the workflow (not hardcoded). The --use-tcp run never sets an +expected-skip list (matching the workflows), so only test FAILs matter there. The tcp pass runs only the tests that can reach the daemon transport, because it follows a full pipe pass over the very same build: --use-tcp is observable only @@ -167,7 +167,7 @@ class Target: name: str ssh_host: str | None # null in JSON => run locally - workflow: str # filename under .github/workflows + workflow: str # workflow containing the matching job or matrix lane configure_flags: list[str] make: str = "make" # e.g. "gmake" on the BSDs/Solaris env_prefix: str = "" # exported before configure AND make (e.g. PATH) From 8b818e0c6af7b41d00a19a07040f0ce3224cd3ba Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 15 Aug 2026 17:15:49 +1000 Subject: [PATCH 02/27] ci: restrict workflow permissions --- .github/workflows/almalinux-8-build.yml | 3 +++ .github/workflows/android-static-build.yml | 3 +++ .github/workflows/asan-build.yml | 3 +++ .github/workflows/coverage.yml | 3 +++ .github/workflows/cygwin-build.yml | 3 +++ .github/workflows/fleettest.yml | 3 +++ .github/workflows/freebsd-build.yml | 3 +++ .github/workflows/macos-build.yml | 3 +++ .github/workflows/netbsd-build.yml | 3 +++ .github/workflows/openbsd-build.yml | 3 +++ .github/workflows/scan-build.yml | 3 +++ .github/workflows/solaris-build.yml | 3 +++ .github/workflows/ubuntu-build.yml | 3 +++ .github/workflows/ubuntu-version-mix.yml | 3 +++ .github/workflows/valgrind.yml | 3 +++ 15 files changed, 45 insertions(+) diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index 8eb4598c3..98d2bf887 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -24,6 +24,9 @@ on: schedule: - cron: '42 8 * * *' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/android-static-build.yml b/.github/workflows/android-static-build.yml index 14593a6dc..dd26f5099 100644 --- a/.github/workflows/android-static-build.yml +++ b/.github/workflows/android-static-build.yml @@ -28,6 +28,9 @@ 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. diff --git a/.github/workflows/asan-build.yml b/.github/workflows/asan-build.yml index 246a31f9d..10acbe7cb 100644 --- a/.github/workflows/asan-build.yml +++ b/.github/workflows/asan-build.yml @@ -23,6 +23,9 @@ on: - cron: '42 9 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: asan: runs-on: ubuntu-latest diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6e0eccaff..23725f364 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,6 +19,9 @@ on: - cron: '42 9 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: coverage: runs-on: ubuntu-latest diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index b82e18b12..0b62a7318 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -18,6 +18,9 @@ on: schedule: - cron: '42 8 * * *' +permissions: + contents: read + jobs: test: runs-on: windows-2022 diff --git a/.github/workflows/fleettest.yml b/.github/workflows/fleettest.yml index 40f6f8fc6..a782b87a9 100644 --- a/.github/workflows/fleettest.yml +++ b/.github/workflows/fleettest.yml @@ -31,6 +31,9 @@ on: schedule: - cron: '17 7 * * 1' +permissions: + contents: read + jobs: fleettest: runs-on: ubuntu-latest diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index fe87913f5..b446ad1eb 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -18,6 +18,9 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index b20638a8f..ca3e514e6 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -18,6 +18,9 @@ on: schedule: - cron: '42 8 * * *' +permissions: + contents: read + jobs: test: runs-on: macos-latest diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml index 4d2463b78..8525c70c8 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -18,6 +18,9 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 6218b9a24..9d594bb50 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -18,6 +18,9 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index 6aafaa12e..033935f2d 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -17,6 +17,9 @@ 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 diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml index 1b328e4a1..fcdab467c 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -18,6 +18,9 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 30c1ccee0..762ac9eff 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -18,6 +18,9 @@ on: schedule: - cron: '42 8 * * 1' +permissions: + contents: read + jobs: test: strategy: diff --git a/.github/workflows/ubuntu-version-mix.yml b/.github/workflows/ubuntu-version-mix.yml index d16dd09dd..6eae85769 100644 --- a/.github/workflows/ubuntu-version-mix.yml +++ b/.github/workflows/ubuntu-version-mix.yml @@ -39,6 +39,9 @@ on: schedule: - cron: '52 8 * * 1' +permissions: + contents: read + jobs: version-mix: runs-on: ubuntu-latest diff --git a/.github/workflows/valgrind.yml b/.github/workflows/valgrind.yml index 33be5f102..2c22bb8dc 100644 --- a/.github/workflows/valgrind.yml +++ b/.github/workflows/valgrind.yml @@ -19,6 +19,9 @@ on: - cron: '17 4 * * *' workflow_dispatch: +permissions: + contents: read + jobs: memcheck: runs-on: ubuntu-latest From c60276dc413ac31094a94308b58afff468771396 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 15 Aug 2026 17:52:58 +1000 Subject: [PATCH 03/27] ci: bound workflow runtimes --- .github/workflows/actionlint.yml | 1 + .github/workflows/almalinux-8-build.yml | 1 + .github/workflows/android-static-build.yml | 1 + .github/workflows/asan-build.yml | 1 + .github/workflows/coverage.yml | 1 + .github/workflows/cygwin-build.yml | 1 + .github/workflows/fleettest.yml | 1 + .github/workflows/freebsd-build.yml | 1 + .github/workflows/macos-build.yml | 1 + .github/workflows/netbsd-build.yml | 1 + .github/workflows/openbsd-build.yml | 1 + .github/workflows/scan-build.yml | 2 ++ .github/workflows/solaris-build.yml | 1 + .github/workflows/ubuntu-build.yml | 1 + .github/workflows/ubuntu-version-mix.yml | 1 + .github/workflows/valgrind.yml | 2 +- 16 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 8d30e4096..d974544c7 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -30,6 +30,7 @@ permissions: jobs: actionlint: runs-on: ubuntu-latest + timeout-minutes: 15 name: actionlint steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index 98d2bf887..5e65e7fdc 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -30,6 +30,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 container: image: almalinux:8 name: Test rsync on AlmaLinux 8 diff --git a/.github/workflows/android-static-build.yml b/.github/workflows/android-static-build.yml index dd26f5099..0e231e004 100644 --- a/.github/workflows/android-static-build.yml +++ b/.github/workflows/android-static-build.yml @@ -39,6 +39,7 @@ env: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 45 name: ${{ matrix.abi }} strategy: fail-fast: false diff --git a/.github/workflows/asan-build.yml b/.github/workflows/asan-build.yml index 10acbe7cb..fc955619c 100644 --- a/.github/workflows/asan-build.yml +++ b/.github/workflows/asan-build.yml @@ -29,6 +29,7 @@ permissions: 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 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 23725f364..26c8f3ad3 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -25,6 +25,7 @@ permissions: jobs: coverage: runs-on: ubuntu-latest + timeout-minutes: 45 name: gcov coverage steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 0b62a7318..eae9474f6 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -24,6 +24,7 @@ permissions: jobs: test: runs-on: windows-2022 + timeout-minutes: 60 name: Test rsync on Cygwin steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/fleettest.yml b/.github/workflows/fleettest.yml index a782b87a9..856607e92 100644 --- a/.github/workflows/fleettest.yml +++ b/.github/workflows/fleettest.yml @@ -37,6 +37,7 @@ permissions: jobs: fleettest: runs-on: ubuntu-latest + timeout-minutes: 45 name: fleettest against localhost steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index b446ad1eb..0c6f674b8 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -24,6 +24,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on FreeBSD steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index ca3e514e6..fc1aff5c5 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -24,6 +24,7 @@ permissions: jobs: test: runs-on: macos-latest + timeout-minutes: 45 name: Test rsync on macOS steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml index 8525c70c8..cf69ace61 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -24,6 +24,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on NetBSD steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 9d594bb50..bda7086c1 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -24,6 +24,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on OpenBSD steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index 033935f2d..2d6464034 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -28,6 +28,7 @@ jobs: # and the runner (ubuntu-24.04, whose apt repos carry those packages). gate-clang18: runs-on: ubuntu-24.04 + timeout-minutes: 45 name: scan-build gate (clang-18, pinned) steps: - uses: actions/checkout@v4 @@ -69,6 +70,7 @@ jobs: # broken run from affecting the workflow's required status. informational-latest: runs-on: ubuntu-latest + timeout-minutes: 45 name: scan-build (latest clang, informational) continue-on-error: true steps: diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml index fcdab467c..240d96775 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -24,6 +24,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 45 name: Test rsync on Solaris steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 762ac9eff..a1937ebe2 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -38,6 +38,7 @@ jobs: nonroot: false install_smoke: false runs-on: ${{ matrix.runner }} + timeout-minutes: 45 name: ${{ matrix.name }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ubuntu-version-mix.yml b/.github/workflows/ubuntu-version-mix.yml index 6eae85769..3d3e7ce78 100644 --- a/.github/workflows/ubuntu-version-mix.yml +++ b/.github/workflows/ubuntu-version-mix.yml @@ -45,6 +45,7 @@ permissions: jobs: version-mix: runs-on: ubuntu-latest + timeout-minutes: 45 name: rsync version-mix steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/valgrind.yml b/.github/workflows/valgrind.yml index 2c22bb8dc..5ae9b0406 100644 --- a/.github/workflows/valgrind.yml +++ b/.github/workflows/valgrind.yml @@ -25,7 +25,7 @@ permissions: jobs: memcheck: runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 60 strategy: fail-fast: false matrix: From bf25c07e0aca78bbb2b74c2486f9e16514cfe12f Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 15:27:52 +1000 Subject: [PATCH 04/27] ci: pin workflows --- .github/workflows/actionlint.yml | 15 +++++++++------ .github/workflows/almalinux-8-build.yml | 5 +++-- .github/workflows/android-static-build.yml | 5 +++-- .github/workflows/asan-build.yml | 3 ++- .github/workflows/coverage.yml | 5 +++-- .github/workflows/cygwin-build.yml | 5 +++-- .github/workflows/fleettest.yml | 3 ++- .github/workflows/freebsd-build.yml | 7 ++++--- .github/workflows/macos-build.yml | 5 +++-- .github/workflows/netbsd-build.yml | 7 ++++--- .github/workflows/openbsd-build.yml | 7 ++++--- .github/workflows/scan-build.yml | 10 ++++++---- .github/workflows/solaris-build.yml | 7 ++++--- .github/workflows/ubuntu-build.yml | 5 +++-- .github/workflows/ubuntu-version-mix.yml | 3 ++- .github/workflows/valgrind.yml | 5 +++-- 16 files changed, 58 insertions(+), 39 deletions(-) diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index d974544c7..188481ed5 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -33,14 +33,17 @@ jobs: 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 5e65e7fdc..10d74590c 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -38,9 +38,10 @@ jobs: - 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 @@ -78,7 +79,7 @@ jobs: - 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 0e231e004..4e34a00ec 100644 --- a/.github/workflows/android-static-build.yml +++ b/.github/workflows/android-static-build.yml @@ -52,9 +52,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 @@ -122,7 +123,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 fc955619c..80a928fb5 100644 --- a/.github/workflows/asan-build.yml +++ b/.github/workflows/asan-build.yml @@ -43,9 +43,10 @@ jobs: # 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 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 26c8f3ad3..e4d86e925 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -28,9 +28,10 @@ jobs: 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 @@ -71,7 +72,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 eae9474f6..16e137374 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -27,9 +27,10 @@ jobs: 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' @@ -99,7 +100,7 @@ jobs: - 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 index 856607e92..293f37378 100644 --- a/.github/workflows/fleettest.yml +++ b/.github/workflows/fleettest.yml @@ -40,9 +40,10 @@ jobs: timeout-minutes: 45 name: fleettest against localhost 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 diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index 0c6f674b8..8e865859b 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -27,12 +27,13 @@ jobs: 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: Test in FreeBSD VM id: test - uses: vmactions/freebsd-vm@v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: usesh: true prepare: | @@ -48,7 +49,7 @@ jobs: ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./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: freebsd-bin diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index fc1aff5c5..3e6bcb521 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -27,9 +27,10 @@ jobs: 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 @@ -60,7 +61,7 @@ jobs: - 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 cf69ace61..9310b5b3c 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -27,12 +27,13 @@ jobs: 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: Test in NetBSD VM id: test - uses: vmactions/netbsd-vm@v1 + uses: vmactions/netbsd-vm@c8a0d7ddb84619a7a8cc4e652efa7950b3fd9f92 # v1.5.2 with: usesh: true prepare: | @@ -47,7 +48,7 @@ jobs: ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./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: netbsd-bin diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index bda7086c1..97041662e 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -27,12 +27,13 @@ jobs: 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: Test in OpenBSD VM id: test - uses: vmactions/openbsd-vm@v1 + uses: vmactions/openbsd-vm@5f7b2c933b8846a138361d6843c52af4bef5d945 # v1.4.8 with: usesh: true prepare: | @@ -63,7 +64,7 @@ jobs: ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 2 ./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: openbsd-bin diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index 2d6464034..021cf5ef0 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -31,9 +31,10 @@ jobs: 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 @@ -55,7 +56,7 @@ 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 @@ -74,9 +75,10 @@ jobs: 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 @@ -90,7 +92,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 240d96775..78a75809f 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -27,12 +27,13 @@ jobs: 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: Test in Solaris VM id: test - uses: vmactions/solaris-vm@v1 + uses: vmactions/solaris-vm@87d3c436511cef802cd1637f86f73b1a97715c8c # v1.4.1 with: usesh: true prepare: | @@ -46,7 +47,7 @@ jobs: ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./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: solaris-bin diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index a1937ebe2..999b41d82 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -41,9 +41,10 @@ jobs: 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 @@ -118,7 +119,7 @@ jobs: - 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: ${{ matrix.artifact }} diff --git a/.github/workflows/ubuntu-version-mix.yml b/.github/workflows/ubuntu-version-mix.yml index 3d3e7ce78..5717c86db 100644 --- a/.github/workflows/ubuntu-version-mix.yml +++ b/.github/workflows/ubuntu-version-mix.yml @@ -48,9 +48,10 @@ jobs: 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 diff --git a/.github/workflows/valgrind.yml b/.github/workflows/valgrind.yml index 5ae9b0406..4a1984c50 100644 --- a/.github/workflows/valgrind.yml +++ b/.github/workflows/valgrind.yml @@ -33,9 +33,10 @@ jobs: transport: [ pipe, tcp ] name: memcheck (${{ 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 @@ -95,7 +96,7 @@ jobs: - name: upload valgrind logs on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: valgrind-logs-${{ matrix.privilege }}-${{ matrix.transport }} path: testtmp/**/valgrind.*.log From 52cf566e09a5fa48f47af287f116a51790316e99 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 15:32:10 +1000 Subject: [PATCH 05/27] testsuite: stabilise daemon argv rejection --- testsuite/daemon-argv-limit_test.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/testsuite/daemon-argv-limit_test.py b/testsuite/daemon-argv-limit_test.py index 58e1ad615..bf1c470c3 100644 --- a/testsuite/daemon-argv-limit_test.py +++ b/testsuite/daemon-argv-limit_test.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import socket +import time from rsyncfns import SCRATCHDIR, claim_ports, require_tcp, start_rsyncd, test_fail @@ -23,6 +24,7 @@ start_rsyncd(conf, PORT) with socket.create_connection(('127.0.0.1', PORT), timeout=5) as s: + s.settimeout(30) greeting = s.recv(4096) if not greeting.startswith(b'@RSYNCD:'): test_fail(f"unexpected daemon greeting: {greeting!r}") @@ -35,12 +37,18 @@ payload = b''.join(b'a%05d\0' % i for i in range(17000)) + b'\0' s.sendall(payload) try: - s.recv(4096) - except (ConnectionResetError, BrokenPipeError): + s.shutdown(socket.SHUT_WR) + except OSError: pass -text = log.read_text(errors='replace') -if 'too many daemon arguments' not in text: - test_fail("daemon did not reject the malicious client's oversized argv list") +deadline = time.monotonic() + 30 +while time.monotonic() < deadline: + if log.exists() and 'too many daemon arguments' in log.read_text(errors='replace'): + break + time.sleep(0.05) +else: + text = log.read_text(errors='replace') if log.exists() else '' + test_fail("daemon did not reject the malicious client's oversized argv " + f"list. Daemon log:\n{text}") print("daemon-argv-limit: malicious client argv flood is rejected") From 1c3bddf2dfc9f82d81a41be26f5c628d481de056 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 15:46:59 +1000 Subject: [PATCH 06/27] testsuite: stabilise legacy peer symlink times --- testsuite/ssh-basic_test.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/testsuite/ssh-basic_test.py b/testsuite/ssh-basic_test.py index b41b0ff1c..08c9b88cc 100644 --- a/testsuite/ssh-basic_test.py +++ b/testsuite/ssh-basic_test.py @@ -7,13 +7,16 @@ # a follow-up --delete pass cleans up after a destination-side rename. import os +import re import shlex import shutil import subprocess +import rsyncfns from rsyncfns import ( - FROMDIR, SRCDIR, TODIR, + FROMDIR, RSYNC, RSYNC_PEER, SRCDIR, TODIR, checkit, hands_setup, runtest, test_skipped, rsync_path_arg, rsh_cmd, + split_rsync_cmd, ) @@ -38,10 +41,26 @@ print(f"Using remote shell: {SSH}") +# Rsync 2.x cannot negotiate setting symlink mtimes. Comparing them only +# passes when both links happen to be created in the same second. +compare_link_times = '-l' in shlex.split(rsyncfns.TLS_ARGS) +peer_version = subprocess.run( + [*split_rsync_cmd(RSYNC_PEER), '--version'], + capture_output=True, text=True, +) +match = re.search(r'rsync\s+version\s+(\d+)\.(\d+)\.(\d+)', + peer_version.stdout) +if peer_version.returncode == 0 and match and int(match.group(1)) < 3: + rsyncfns.TLS_ARGS = ' '.join( + arg for arg in shlex.split(rsyncfns.TLS_ARGS) if arg != '-l' + ) + hands_setup() +if compare_link_times: + os.utime(FROMDIR / 'nolf-symlink', (1_000_000_000, 1_000_000_000), + follow_symlinks=False) # RSYNC may be a multi-word command line; pass it through --rsync-path. -from rsyncfns import RSYNC, RSYNC_PEER def _basic(): From 96155abbc53c8b7b5ceb976a310aa472e85d3e10 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 15:58:23 +1000 Subject: [PATCH 07/27] testsuite: make timestamp fixtures deterministic --- testsuite/alt-dest_test.py | 7 +++--- testsuite/chgrp_test.py | 2 -- testsuite/devices_test.py | 11 +++++++-- testsuite/fuzzy_test.py | 3 --- testsuite/itemize_test.py | 17 ++++++++++---- testsuite/merge_test.py | 4 ---- testsuite/relative_test.py | 3 --- testsuite/symlink-dirlink-basis_test.py | 30 +++++++++++-------------- 8 files changed, 38 insertions(+), 39 deletions(-) diff --git a/testsuite/alt-dest_test.py b/testsuite/alt-dest_test.py index 19bce222e..6430339b1 100644 --- a/testsuite/alt-dest_test.py +++ b/testsuite/alt-dest_test.py @@ -8,7 +8,6 @@ import os import shutil -import time from rsyncfns import ( CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, @@ -36,9 +35,9 @@ alt3dir.mkdir() (alt3dir / 'likely').write_text("This is a test file\n") -time.sleep(1) -os.utime(FROMDIR / 'dir' / 'text') -os.utime(FROMDIR / 'likely') +for path in (FROMDIR / 'dir' / 'text', FROMDIR / 'likely'): + st = path.stat() + os.utime(path, (st.st_atime, st.st_mtime + 10)) # chkdir: what a vanilla copy would produce, minus /text and etc-ltr-list. run_rsync('-av', '--exclude=/text', '--exclude=etc-ltr-list', diff --git a/testsuite/chgrp_test.py b/testsuite/chgrp_test.py index ad20fc0e7..5d43c223e 100644 --- a/testsuite/chgrp_test.py +++ b/testsuite/chgrp_test.py @@ -33,6 +33,4 @@ if proc.returncode != 0: test_fail("Can't chgrp") -time.sleep(2) - checkit(['-rtgpvvv', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) diff --git a/testsuite/devices_test.py b/testsuite/devices_test.py index 40c9419a9..db4da0b19 100644 --- a/testsuite/devices_test.py +++ b/testsuite/devices_test.py @@ -109,12 +109,19 @@ def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool: checkdiff(['-ai', f'{FROMDIR}/block2', f'{TODIR}/block'], f"cD{all_plus} block2\n") -import time -time.sleep(1) +ref = (TODIR / 'block').stat() +os.utime(FROMDIR / 'block3', (ref.st_atime, ref.st_mtime + 10), + follow_symlinks=False) checkdiff(['-Di', f'{FROMDIR}/block3', f'{TODIR}/block'], f"cDc.T.{dots} block3\n") +for source, destination in ((FROMDIR / 'block', TODIR / 'block'), + (FROMDIR, TODIR)): + st = source.stat() + os.utime(destination, (st.st_atime, st.st_mtime + 10), + follow_symlinks=False) + # Build the expected -aiHvv listing. chkfile_lines = [ f".d..t.{dots} ./", diff --git a/testsuite/fuzzy_test.py b/testsuite/fuzzy_test.py index 03c57301b..7cbec0e5a 100644 --- a/testsuite/fuzzy_test.py +++ b/testsuite/fuzzy_test.py @@ -6,8 +6,6 @@ # instead of re-transferring (and --delete-delay still removes the stale # basis file at the end). -import time - from rsyncfns import ( FROMDIR, SRCDIR, TODIR, cp_p, cp_touch, run_rsync, test_fail, verify_dirs, ) @@ -18,7 +16,6 @@ cp_p(SRCDIR / 'rsync.c', FROMDIR / 'rsync.c') cp_touch(FROMDIR / 'rsync.c', TODIR / 'rsync2.c') -time.sleep(1) # Drive rsync directly (rather than checkit) so we can capture --debug=FUZZY: # a final tree match alone would also be produced by a full transfer that diff --git a/testsuite/itemize_test.py b/testsuite/itemize_test.py index b6a4e8951..6d5539f0a 100644 --- a/testsuite/itemize_test.py +++ b/testsuite/itemize_test.py @@ -92,14 +92,19 @@ # Re-touch dirs, permute config2 again and replace the symlink target. run_rsync('-a', '-f', '-! */', f'{FROMDIR}/', str(TODIR)) cp_p(SRCDIR / 'config.sub', FROMDIR / 'foo' / 'config2') -import time -time.sleep(1) # to provoke a directory mtime change below (TODIR / 'foo' / 'sym').unlink() old_umask = os.umask(0) try: os.symlink('../bar/baz', TODIR / 'foo' / 'sym') finally: os.umask(old_umask) +src_foo = (FROMDIR / 'foo').stat() +os.utime(TODIR / 'foo', (src_foo.st_atime, src_foo.st_mtime + 10)) +if symtimes_supported: + src_sym = os.stat(FROMDIR / 'foo' / 'sym', follow_symlinks=False) + os.utime(TODIR / 'foo' / 'sym', + (src_sym.st_atime, src_sym.st_mtime + 10), + follow_symlinks=False) os.chmod(FROMDIR / 'foo' / 'config2', 0o600) os.chmod(TODIR / 'bar' / 'baz' / 'rsync', 0o777) @@ -141,13 +146,17 @@ # Permute one perm and re-touch a file; expect just those two itemizes. os.chmod(TODIR / 'foo' / 'config1', 0o757) -(TODIR / 'foo' / 'config2').touch() +src_config2 = (FROMDIR / 'foo' / 'config2').stat() +os.utime(TODIR / 'foo' / 'config2', + (src_config2.st_atime, src_config2.st_mtime + 10)) checkdiff(['-vplrtH', f'{FROMDIR}/', f'{TODIR}/'], "foo/config2\n", filter=v_filt) os.chmod(TODIR / 'foo' / 'config1', 0o757) -(TODIR / 'foo' / 'config2').touch() +src_config2 = (FROMDIR / 'foo' / 'config2').stat() +os.utime(TODIR / 'foo' / 'config2', + (src_config2.st_atime, src_config2.st_mtime + 10)) checkdiff(['-iplrtH', f'{FROMDIR}/', f'{TODIR}/'], f".f...p{dots} foo/config1\n" f">f..t.{dots} foo/config2\n") diff --git a/testsuite/merge_test.py b/testsuite/merge_test.py index 069a74bbe..dc04609da 100644 --- a/testsuite/merge_test.py +++ b/testsuite/merge_test.py @@ -7,7 +7,6 @@ # the canonical case. import os -import time from rsyncfns import ( CHKDIR, TMPDIR, TODIR, @@ -71,9 +70,6 @@ cp_touch(src, str(CHKDIR / 'sub1')) cp_touch('from3/sub2/subby', str(CHKDIR / 'sub2')) -# Make sure time has moved on before the rsync runs. -time.sleep(1) - # Pre-sync directory-only updates to flatten directory-time differences, # matching the shell test's --existing -f 'exclude,! */' preparation. def _flatten_dirs(src, dst): diff --git a/testsuite/relative_test.py b/testsuite/relative_test.py index 123189c12..39639c433 100644 --- a/testsuite/relative_test.py +++ b/testsuite/relative_test.py @@ -8,7 +8,6 @@ import os import subprocess -import time from rsyncfns import ( CHKDIR, FROMDIR, OUTFILE, TMPDIR, TODIR, @@ -52,8 +51,6 @@ run_rsync('-ai', '--include=/down/', '--exclude=/*', f'{FROMDIR}/', f'{CHKDIR}/') -time.sleep(1) - print("Test basic relative:") checkit(['-avR', f'./{deepstr}', str(TODIR)], CHKDIR, TODIR) diff --git a/testsuite/symlink-dirlink-basis_test.py b/testsuite/symlink-dirlink-basis_test.py index ffe368e27..c059a2102 100644 --- a/testsuite/symlink-dirlink-basis_test.py +++ b/testsuite/symlink-dirlink-basis_test.py @@ -15,7 +15,6 @@ import filecmp import os import subprocess -import time from rsyncfns import ( RSYNC, SCRATCHDIR, RSYNC_PEER, SRCDIR, TMPDIR, @@ -42,6 +41,11 @@ def make_testfile(path) -> None: make_data_file(path, 32768) +def advance_mtime(path) -> None: + st = path.stat() + os.utime(path, (st.st_atime, st.st_mtime + 10)) + + def push(*args, label: str) -> None: # --rsync-path goes BEFORE the positional source/dest args, matching # the shell test's order. With it at the end rsync mis-classifies @@ -79,8 +83,7 @@ def assert_same(label: str, a, b) -> None: # Trigger delta transfer. with open(srcbase / 'dir' / 'file', 'ab') as f: f.write(b"appended update\n") -time.sleep(1) -(srcbase / 'dir' / 'file').touch() +advance_mtime(srcbase / 'dir' / 'file') push('-KRlptv', 'dir/file', 'localhost:', label="test 1 update") assert_same("test 1 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file') @@ -89,8 +92,7 @@ def assert_same(label: str, a, b) -> None: # Test 2: compression. with open(srcbase / 'dir' / 'file', 'ab') as f: f.write(b"another line\n") -time.sleep(1) -(srcbase / 'dir' / 'file').touch() +advance_mtime(srcbase / 'dir' / 'file') push('-KRlptzv', 'dir/file', 'localhost:', label="test 2") assert_same("test 2", srcbase / 'dir' / 'file', home / 'real-dir' / 'file') @@ -107,8 +109,7 @@ def assert_same(label: str, a, b) -> None: with open(srcbase / 'nested' / 'sub' / 'data.txt', 'ab') as f: f.write(b"appended nested\n") -time.sleep(1) -(srcbase / 'nested' / 'sub' / 'data.txt').touch() +advance_mtime(srcbase / 'nested' / 'sub' / 'data.txt') push('-KRlptv', 'nested/sub/data.txt', 'localhost:', label="test 3 update") assert_same("test 3 update", @@ -126,8 +127,7 @@ def assert_same(label: str, a, b) -> None: old_content = (srcbase / 'dir' / 'file').read_bytes() # the backup should hold this with open(srcbase / 'dir' / 'file', 'ab') as f: f.write(b"backup update\n") -time.sleep(1) -(srcbase / 'dir' / 'file').touch() +advance_mtime(srcbase / 'dir' / 'file') push('-KRlptv', '--backup', 'dir/file', 'localhost:', label="test 4 update") assert_same("test 4 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file') @@ -147,8 +147,7 @@ def assert_same(label: str, a, b) -> None: with open(srcbase / 'dir' / 'file', 'ab') as f: f.write(b"inplace update\n") -time.sleep(1) -(srcbase / 'dir' / 'file').touch() +advance_mtime(srcbase / 'dir' / 'file') push('-KRlptv', '--inplace', 'dir/file', 'localhost:', label="test 5 update") assert_same("test 5 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file') @@ -162,8 +161,7 @@ def assert_same(label: str, a, b) -> None: with open(srcbase / 'topfile', 'ab') as f: f.write(b"toplevel update\n") -time.sleep(1) -(srcbase / 'topfile').touch() +advance_mtime(srcbase / 'topfile') push('-Rlptv', 'topfile', 'localhost:', label="test 6 update") assert_same("test 6 update", srcbase / 'topfile', home / 'topfile') @@ -178,8 +176,7 @@ def assert_same(label: str, a, b) -> None: with open(srcbase / 'dir' / 'file', 'ab') as f: f.write(b"partial-dir update\n") -time.sleep(1) -(srcbase / 'dir' / 'file').touch() +advance_mtime(srcbase / 'dir' / 'file') push('-KRlptv', '--protocol=28', '--partial-dir=.rsync-partial', 'dir/file', 'localhost:', label="test 7 update") @@ -194,8 +191,7 @@ def assert_same(label: str, a, b) -> None: with open(srcbase / 'dir' / 'file', 'ab') as f: f.write(b"proto28 update\n") -time.sleep(1) -(srcbase / 'dir' / 'file').touch() +advance_mtime(srcbase / 'dir' / 'file') push('-KRlptv', '--protocol=28', 'dir/file', 'localhost:', label="test 8 update") assert_same("test 8 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file') From 172ea5ba97e31a1ca1e0b9390154ba192ac29cd2 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 15:58:24 +1000 Subject: [PATCH 08/27] testsuite: wait for observable process state --- testsuite/ki62-io-error-mask_test.py | 29 ++++++++++++++++----------- testsuite/msg-io-timeout-zero_test.py | 4 ++-- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/testsuite/ki62-io-error-mask_test.py b/testsuite/ki62-io-error-mask_test.py index b468ac696..32a16dd5b 100644 --- a/testsuite/ki62-io-error-mask_test.py +++ b/testsuite/ki62-io-error-mask_test.py @@ -86,19 +86,13 @@ daemon = start_rsyncd(conf, PORT) # --bwlimit keeps the transfer slow enough to guarantee the sender is -# mid-stream when we SIGKILL it. --info=progress gives a heartbeat we could -# observe, but the bwlimit + short sleep is enough on loopback. +# mid-stream when we SIGKILL it. client = subprocess.Popen( rsync_argv('-a', '--bwlimit=64', '--timeout=60', f'rsync://127.0.0.1:{PORT}/test-from/', f'{TODIR}/'), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) -# Let the receiver get past the handshake and into the data stream before we -# cut the sender off, so the death is observed as a mid-transfer stream error -# (the path that exercises the io_error -> exit-code mapping). -time.sleep(2) - # Kill the sender mid-transfer. rsyncd forks a child per connection, so the # actual sender is NOT `daemon` (the listener) -- killing only the listener # leaves the child streaming and the receiver never sees EOF. The child logs @@ -106,11 +100,22 @@ # listener). The daemon shares this test's process group, so killpg is not an # option (it would kill the test itself). child_pids = [] -try: - logtext = (SCRATCHDIR / 'rsyncd.log').read_text() - child_pids = [int(pid) for pid in re.findall(r'\[(\d+)\] rsync on ', logtext)] -except OSError: - pass +deadline = time.monotonic() + 10 +while time.monotonic() < deadline: + try: + logtext = (SCRATCHDIR / 'rsyncd.log').read_text() + child_pids = [int(pid) for pid in re.findall(r'\[(\d+)\] rsync on ', logtext)] + except OSError: + pass + if child_pids: + break + if client.poll() is not None: + break + time.sleep(0.05) +if not child_pids: + client.kill() + out, _ = client.communicate() + test_fail(f"daemon sender did not start before the transfer exited:\n{out}") for pid in child_pids: try: os.kill(pid, signal.SIGKILL) diff --git a/testsuite/msg-io-timeout-zero_test.py b/testsuite/msg-io-timeout-zero_test.py index b60514cbe..4f6efb13e 100644 --- a/testsuite/msg-io-timeout-zero_test.py +++ b/testsuite/msg-io-timeout-zero_test.py @@ -55,8 +55,8 @@ def le32(v): f.readline(); f.readline() f.write(b"@RSYNCD: OK\n"); f.flush() conn.setblocking(False) -dl, quiet = time.time() + 4.0, 0.0 -while time.time() < dl: +dl, quiet = time.monotonic() + 4.0, 0.0 +while time.monotonic() < dl: r, _, _ = select.select([conn], [], [], 0.1) if r: try: From 9d078e3dd823311f7a7f78805d85e63816aeacf3 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 16:20:11 +1000 Subject: [PATCH 09/27] testsuite: cover inetd socket detection --- testsuite/daemon-stdin-inetd_test.py | 98 ++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 testsuite/daemon-stdin-inetd_test.py diff --git a/testsuite/daemon-stdin-inetd_test.py b/testsuite/daemon-stdin-inetd_test.py new file mode 100644 index 000000000..37c71a9c9 --- /dev/null +++ b/testsuite/daemon-stdin-inetd_test.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Connected Internet sockets on stdin select inetd-style daemon mode.""" + +import socket +import subprocess + +from rsyncfns import ( + SCRATCHDIR, claim_free_port, rmtree, rsync_argv, test_fail, + write_daemon_conf, +) + + +base = SCRATCHDIR / 'daemon-stdin-inetd' +rmtree(base) +module = base / 'module' +module.mkdir(parents=True) +conf = write_daemon_conf([ + ('module', {'path': str(module), 'read only': 'yes'}), +]) + + +def check_inetd(family, address, label, fallback_port): + listener = socket.socket(family, socket.SOCK_STREAM) + client = socket.socket(family, socket.SOCK_STREAM) + accepted = None + proc = None + try: + if family == socket.AF_INET6: + listener.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + listener.bind(address) + listener.listen(1) + client.settimeout(10) + client.connect(listener.getsockname()) + accepted, _ = listener.accept() + + proc = subprocess.Popen( + rsync_argv('--daemon', '--no-detach', f'--config={conf}', + f'--port={fallback_port}'), + stdin=accepted, stdout=accepted, + stderr=subprocess.PIPE, close_fds=True, + ) + accepted.close() + accepted = None + + try: + greeting = client.recv(256) + except socket.timeout: + test_fail(f'{label}: daemon did not greet the connected stdin socket') + if not greeting.startswith(b'@RSYNCD:'): + test_fail(f'{label}: unexpected daemon greeting: {greeting!r}') + + client.sendall(b'@RSYNCD: 31.0\n#exit\n') + try: + client.shutdown(socket.SHUT_WR) + except OSError: + pass + try: + while client.recv(4096): + pass + except OSError: + pass + + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + test_fail(f'{label}: inetd-style daemon did not exit after #exit') + finally: + if accepted is not None: + accepted.close() + client.close() + listener.close() + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + +check_inetd(socket.AF_INET, ('127.0.0.1', 0), 'IPv4', + claim_free_port(12980)) + +ipv6_tested = False +if socket.has_ipv6: + try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + probe.bind(('::1', 0)) + except OSError as error: + print(f'IPv6 loopback is unavailable: {error}') + else: + check_inetd(socket.AF_INET6, ('::1', 0), 'IPv6', + claim_free_port(12981)) + ipv6_tested = True + +print('connected IPv4 stdin selects inetd mode' + + ('; connected IPv6 stdin selects inetd mode' if ipv6_tested else '')) From 78421e31eb138405bd53efe18b9c02f3136b3ffe Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 16:25:56 +1000 Subject: [PATCH 10/27] testsuite: cover daemon address families --- testsuite/daemon-address-family_test.py | 55 +++++++++++++++++++++++++ testsuite/rsyncfns.py | 32 +++++++------- 2 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 testsuite/daemon-address-family_test.py diff --git a/testsuite/daemon-address-family_test.py b/testsuite/daemon-address-family_test.py new file mode 100644 index 000000000..ed5bbdbfe --- /dev/null +++ b/testsuite/daemon-address-family_test.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Exercise explicit IPv4 and IPv6 daemon connections.""" + +import socket + +from rsyncfns import ( + FROMDIR, RSYNC, SCRATCHDIR, claim_ports, make_tree, rmtree, run_rsync, + start_rsyncd, write_daemon_conf, +) + + +PORT4 = 13020 +PORT6 = 13021 +claim_ports(PORT4, PORT6) + +rmtree(FROMDIR) +make_tree(FROMDIR, depth=1) +modules = [('module', {'path': str(FROMDIR), 'read only': 'yes'})] +conf4 = write_daemon_conf( + modules, + globals={ + 'pid file': str(SCRATCHDIR / 'rsyncd-v4.pid'), + 'log file': str(SCRATCHDIR / 'rsyncd-v4.log'), + 'hosts allow': '127.0.0.0/8', + }, + name='address-family-v4.conf', +) +conf6 = write_daemon_conf( + modules, + globals={ + 'pid file': str(SCRATCHDIR / 'rsyncd-v6.pid'), + 'log file': str(SCRATCHDIR / 'rsyncd-v6.log'), + 'hosts allow': '::1', + }, + name='address-family-v6.conf', +) + +start_rsyncd(conf4, PORT4, rsync_cmd=RSYNC, address='127.0.0.1') +run_rsync('-4', '--list-only', f'rsync://127.0.0.1:{PORT4}/module/') + +ipv6_tested = False +if socket.has_ipv6: + try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + probe.bind(('::1', 0)) + except OSError as error: + print(f'IPv6 loopback is unavailable: {error}') + else: + start_rsyncd(conf6, PORT6, rsync_cmd=RSYNC, address='::1') + run_rsync('-6', '--list-only', f'rsync://[::1]:{PORT6}/module/') + ipv6_tested = True + +print('explicit IPv4 daemon connection succeeded' + + ('; explicit IPv6 daemon connection succeeded' if ipv6_tested else '')) diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index 7524cfff0..a1a1dcfa8 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -768,18 +768,19 @@ def _cleanup_rsyncd(proc, port: int) -> 'None': def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, - stdin=subprocess.DEVNULL) -> 'subprocess.Popen': - """Spawn `rsync --daemon --no-detach --address=127.0.0.1 --port=N + stdin=subprocess.DEVNULL, + address: str = '127.0.0.1') -> 'subprocess.Popen': + """Spawn `rsync --daemon --no-detach --address=ADDRESS --port=N --config=conf` and return the Popen handle after the port is accepting connections. - The daemon is bound to LOOPBACK ONLY (--address=127.0.0.1): without it, - rsync --daemon binds 0.0.0.0 and the test modules would be reachable from - the whole LAN. The daemon is killed automatically when this Python - process exits (atexit). On Linux, the kernel also signals SIGTERM to the - daemon if the parent dies abruptly (PR_SET_PDEATHSIG), so a SIGKILL on - the test process doesn't strand the daemon either. The caller is expected - to have already claim_ports()'d `port`. + ADDRESS must be IPv4 or IPv6 loopback. Without an explicit address, rsync + binds every interface and exposes test modules to the LAN. The daemon is + killed automatically when this Python process exits (atexit). On Linux, + the kernel also signals SIGTERM to the daemon if the parent dies abruptly + (PR_SET_PDEATHSIG), so a SIGKILL on the test process doesn't strand the + daemon either. The caller is expected to have already claim_ports()'d + `port`. rsync_cmd selects the binary to run as the daemon; it defaults to RSYNC_PEER (the peer side of a two-sided run), so ordinary daemon tests @@ -788,12 +789,15 @@ def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, the old client. stdin may override the default /dev/null input when a test needs to exercise daemon launch detection. - This is only ever reached from start_test_daemon() in --use-tcp mode; the - default (pipe) mode never starts a listening daemon. + start_test_daemon() calls this only in --use-tcp mode. Tests which require + a real protocol peer may call it directly in either suite transport. """ + if address not in ('127.0.0.1', '::1'): + test_fail('refusing to expose test daemon on non-loopback address ' + f'{address!r}') argv = shlex.split(rsync_cmd or RSYNC_PEER) + [ '--daemon', '--no-detach', - '--address=127.0.0.1', + f'--address={address}', f'--port={port}', f'--config={conf_path}', ] @@ -820,14 +824,14 @@ def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, f"(status={proc.returncode})" ) try: - with _socket.create_connection(('127.0.0.1', port), timeout=0.5): + with _socket.create_connection((address, port), timeout=0.5): return proc except OSError as e: last_err = e time.sleep(0.05) _stop_rsyncd(proc) - test_fail(f"rsyncd never listened on 127.0.0.1:{port}: {last_err}") + test_fail(f"rsyncd never listened on {address}:{port}: {last_err}") def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: From ae0bcba3a88c86c3b5b918be39fad6a2395fedc9 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 16:31:58 +1000 Subject: [PATCH 11/27] testsuite: cover daemon connection limits --- testsuite/daemon-connection-limits_test.py | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 testsuite/daemon-connection-limits_test.py diff --git a/testsuite/daemon-connection-limits_test.py b/testsuite/daemon-connection-limits_test.py new file mode 100644 index 000000000..6538285af --- /dev/null +++ b/testsuite/daemon-connection-limits_test.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Exercise concurrent daemon connection limits and lock-file release.""" + +import socket +import time + +from rsyncfns import ( + FROMDIR, RSYNC, SCRATCHDIR, claim_ports, make_tree, rmtree, start_rsyncd, + test_fail, write_daemon_conf, +) + + +PORT = 13022 +claim_ports(PORT) + +rmtree(FROMDIR) +make_tree(FROMDIR, depth=1) +conf = write_daemon_conf([ + ('limited', { + 'path': str(FROMDIR), + 'read only': 'yes', + 'max connections': '1', + 'lock file': str(SCRATCHDIR / 'connections.lock'), + }), +], name='connection-limits.conf') +start_rsyncd(conf, PORT, rsync_cmd=RSYNC) + + +def recv_line(sock): + data = bytearray() + while not data.endswith(b'\n'): + chunk = sock.recv(1) + if not chunk: + break + data += chunk + if len(data) > 4096: + test_fail(f'daemon response line is too long: {bytes(data)!r}') + return bytes(data) + + +def open_module(module): + sock = socket.create_connection(('127.0.0.1', PORT), timeout=10) + greeting = recv_line(sock) + if not greeting.startswith(b'@RSYNCD:'): + sock.close() + test_fail(f'unexpected daemon greeting: {greeting!r}') + sock.sendall(f'@RSYNCD: 31.0\n{module}\n'.encode()) + return sock, recv_line(sock) + + +first, reply = open_module('limited') +if reply != b'@RSYNCD: OK\n': + first.close() + test_fail(f'first limited connection was not accepted: {reply!r}') + +second, reply = open_module('limited') +second.close() +if b'max connections (1) reached' not in reply: + first.close() + test_fail(f'second limited connection was not rejected: {reply!r}') + +first.close() + +deadline = time.monotonic() + 10 +while True: + third, reply = open_module('limited') + if reply == b'@RSYNCD: OK\n': + third.close() + break + third.close() + if time.monotonic() >= deadline: + test_fail(f'connection slot was not released after disconnect: {reply!r}') + time.sleep(0.05) + +print('daemon enforces max connections and releases its lock after disconnect') From 9d893bdd02d01406202a514851643df165f63c27 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 16:34:32 +1000 Subject: [PATCH 12/27] testsuite: refresh coverage matrix --- testsuite/COVERAGE.md | 48 ++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 470d87a09..94a2f9570 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -65,7 +65,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | -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 ✗ | +| --numeric-ids | ownership-depth | Y | — | ✓ client uid/gid mapping | | --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 | @@ -88,8 +88,9 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | -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 | — | — | — | ✗ | +| --preallocate | preallocate | Y | — | ✓ allocation and delta-update paths | +| --fsync | — | — | — | ✗ | +| -B, --block-size | hashsearch-chain, compress-zlib-insert, preallocate | Y | — | ✓ | | --max-alloc | max-alloc-zero | — | — | ✓ zero resolves to each peer's supported ceiling; values above the limit are rejected | ### Filtering @@ -103,7 +104,8 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | -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 | — | — | — | ✗ | +| --ignore-missing-args | — | — | — | ✗ | +| --delete-missing-args | delete-missing-args-files-from | `~` | — | ✓ with `--files-from` | ### Deletion | option | test(s) | depth | x-dir | notes / gap | @@ -148,12 +150,14 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | --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 | `~` | +| --address / --port | daemon-address-family, daemon-standalone-detach | ✓ explicit IPv4/IPv6 and config-derived binding | | --password-file | daemon-auth*new* | ✓ | -| --early-input / daemon `early exec` | — | ✗ | -| --sockopts / --blocking-io / --timeout / --contimeout | — | ✗ | -| -4/-6, --ipv4/--ipv6 | — | ✗ | -| --stop-after / --stop-at | — | ✗ | +| --early-input / daemon `early exec` | daemon-early-exec-nameconv, early-input-symlink | ✓ data delivery and confined input path | +| --sockopts | daemon-module-options | ✓ client and daemon socket options | +| --blocking-io | — | ✗ | +| --timeout / --contimeout | daemon-handshake-timeout, msg-io-timeout-{zero,overflow}, contimeout-rsh | ✓ precedence, bounds and remote-shell daemon path | +| -4/-6, --ipv4/--ipv6 | daemon-address-family | ✓ IPv4 and IPv6 daemon binding and client connection | +| --stop-after / --stop-at | stop-time | ✓ future, past and duration parsing | | --bwlimit | partial*new* (used, not asserted) | `~` | | --copy-as | — | ✗ root-gated | | --iconv | — | ✗ | @@ -179,26 +183,32 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | 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) | +| early exec | daemon-early-exec-nameconv | ✓ environment and `--early-input` bytes | | 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) | +| numeric ids | daemon-early-exec-nameconv, daemon-namecvt-* | ✓ `numeric ids = no` conversion path | +| fake super | chown-fake, daemon-namecvt-empty-response | ✓ client and daemon parameter | +| timeout | daemon-handshake-timeout | ✓ handshake and transfer precedence, including zero | +| max connections / lock file | daemon-include-maxconn, daemon-connection-limits | ✓ sequential reuse, concurrent refusal and release | | temp dir / open noatime / ignore errors / ignore nonreadable | — | ✗ | -| charset / name converter / dont compress | — | ✗ | +| charset | — | ✗ | +| name converter | daemon-early-exec-nameconv, daemon-namecvt-* | ✓ success, empty and malformed responses | +| dont compress | daemon-module-options | `~` configured but compression choice is not asserted | | 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) | +| motd file | daemon-module-options | ✓ banner content asserted | +| socket options | daemon-module-options | `~` live socket path; kernel effects are not introspected | +| pid file / port / address | daemon-standalone-detach, daemon-address-family | ✓ configuration and CLI binding paths | +| proxy protocol | daemon-proxy-protocol, proxy-protocol-trusted-peer | ✓ enabled, disabled and trusted-peer policy | +| listen backlog / syslog facility / syslog tag | — | ✗ | --- ## 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). +* `--ignore-errors` and `-x/--one-file-system` have no dedicated test yet. +* `--fsync`, `--blocking-io`, daemon charset and daemon listen/syslog settings + remain unisolated. From 9b1a3f21931e5d7ef12b6095f3a86334b41d4850 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 16:36:26 +1000 Subject: [PATCH 13/27] testsuite: cover ignore-missing-args --- testsuite/COVERAGE.md | 2 +- testsuite/ignore-missing-args_test.py | 56 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 testsuite/ignore-missing-args_test.py diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 94a2f9570..a6e75f9fe 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -104,7 +104,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | -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 | — | — | — | ✗ | +| --ignore-missing-args | ignore-missing-args | Y | — | ✓ direct, remote-shell and `--files-from` sources | | --delete-missing-args | delete-missing-args-files-from | `~` | — | ✓ with `--files-from` | ### Deletion diff --git a/testsuite/ignore-missing-args_test.py b/testsuite/ignore-missing-args_test.py new file mode 100644 index 000000000..c558c4708 --- /dev/null +++ b/testsuite/ignore-missing-args_test.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Exercise --ignore-missing-args for direct and --files-from sources.""" + +from rsyncfns import ( + SCRATCHDIR, makepath, rmtree, rsync_path_arg, rsh_cmd, run_rsync, + test_fail, +) + + +base = SCRATCHDIR / 'ignore-missing-args' +src = base / 'src' +dest = base / 'dest' +existing = src / 'deep' / 'existing' +missing = src / 'deep' / 'missing' +rmtree(base) +makepath(existing.parent, dest) +existing.write_text('retained\n') + + +def assert_existing(path, label): + if not path.is_file() or path.read_text() != 'retained\n': + test_fail(f'{label}: existing source was not transferred') + + +control = run_rsync('-r', str(existing), str(missing), f'{dest}/', + check=False, capture_output=True) +if control.returncode != 23: + test_fail('missing source control did not report a partial transfer: ' + f'rc={control.returncode}\n{control.stderr}') + +rmtree(dest) +makepath(dest) +run_rsync('-r', '--ignore-missing-args', str(existing), str(missing), + f'{dest}/') +assert_existing(dest / 'existing', 'local arguments') + +rmtree(dest) +makepath(dest) +ssh = rsh_cmd() +run_rsync('-r', '--ignore-missing-args', '-e', ssh, + f'--rsync-path={rsync_path_arg()}', + f'localhost:{existing}', f'localhost:{missing}', f'{dest}/') +assert_existing(dest / 'existing', 'remote-shell arguments') + +rmtree(dest) +makepath(dest) +files_from = base / 'files-from' +files_from.write_text('deep/existing\ndeep/missing\n') +run_rsync('-r', '--ignore-missing-args', f'--files-from={files_from}', + f'{src}/', f'{dest}/') +assert_existing(dest / 'deep' / 'existing', '--files-from arguments') + +if (dest / 'deep' / 'missing').exists(): + test_fail('--ignore-missing-args created the absent source') + +print('--ignore-missing-args suppresses only missing initial source arguments') From 0a667a73f1e03007bc7d47082912be1105a1f412 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 8 Oct 2026 16:49:13 +1000 Subject: [PATCH 14/27] testsuite: cover old-dirs --- testsuite/COVERAGE.md | 2 +- testsuite/expect/rsync_2.6.0.expect | 1 + testsuite/expect/rsync_3.0.0.expect | 1 + testsuite/expect/rsync_3.1.0.expect | 1 + testsuite/expect/rsync_3.1.3.expect | 1 + testsuite/expect/rsync_3.2.0.expect | 1 + testsuite/expect/rsync_3.2.7.expect | 1 + testsuite/expect/rsync_3.3.0.expect | 1 + testsuite/expect/rsync_3.4.0.expect | 1 + testsuite/expect/rsync_3.4.1.expect | 1 + testsuite/old-dirs_test.py | 42 +++++++++++++++++++++++++++++ 11 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 testsuite/old-dirs_test.py diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index a6e75f9fe..ac5b4e883 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -33,7 +33,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | --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 | — | — | — | ✗ | +| --old-dirs / --old-d | old-dirs | `~` | — | ✓ both mixed-version remote directions | | -m, --prune-empty-dirs | prune-empty-dirs*new* | Y | — | ✓ incl. filter-emptied chains | ### Links diff --git a/testsuite/expect/rsync_2.6.0.expect b/testsuite/expect/rsync_2.6.0.expect index d4df2c946..a43f5b5b0 100644 --- a/testsuite/expect/rsync_2.6.0.expect +++ b/testsuite/expect/rsync_2.6.0.expect @@ -27,6 +27,7 @@ daemon-refuse-compress xfail # feature/negotiation absent in 2.6.0 (prot exclude pass files-from xfail # feature/negotiation absent in 2.6.0 (proto 27) hardlinks pass +old-dirs pass reverse-daemon-delta pass sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) ssh-basic pass diff --git a/testsuite/expect/rsync_3.0.0.expect b/testsuite/expect/rsync_3.0.0.expect index 8a4333e5c..ee48ded5b 100644 --- a/testsuite/expect/rsync_3.0.0.expect +++ b/testsuite/expect/rsync_3.0.0.expect @@ -27,6 +27,7 @@ daemon-refuse-compress pass exclude pass files-from pass hardlinks pass +old-dirs pass reverse-daemon-delta pass sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) ssh-basic pass diff --git a/testsuite/expect/rsync_3.1.0.expect b/testsuite/expect/rsync_3.1.0.expect index 9a4438dc0..5c511b829 100644 --- a/testsuite/expect/rsync_3.1.0.expect +++ b/testsuite/expect/rsync_3.1.0.expect @@ -27,6 +27,7 @@ daemon-refuse-compress xfail # feature/negotiation absent in 3.1.0 (prot exclude pass files-from pass hardlinks pass +old-dirs pass reverse-daemon-delta pass sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) ssh-basic pass diff --git a/testsuite/expect/rsync_3.1.3.expect b/testsuite/expect/rsync_3.1.3.expect index 0ba653441..339091c76 100644 --- a/testsuite/expect/rsync_3.1.3.expect +++ b/testsuite/expect/rsync_3.1.3.expect @@ -34,6 +34,7 @@ daemon-munge pass exclude pass files-from pass hardlinks pass +old-dirs pass ssh-basic pass variety pass variety-symlink-traversal pass diff --git a/testsuite/expect/rsync_3.2.0.expect b/testsuite/expect/rsync_3.2.0.expect index 36f4282dc..00d9a77f9 100644 --- a/testsuite/expect/rsync_3.2.0.expect +++ b/testsuite/expect/rsync_3.2.0.expect @@ -19,6 +19,7 @@ daemon-refuse pass exclude pass files-from pass hardlinks pass +old-dirs pass ssh-basic pass variety pass variety-symlink-traversal pass diff --git a/testsuite/expect/rsync_3.2.7.expect b/testsuite/expect/rsync_3.2.7.expect index 455873edb..1a995fe61 100644 --- a/testsuite/expect/rsync_3.2.7.expect +++ b/testsuite/expect/rsync_3.2.7.expect @@ -21,6 +21,7 @@ daemon-refuse-compress pass exclude pass files-from pass hardlinks pass +old-dirs pass ssh-basic pass symlink-dirlink-basis pass variety pass diff --git a/testsuite/expect/rsync_3.3.0.expect b/testsuite/expect/rsync_3.3.0.expect index 85b274201..208f6fb5b 100644 --- a/testsuite/expect/rsync_3.3.0.expect +++ b/testsuite/expect/rsync_3.3.0.expect @@ -21,6 +21,7 @@ daemon-refuse-compress pass exclude pass files-from pass hardlinks pass +old-dirs pass ssh-basic pass variety pass variety-symlink-traversal pass diff --git a/testsuite/expect/rsync_3.4.0.expect b/testsuite/expect/rsync_3.4.0.expect index 085e50f6f..354837be4 100644 --- a/testsuite/expect/rsync_3.4.0.expect +++ b/testsuite/expect/rsync_3.4.0.expect @@ -21,6 +21,7 @@ daemon-refuse-compress pass exclude pass files-from pass hardlinks pass +old-dirs pass ssh-basic pass variety pass variety-symlink-traversal pass diff --git a/testsuite/expect/rsync_3.4.1.expect b/testsuite/expect/rsync_3.4.1.expect index 4dbacbd15..0a88f9c13 100644 --- a/testsuite/expect/rsync_3.4.1.expect +++ b/testsuite/expect/rsync_3.4.1.expect @@ -21,6 +21,7 @@ daemon-refuse-compress pass exclude pass files-from pass hardlinks pass +old-dirs pass ssh-basic pass variety pass variety-symlink-traversal pass diff --git a/testsuite/old-dirs_test.py b/testsuite/old-dirs_test.py new file mode 100644 index 000000000..00fc8fec2 --- /dev/null +++ b/testsuite/old-dirs_test.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Exercise --old-dirs through both remote-shell transfer directions.""" + +from rsyncfns import ( + SCRATCHDIR, makepath, rmtree, rsync_path_arg, rsh_cmd, run_rsync, + test_fail, +) + + +base = SCRATCHDIR / 'old-dirs' +src = base / 'src' +push = base / 'push' +pull = base / 'pull' +rmtree(base) +makepath(src / 'one' / 'two', push, pull) +(src / 'top').write_text('top\n') +(src / 'one' / 'inside').write_text('inside\n') +(src / 'one' / 'two' / 'deep').write_text('deep\n') + +ssh = rsh_cmd() +rpath = f'--rsync-path={rsync_path_arg()}' + + +def assert_shallow(path, label): + top = path / 'top' + if not top.is_file() or top.read_text() != 'top\n': + test_fail(f'{label}: root file was not transferred') + if not (path / 'one').is_dir(): + test_fail(f'{label}: first-level directory was not transferred') + if (path / 'one' / 'inside').exists() or (path / 'one' / 'two').exists(): + test_fail(f'{label}: --old-dirs recursed below the first directory level') + + +run_rsync('--old-dirs', '-e', ssh, rpath, + f'{src}/', f'localhost:{push}/') +assert_shallow(push, 'current sender') + +run_rsync('--old-d', '-e', ssh, rpath, + f'localhost:{src}/', f'{pull}/') +assert_shallow(pull, 'peer sender') + +print('--old-dirs transfers one directory level in both remote directions') From 1cfbbe8a19dbd1897103b67cc3388c75b441a348 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 9 Oct 2026 03:11:00 +1000 Subject: [PATCH 15/27] testsuite: cover iconv --- testsuite/COVERAGE.md | 6 +- testsuite/iconv_test.py | 222 +++++++++++++++++++++++++++++++++++ testsuite/skiplist/macos.txt | 1 + 3 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 testsuite/iconv_test.py diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index ac5b4e883..392cc50bb 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -160,8 +160,8 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | --stop-after / --stop-at | stop-time | ✓ future, past and duration parsing | | --bwlimit | partial*new* (used, not asserted) | `~` | | --copy-as | — | ✗ root-gated | -| --iconv | — | ✗ | -| -s/--secluded-args, --old-args, --trust-sender | (default arg-protection exercised) | `~` | +| --iconv | iconv | ✓ filenames, arguments, file lists, protocol 30+ symlink targets and invalid input; macOS raw-byte fixture unavailable | +| -s/--secluded-args, --old-args, --trust-sender | iconv (-s); default arg protection | `~` secluded filename conversion asserted; old/trust not isolated | --- @@ -194,7 +194,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | timeout | daemon-handshake-timeout | ✓ handshake and transfer precedence, including zero | | max connections / lock file | daemon-include-maxconn, daemon-connection-limits | ✓ sequential reuse, concurrent refusal and release | | temp dir / open noatime / ignore errors / ignore nonreadable | — | ✗ | -| charset | — | ✗ | +| charset | iconv | ✓ module charset overrides the client remote charset | | name converter | daemon-early-exec-nameconv, daemon-namecvt-* | ✓ success, empty and malformed responses | | dont compress | daemon-module-options | `~` configured but compression choice is not asserted | | uid / gid / daemon uid / daemon gid / daemon chroot | build_rsyncd_conf (uid/gid when root), daemon-chroot-acl | `~` root-gated | diff --git a/testsuite/iconv_test.py b/testsuite/iconv_test.py new file mode 100644 index 000000000..6eb3e8a09 --- /dev/null +++ b/testsuite/iconv_test.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Exercise filename conversion across remote-shell and daemon transfers.""" + +import os +import subprocess + +from rsyncfns import ( + RSYNC, RSYNC_PEER, SCRATCHDIR, + makepath, rmtree, rsync_argv, rsync_path_arg, rsh_cmd, + split_rsync_cmd, start_test_daemon, test_fail, test_skipped, + write_daemon_conf, +) + + +UTF8_NAME = b'caf\xc3\xa9.txt' +LATIN1_NAME = b'caf\xe9.txt' +UTF8_TARGET = b'cibl\xc3\xa9.txt' +LATIN1_TARGET = b'cibl\xe9.txt' +PAYLOAD = b'contents are not converted\n\x00\xff' + +base = SCRATCHDIR / 'iconv' +rmtree(base) +makepath(base) + + +def iconv_works(command, label): + source = base / f'probe-{label}-src' + destination = base / f'probe-{label}-dst' + makepath(source, destination) + (source / 'plain').write_bytes(b'probe\n') + proc = subprocess.run( + split_rsync_cmd(command) + [ + '-r', '--iconv=UTF-8,ISO-8859-1', + f'{source}/', f'{destination}/', + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + return proc.returncode == 0 and (destination / 'plain').is_file() + + +def forced_protocol(command): + for arg in split_rsync_cmd(command): + if arg.startswith('--protocol='): + return int(arg.partition('=')[2]) + return None + + +if not iconv_works(RSYNC, 'client'): + test_skipped('rsync under test does not provide working --iconv support') +if RSYNC_PEER != RSYNC and not iconv_works(RSYNC_PEER, 'peer'): + test_skipped('selected peer does not provide working --iconv support') + + +def raw_path(directory, name): + return os.path.join(os.fsencode(directory), name) + + +def write_raw(directory, name, contents=PAYLOAD): + makepath(directory) + with open(raw_path(directory, name), 'wb') as stream: + stream.write(contents) + + +def assert_file(directory, name, label): + path = raw_path(directory, name) + if not os.path.isfile(path): + test_fail(f'{label}: missing filename bytes {name!r}') + with open(path, 'rb') as stream: + if stream.read() != PAYLOAD: + test_fail(f'{label}: file contents changed during filename conversion') + + +def assert_only_names(directory, expected, label): + actual = set(os.listdir(os.fsencode(directory))) + if actual != set(expected): + test_fail(f'{label}: names {actual!r}, expected {set(expected)!r}') + + +def invoke(*args, env=None, expected=0): + proc = subprocess.run( + rsync_argv(*args), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + if proc.returncode != expected: + test_fail( + f'rsync exited {proc.returncode}, expected {expected} for {args!r}: ' + f'{proc.stderr.decode(errors="replace")}' + ) + return proc + + +# A destination charset with single-byte non-ASCII names is required to prove +# that conversion happened. Some filesystems expose filenames only as Unicode. +probe = raw_path(base, b'probe-\xe9') +try: + fd = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(fd) + preserves_bytes = b'probe-\xe9' in os.listdir(os.fsencode(base)) + os.unlink(probe) + if not preserves_bytes: + test_skipped('filesystem does not preserve ISO-8859-1 filename bytes') +except OSError as exc: + test_skipped(f'filesystem cannot represent ISO-8859-1 filename bytes: {exc}') + + +ssh = rsh_cmd() +rpath = f'--rsync-path={rsync_path_arg()}' +convert = '--iconv=UTF-8,ISO-8859-1' +protocols = [p for p in (forced_protocol(RSYNC), forced_protocol(RSYNC_PEER)) if p] +convert_symlink_target = not protocols or min(protocols) >= 30 + +# Push and pull must use the same LOCAL,REMOTE charset order. File contents are +# opaque and symlink targets follow the filename conversion. +push_src = base / 'push-src' +push_dst = base / 'push-dst' +pull_dst = base / 'pull-dst' +makepath(push_src, push_dst, pull_dst) +write_raw(push_src, UTF8_NAME) +write_raw(push_src, UTF8_TARGET) +os.symlink(UTF8_TARGET, raw_path(push_src, b'link')) + +invoke('-rl', convert, '-e', ssh, rpath, + f'{push_src}/', f'localhost:{push_dst}/') +assert_only_names(push_dst, (LATIN1_NAME, LATIN1_TARGET, b'link'), 'push') +assert_file(push_dst, LATIN1_NAME, 'push') +push_target = LATIN1_TARGET if convert_symlink_target else UTF8_TARGET +actual_target = os.readlink(raw_path(push_dst, b'link')) +if actual_target != push_target: + test_fail(f'push: symlink target {actual_target!r}, expected {push_target!r}') + +invoke('-rl', convert, '-e', ssh, rpath, + f'localhost:{push_dst}/', f'{pull_dst}/') +assert_only_names(pull_dst, (UTF8_NAME, UTF8_TARGET, b'link'), 'pull') +assert_file(pull_dst, UTF8_NAME, 'pull') +if os.readlink(raw_path(pull_dst, b'link')) != UTF8_TARGET: + test_fail('pull: symlink target was not converted to UTF-8') + +# A local files-from list names the local source in its charset. A remote list +# names the remote source in the remote charset and is converted with -s. +files_push = base / 'files-push' +files_pull = base / 'files-pull' +makepath(files_push, files_pull) +local_list = base / 'local-list' +local_list.write_bytes(UTF8_NAME + b'\n') +invoke('-r', '-s', convert, '-e', ssh, rpath, + f'--files-from={local_list}', f'{push_src}/', + f'localhost:{files_push}/') +assert_only_names(files_push, (LATIN1_NAME,), 'local --files-from') +assert_file(files_push, LATIN1_NAME, 'local --files-from') + +remote_list = base / 'remote-list' +remote_list.write_bytes(LATIN1_NAME + b'\n') +invoke('-r', '-s', convert, '-e', ssh, rpath, + f'--files-from=localhost:{remote_list}', + f'localhost:{push_dst}/', f'{files_pull}/') +assert_only_names(files_pull, (UTF8_NAME,), 'remote --files-from') +assert_file(files_pull, UTF8_NAME, 'remote --files-from') + +# With secluded args the requested remote source name is converted before the +# peer interprets it. +arg_pull = base / 'arg-pull' +makepath(arg_pull) +remote_name = os.fsdecode(UTF8_NAME) +invoke('-s', convert, '-e', ssh, rpath, + f'localhost:{push_dst}/{remote_name}', f'{arg_pull}/') +assert_only_names(arg_pull, (UTF8_NAME,), '--secluded-args') +assert_file(arg_pull, UTF8_NAME, '--secluded-args') + +# An invalid UTF-8 source name is omitted and reported without disturbing an +# existing destination entry. +invalid_src = base / 'invalid-src' +invalid_dst = base / 'invalid-dst' +makepath(invalid_src, invalid_dst) +write_raw(invalid_src, b'bad-\xff') +(invalid_dst / 'sentinel').write_bytes(b'keep\n') +proc = invoke('-r', convert, '-e', ssh, rpath, + f'{invalid_src}/', f'localhost:{invalid_dst}/', expected=23) +if b'cannot convert filename' not in proc.stderr: + test_fail('invalid UTF-8 filename did not report a conversion failure') +assert_only_names(invalid_dst, (b'sentinel',), 'invalid filename') +if (invalid_dst / 'sentinel').read_bytes() != b'keep\n': + test_fail('invalid filename transfer changed the existing destination') + +# An explicit --no-iconv overrides the environment default on both sides. +noiconv_dst = base / 'noiconv-dst' +makepath(noiconv_dst) +env = os.environ.copy() +env['RSYNC_ICONV'] = 'UTF-8,ISO-8859-1' +invoke('-rl', '--no-iconv', '-e', ssh, rpath, + f'{push_src}/', f'localhost:{noiconv_dst}/', env=env) +assert_only_names(noiconv_dst, (UTF8_NAME, UTF8_TARGET, b'link'), '--no-iconv') +assert_file(noiconv_dst, UTF8_NAME, '--no-iconv') +if os.readlink(raw_path(noiconv_dst, b'link')) != UTF8_TARGET: + test_fail('--no-iconv converted the symlink target from the environment default') + +# A daemon module's charset overrides the remote charset supplied by the +# client. Supplying only the local charset exercises that daemon contract. +daemon_dst = base / 'daemon-dst' +makepath(daemon_dst) +conf = write_daemon_conf([ + ('encoded', { + 'path': str(daemon_dst), + 'read only': 'no', + 'charset': 'ISO-8859-1', + # Isolate charset conversion from the daemon's symlink munging. + 'munge symlinks': 'no', + }), +], name='iconv-rsyncd.conf') +url = start_test_daemon(conf, 18732) +invoke('-rl', '--iconv=UTF-8', f'{push_src}/', f'{url}encoded/') +assert_only_names(daemon_dst, (LATIN1_NAME, LATIN1_TARGET, b'link'), + 'daemon charset') +assert_file(daemon_dst, LATIN1_NAME, 'daemon charset') +daemon_target = LATIN1_TARGET if convert_symlink_target else UTF8_TARGET +actual_target = os.readlink(raw_path(daemon_dst, b'link')) +if actual_target != daemon_target: + test_fail(f'daemon: symlink target {actual_target!r}, expected {daemon_target!r}') + +print('iconv: filenames, arguments, file lists, symlinks, errors and daemon charset') diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 7ba8d589d..7d0b3da2c 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -16,6 +16,7 @@ copy-xattrs-symlink-race daemon-auth-group dir-sgid fake-super-acl-xattr +iconv # raw filename bytes unavailable link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only open-noatime partial-protected-regular-retry-linux From ac081787d24036a2d711530329e5e1df47750a61 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 9 Oct 2026 03:17:13 +1000 Subject: [PATCH 16/27] testsuite: cover output and logging controls --- testsuite/COVERAGE.md | 4 +- testsuite/daemon-module-options_test.py | 15 ++++++ testsuite/output-options_test.py | 70 ++++++++++++++++++++++++- testsuite/remote-logging_test.py | 57 ++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 testsuite/remote-logging_test.py diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 392cc50bb..25a56e8ac 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -141,8 +141,8 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | --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) | +| --info / --debug / --stderr / --no-motd / --outbuf | output-options, daemon-module-options | ✓ output selection, routing, MOTD suppression and line buffering | +| -M, --remote-option / --log-file / --log-file-format | remote-logging | ✓ client and remote-shell sender/receiver logs | ### Batch / connection / misc | option | test(s) | notes / gap | diff --git a/testsuite/daemon-module-options_test.py b/testsuite/daemon-module-options_test.py index 88fad87b2..aac2bd502 100644 --- a/testsuite/daemon-module-options_test.py +++ b/testsuite/daemon-module-options_test.py @@ -97,6 +97,21 @@ if 'incoming-chmod test module' not in r.stdout: test_fail(f"module comment missing from listing:\n{r.stdout!r}") +# --no-motd suppresses the banner during a real module transfer. +nomotd_dst = SCRATCHDIR / 'pull-no-motd' +makepath(nomotd_dst) +r = subprocess.run(rsync_argv('-r', '--no-motd', f'{url}outmod/', + f'{nomotd_dst}/'), + capture_output=True, text=True) +if r.returncode != 0: + test_fail(f"module transfer with --no-motd failed (rc={r.returncode}):\n" + f"{r.stderr}") +if 'TEST MOTD BANNER' in r.stdout + r.stderr: + test_fail(f"--no-motd did not suppress the banner:\n{r.stdout!r}\n{r.stderr!r}") +nomotd_file = nomotd_dst / 'o0' +if not nomotd_file.is_file() or nomotd_file.read_text() != 'out\n': + test_fail('--no-motd module transfer did not copy the payload') + # --- incoming chmod: push, then check Fu+x,g-w,o=r applied ----------------- r = subprocess.run( rsync_argv('-rzp', '--sockopts=SO_KEEPALIVE,SO_SNDBUF=8192', diff --git a/testsuite/output-options_test.py b/testsuite/output-options_test.py index be68297be..214e31019 100644 --- a/testsuite/output-options_test.py +++ b/testsuite/output-options_test.py @@ -4,7 +4,8 @@ These options control rsync's OUTPUT, not its path handling, so they are checked for the documented output shape rather than at depth: --version, --help, --itemize-changes (-i), --dry-run (-n), --stats, - --out-format, --list-only, --quiet (-q), --progress, -h, -8. + --out-format, --list-only, --quiet (-q), --progress, -h, -8, --info, + --debug, --stderr, --outbuf. Every rsync run that is expected to succeed has its exit status checked (a silent failure must not pass as "no output"), and the format-changing options @@ -13,6 +14,7 @@ import os import re +import select import subprocess from rsyncfns import ( @@ -172,5 +174,69 @@ def out(*args, want_rc=0, env=None, text=True): test_fail("-8 should leave the high-bit name byte unescaped, but " f"the \\#371 escape was still present:\n{esc.stdout!r}") +# --- --info and --debug select output without blanket verbosity ------------ +rmtree(src) +rmtree(TODIR) +makepath(src, TODIR) +(src / 'keep').write_text('keep\n') +(src / 'hide.tmp').write_text('hide\n') + +p = out('-r', '--info=name1', f'{src}/', f'{TODIR}/') +if 'keep' not in p.stdout or 'hide.tmp' not in p.stdout: + test_fail(f"--info=name1 did not report transferred names:\n{p.stdout}") + +rmtree(TODIR) +makepath(TODIR) +p = out('-r', '--exclude=*.tmp', '--debug=filter1', + f'{src}/', f'{TODIR}/') +filter_msg = '[sender] hiding file hide.tmp because of pattern *.tmp' +if filter_msg not in p.stdout or p.stderr: + test_fail('--debug=filter1 did not use the default stdout route') + +rmtree(TODIR) +makepath(TODIR) +p = out('-r', '--exclude=*.tmp', '--debug=filter1', '--stderr=all', + f'{src}/', f'{TODIR}/') +if filter_msg not in p.stderr or p.stdout: + test_fail('--stderr=all did not route debug output exclusively to stderr') + +# --- --outbuf=L makes each complete output line observable immediately ----- +rmtree(src) +rmtree(TODIR) +makepath(src, TODIR) +(src / 'a-small').write_text('small\n') +make_data_file(src / 'z-large', 256 * 1024) +proc = subprocess.Popen( + rsync_argv('-r', '--outbuf=L', '--out-format=%n', '--bwlimit=128', + f'{src}/', f'{TODIR}/'), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, +) +ready, _, _ = select.select([proc.stdout], [], [], 5) +if not ready: + proc.kill() + proc.communicate() + test_fail('--outbuf=L did not flush a complete output line within 5 seconds') +first_line = proc.stdout.readline() +if not first_line.strip() or not first_line.endswith('\n'): + proc.kill() + proc.communicate() + test_fail(f'--outbuf=L returned an incomplete line: {first_line!r}') +if proc.poll() is not None: + stdout, stderr = proc.communicate() + test_fail('--outbuf=L transfer finished before buffering could be observed: ' + f'{first_line + stdout!r} {stderr!r}') +try: + stdout, stderr = proc.communicate(timeout=10) +except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + test_fail('--outbuf=L transfer did not finish within 10 seconds') +if proc.returncode != 0: + test_fail(f'--outbuf=L transfer exited {proc.returncode}: {stderr}') +if not (TODIR / 'z-large').is_file(): + test_fail('--outbuf=L transfer did not finish copying the payload') + print("output-options: version/help/-i/-n/--stats/--out-format/--list-only/" - "-q/--progress/-h/-8 verified") + "-q/--progress/-h/-8/--info/--debug/--stderr/--outbuf verified") diff --git a/testsuite/remote-logging_test.py b/testsuite/remote-logging_test.py new file mode 100644 index 000000000..d63a11a89 --- /dev/null +++ b/testsuite/remote-logging_test.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Exercise client and remote-shell transfer logging.""" + +from rsyncfns import ( + SCRATCHDIR, makepath, rmtree, rsync_path_arg, rsh_cmd, run_rsync, test_fail, +) + + +base = SCRATCHDIR / 'remote-logging' +src = base / 'src' +local_dst = base / 'local-dst' +push_dst = base / 'push-dst' +pull_dst = base / 'pull-dst' +rmtree(base) +makepath(src, local_dst, push_dst, pull_dst) +(src / 'payload').write_text('payload\n') + +ssh = rsh_cmd() +rpath = f'--rsync-path={rsync_path_arg()}' + + +def assert_log(path, marker, label): + if not path.is_file(): + test_fail(f'{label}: log file was not created') + text = path.read_text() + if f'{marker}:payload' not in text: + test_fail(f'{label}: formatted payload entry missing from {text!r}') + + +def assert_payload(directory, label): + path = directory / 'payload' + if not path.is_file() or path.read_text() != 'payload\n': + test_fail(f'{label}: payload was not transferred') + + +local_log = base / 'local.log' +run_rsync('-r', f'--log-file={local_log}', '--log-file-format=LOCAL:%n', + f'{src}/', f'{local_dst}/') +assert_log(local_log, 'LOCAL', 'client log') +assert_payload(local_dst, 'client log') + +push_log = base / 'push-remote.log' +run_rsync('-r', '-e', ssh, rpath, + f'--remote-option=--log-file={push_log}', + '--remote-option=--log-file-format=PUSH:%n', + f'{src}/', f'localhost:{push_dst}/') +assert_log(push_log, 'PUSH', 'remote receiver log') +assert_payload(push_dst, 'remote receiver log') + +pull_log = base / 'pull-remote.log' +run_rsync('-r', '-e', ssh, rpath, + f'-M--log-file={pull_log}', f'-M--log-file-format=PULL:%n', + f'localhost:{src}/', f'{pull_dst}/') +assert_log(pull_log, 'PULL', 'remote sender log') +assert_payload(pull_dst, 'remote sender log') + +print('remote-logging: client, remote receiver and remote sender formats verified') From e74ff0d7c71beb2990fb033b3cc4b8bd341c1af1 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 9 Oct 2026 03:22:02 +1000 Subject: [PATCH 17/27] testsuite: cover ignore-errors --- testsuite/COVERAGE.md | 4 ++-- testsuite/iconv_test.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 25a56e8ac..096947394 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -116,7 +116,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | --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 ✗) | +| --ignore-errors | iconv | — | — | ✓ deletion proceeds after sender I/O error | ### Comparison / checksum / compression | option | test(s) | depth | x-dir | notes / gap | @@ -193,7 +193,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | fake super | chown-fake, daemon-namecvt-empty-response | ✓ client and daemon parameter | | timeout | daemon-handshake-timeout | ✓ handshake and transfer precedence, including zero | | max connections / lock file | daemon-include-maxconn, daemon-connection-limits | ✓ sequential reuse, concurrent refusal and release | -| temp dir / open noatime / ignore errors / ignore nonreadable | — | ✗ | +| temp dir / open noatime / ignore errors / ignore nonreadable | iconv (ignore errors) | `~` ignore errors asserted; remaining parameters uncovered | | charset | iconv | ✓ module charset overrides the client remote charset | | name converter | daemon-early-exec-nameconv, daemon-namecvt-* | ✓ success, empty and malformed responses | | dont compress | daemon-module-options | `~` configured but compression choice is not asserted | diff --git a/testsuite/iconv_test.py b/testsuite/iconv_test.py index 6eb3e8a09..758952bac 100644 --- a/testsuite/iconv_test.py +++ b/testsuite/iconv_test.py @@ -169,21 +169,29 @@ def invoke(*args, env=None, expected=0): assert_only_names(arg_pull, (UTF8_NAME,), '--secluded-args') assert_file(arg_pull, UTF8_NAME, '--secluded-args') -# An invalid UTF-8 source name is omitted and reported without disturbing an -# existing destination entry. +# An invalid UTF-8 source name is omitted and reported. The resulting I/O error +# blocks deletion unless --ignore-errors explicitly permits it. invalid_src = base / 'invalid-src' invalid_dst = base / 'invalid-dst' makepath(invalid_src, invalid_dst) write_raw(invalid_src, b'bad-\xff') +(invalid_src / 'sentinel').write_bytes(b'keep\n') (invalid_dst / 'sentinel').write_bytes(b'keep\n') -proc = invoke('-r', convert, '-e', ssh, rpath, +(invalid_dst / 'stale').write_bytes(b'do not delete yet\n') +proc = invoke('-r', '--delete', convert, '-e', ssh, rpath, f'{invalid_src}/', f'localhost:{invalid_dst}/', expected=23) if b'cannot convert filename' not in proc.stderr: test_fail('invalid UTF-8 filename did not report a conversion failure') -assert_only_names(invalid_dst, (b'sentinel',), 'invalid filename') +assert_only_names(invalid_dst, (b'sentinel', b'stale'), 'I/O-safe deletion') if (invalid_dst / 'sentinel').read_bytes() != b'keep\n': test_fail('invalid filename transfer changed the existing destination') +invoke('-r', '--delete', '--ignore-errors', convert, '-e', ssh, rpath, + f'{invalid_src}/', f'localhost:{invalid_dst}/', expected=23) +assert_only_names(invalid_dst, (b'sentinel',), '--ignore-errors') +if (invalid_dst / 'sentinel').read_bytes() != b'keep\n': + test_fail('--ignore-errors changed the retained payload') + # An explicit --no-iconv overrides the environment default on both sides. noiconv_dst = base / 'noiconv-dst' makepath(noiconv_dst) @@ -199,7 +207,10 @@ def invoke(*args, env=None, expected=0): # A daemon module's charset overrides the remote charset supplied by the # client. Supplying only the local charset exercises that daemon contract. daemon_dst = base / 'daemon-dst' -makepath(daemon_dst) +daemon_ignore_dst = base / 'daemon-ignore-dst' +makepath(daemon_dst, daemon_ignore_dst) +(daemon_ignore_dst / 'sentinel').write_bytes(b'keep\n') +(daemon_ignore_dst / 'stale').write_bytes(b'delete through module policy\n') conf = write_daemon_conf([ ('encoded', { 'path': str(daemon_dst), @@ -208,6 +219,12 @@ def invoke(*args, env=None, expected=0): # Isolate charset conversion from the daemon's symlink munging. 'munge symlinks': 'no', }), + ('encoded-ignore', { + 'path': str(daemon_ignore_dst), + 'read only': 'no', + 'charset': 'ISO-8859-1', + 'ignore errors': 'yes', + }), ], name='iconv-rsyncd.conf') url = start_test_daemon(conf, 18732) invoke('-rl', '--iconv=UTF-8', f'{push_src}/', f'{url}encoded/') @@ -219,4 +236,10 @@ def invoke(*args, env=None, expected=0): if actual_target != daemon_target: test_fail(f'daemon: symlink target {actual_target!r}, expected {daemon_target!r}') -print('iconv: filenames, arguments, file lists, symlinks, errors and daemon charset') +invoke('-r', '--delete', '--iconv=UTF-8', f'{invalid_src}/', + f'{url}encoded-ignore/', expected=23) +assert_only_names(daemon_ignore_dst, (b'sentinel',), 'daemon ignore errors') +if (daemon_ignore_dst / 'sentinel').read_bytes() != b'keep\n': + test_fail('daemon ignore errors changed the retained payload') + +print('iconv: names, arguments, file lists, symlinks, error deletion and daemon charset') From b6e5cccadbf2c1cd84dc9643dd12ec72b84c4fc6 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 9 Oct 2026 03:24:28 +1000 Subject: [PATCH 18/27] testsuite: narrow stderr routing checks --- testsuite/output-options_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testsuite/output-options_test.py b/testsuite/output-options_test.py index 214e31019..53462687b 100644 --- a/testsuite/output-options_test.py +++ b/testsuite/output-options_test.py @@ -190,14 +190,14 @@ def out(*args, want_rc=0, env=None, text=True): p = out('-r', '--exclude=*.tmp', '--debug=filter1', f'{src}/', f'{TODIR}/') filter_msg = '[sender] hiding file hide.tmp because of pattern *.tmp' -if filter_msg not in p.stdout or p.stderr: +if filter_msg not in p.stdout or filter_msg in p.stderr: test_fail('--debug=filter1 did not use the default stdout route') rmtree(TODIR) makepath(TODIR) p = out('-r', '--exclude=*.tmp', '--debug=filter1', '--stderr=all', f'{src}/', f'{TODIR}/') -if filter_msg not in p.stderr or p.stdout: +if filter_msg not in p.stderr or filter_msg in p.stdout: test_fail('--stderr=all did not route debug output exclusively to stderr') # --- --outbuf=L makes each complete output line observable immediately ----- From 5f33a0f64fae3cd3ac2ef265bb128272295644cc Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 9 Oct 2026 03:27:27 +1000 Subject: [PATCH 19/27] ci: cache prepared virtual machines --- .github/workflows/freebsd-build.yml | 2 +- .github/workflows/netbsd-build.yml | 2 +- .github/workflows/openbsd-build.yml | 2 +- .github/workflows/solaris-build.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index 8e865859b..f15ee668c 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -35,7 +35,7 @@ jobs: id: test 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: | diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml index 9310b5b3c..25973ec3b 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -35,7 +35,7 @@ jobs: id: test 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 diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 97041662e..1d88aeddc 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -35,7 +35,7 @@ jobs: id: test 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: | diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml index 78a75809f..c9581d25b 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -35,7 +35,7 @@ jobs: id: test 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: | From 5715253d8f4cbb53c53ac5949d757d62ff567bdb Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 9 Oct 2026 12:54:17 +1000 Subject: [PATCH 20/27] testsuite: rework Python test harness --- .github/workflows/almalinux-8-build.yml | 4 +- .github/workflows/asan-build.yml | 4 +- .github/workflows/cygwin-build.yml | 4 +- .github/workflows/fleettest.yml | 16 +- .github/workflows/freebsd-build.yml | 2 +- .github/workflows/macos-build.yml | 4 +- .github/workflows/netbsd-build.yml | 2 +- .github/workflows/openbsd-build.yml | 2 +- .github/workflows/solaris-build.yml | 2 +- .github/workflows/ubuntu-build.yml | 10 +- .github/workflows/ubuntu-version-mix.yml | 41 +- .github/workflows/valgrind.yml | 2 +- .gitignore | 1 + Makefile.in | 36 +- testsuite/00-hello_test.py | 104 ----- testsuite/abdiff.py | 2 +- testsuite/crtimes_test.py | 38 -- testsuite/exitcodes.py | 12 +- testsuite/expect/rsync_2.6.0.expect | 36 -- testsuite/expect/rsync_3.0.0.expect | 36 -- testsuite/expect/rsync_3.1.0.expect | 36 -- testsuite/expect/rsync_3.1.3.expect | 52 --- testsuite/expect/rsync_3.2.0.expect | 37 -- testsuite/expect/rsync_3.2.7.expect | 34 -- testsuite/expect/rsync_3.3.0.expect | 34 -- testsuite/expect/rsync_3.4.0.expect | 43 --- testsuite/expect/rsync_3.4.1.expect | 43 --- testsuite/fleettest.json.example | 80 ++-- testsuite/fleettest.py | 114 ++++-- testsuite/harness/__init__.py | 11 + testsuite/harness/context.py | 30 ++ testsuite/harness/metadata.py | 156 ++++++++ testsuite/harness/profile.py | 83 ++++ testsuite/harness/receipt.py | 19 + testsuite/harness/results.py | 81 ++++ testsuite/profiles/cygwin.json | 30 ++ testsuite/profiles/linux.json | 8 + testsuite/profiles/macos.json | 24 ++ testsuite/profiles/non-asan.json | 5 + testsuite/profiles/nonroot.json | 5 + testsuite/profiles/peer-2.6.0.json | 28 ++ testsuite/profiles/peer-3.0.0.json | 18 + testsuite/profiles/peer-3.1.0.json | 15 + testsuite/profiles/peer-3.1.3.json | 13 + testsuite/profiles/peer-3.2.0.json | 13 + testsuite/profiles/peer-3.2.7.json | 10 + testsuite/profiles/peer-3.3.0.json | 10 + testsuite/profiles/peer-3.4.0.json | 11 + testsuite/profiles/peer-3.4.1.json | 11 + testsuite/profiles/pipe.json | 5 + testsuite/profiles/protocol-29.json | 7 + testsuite/profiles/protocol-30.json | 6 + testsuite/profiles/root.json | 5 + testsuite/profiles/self-peer.json | 5 + testsuite/rsyncfns.py | 34 +- runtests.py => testsuite/runtests.py | 363 ++++++++++++------ .../{ => tests}/acl-symlink-race_test.py | 12 +- testsuite/{ => tests}/acls-default_test.py | 6 +- testsuite/{ => tests}/acls-depth_test.py | 6 +- testsuite/{ => tests}/acls-unpinnable_test.py | 12 +- testsuite/{ => tests}/acls_test.py | 8 +- testsuite/{ => tests}/alt-dest-deep_test.py | 0 .../{ => tests}/alt-dest-symlink-race_test.py | 2 + testsuite/{ => tests}/alt-dest_test.py | 2 + testsuite/{ => tests}/append-shortsum_test.py | 0 testsuite/{ => tests}/append_test.py | 0 testsuite/{ => tests}/atimes_test.py | 0 ...icate-no-ocloexec-build-regression_test.py | 2 +- .../backup-acl-xattr-cache_test.py | 6 +- .../{ => tests}/backup-crossdev-copy_test.py | 3 +- testsuite/{ => tests}/backup-deep_test.py | 0 .../{ => tests}/backup-dir-relative_test.py | 0 ...ckup-dir-repeated-separator-delete_test.py | 0 .../backup-dir-symlink-race_test.py | 5 +- .../{ => tests}/backup-incremental_test.py | 0 testsuite/{ => tests}/backup_test.py | 0 .../bare-do-open-symlink-race_test.py | 2 + .../{ => tests}/basis-xname-traversal_test.py | 0 .../{ => tests}/batch-file-symlink_test.py | 4 +- testsuite/{ => tests}/batch-mode_test.py | 2 + ...atch-only-remove-source-regression_test.py | 0 testsuite/{ => tests}/change-shrink_test.py | 0 testsuite/{ => tests}/change-vanish_test.py | 0 .../{ => tests}/chdir-symlink-race_test.py | 2 + .../checksum-zero-blocklen_test.py | 0 testsuite/{ => tests}/chgrp_test.py | 0 testsuite/{ => tests}/chmod-option_test.py | 2 + testsuite/{ => tests}/chmod-setid_test.py | 0 .../{ => tests}/chmod-symlink-race_test.py | 0 testsuite/{ => tests}/chmod-temp-dir_test.py | 2 +- testsuite/{ => tests}/chmod_test.py | 0 testsuite/{ => tests}/chown-fake_test.py | 0 testsuite/{ => tests}/chown_test.py | 6 +- .../chroot-alt-dest-inner-module_test.py | 0 .../chroot-basis-forge-inner-module_test.py | 0 .../chroot-copy-dest-inner-module_test.py | 0 .../chroot-link-dest-inner-module_test.py | 0 ...chroot-receiver-write-inner-module_test.py | 0 .../chroot-special-inner-module_test.py | 0 .../{ => tests}/clean-fname-collapse_test.py | 0 .../{ => tests}/clean-fname-underflow_test.py | 0 testsuite/{ => tests}/compare_test.py | 0 .../{ => tests}/compress-options_test.py | 0 .../{ => tests}/compress-zlib-insert_test.py | 0 .../connect-prog-host-quoting_test.py | 0 ...-nested-singlequote-host-injection_test.py | 0 testsuite/{ => tests}/contimeout-rsh_test.py | 0 .../copy-dest-source-symlink_test.py | 2 + .../copy-dest-symlink-readleak_test.py | 5 +- .../copy-xattrs-symlink-race_test.py | 7 +- testsuite/tests/crtimes_test.py | 34 ++ testsuite/{ => tests}/cvs-exclude_test.py | 0 .../{ => tests}/daemon-access-idn_test.py | 0 .../{ => tests}/daemon-access-ip_test.py | 0 testsuite/{ => tests}/daemon-access_test.py | 3 + .../{ => tests}/daemon-address-family_test.py | 0 .../{ => tests}/daemon-argv-limit_test.py | 0 .../daemon-auth-digest-floor_test.py | 0 .../{ => tests}/daemon-auth-group_test.py | 6 +- .../daemon-auth-users-comma-only_test.py | 0 testsuite/{ => tests}/daemon-auth_test.py | 3 + .../{ => tests}/daemon-chroot-acl_test.py | 0 .../daemon-chroot-munge-default_test.py | 2 +- testsuite/{ => tests}/daemon-chroot_test.py | 4 +- .../{ => tests}/daemon-config-symlink_test.py | 4 +- testsuite/{ => tests}/daemon-config_test.py | 3 + .../daemon-connection-limits_test.py | 0 .../daemon-copy-links-symlink_test.py | 0 .../daemon-copylinks-intree_test.py | 0 .../daemon-copylinks-parent-escape_test.py | 0 ...copylinks-parent-target-regression_test.py | 3 +- .../{ => tests}/daemon-delete-stats_test.py | 0 .../daemon-deny-dns-failopen_test.py | 0 .../daemon-dot-file-force-wipe_test.py | 0 .../daemon-early-exec-nameconv_test.py | 0 ...daemon-exclude-from-outside-module_test.py | 0 .../daemon-exclude-namebased_test.py | 0 ...mon-exec-metachar-documented-limit_test.py | 0 .../daemon-exec-metachar-refused_test.py | 0 ...daemon-exec-rsync-env-shell-escape_test.py | 0 ...ec-second-shell-argument-injection_test.py | 0 .../daemon-exec-singlequote-injection_test.py | 0 testsuite/{ => tests}/daemon-exec_test.py | 2 + .../daemon-filter-merge-bypass_test.py | 0 testsuite/{ => tests}/daemon-filter_test.py | 3 + .../{ => tests}/daemon-groupmap-wild_test.py | 0 .../{ => tests}/daemon-gzip-download_test.py | 2 + .../{ => tests}/daemon-gzip-upload_test.py | 2 + .../daemon-handshake-timeout_test.py | 0 .../{ => tests}/daemon-http-proxy_test.py | 0 .../daemon-include-maxconn_test.py | 0 .../daemon-leaf-type-race-fchmod_test.py | 8 +- .../daemon-link-dest-escape_test.py | 0 .../daemon-module-chdir-symlink_test.py | 4 +- .../{ => tests}/daemon-module-options_test.py | 0 .../daemon-module-private-parent_test.py | 4 +- testsuite/{ => tests}/daemon-munge_test.py | 3 + .../daemon-namecvt-empty-response_test.py | 9 +- .../daemon-namecvt-newline-token_test.py | 0 .../{ => tests}/daemon-path-root-read_test.py | 0 .../{ => tests}/daemon-path-rsync-var_test.py | 0 .../{ => tests}/daemon-proxy-protocol_test.py | 0 ...emon-refuse-compress-threads-alias_test.py | 0 .../daemon-refuse-compress_test.py | 2 + .../daemon-refuse-delete-alias_test.py | 0 testsuite/{ => tests}/daemon-refuse_test.py | 3 + .../daemon-scan-cwd-desync_test.py | 0 .../daemon-scan-dir-escape_test.py | 0 .../daemon-secrets-file-symlink_test.py | 4 +- .../daemon-size-arg-overflow_test.py | 0 .../daemon-standalone-detach_test.py | 3 +- .../{ => tests}/daemon-stdin-inetd_test.py | 0 .../daemon-stdin-local-socket_test.py | 4 +- .../daemon-stdin-unix-listener_test.py | 7 +- .../daemon-strict-modes-matrix_test.py | 0 .../daemon-subdir-climb-symlink_test.py | 0 .../daemon-symlink-escape-matrix_test.py | 2 +- .../daemon-unix-socket-atfd_test.py | 0 .../daemon-zstd-thread-exhaustion_test.py | 0 testsuite/{ => tests}/daemon_test.py | 2 + testsuite/{ => tests}/deep-path_test.py | 0 .../{ => tests}/delay-updates-deep_test.py | 0 testsuite/{ => tests}/delay-updates_test.py | 0 testsuite/{ => tests}/delete-deep_test.py | 0 .../delete-missing-args-files-from_test.py | 0 testsuite/{ => tests}/delete_test.py | 0 .../{ => tests}/dest-symlinked-dir_test.py | 0 testsuite/{ => tests}/devices-fake_test.py | 0 testsuite/{ => tests}/devices_test.py | 18 +- testsuite/{ => tests}/dir-sgid_test.py | 9 +- testsuite/{ => tests}/dirs_test.py | 0 testsuite/{ => tests}/duplicates_test.py | 0 .../{ => tests}/early-input-symlink_test.py | 4 +- ...exclude-implied-trailing-backslash_test.py | 0 testsuite/{ => tests}/exclude-lsh_test.py | 0 testsuite/{ => tests}/exclude_test.py | 2 + .../{ => tests}/excludefrom-symlink_test.py | 4 +- testsuite/{ => tests}/executability_test.py | 0 .../{ => tests}/fake-super-acl-xattr_test.py | 11 +- .../fake-super-backup-fifo-regression_test.py | 5 +- .../file-to-file-mkpath-dry-run_test.py | 0 .../{ => tests}/files-from-depth_test.py | 0 testsuite/{ => tests}/files-from-leak_test.py | 8 +- .../{ => tests}/files-from-path-clamp_test.py | 0 testsuite/{ => tests}/files-from_test.py | 2 + testsuite/{ => tests}/filter-depth_test.py | 0 testsuite/{ => tests}/filter-leak_test.py | 8 +- .../filter-merge-content-echo_test.py | 0 .../filter-merge-recursion_test.py | 0 .../{ => tests}/filter-merge-symlink_test.py | 5 +- testsuite/{ => tests}/fuzzy-basis_test.py | 0 testsuite/{ => tests}/fuzzy_test.py | 0 ...ile-times-python-compat-regression_test.py | 2 +- testsuite/{ => tests}/growing-file_test.py | 0 testsuite/{ => tests}/hands_test.py | 0 testsuite/{ => tests}/hardlinks-deep_test.py | 0 testsuite/{ => tests}/hardlinks_test.py | 2 + .../{ => tests}/hashsearch-chain_test.py | 0 .../{ => tests}/hashtable-overflow_test.py | 0 testsuite/{ => tests}/highfd-hang_test.py | 0 testsuite/{ => tests}/iconv_test.py | 10 +- testsuite/{ => tests}/idn_test.py | 0 .../{ => tests}/ignore-missing-args_test.py | 0 .../{ => tests}/inband-modname-leak_test.py | 0 testsuite/{ => tests}/inplace_test.py | 0 .../insecure-links-admin-optout_test.py | 5 +- testsuite/{ => tests}/install-strip_test.py | 0 .../io-noop-flood-recursion_test.py | 0 .../io-nosend-flood-recursion_test.py | 0 .../io-readargs-argv-nullwrite_test.py | 0 testsuite/{ => tests}/itemize_test.py | 0 testsuite/{ => tests}/iwildmatch-fold_test.py | 0 .../{ => tests}/keep-dirlinks-rule_test.py | 0 .../keep-dirlinks-symlinked-dest_test.py | 0 .../ki58-log-format-percent_test.py | 0 .../{ => tests}/ki62-io-error-mask_test.py | 2 +- .../ki72-safe-links-backup_test.py | 0 .../{ => tests}/ki73-cvs-clear-list_test.py | 0 .../link-dest-module-escape_test.py | 0 .../{ => tests}/link-dest-pathroot_test.py | 0 .../link-dest-relative-basis_test.py | 0 .../link-dest-symlink-enotsup_test.py | 9 +- testsuite/{ => tests}/links_test.py | 0 .../{ => tests}/log-control-chars_test.py | 0 .../{ => tests}/log-file-symlink_test.py | 4 +- testsuite/{ => tests}/longdir_test.py | 0 ...os-setgid-ordinary-mode-regression_test.py | 14 +- .../malicious-dot-dir-delete-scope_test.py | 0 .../malicious-dot-file-delete-scope_test.py | 0 .../malicious-sender-delete-scope_test.py | 0 ...er-partial-basis-symlink-overwrite_test.py | 0 .../match-append-empty-nullmap_test.py | 0 .../{ => tests}/match-want-i-nolen_test.py | 0 testsuite/{ => tests}/max-alloc-zero_test.py | 0 testsuite/{ => tests}/merge_test.py | 0 testsuite/{ => tests}/metadata-depth_test.py | 0 testsuite/tests/metadata_test.py | 71 ++++ testsuite/{ => tests}/misc-coverage_test.py | 0 testsuite/{ => tests}/missing_test.py | 0 testsuite/{ => tests}/mkpath_test.py | 0 .../msg-io-timeout-overflow_test.py | 0 .../{ => tests}/msg-io-timeout-zero_test.py | 0 .../nested-socket-specials_test.py | 0 .../no-implied-dirs-symlink_test.py | 0 .../nondaemon-symlink-race_test.py | 5 +- .../nonroot-restrictive-perms_test.py | 4 +- testsuite/{ => tests}/old-dirs_test.py | 2 + testsuite/{ => tests}/omit-times_test.py | 0 testsuite/{ => tests}/open-noatime_test.py | 4 +- .../operator-path-backup-dir-daemon_test.py | 0 ...tor-path-backup-dir-exclude-daemon_test.py | 0 .../operator-path-backup-dir_test.py | 0 .../operator-path-backup-rmdir_test.py | 5 +- .../operator-path-backup-symlink_test.py | 5 +- .../operator-path-compare-dest_test.py | 0 .../operator-path-copy-dest_test.py | 0 .../operator-path-dir-daemon-inmodule_test.py | 0 .../operator-path-dir-daemon-leaf_test.py | 0 .../operator-path-dir-daemon-mkdir_test.py | 0 .../operator-path-dir-daemon-outside_test.py | 0 .../operator-path-files-from_test.py | 0 .../operator-path-inplace-backup-dir_test.py | 0 ...perator-path-insecure-links-daemon_test.py | 4 +- ...erator-path-insecure-links-refused_test.py | 0 .../operator-path-link-dest_test.py | 0 .../operator-path-log-file_test.py | 0 .../operator-path-partial-dir-daemon_test.py | 0 ...or-path-partial-dir-exclude-daemon_test.py | 0 .../operator-path-partial-dir_test.py | 0 .../operator-path-temp-dir_test.py | 0 ...r-path-traversal-backup-dir-daemon_test.py | 0 ...tor-path-traversal-dest-dir-daemon_test.py | 0 ...-path-traversal-partial-dir-daemon_test.py | 0 .../operator-path-write-batch_test.py | 0 .../{ => tests}/output-control-chars_test.py | 0 testsuite/{ => tests}/output-options_test.py | 0 testsuite/{ => tests}/ownership-depth_test.py | 0 .../{ => tests}/partial-dir-abs-delta_test.py | 0 ...tial-protected-regular-retry-linux_test.py | 11 +- ...ial-protected-regular-retry-policy_test.py | 3 +- testsuite/{ => tests}/partial_nowrite_test.py | 0 testsuite/{ => tests}/partial_test.py | 0 .../{ => tests}/password-file-symlink_test.py | 4 +- .../peer-legacy-implied-delete-scope_test.py | 0 testsuite/{ => tests}/preallocate_test.py | 2 +- testsuite/tests/profile_test.py | 60 +++ .../{ => tests}/protected-regular_test.py | 11 +- .../proto-cleared-dirflist_test.py | 0 .../{ => tests}/proto-cleared-ndx_test.py | 0 .../{ => tests}/proto-hlink-flag-oob_test.py | 0 .../{ => tests}/proto-hlink-gnum_test.py | 0 .../{ => tests}/proto-msg-info-assert_test.py | 0 .../proto-parent-ndx-empty-dirflist_test.py | 0 .../{ => tests}/proto-sender-selftest_test.py | 0 .../{ => tests}/proto-subflist-freed_test.py | 0 .../proxy-connect-request-too-long_test.py | 0 testsuite/{ => tests}/proxy-host-crlf_test.py | 0 .../proxy-protocol-trusted-peer_test.py | 0 .../proxy-response-header-too-long_test.py | 0 .../proxy-response-line-too-long_test.py | 0 .../{ => tests}/prune-empty-dirs_test.py | 0 .../{ => tests}/pseudo-paths-daemon_test.py | 6 +- testsuite/{ => tests}/pseudo-paths_test.py | 6 +- testsuite/{ => tests}/read-batch-pipe_test.py | 6 +- ...only-partial-abort-mode-regression_test.py | 5 +- testsuite/tests/receipt_test.py | 47 +++ .../recv-discard-nullderef_test.py | 5 +- .../recv-generator-acl-leak_test.py | 0 .../{ => tests}/relative-content_test.py | 0 .../relative-implied-symlink_test.py | 0 .../{ => tests}/relative-implied_test.py | 0 .../relative-mkpath-dir-symlink_test.py | 0 .../relative-mkpath-symlink_test.py | 0 .../relative-source-ancestor_test.py | 0 .../relative-symlinked-parent-dotdot_test.py | 0 .../relative-symlinked-parent_test.py | 0 testsuite/{ => tests}/relative_test.py | 0 testsuite/{ => tests}/remote-logging_test.py | 0 .../remote-shell-newline-escaping_test.py | 0 .../rename-fullpath-symlink-race_test.py | 0 .../rename-mixed-parent-escape-poc_test.py | 0 .../rename-mixed-parent-symlink-race_test.py | 0 .../rename-mixed-parent-transfer_test.py | 5 +- testsuite/tests/results_test.py | 42 ++ .../{ => tests}/reverse-daemon-delta_test.py | 3 + .../rrsync-alt-dest-inband-pivot_test.py | 0 .../{ => tests}/rrsync-archive-mode_test.py | 0 .../rrsync-backup-dir-inband-pivot_test.py | 0 .../rrsync-copy-unsafe-links-denied_test.py | 0 .../{ => tests}/rrsync-debug-denied_test.py | 0 .../rrsync-files-from-stdin_test.py | 0 .../rrsync-logfile-symlink_test.py | 0 .../rrsync-merge-file-confine_test.py | 0 ...sync-no-overwrite-backup-collision_test.py | 0 .../rrsync-no-overwrite-delay-updates_test.py | 0 .../rrsync-no-overwrite-logfile_test.py | 0 .../rrsync-no-overwrite-partial-dir_test.py | 0 .../rrsync-pull-arg-shapes_test.py | 0 .../rrsync-pull-delivers-content_test.py | 0 .../rrsync-root-relative-paths_test.py | 0 .../rrsync-sender-leaf-flip_test.py | 2 +- .../rrsync-sender-parent-pin_test.py | 2 +- .../rrsync-specials-denied_test.py | 0 testsuite/{ => tests}/rrsync-symlink_test.py | 2 +- .../{ => tests}/rrsync-userns-procfs_test.py | 18 +- .../rrsync-write-only-files-from_test.py | 0 .../rsync-ssl-hostname-validation_test.py | 0 .../rsync-ssl-openssl-hostname-check_test.py | 0 .../rsync-ssl-stunnel-ca-required_test.py | 0 .../rsync-ssl-stunnel-hostname-check_test.py | 0 .../{ => tests}/rsync-ssl-type-option_test.py | 0 testsuite/{ => tests}/safe-arg-leak_test.py | 0 .../safe-links-absolute-intree_test.py | 0 .../{ => tests}/safe-links-unsafe-def_test.py | 0 testsuite/{ => tests}/safe-links_test.py | 0 .../{ => tests}/scanner-argv-bounds_test.py | 0 .../scanner-batch-flag-mismatch_test.py | 7 +- .../scanner-daemon-log-checksum_test.py | 0 .../scanner-delete-delay-overread_test.py | 0 .../search-only-destination_test.py | 7 +- .../search-only-held-dirfd_test.py | 6 +- .../secure-relpath-validation_test.py | 0 .../sender-flist-symlink-leak_test.py | 2 + .../{ => tests}/sender-readlink-atfd_test.py | 0 ...nder-remove-source-relative-anchor_test.py | 0 .../sender-remove-source-root-anchor_test.py | 4 +- .../sender-remove-source-secure_test.py | 0 .../sender-scan-dir-escape_test.py | 0 testsuite/{ => tests}/simd-checksum_test.py | 2 +- testsuite/{ => tests}/size-filter_test.py | 0 testsuite/{ => tests}/skiplist-spec_test.py | 51 ++- testsuite/tests/smoke_test.py | 55 +++ .../source-change-size-continues_test.py | 12 +- testsuite/{ => tests}/sparse_test.py | 2 +- testsuite/{ => tests}/ssh-basic_test.py | 2 + testsuite/{ => tests}/stop-time_test.py | 0 testsuite/{ => tests}/strict-basis_test.py | 0 .../symlink-dest-backupdir_test.py | 5 +- .../{ => tests}/symlink-dirlink-basis_test.py | 2 + .../symlink-exclude-chdir-alias_test.py | 0 .../symlink-exclude-component_test.py | 0 .../{ => tests}/symlink-exclude-deep_test.py | 0 .../{ => tests}/symlink-exclude-leaf_test.py | 0 .../{ => tests}/symlink-exclude-meta_test.py | 0 .../{ => tests}/symlink-exclude-xattr_test.py | 7 +- testsuite/{ => tests}/symlink-exclude_test.py | 0 testsuite/{ => tests}/symlink-ignore_test.py | 0 ...mlink-mknod-fakesuper-symlink-race_test.py | 2 +- .../{ => tests}/symlink-race-dest_test.py | 5 +- .../symlink-race-relative-dest_test.py | 4 +- .../{ => tests}/symlink-race-source_test.py | 0 .../{ => tests}/symlink-unix-perms_test.py | 4 +- .../temp-dir-symlink-injection_test.py | 5 +- testsuite/{ => tests}/temp-dir_test.py | 0 testsuite/{ => tests}/trimslash_test.py | 0 .../{ => tests}/uidlist-id0-name-leak_test.py | 0 testsuite/{ => tests}/unsafe-byname_test.py | 0 testsuite/{ => tests}/unsafe-links_test.py | 0 testsuite/{ => tests}/update_test.py | 0 .../variety-symlink-traversal_test.py | 5 +- testsuite/{ => tests}/variety_test.py | 5 +- testsuite/{ => tests}/wildmatch_test.py | 0 .../write-batch-filter-injection_test.py | 0 .../{ => tests}/write-batch-quoting_test.py | 0 .../{ => tests}/write-touched-blocks_test.py | 3 +- testsuite/{ => tests}/xattr-wire-cap_test.py | 6 +- testsuite/{ => tests}/xattrs-depth_test.py | 6 +- testsuite/{ => tests}/xattrs-hlink_test.py | 0 testsuite/{ => tests}/xattrs_test.py | 4 +- testsuite/{ => tests}/xrsync_test.py | 0 430 files changed, 1749 insertions(+), 989 deletions(-) delete mode 100644 testsuite/00-hello_test.py delete mode 100644 testsuite/crtimes_test.py delete mode 100644 testsuite/expect/rsync_2.6.0.expect delete mode 100644 testsuite/expect/rsync_3.0.0.expect delete mode 100644 testsuite/expect/rsync_3.1.0.expect delete mode 100644 testsuite/expect/rsync_3.1.3.expect delete mode 100644 testsuite/expect/rsync_3.2.0.expect delete mode 100644 testsuite/expect/rsync_3.2.7.expect delete mode 100644 testsuite/expect/rsync_3.3.0.expect delete mode 100644 testsuite/expect/rsync_3.4.0.expect delete mode 100644 testsuite/expect/rsync_3.4.1.expect create mode 100644 testsuite/harness/__init__.py create mode 100644 testsuite/harness/context.py create mode 100644 testsuite/harness/metadata.py create mode 100644 testsuite/harness/profile.py create mode 100644 testsuite/harness/receipt.py create mode 100644 testsuite/harness/results.py create mode 100644 testsuite/profiles/cygwin.json create mode 100644 testsuite/profiles/linux.json create mode 100644 testsuite/profiles/macos.json create mode 100644 testsuite/profiles/non-asan.json create mode 100644 testsuite/profiles/nonroot.json create mode 100644 testsuite/profiles/peer-2.6.0.json create mode 100644 testsuite/profiles/peer-3.0.0.json create mode 100644 testsuite/profiles/peer-3.1.0.json create mode 100644 testsuite/profiles/peer-3.1.3.json create mode 100644 testsuite/profiles/peer-3.2.0.json create mode 100644 testsuite/profiles/peer-3.2.7.json create mode 100644 testsuite/profiles/peer-3.3.0.json create mode 100644 testsuite/profiles/peer-3.4.0.json create mode 100644 testsuite/profiles/peer-3.4.1.json create mode 100644 testsuite/profiles/pipe.json create mode 100644 testsuite/profiles/protocol-29.json create mode 100644 testsuite/profiles/protocol-30.json create mode 100644 testsuite/profiles/root.json create mode 100644 testsuite/profiles/self-peer.json rename runtests.py => testsuite/runtests.py (76%) rename testsuite/{ => tests}/acl-symlink-race_test.py (95%) rename testsuite/{ => tests}/acls-default_test.py (93%) rename testsuite/{ => tests}/acls-depth_test.py (89%) rename testsuite/{ => tests}/acls-unpinnable_test.py (92%) rename testsuite/{ => tests}/acls_test.py (91%) rename testsuite/{ => tests}/alt-dest-deep_test.py (100%) rename testsuite/{ => tests}/alt-dest-symlink-race_test.py (91%) rename testsuite/{ => tests}/alt-dest_test.py (92%) rename testsuite/{ => tests}/append-shortsum_test.py (100%) rename testsuite/{ => tests}/append_test.py (100%) rename testsuite/{ => tests}/atimes_test.py (100%) rename testsuite/{ => tests}/authenticate-no-ocloexec-build-regression_test.py (97%) rename testsuite/{ => tests}/backup-acl-xattr-cache_test.py (92%) rename testsuite/{ => tests}/backup-crossdev-copy_test.py (98%) rename testsuite/{ => tests}/backup-deep_test.py (100%) rename testsuite/{ => tests}/backup-dir-relative_test.py (100%) rename testsuite/{ => tests}/backup-dir-repeated-separator-delete_test.py (100%) rename testsuite/{ => tests}/backup-dir-symlink-race_test.py (98%) rename testsuite/{ => tests}/backup-incremental_test.py (100%) rename testsuite/{ => tests}/backup_test.py (100%) rename testsuite/{ => tests}/bare-do-open-symlink-race_test.py (95%) rename testsuite/{ => tests}/basis-xname-traversal_test.py (100%) rename testsuite/{ => tests}/batch-file-symlink_test.py (98%) rename testsuite/{ => tests}/batch-mode_test.py (92%) rename testsuite/{ => tests}/batch-only-remove-source-regression_test.py (100%) rename testsuite/{ => tests}/change-shrink_test.py (100%) rename testsuite/{ => tests}/change-vanish_test.py (100%) rename testsuite/{ => tests}/chdir-symlink-race_test.py (94%) rename testsuite/{ => tests}/checksum-zero-blocklen_test.py (100%) rename testsuite/{ => tests}/chgrp_test.py (100%) rename testsuite/{ => tests}/chmod-option_test.py (94%) rename testsuite/{ => tests}/chmod-setid_test.py (100%) rename testsuite/{ => tests}/chmod-symlink-race_test.py (100%) rename testsuite/{ => tests}/chmod-temp-dir_test.py (98%) rename testsuite/{ => tests}/chmod_test.py (100%) rename testsuite/{ => tests}/chown-fake_test.py (100%) rename testsuite/{ => tests}/chown_test.py (91%) rename testsuite/{ => tests}/chroot-alt-dest-inner-module_test.py (100%) rename testsuite/{ => tests}/chroot-basis-forge-inner-module_test.py (100%) rename testsuite/{ => tests}/chroot-copy-dest-inner-module_test.py (100%) rename testsuite/{ => tests}/chroot-link-dest-inner-module_test.py (100%) rename testsuite/{ => tests}/chroot-receiver-write-inner-module_test.py (100%) rename testsuite/{ => tests}/chroot-special-inner-module_test.py (100%) rename testsuite/{ => tests}/clean-fname-collapse_test.py (100%) rename testsuite/{ => tests}/clean-fname-underflow_test.py (100%) rename testsuite/{ => tests}/compare_test.py (100%) rename testsuite/{ => tests}/compress-options_test.py (100%) rename testsuite/{ => tests}/compress-zlib-insert_test.py (100%) rename testsuite/{ => tests}/connect-prog-host-quoting_test.py (100%) rename testsuite/{ => tests}/connect-prog-nested-singlequote-host-injection_test.py (100%) rename testsuite/{ => tests}/contimeout-rsh_test.py (100%) rename testsuite/{ => tests}/copy-dest-source-symlink_test.py (91%) rename testsuite/{ => tests}/copy-dest-symlink-readleak_test.py (97%) rename testsuite/{ => tests}/copy-xattrs-symlink-race_test.py (97%) create mode 100644 testsuite/tests/crtimes_test.py rename testsuite/{ => tests}/cvs-exclude_test.py (100%) rename testsuite/{ => tests}/daemon-access-idn_test.py (100%) rename testsuite/{ => tests}/daemon-access-ip_test.py (100%) rename testsuite/{ => tests}/daemon-access_test.py (92%) rename testsuite/{ => tests}/daemon-address-family_test.py (100%) rename testsuite/{ => tests}/daemon-argv-limit_test.py (100%) rename testsuite/{ => tests}/daemon-auth-digest-floor_test.py (100%) rename testsuite/{ => tests}/daemon-auth-group_test.py (98%) rename testsuite/{ => tests}/daemon-auth-users-comma-only_test.py (100%) rename testsuite/{ => tests}/daemon-auth_test.py (92%) rename testsuite/{ => tests}/daemon-chroot-acl_test.py (100%) rename testsuite/{ => tests}/daemon-chroot-munge-default_test.py (98%) rename testsuite/{ => tests}/daemon-chroot_test.py (95%) rename testsuite/{ => tests}/daemon-config-symlink_test.py (97%) rename testsuite/{ => tests}/daemon-config_test.py (88%) rename testsuite/{ => tests}/daemon-connection-limits_test.py (100%) rename testsuite/{ => tests}/daemon-copy-links-symlink_test.py (100%) rename testsuite/{ => tests}/daemon-copylinks-intree_test.py (100%) rename testsuite/{ => tests}/daemon-copylinks-parent-escape_test.py (100%) rename testsuite/{ => tests}/daemon-copylinks-parent-target-regression_test.py (97%) rename testsuite/{ => tests}/daemon-delete-stats_test.py (100%) rename testsuite/{ => tests}/daemon-deny-dns-failopen_test.py (100%) rename testsuite/{ => tests}/daemon-dot-file-force-wipe_test.py (100%) rename testsuite/{ => tests}/daemon-early-exec-nameconv_test.py (100%) rename testsuite/{ => tests}/daemon-exclude-from-outside-module_test.py (100%) rename testsuite/{ => tests}/daemon-exclude-namebased_test.py (100%) rename testsuite/{ => tests}/daemon-exec-metachar-documented-limit_test.py (100%) rename testsuite/{ => tests}/daemon-exec-metachar-refused_test.py (100%) rename testsuite/{ => tests}/daemon-exec-rsync-env-shell-escape_test.py (100%) rename testsuite/{ => tests}/daemon-exec-second-shell-argument-injection_test.py (100%) rename testsuite/{ => tests}/daemon-exec-singlequote-injection_test.py (100%) rename testsuite/{ => tests}/daemon-exec_test.py (92%) rename testsuite/{ => tests}/daemon-filter-merge-bypass_test.py (100%) rename testsuite/{ => tests}/daemon-filter_test.py (92%) rename testsuite/{ => tests}/daemon-groupmap-wild_test.py (100%) rename testsuite/{ => tests}/daemon-gzip-download_test.py (76%) rename testsuite/{ => tests}/daemon-gzip-upload_test.py (76%) rename testsuite/{ => tests}/daemon-handshake-timeout_test.py (100%) rename testsuite/{ => tests}/daemon-http-proxy_test.py (100%) rename testsuite/{ => tests}/daemon-include-maxconn_test.py (100%) rename testsuite/{ => tests}/daemon-leaf-type-race-fchmod_test.py (98%) rename testsuite/{ => tests}/daemon-link-dest-escape_test.py (100%) rename testsuite/{ => tests}/daemon-module-chdir-symlink_test.py (97%) rename testsuite/{ => tests}/daemon-module-options_test.py (100%) rename testsuite/{ => tests}/daemon-module-private-parent_test.py (98%) rename testsuite/{ => tests}/daemon-munge_test.py (87%) rename testsuite/{ => tests}/daemon-namecvt-empty-response_test.py (97%) rename testsuite/{ => tests}/daemon-namecvt-newline-token_test.py (100%) rename testsuite/{ => tests}/daemon-path-root-read_test.py (100%) rename testsuite/{ => tests}/daemon-path-rsync-var_test.py (100%) rename testsuite/{ => tests}/daemon-proxy-protocol_test.py (100%) rename testsuite/{ => tests}/daemon-refuse-compress-threads-alias_test.py (100%) rename testsuite/{ => tests}/daemon-refuse-compress_test.py (85%) rename testsuite/{ => tests}/daemon-refuse-delete-alias_test.py (100%) rename testsuite/{ => tests}/daemon-refuse_test.py (91%) rename testsuite/{ => tests}/daemon-scan-cwd-desync_test.py (100%) rename testsuite/{ => tests}/daemon-scan-dir-escape_test.py (100%) rename testsuite/{ => tests}/daemon-secrets-file-symlink_test.py (98%) rename testsuite/{ => tests}/daemon-size-arg-overflow_test.py (100%) rename testsuite/{ => tests}/daemon-standalone-detach_test.py (98%) rename testsuite/{ => tests}/daemon-stdin-inetd_test.py (100%) rename testsuite/{ => tests}/daemon-stdin-local-socket_test.py (89%) rename testsuite/{ => tests}/daemon-stdin-unix-listener_test.py (93%) rename testsuite/{ => tests}/daemon-strict-modes-matrix_test.py (100%) rename testsuite/{ => tests}/daemon-subdir-climb-symlink_test.py (100%) rename testsuite/{ => tests}/daemon-symlink-escape-matrix_test.py (99%) rename testsuite/{ => tests}/daemon-unix-socket-atfd_test.py (100%) rename testsuite/{ => tests}/daemon-zstd-thread-exhaustion_test.py (100%) rename testsuite/{ => tests}/daemon_test.py (95%) rename testsuite/{ => tests}/deep-path_test.py (100%) rename testsuite/{ => tests}/delay-updates-deep_test.py (100%) rename testsuite/{ => tests}/delay-updates_test.py (100%) rename testsuite/{ => tests}/delete-deep_test.py (100%) rename testsuite/{ => tests}/delete-missing-args-files-from_test.py (100%) rename testsuite/{ => tests}/delete_test.py (100%) rename testsuite/{ => tests}/dest-symlinked-dir_test.py (100%) rename testsuite/{ => tests}/devices-fake_test.py (100%) rename testsuite/{ => tests}/devices_test.py (89%) rename testsuite/{ => tests}/dir-sgid_test.py (93%) rename testsuite/{ => tests}/dirs_test.py (100%) rename testsuite/{ => tests}/duplicates_test.py (100%) rename testsuite/{ => tests}/early-input-symlink_test.py (98%) rename testsuite/{ => tests}/exclude-implied-trailing-backslash_test.py (100%) rename testsuite/{ => tests}/exclude-lsh_test.py (100%) rename testsuite/{ => tests}/exclude_test.py (97%) rename testsuite/{ => tests}/excludefrom-symlink_test.py (97%) rename testsuite/{ => tests}/executability_test.py (100%) rename testsuite/{ => tests}/fake-super-acl-xattr_test.py (92%) rename testsuite/{ => tests}/fake-super-backup-fifo-regression_test.py (98%) rename testsuite/{ => tests}/file-to-file-mkpath-dry-run_test.py (100%) rename testsuite/{ => tests}/files-from-depth_test.py (100%) rename testsuite/{ => tests}/files-from-leak_test.py (97%) rename testsuite/{ => tests}/files-from-path-clamp_test.py (100%) rename testsuite/{ => tests}/files-from_test.py (87%) rename testsuite/{ => tests}/filter-depth_test.py (100%) rename testsuite/{ => tests}/filter-leak_test.py (97%) rename testsuite/{ => tests}/filter-merge-content-echo_test.py (100%) rename testsuite/{ => tests}/filter-merge-recursion_test.py (100%) rename testsuite/{ => tests}/filter-merge-symlink_test.py (98%) rename testsuite/{ => tests}/fuzzy-basis_test.py (100%) rename testsuite/{ => tests}/fuzzy_test.py (100%) rename testsuite/{ => tests}/git-set-file-times-python-compat-regression_test.py (98%) rename testsuite/{ => tests}/growing-file_test.py (100%) rename testsuite/{ => tests}/hands_test.py (100%) rename testsuite/{ => tests}/hardlinks-deep_test.py (100%) rename testsuite/{ => tests}/hardlinks_test.py (93%) rename testsuite/{ => tests}/hashsearch-chain_test.py (100%) rename testsuite/{ => tests}/hashtable-overflow_test.py (100%) rename testsuite/{ => tests}/highfd-hang_test.py (100%) rename testsuite/{ => tests}/iconv_test.py (97%) rename testsuite/{ => tests}/idn_test.py (100%) rename testsuite/{ => tests}/ignore-missing-args_test.py (100%) rename testsuite/{ => tests}/inband-modname-leak_test.py (100%) rename testsuite/{ => tests}/inplace_test.py (100%) rename testsuite/{ => tests}/insecure-links-admin-optout_test.py (97%) rename testsuite/{ => tests}/install-strip_test.py (100%) rename testsuite/{ => tests}/io-noop-flood-recursion_test.py (100%) rename testsuite/{ => tests}/io-nosend-flood-recursion_test.py (100%) rename testsuite/{ => tests}/io-readargs-argv-nullwrite_test.py (100%) rename testsuite/{ => tests}/itemize_test.py (100%) rename testsuite/{ => tests}/iwildmatch-fold_test.py (100%) rename testsuite/{ => tests}/keep-dirlinks-rule_test.py (100%) rename testsuite/{ => tests}/keep-dirlinks-symlinked-dest_test.py (100%) rename testsuite/{ => tests}/ki58-log-format-percent_test.py (100%) rename testsuite/{ => tests}/ki62-io-error-mask_test.py (99%) rename testsuite/{ => tests}/ki72-safe-links-backup_test.py (100%) rename testsuite/{ => tests}/ki73-cvs-clear-list_test.py (100%) rename testsuite/{ => tests}/link-dest-module-escape_test.py (100%) rename testsuite/{ => tests}/link-dest-pathroot_test.py (100%) rename testsuite/{ => tests}/link-dest-relative-basis_test.py (100%) rename testsuite/{ => tests}/link-dest-symlink-enotsup_test.py (95%) rename testsuite/{ => tests}/links_test.py (100%) rename testsuite/{ => tests}/log-control-chars_test.py (100%) rename testsuite/{ => tests}/log-file-symlink_test.py (97%) rename testsuite/{ => tests}/longdir_test.py (100%) rename testsuite/{ => tests}/macos-setgid-ordinary-mode-regression_test.py (90%) rename testsuite/{ => tests}/malicious-dot-dir-delete-scope_test.py (100%) rename testsuite/{ => tests}/malicious-dot-file-delete-scope_test.py (100%) rename testsuite/{ => tests}/malicious-sender-delete-scope_test.py (100%) rename testsuite/{ => tests}/malicious-server-partial-basis-symlink-overwrite_test.py (100%) rename testsuite/{ => tests}/match-append-empty-nullmap_test.py (100%) rename testsuite/{ => tests}/match-want-i-nolen_test.py (100%) rename testsuite/{ => tests}/max-alloc-zero_test.py (100%) rename testsuite/{ => tests}/merge_test.py (100%) rename testsuite/{ => tests}/metadata-depth_test.py (100%) create mode 100644 testsuite/tests/metadata_test.py rename testsuite/{ => tests}/misc-coverage_test.py (100%) rename testsuite/{ => tests}/missing_test.py (100%) rename testsuite/{ => tests}/mkpath_test.py (100%) rename testsuite/{ => tests}/msg-io-timeout-overflow_test.py (100%) rename testsuite/{ => tests}/msg-io-timeout-zero_test.py (100%) rename testsuite/{ => tests}/nested-socket-specials_test.py (100%) rename testsuite/{ => tests}/no-implied-dirs-symlink_test.py (100%) rename testsuite/{ => tests}/nondaemon-symlink-race_test.py (97%) rename testsuite/{ => tests}/nonroot-restrictive-perms_test.py (97%) rename testsuite/{ => tests}/old-dirs_test.py (84%) rename testsuite/{ => tests}/omit-times_test.py (100%) rename testsuite/{ => tests}/open-noatime_test.py (91%) rename testsuite/{ => tests}/operator-path-backup-dir-daemon_test.py (100%) rename testsuite/{ => tests}/operator-path-backup-dir-exclude-daemon_test.py (100%) rename testsuite/{ => tests}/operator-path-backup-dir_test.py (100%) rename testsuite/{ => tests}/operator-path-backup-rmdir_test.py (97%) rename testsuite/{ => tests}/operator-path-backup-symlink_test.py (97%) rename testsuite/{ => tests}/operator-path-compare-dest_test.py (100%) rename testsuite/{ => tests}/operator-path-copy-dest_test.py (100%) rename testsuite/{ => tests}/operator-path-dir-daemon-inmodule_test.py (100%) rename testsuite/{ => tests}/operator-path-dir-daemon-leaf_test.py (100%) rename testsuite/{ => tests}/operator-path-dir-daemon-mkdir_test.py (100%) rename testsuite/{ => tests}/operator-path-dir-daemon-outside_test.py (100%) rename testsuite/{ => tests}/operator-path-files-from_test.py (100%) rename testsuite/{ => tests}/operator-path-inplace-backup-dir_test.py (100%) rename testsuite/{ => tests}/operator-path-insecure-links-daemon_test.py (97%) rename testsuite/{ => tests}/operator-path-insecure-links-refused_test.py (100%) rename testsuite/{ => tests}/operator-path-link-dest_test.py (100%) rename testsuite/{ => tests}/operator-path-log-file_test.py (100%) rename testsuite/{ => tests}/operator-path-partial-dir-daemon_test.py (100%) rename testsuite/{ => tests}/operator-path-partial-dir-exclude-daemon_test.py (100%) rename testsuite/{ => tests}/operator-path-partial-dir_test.py (100%) rename testsuite/{ => tests}/operator-path-temp-dir_test.py (100%) rename testsuite/{ => tests}/operator-path-traversal-backup-dir-daemon_test.py (100%) rename testsuite/{ => tests}/operator-path-traversal-dest-dir-daemon_test.py (100%) rename testsuite/{ => tests}/operator-path-traversal-partial-dir-daemon_test.py (100%) rename testsuite/{ => tests}/operator-path-write-batch_test.py (100%) rename testsuite/{ => tests}/output-control-chars_test.py (100%) rename testsuite/{ => tests}/output-options_test.py (100%) rename testsuite/{ => tests}/ownership-depth_test.py (100%) rename testsuite/{ => tests}/partial-dir-abs-delta_test.py (100%) rename testsuite/{ => tests}/partial-protected-regular-retry-linux_test.py (98%) rename testsuite/{ => tests}/partial-protected-regular-retry-policy_test.py (99%) rename testsuite/{ => tests}/partial_nowrite_test.py (100%) rename testsuite/{ => tests}/partial_test.py (100%) rename testsuite/{ => tests}/password-file-symlink_test.py (98%) rename testsuite/{ => tests}/peer-legacy-implied-delete-scope_test.py (100%) rename testsuite/{ => tests}/preallocate_test.py (98%) create mode 100644 testsuite/tests/profile_test.py rename testsuite/{ => tests}/protected-regular_test.py (91%) rename testsuite/{ => tests}/proto-cleared-dirflist_test.py (100%) rename testsuite/{ => tests}/proto-cleared-ndx_test.py (100%) rename testsuite/{ => tests}/proto-hlink-flag-oob_test.py (100%) rename testsuite/{ => tests}/proto-hlink-gnum_test.py (100%) rename testsuite/{ => tests}/proto-msg-info-assert_test.py (100%) rename testsuite/{ => tests}/proto-parent-ndx-empty-dirflist_test.py (100%) rename testsuite/{ => tests}/proto-sender-selftest_test.py (100%) rename testsuite/{ => tests}/proto-subflist-freed_test.py (100%) rename testsuite/{ => tests}/proxy-connect-request-too-long_test.py (100%) rename testsuite/{ => tests}/proxy-host-crlf_test.py (100%) rename testsuite/{ => tests}/proxy-protocol-trusted-peer_test.py (100%) rename testsuite/{ => tests}/proxy-response-header-too-long_test.py (100%) rename testsuite/{ => tests}/proxy-response-line-too-long_test.py (100%) rename testsuite/{ => tests}/prune-empty-dirs_test.py (100%) rename testsuite/{ => tests}/pseudo-paths-daemon_test.py (98%) rename testsuite/{ => tests}/pseudo-paths_test.py (99%) rename testsuite/{ => tests}/read-batch-pipe_test.py (95%) rename testsuite/{ => tests}/readonly-partial-abort-mode-regression_test.py (97%) create mode 100644 testsuite/tests/receipt_test.py rename testsuite/{ => tests}/recv-discard-nullderef_test.py (97%) rename testsuite/{ => tests}/recv-generator-acl-leak_test.py (100%) rename testsuite/{ => tests}/relative-content_test.py (100%) rename testsuite/{ => tests}/relative-implied-symlink_test.py (100%) rename testsuite/{ => tests}/relative-implied_test.py (100%) rename testsuite/{ => tests}/relative-mkpath-dir-symlink_test.py (100%) rename testsuite/{ => tests}/relative-mkpath-symlink_test.py (100%) rename testsuite/{ => tests}/relative-source-ancestor_test.py (100%) rename testsuite/{ => tests}/relative-symlinked-parent-dotdot_test.py (100%) rename testsuite/{ => tests}/relative-symlinked-parent_test.py (100%) rename testsuite/{ => tests}/relative_test.py (100%) rename testsuite/{ => tests}/remote-logging_test.py (100%) rename testsuite/{ => tests}/remote-shell-newline-escaping_test.py (100%) rename testsuite/{ => tests}/rename-fullpath-symlink-race_test.py (100%) rename testsuite/{ => tests}/rename-mixed-parent-escape-poc_test.py (100%) rename testsuite/{ => tests}/rename-mixed-parent-symlink-race_test.py (100%) rename testsuite/{ => tests}/rename-mixed-parent-transfer_test.py (98%) create mode 100644 testsuite/tests/results_test.py rename testsuite/{ => tests}/reverse-daemon-delta_test.py (95%) rename testsuite/{ => tests}/rrsync-alt-dest-inband-pivot_test.py (100%) rename testsuite/{ => tests}/rrsync-archive-mode_test.py (100%) rename testsuite/{ => tests}/rrsync-backup-dir-inband-pivot_test.py (100%) rename testsuite/{ => tests}/rrsync-copy-unsafe-links-denied_test.py (100%) rename testsuite/{ => tests}/rrsync-debug-denied_test.py (100%) rename testsuite/{ => tests}/rrsync-files-from-stdin_test.py (100%) rename testsuite/{ => tests}/rrsync-logfile-symlink_test.py (100%) rename testsuite/{ => tests}/rrsync-merge-file-confine_test.py (100%) rename testsuite/{ => tests}/rrsync-no-overwrite-backup-collision_test.py (100%) rename testsuite/{ => tests}/rrsync-no-overwrite-delay-updates_test.py (100%) rename testsuite/{ => tests}/rrsync-no-overwrite-logfile_test.py (100%) rename testsuite/{ => tests}/rrsync-no-overwrite-partial-dir_test.py (100%) rename testsuite/{ => tests}/rrsync-pull-arg-shapes_test.py (100%) rename testsuite/{ => tests}/rrsync-pull-delivers-content_test.py (100%) rename testsuite/{ => tests}/rrsync-root-relative-paths_test.py (100%) rename testsuite/{ => tests}/rrsync-sender-leaf-flip_test.py (99%) rename testsuite/{ => tests}/rrsync-sender-parent-pin_test.py (98%) rename testsuite/{ => tests}/rrsync-specials-denied_test.py (100%) rename testsuite/{ => tests}/rrsync-symlink_test.py (99%) rename testsuite/{ => tests}/rrsync-userns-procfs_test.py (90%) rename testsuite/{ => tests}/rrsync-write-only-files-from_test.py (100%) rename testsuite/{ => tests}/rsync-ssl-hostname-validation_test.py (100%) rename testsuite/{ => tests}/rsync-ssl-openssl-hostname-check_test.py (100%) rename testsuite/{ => tests}/rsync-ssl-stunnel-ca-required_test.py (100%) rename testsuite/{ => tests}/rsync-ssl-stunnel-hostname-check_test.py (100%) rename testsuite/{ => tests}/rsync-ssl-type-option_test.py (100%) rename testsuite/{ => tests}/safe-arg-leak_test.py (100%) rename testsuite/{ => tests}/safe-links-absolute-intree_test.py (100%) rename testsuite/{ => tests}/safe-links-unsafe-def_test.py (100%) rename testsuite/{ => tests}/safe-links_test.py (100%) rename testsuite/{ => tests}/scanner-argv-bounds_test.py (100%) rename testsuite/{ => tests}/scanner-batch-flag-mismatch_test.py (90%) rename testsuite/{ => tests}/scanner-daemon-log-checksum_test.py (100%) rename testsuite/{ => tests}/scanner-delete-delay-overread_test.py (100%) rename testsuite/{ => tests}/search-only-destination_test.py (92%) rename testsuite/{ => tests}/search-only-held-dirfd_test.py (97%) rename testsuite/{ => tests}/secure-relpath-validation_test.py (100%) rename testsuite/{ => tests}/sender-flist-symlink-leak_test.py (92%) rename testsuite/{ => tests}/sender-readlink-atfd_test.py (100%) rename testsuite/{ => tests}/sender-remove-source-relative-anchor_test.py (100%) rename testsuite/{ => tests}/sender-remove-source-root-anchor_test.py (97%) rename testsuite/{ => tests}/sender-remove-source-secure_test.py (100%) rename testsuite/{ => tests}/sender-scan-dir-escape_test.py (100%) rename testsuite/{ => tests}/simd-checksum_test.py (87%) rename testsuite/{ => tests}/size-filter_test.py (100%) rename testsuite/{ => tests}/skiplist-spec_test.py (73%) create mode 100644 testsuite/tests/smoke_test.py rename testsuite/{ => tests}/source-change-size-continues_test.py (95%) rename testsuite/{ => tests}/sparse_test.py (98%) rename testsuite/{ => tests}/ssh-basic_test.py (91%) rename testsuite/{ => tests}/stop-time_test.py (100%) rename testsuite/{ => tests}/strict-basis_test.py (100%) rename testsuite/{ => tests}/symlink-dest-backupdir_test.py (97%) rename testsuite/{ => tests}/symlink-dirlink-basis_test.py (96%) rename testsuite/{ => tests}/symlink-exclude-chdir-alias_test.py (100%) rename testsuite/{ => tests}/symlink-exclude-component_test.py (100%) rename testsuite/{ => tests}/symlink-exclude-deep_test.py (100%) rename testsuite/{ => tests}/symlink-exclude-leaf_test.py (100%) rename testsuite/{ => tests}/symlink-exclude-meta_test.py (100%) rename testsuite/{ => tests}/symlink-exclude-xattr_test.py (91%) rename testsuite/{ => tests}/symlink-exclude_test.py (100%) rename testsuite/{ => tests}/symlink-ignore_test.py (100%) rename testsuite/{ => tests}/symlink-mknod-fakesuper-symlink-race_test.py (98%) rename testsuite/{ => tests}/symlink-race-dest_test.py (97%) rename testsuite/{ => tests}/symlink-race-relative-dest_test.py (96%) rename testsuite/{ => tests}/symlink-race-source_test.py (100%) rename testsuite/{ => tests}/symlink-unix-perms_test.py (96%) rename testsuite/{ => tests}/temp-dir-symlink-injection_test.py (98%) rename testsuite/{ => tests}/temp-dir_test.py (100%) rename testsuite/{ => tests}/trimslash_test.py (100%) rename testsuite/{ => tests}/uidlist-id0-name-leak_test.py (100%) rename testsuite/{ => tests}/unsafe-byname_test.py (100%) rename testsuite/{ => tests}/unsafe-links_test.py (100%) rename testsuite/{ => tests}/update_test.py (100%) rename testsuite/{ => tests}/variety-symlink-traversal_test.py (95%) rename testsuite/{ => tests}/variety_test.py (96%) rename testsuite/{ => tests}/wildmatch_test.py (100%) rename testsuite/{ => tests}/write-batch-filter-injection_test.py (100%) rename testsuite/{ => tests}/write-batch-quoting_test.py (100%) rename testsuite/{ => tests}/write-touched-blocks_test.py (98%) rename testsuite/{ => tests}/xattr-wire-cap_test.py (96%) rename testsuite/{ => tests}/xattrs-depth_test.py (93%) rename testsuite/{ => tests}/xattrs-hlink_test.py (100%) rename testsuite/{ => tests}/xattrs_test.py (98%) rename testsuite/{ => tests}/xrsync_test.py (100%) diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index 10d74590c..a345ea266 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -72,10 +72,10 @@ jobs: # 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 RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt 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 ./testsuite/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 diff --git a/.github/workflows/asan-build.yml b/.github/workflows/asan-build.yml index 80a928fb5..308e4fbdd 100644 --- a/.github/workflows/asan-build.yml +++ b/.github/workflows/asan-build.yml @@ -74,8 +74,8 @@ jobs: - 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: ./testsuite/runtests.py --rsync-bin="$PWD/rsync" -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: ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j8 diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 16e137374..b6b83f249 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -92,11 +92,11 @@ jobs: # 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 RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/cygwin.txt,-daemon-namecvt-empty-response 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 -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 diff --git a/.github/workflows/fleettest.yml b/.github/workflows/fleettest.yml index 293f37378..724191647 100644 --- a/.github/workflows/fleettest.yml +++ b/.github/workflows/fleettest.yml @@ -16,17 +16,25 @@ on: paths: - 'testsuite/fleettest.py' - '.github/workflows/fleettest.yml' - - 'runtests.py' + - 'testsuite/runtests.py' + - 'testsuite/harness/**' + - 'testsuite/profiles/**' + - 'testsuite/tests/**' + - 'testsuite/exitcodes.py' - 'testsuite/skiplist/**' - - 'testsuite/skiplist-spec_test.py' + - 'testsuite/tests/skiplist-spec_test.py' pull_request: types: [opened, synchronize, reopened] paths: - 'testsuite/fleettest.py' - '.github/workflows/fleettest.yml' - - 'runtests.py' + - 'testsuite/runtests.py' + - 'testsuite/harness/**' + - 'testsuite/profiles/**' + - 'testsuite/tests/**' + - 'testsuite/exitcodes.py' - 'testsuite/skiplist/**' - - 'testsuite/skiplist-spec_test.py' + - 'testsuite/tests/skiplist-spec_test.py' workflow_dispatch: schedule: - cron: '17 7 * * 1' diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index f15ee668c..fd61ee8aa 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -46,7 +46,7 @@ jobs: make ./rsync --version make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 + ./testsuite/runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index 3e6bcb521..2d39c2fd0 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -53,11 +53,11 @@ jobs: # 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 RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/macos.txt 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 -j 8 - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml index 25973ec3b..dab7bb0a9 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -45,7 +45,7 @@ jobs: make ./rsync --version make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 + ./testsuite/runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 1d88aeddc..732035490 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -61,7 +61,7 @@ jobs: # 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 + ./testsuite/runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 2 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml index c9581d25b..448151264 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -44,7 +44,7 @@ jobs: make ./rsync --version make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 + ./testsuite/runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 999b41d82..c2bf83597 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -88,17 +88,17 @@ jobs: - 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,non-asan,root,self-peer,linux 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 + run: sudo RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,linux,protocol-30 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 + run: sudo RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,linux,protocol-29 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 (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=non-asan,root,self-peer,linux ./testsuite/runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 - name: check (non-root, targeted) if: matrix.nonroot # Every run above is root (sudo), so privilege-sensitive tests never hit @@ -114,7 +114,7 @@ jobs: # harness's nonroot_tests). run: | sudo rm -rf testtmp # prior root steps left it root-owned - ./runtests.py --rsync-bin="$PWD/rsync" \ + ./testsuite/runtests.py --rsync-bin="$PWD/rsync" \ daemon-namecvt-empty-response ownership-depth daemon - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true diff --git a/.github/workflows/ubuntu-version-mix.yml b/.github/workflows/ubuntu-version-mix.yml index 5717c86db..b3fc01110 100644 --- a/.github/workflows/ubuntu-version-mix.yml +++ b/.github/workflows/ubuntu-version-mix.yml @@ -4,26 +4,8 @@ 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. +# Run the current suite against every retained peer over pipe and TCP transports. +# Test metadata selects applicable coverage while peer profiles record deviations. on: push: @@ -60,24 +42,27 @@ 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. + # Build rsync and the helpers used by direct runner invocations. 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) 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,$profile" --receipt="test-results/version-mix/$name-$transport.json" \ + "${tcp[@]}" -j 8; then echo "::error::version-mix failed: $name ($transport)" rc=1 fi @@ -85,3 +70,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 4a1984c50..df4ab20ee 100644 --- a/.github/workflows/valgrind.yml +++ b/.github/workflows/valgrind.yml @@ -65,7 +65,7 @@ jobs: [ "${{ matrix.privilege }}" = root ] && SUDO="sudo -E" TCP= [ "${{ matrix.transport }}" = tcp ] && TCP="--use-tcp" - $SUDO ./runtests.py --valgrind \ + $SUDO ./testsuite/runtests.py --valgrind \ --valgrind-opts="--leak-check=no --error-exitcode=0" \ $TCP -j8 --preserve-scratch || true diff --git a/.gitignore b/.gitignore index f73d65f27..a1927ff3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.[oa] *~ +__pycache__/ dummy ID Makefile diff --git a/Makefile.in b/Makefile.in index 716171db1..5f782c961 100644 --- a/Makefile.in +++ b/Makefile.in @@ -62,8 +62,8 @@ 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/chown-fake_test.py testsuite/tests/devices-fake_test.py \ + testsuite/tests/xattrs-hlink_test.py testsuite/tests/exclude-lsh_test.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 +436,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 @@ -479,7 +479,7 @@ coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS) 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 \ @@ -519,7 +519,7 @@ coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS) @rc=0; \ for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp'; 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 +550,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/chown-fake_test.py: + ln -s chown_test.py $(srcdir)/testsuite/tests/chown-fake_test.py -testsuite/devices-fake_test.py: - ln -s devices_test.py $(srcdir)/testsuite/devices-fake_test.py +testsuite/tests/devices-fake_test.py: + ln -s devices_test.py $(srcdir)/testsuite/tests/devices-fake_test.py -testsuite/xattrs-hlink_test.py: - ln -s xattrs_test.py $(srcdir)/testsuite/xattrs-hlink_test.py +testsuite/tests/xattrs-hlink_test.py: + ln -s xattrs_test.py $(srcdir)/testsuite/tests/xattrs-hlink_test.py -testsuite/exclude-lsh_test.py: - ln -s exclude_test.py $(srcdir)/testsuite/exclude-lsh_test.py +testsuite/tests/exclude-lsh_test.py: + ln -s exclude_test.py $(srcdir)/testsuite/tests/exclude-lsh_test.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 +568,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/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/abdiff.py b/testsuite/abdiff.py index 06bfa4846..38bd3fc66 100644 --- a/testsuite/abdiff.py +++ b/testsuite/abdiff.py @@ -13,7 +13,7 @@ This is a developer tool, NOT a runtests.py test (it does not end in _test.py and imports nothing from the test harness). Findings are printed and appended to a -log; minimize each into a testsuite/*_test.py. +log; minimise each into a testsuite/tests/*_test.py. Usage: testsuite/abdiff.py [--rsync-a ./rsync] [--rsync-b old_versions/rsync_3.4.1] diff --git a/testsuite/crtimes_test.py b/testsuite/crtimes_test.py deleted file mode 100644 index 7142b5ea4..000000000 --- a/testsuite/crtimes_test.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -# Python rewrite of testsuite/crtimes.test. -# -# Test that rsync preserves source create times when the binary was built -# with crtimes support. Touch tricks: setting an older time via touch sets -# the create time to the mtime; setting a newer time affects only mtime. - -import datetime -import os - -import rsyncfns -from rsyncfns import FROMDIR, TODIR, checkit, run_rsync, test_skipped - - -vv = run_rsync('-VV', check=True, capture_output=True) -if '"crtimes": true' not in vv.stdout: - test_skipped("Rsync is configured without crtimes support") - -FROMDIR.mkdir(parents=True, exist_ok=True) -(FROMDIR / 'foo').write_text("hiho\n") - - -def _utime(path, when: 'datetime.datetime') -> None: - ts = when.timestamp() - os.utime(path, (ts, ts)) - - -# Touch fromdir to an old time then to a newer time -- in shells with the -# right kernel support this leaves the create time pinned to the older. -_utime(FROMDIR, datetime.datetime(2001, 1, 1, 11, 11, 11)) -_utime(FROMDIR, datetime.datetime(2002, 2, 2, 22, 22, 22)) - -_utime(FROMDIR / 'foo', datetime.datetime(2001, 11, 11, 11, 11, 11)) -_utime(FROMDIR / 'foo', datetime.datetime(2002, 12, 12, 22, 22, 22)) - -rsyncfns.TLS_ARGS = '--crtimes' - -checkit(['-rtgvvv', '--crtimes', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) diff --git a/testsuite/exitcodes.py b/testsuite/exitcodes.py index faf3efcb5..f817d635e 100644 --- a/testsuite/exitcodes.py +++ b/testsuite/exitcodes.py @@ -1,17 +1,9 @@ -"""Exit codes a test reports to runtests.py (autotools test convention). - -Shared by runtests.py (the harness, which reads these from each test) and -rsyncfns.py (the helpers, which exit with them) so the 0/1/2/77/78 values are -named in exactly one place. This module has no import-time side effects, so -runtests.py can import it without pulling in rsyncfns's environment checks. -""" - import enum class Exit(enum.IntEnum): PASS = 0 FAIL = 1 - ERROR = 2 # the test could not run (e.g. missing environment) + ERROR = 2 SKIP = 77 - XFAIL = 78 # expected failure: a known, documented residual + XFAIL = 78 diff --git a/testsuite/expect/rsync_2.6.0.expect b/testsuite/expect/rsync_2.6.0.expect deleted file mode 100644 index a43f5b5b0..000000000 --- a/testsuite/expect/rsync_2.6.0.expect +++ /dev/null @@ -1,36 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 2.6.0 peer (protocol 27). -# See rsync_3.1.3.expect for format/semantics; listed tests are the run set. -# 2.6.0 predates much of the suite; many daemon/feature tests are expected -# to fail (xfail) -- a documented old-peer gap, not a current-side bug. -# The four *-symlink-race / sender-flist-symlink-leak xfails show the peer -# is vulnerable to the May-2026 findings that current master fixes. - -00-hello xfail # feature/negotiation absent in 2.6.0 (proto 27) -alt-dest xfail # peer vulnerable to May-2026 finding (fixed in current) -alt-dest-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) -bare-do-open-symlink-race pass -batch-mode xfail # feature/negotiation absent in 2.6.0 (proto 27) -chdir-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) -chmod-option xfail # feature/negotiation absent in 2.6.0 (proto 27) -copy-dest-source-symlink xfail # peer vulnerable to May-2026 finding (fixed in current) -daemon xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-access xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-auth pass -daemon-config xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-exec xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-filter xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-gzip-download xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-gzip-upload xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-munge xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-refuse xfail # feature/negotiation absent in 2.6.0 (proto 27) -daemon-refuse-compress xfail # feature/negotiation absent in 2.6.0 (proto 27) -exclude pass -files-from xfail # feature/negotiation absent in 2.6.0 (proto 27) -hardlinks pass -old-dirs pass -reverse-daemon-delta pass -sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) -ssh-basic pass -variety xfail # specials/devices split & keep-dirlinks absent in 2.6.0 (proto 27) -variety-symlink-traversal xfail # keep-dirlinks absent in 2.6.0 (proto 27) -symlink-dirlink-basis xfail # feature/negotiation absent in 2.6.0 (proto 27) diff --git a/testsuite/expect/rsync_3.0.0.expect b/testsuite/expect/rsync_3.0.0.expect deleted file mode 100644 index ee48ded5b..000000000 --- a/testsuite/expect/rsync_3.0.0.expect +++ /dev/null @@ -1,36 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 3.0.0 peer (protocol 30). -# See rsync_3.1.3.expect for format/semantics; listed tests are the run set. -# 3.0.0 predates much of the suite; many daemon/feature tests are expected -# to fail (xfail) -- a documented old-peer gap, not a current-side bug. -# The four *-symlink-race / sender-flist-symlink-leak xfails show the peer -# is vulnerable to the May-2026 findings that current master fixes. - -00-hello pass -alt-dest pass -alt-dest-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) -bare-do-open-symlink-race pass -batch-mode xfail # feature/negotiation absent in 3.0.0 (proto 30) -chdir-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) -chmod-option xfail # feature/negotiation absent in 3.0.0 (proto 30) -copy-dest-source-symlink xfail # peer vulnerable to May-2026 finding (fixed in current) -daemon xfail # feature/negotiation absent in 3.0.0 (proto 30) -daemon-access pass -daemon-auth pass -daemon-config xfail # feature/negotiation absent in 3.0.0 (proto 30) -daemon-exec pass -daemon-filter xfail # feature/negotiation absent in 3.0.0 (proto 30) -daemon-gzip-download xfail # feature/negotiation absent in 3.0.0 (proto 30) -daemon-gzip-upload xfail # feature/negotiation absent in 3.0.0 (proto 30) -daemon-munge pass -daemon-refuse xfail # feature/negotiation absent in 3.0.0 (proto 30) -daemon-refuse-compress pass -exclude pass -files-from pass -hardlinks pass -old-dirs pass -reverse-daemon-delta pass -sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) -ssh-basic pass -variety pass -variety-symlink-traversal pass -symlink-dirlink-basis pass diff --git a/testsuite/expect/rsync_3.1.0.expect b/testsuite/expect/rsync_3.1.0.expect deleted file mode 100644 index 5c511b829..000000000 --- a/testsuite/expect/rsync_3.1.0.expect +++ /dev/null @@ -1,36 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 3.1.0 peer (protocol 31). -# See rsync_3.1.3.expect for format/semantics; listed tests are the run set. -# 3.1.0 predates much of the suite; many daemon/feature tests are expected -# to fail (xfail) -- a documented old-peer gap, not a current-side bug. -# The four *-symlink-race / sender-flist-symlink-leak xfails show the peer -# is vulnerable to the May-2026 findings that current master fixes. - -00-hello pass -alt-dest pass -alt-dest-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) -bare-do-open-symlink-race pass -batch-mode pass -chdir-symlink-race xfail # peer vulnerable to May-2026 finding (fixed in current) -chmod-option pass -copy-dest-source-symlink xfail # peer vulnerable to May-2026 finding (fixed in current) -daemon xfail # feature/negotiation absent in 3.1.0 (proto 31) -daemon-access pass -daemon-auth pass -daemon-config pass -daemon-exec pass -daemon-filter pass -daemon-gzip-download xfail # feature/negotiation absent in 3.1.0 (proto 31) -daemon-gzip-upload xfail # feature/negotiation absent in 3.1.0 (proto 31) -daemon-munge pass -daemon-refuse xfail # feature/negotiation absent in 3.1.0 (proto 31) -daemon-refuse-compress xfail # feature/negotiation absent in 3.1.0 (proto 31) -exclude pass -files-from pass -hardlinks pass -old-dirs pass -reverse-daemon-delta pass -sender-flist-symlink-leak xfail # peer vulnerable to May-2026 finding (fixed in current) -ssh-basic pass -variety pass -variety-symlink-traversal pass -symlink-dirlink-basis pass diff --git a/testsuite/expect/rsync_3.1.3.expect b/testsuite/expect/rsync_3.1.3.expect deleted file mode 100644 index 339091c76..000000000 --- a/testsuite/expect/rsync_3.1.3.expect +++ /dev/null @@ -1,52 +0,0 @@ -# Expected outcomes for current rsync (client/driver) <-> rsync 3.1.3 peer -# (daemon / remote-shell server), used by runtests.py --expect-result. -# -# Format: " ", '#' comments ignored. The listed -# tests ARE the run set. outcome meanings: -# pass - must succeed -# skip - must skip (exit 77) -# xfail - expected to fail: a known, benign old-peer limitation or an -# unpatched-old-peer vulnerability (documented per line below) -# fail - expected to fail, flagged for investigation (not understood / may -# be a current-side bug) -# fail and xfail are equivalent for pass/fail accounting; the label documents -# why. An UNEXPECTED pass (xpass) of an xfail/fail line fails the run, which is -# the signal to revisit this manifest (e.g. an old-peer gap got closed). -# -# daemon-access-ip and daemon-chroot-acl are intentionally omitted: they need a -# real TCP peer and would skip/run differently by transport. - -# --- two-sided tests that pass against a 3.1.3 peer --- -00-hello pass -reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) -alt-dest pass -bare-do-open-symlink-race pass -batch-mode pass -chmod-option pass -daemon-access pass -daemon-auth pass -daemon-config pass -daemon-exec pass -daemon-filter pass -daemon-gzip-download pass -daemon-gzip-upload pass -daemon-munge pass -exclude pass -files-from pass -hardlinks pass -old-dirs pass -ssh-basic pass -variety pass -variety-symlink-traversal pass -symlink-dirlink-basis pass - -# --- version feature/option gaps in 3.1.3 --- -daemon xfail # exercises -U/--atimes etc. unknown to 3.1.3 (server errors) -daemon-refuse xfail # daemon refuse/option semantics differ (connection reset) -daemon-refuse-compress xfail # pre-zstd compress negotiation; no --compress refuse error - -# --- May-2026 security fixes absent in 3.1.3 (peer is vulnerable) --- -alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) -chdir-symlink-race xfail # chmod escape via cd symlink during chdir -copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) -sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.2.0.expect b/testsuite/expect/rsync_3.2.0.expect deleted file mode 100644 index 00d9a77f9..000000000 --- a/testsuite/expect/rsync_3.2.0.expect +++ /dev/null @@ -1,37 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 3.2.0 peer (see -# rsync_3.1.3.expect for the format/semantics; listed tests are the run set; -# fail/xfail both keep CI green, an unexpected pass fails the run). - -00-hello pass -reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) -alt-dest pass -bare-do-open-symlink-race pass -batch-mode pass -daemon-access pass -daemon-auth pass -daemon-config pass -daemon-exec pass -daemon-filter pass -daemon-gzip-download pass -daemon-gzip-upload pass -daemon-munge pass -daemon-refuse pass -exclude pass -files-from pass -hardlinks pass -old-dirs pass -ssh-basic pass -variety pass -variety-symlink-traversal pass -symlink-dirlink-basis pass - -# --- version feature/option gaps in 3.2.0 --- -chmod-option xfail # client-server protocol start error (code 5) -daemon xfail # daemon listing/option path errors (code 5) -daemon-refuse-compress xfail # --compress refuse semantics differ from current - -# --- May-2026 security fixes absent in 3.2.0 (peer is vulnerable) --- -alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) -chdir-symlink-race xfail # chmod escape via cd symlink during chdir -copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) -sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.2.7.expect b/testsuite/expect/rsync_3.2.7.expect deleted file mode 100644 index 1a995fe61..000000000 --- a/testsuite/expect/rsync_3.2.7.expect +++ /dev/null @@ -1,34 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 3.2.7 peer (see -# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). - -00-hello pass -reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) -alt-dest pass -bare-do-open-symlink-race pass -batch-mode pass -chmod-option pass -daemon pass -daemon-access pass -daemon-auth pass -daemon-config pass -daemon-exec pass -daemon-filter pass -daemon-gzip-download pass -daemon-gzip-upload pass -daemon-munge pass -daemon-refuse pass -daemon-refuse-compress pass -exclude pass -files-from pass -hardlinks pass -old-dirs pass -ssh-basic pass -symlink-dirlink-basis pass -variety pass -variety-symlink-traversal pass - -# --- May-2026 security fixes absent in 3.2.7 (peer is vulnerable) --- -alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) -chdir-symlink-race xfail # chmod escape via cd symlink during chdir -copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) -sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.3.0.expect b/testsuite/expect/rsync_3.3.0.expect deleted file mode 100644 index 208f6fb5b..000000000 --- a/testsuite/expect/rsync_3.3.0.expect +++ /dev/null @@ -1,34 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 3.3.0 peer (see -# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). - -00-hello pass -reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) -alt-dest pass -bare-do-open-symlink-race pass -batch-mode pass -chmod-option pass -daemon pass -daemon-access pass -daemon-auth pass -daemon-config pass -daemon-exec pass -daemon-filter pass -daemon-gzip-download pass -daemon-gzip-upload pass -daemon-munge pass -daemon-refuse pass -daemon-refuse-compress pass -exclude pass -files-from pass -hardlinks pass -old-dirs pass -ssh-basic pass -variety pass -variety-symlink-traversal pass -symlink-dirlink-basis pass - -# --- May-2026 security fixes absent in 3.3.0 (peer is vulnerable) --- -alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) -chdir-symlink-race xfail # chmod escape via cd symlink during chdir -copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) -sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.4.0.expect b/testsuite/expect/rsync_3.4.0.expect deleted file mode 100644 index 354837be4..000000000 --- a/testsuite/expect/rsync_3.4.0.expect +++ /dev/null @@ -1,43 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 3.4.0 peer (see -# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). - -00-hello pass -reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) -alt-dest pass -bare-do-open-symlink-race pass -batch-mode pass -chmod-option pass -daemon pass -daemon-access pass -daemon-auth pass -daemon-config pass -daemon-exec pass -daemon-filter pass -daemon-gzip-download pass -daemon-gzip-upload pass -daemon-munge pass -daemon-refuse pass -daemon-refuse-compress pass -exclude pass -files-from pass -hardlinks pass -old-dirs pass -ssh-basic pass -variety pass -variety-symlink-traversal pass - -# issue #715, present in 3.4.0: updating a file through a -K (--copy-dirlinks) -# directory symlink fails on the receiver because its secure_relative_open() -# O_NOFOLLOW-confines every path component, including the basedir reached via -# the symlink, so the basis open is rejected and the update is discarded -# ("failed verification"). Current master fixes this with secure_basis_open() -# (receiver.c), which splits the path so the basedir may follow symlinks while -# only the leaf is O_NOFOLLOW'd. Verified peer-version-, not protocol-, driven -# (forcing this peer to --protocol=31 still fails; 3.3.0 at proto 31 passes). -symlink-dirlink-basis xfail - -# --- May-2026 security fixes absent in 3.4.0 (peer is vulnerable) --- -alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) -chdir-symlink-race xfail # chmod escape via cd symlink during chdir -copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) -sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/expect/rsync_3.4.1.expect b/testsuite/expect/rsync_3.4.1.expect deleted file mode 100644 index 0a88f9c13..000000000 --- a/testsuite/expect/rsync_3.4.1.expect +++ /dev/null @@ -1,43 +0,0 @@ -# Expected outcomes for current rsync <-> rsync 3.4.1 peer (see -# rsync_3.1.3.expect for the format/semantics; listed tests are the run set). - -00-hello pass -reverse-daemon-delta pass # old client -> current daemon (backward-compat: delta+compress, both directions) -alt-dest pass -bare-do-open-symlink-race pass -batch-mode pass -chmod-option pass -daemon pass -daemon-access pass -daemon-auth pass -daemon-config pass -daemon-exec pass -daemon-filter pass -daemon-gzip-download pass -daemon-gzip-upload pass -daemon-munge pass -daemon-refuse pass -daemon-refuse-compress pass -exclude pass -files-from pass -hardlinks pass -old-dirs pass -ssh-basic pass -variety pass -variety-symlink-traversal pass - -# issue #715, present in 3.4.1: updating a file through a -K (--copy-dirlinks) -# directory symlink fails on the receiver because its secure_relative_open() -# O_NOFOLLOW-confines every path component, including the basedir reached via -# the symlink, so the basis open is rejected and the update is discarded -# ("failed verification"). Current master fixes this with secure_basis_open() -# (receiver.c), which splits the path so the basedir may follow symlinks while -# only the leaf is O_NOFOLLOW'd. Verified peer-version-, not protocol-, driven -# (forcing this peer to --protocol=31 still fails; 3.3.0 at proto 31 passes). -symlink-dirlink-basis xfail - -# --- May-2026 security fixes absent in 3.4.1 (peer is vulnerable) --- -alt-dest-symlink-race xfail # --link-dest basis lookup follows basedir symlink (escape) -chdir-symlink-race xfail # chmod escape via cd symlink during chdir -copy-dest-source-symlink xfail # copy_file source reads /outside via cd symlink (poison) -sender-flist-symlink-leak xfail # sender flist follows cd symlink, leaks outside paths diff --git a/testsuite/fleettest.json.example b/testsuite/fleettest.json.example index e697a630c..d4bee0104 100644 --- a/testsuite/fleettest.json.example +++ b/testsuite/fleettest.json.example @@ -1,31 +1,17 @@ { "_comment": [ - "Example fleet definition for testsuite/fleettest.py -- this is one", - "maintainer's setup. Copy (or symlink) this file to testsuite/fleettest.json", - "and edit it for your own machines, or point at another file with --fleet PATH.", - "fleettest.json is git-ignored; this .example is the committed template.", + "Copy this file to testsuite/fleettest.json and edit it for the available hosts.", + "Use --fleet PATH to select another file. fleettest.json is ignored by Git.", "", - "Each object under \"targets\" maps to fields of the Target dataclass in", - "fleettest.py. Required: name, ssh_host (null = run locally), workflow", - "(a file under .github/workflows, whose configure flags and RSYNC_EXPECT_SKIPPED", - "this target mirrors), configure_flags. Optional (with defaults): make (\"make\"),", - "python (\"python3\"), rsync_bin (\"rsync\"; \"rsync.exe\" on Cygwin), privilege", - "(\"root\" | \"sudo\" | \"user\"), pipe_jobs/tcp_jobs (8), builddir (\"rsync-citest\",", - "relative to the remote $HOME), env_prefix, configure_pre, nonroot, protocols,", - "max_retry.", + "Required target fields are name, ssh_host, workflow and configure_flags.", + "Set ssh_host to null for a local target. workflow names a file under .github/workflows.", + "Optional fields mirror the Target dataclass defaults in fleettest.py.", "", - "nonroot: true reruns -- as the non-root ssh user, after the sudo runs -- the", - "tests that declare `fleet_nonroot = True` at module level (so the set is", - "maintained in the test files, not here).", + "nonroot reruns tests marked fleet_nonroot as the SSH user after privileged passes.", "", - "protocols: [30, 29] adds one extra stdio-pipe test pass per listed version,", - "each run with runtests --protocol=N (the fleet analogue of a workflow's", - "check30/check29 steps) and shown as a protoNN column.", + "protocols adds one pipe pass per version and uses the matching workflow policy.", "", - "max_retry: N (default 0) re-runs each failed test on its own up to N more", - "times and drops any that then pass (listed under RECOVERED, not hidden). Use", - "on a slow/loaded box where concurrency-sensitive tests occasionally flake,", - "instead of dropping the whole target to a lower pipe_jobs/tcp_jobs.", + "max_retry reruns a failed test alone and reports recovered tests separately.", "", "Keys starting with \"_\" are comments. See testsuite/README.md." ], @@ -59,7 +45,7 @@ ] }, { - "_comment": "Nested-VM OpenBSD. max_retry re-runs a flaky test on its own. The daemon+flipper symlink-race tests are excluded here: this box's kernel has a connect()-under-rename-load lost-wakeup (and an FFS rename-storm corruption) that hangs them for non-rsync reasons (see dev-notes/openbsd-connect-lost-wakeup-report.txt); the protections they check are verified on the Linux/BSD boxes.", + "_comment": "The nested OpenBSD VM excludes three rename-race tests because of a kernel lost wake-up and FFS rename-storm corruption. See dev-notes/openbsd-connect-lost-wakeup-report.txt.", "name": "openbsd", "ssh_host": "root@openbsd", "workflow": "openbsd-build.yml", @@ -95,25 +81,28 @@ ] }, { - "_comment": "Ubuntu 18.04 older-LTS backport coverage on a root@ box; no 18.04 runner image exists so it mirrors the 22.04 matrix lane.", + "_comment": "Ubuntu 18.04 coverage using the Ubuntu workflow because no matching hosted runner exists.", "name": "ubuntu-1804", "ssh_host": "root@ubuntu-1804", "workflow": "ubuntu-build.yml", - "_python_comment": "18.04's default python3 is 3.6, but runtests.py uses subprocess capture_output (3.7+); run the suite under the 3.10 that's also installed.", + "_python_comment": "The stock Python 3.6 is too old for the runner. Use the installed Python 3.10.", "python": "python3.10", - "_configure_flags_comment": "18.04's libzstd 1.3.3 lacks ZSTD_minCLevel (configure aborts), so disable zstd; also disable lz4 so the default -z compressor is zlib -- with lz4 as the default, compress-options' --compress-level=9 check fails since lz4 has no levels. xxhash is fine.", + "_configure_flags_comment": "Disable zstd because 1.3.3 lacks ZSTD_minCLevel. Disable lz4 so compression-level tests use zlib.", "configure_flags": [ "--with-rrsync", "--disable-zstd", "--disable-lz4" ], - "_expect_skip_extra_comment": "protected-regular's fs.protected_regular path is not exercised on this box.", + "_expect_skip_extra_comment": "This host does not expose the protected_regular condition.", "expect_skip_extra": [ "protected-regular" + ], + "unsupported_extra": [ + "protected_regular" ] }, { - "_comment": "Ubuntu 20.04 older-LTS backport coverage on a root@ box; no 20.04 runner image exists so it mirrors the 22.04 matrix lane.", + "_comment": "Ubuntu 20.04 coverage using the Ubuntu workflow because no matching hosted runner exists.", "name": "ubuntu-2004", "ssh_host": "root@ubuntu-2004", "workflow": "ubuntu-build.yml", @@ -122,7 +111,7 @@ ] }, { - "_comment": "Builds unprivileged (like a CI runner) and runs the suite via sudo; the nonroot pass reruns the privilege-sensitive tests as the ssh user. protocols: [30, 29] runs the check30/check29 passes on this NO-xattrat kernel: the CI's proto-29 step runs here, but the only other proto-29 fleet box (ubuntu-2604) is xattrat, so without this the (no-xattrat x proto-29) cell is uncovered.", + "_comment": "Build unprivileged then run the main suite through sudo. Protocol 29 and 30 runs cover a kernel without xattrat.", "name": "ubuntu-2204", "ssh_host": "runner@ubuntu-2204", "workflow": "ubuntu-build.yml", @@ -137,7 +126,7 @@ ] }, { - "_comment": "Noble (24.04), kernel 6.8 -- a NO-xattrat kernel like ubuntu-2204 but with modern userspace; mirrors ubuntu-build.yml.", + "_comment": "Ubuntu 24.04 with kernel 6.8 and no xattrat support.", "name": "ubuntu-2404", "ssh_host": "runner@ubuntu-2404", "workflow": "ubuntu-build.yml", @@ -152,7 +141,7 @@ ] }, { - "_comment": "Modern Ubuntu (mirrors ubuntu-build.yml). protocols: [30, 29] also runs the workflow's check30/check29 passes as extra stdio-pipe runs.", + "_comment": "Current Ubuntu coverage with protocol 29 and 30 pipe passes.", "name": "ubuntu-2604", "ssh_host": "runner@ubuntu-2604", "workflow": "ubuntu-build.yml", @@ -167,20 +156,23 @@ ] }, { - "_comment": "AlmaLinux 8 (RHEL 8 rebuild): the oldest active LTS in the Fedora/RHEL family, for backport coverage. glibc 2.28 / gcc 8.5. The CI job runs in an almalinux:8 container; this is the VM equivalent. python3 must be the python39 module (alternatives --set python3 /usr/bin/python3.9) -- the stock 3.6 is too old for runtests.py.", + "_comment": "AlmaLinux 8 coverage with glibc 2.28, GCC 8.5 and the Python 3.9 module. The stock Python 3.6 is unsupported.", "name": "almalinux-8", "ssh_host": "root@almalinux-8", "workflow": "almalinux-8-build.yml", "configure_flags": [ "--with-rrsync" ], - "_expect_skip_extra_comment": "Box-specific, not workflow-specific: this VM has no separate filesystem for a cross-device temp dir. fs.protected_regular is enabled in /etc/sysctl.d/90-rsync-fleettest.conf so protected-regular runs here rather than skipping.", + "_expect_skip_extra_comment": "This VM has no second filesystem for the cross-device temporary-directory test. protected_regular is enabled separately.", "expect_skip_extra": [ "chmod-temp-dir" + ], + "unsupported_extra": [ + "cross_device" ] }, { - "_comment": "macOS: brew is not on the non-interactive ssh PATH, so put it on PATH for the whole build and pass brew include/lib dirs to configure.", + "_comment": "Add Homebrew to the non-interactive SSH path and pass its include and library directories to configure.", "name": "mac2", "ssh_host": "runner@mac2", "workflow": "macos-build.yml", @@ -190,13 +182,13 @@ "configure_flags": [ "--with-rrsync" ], - "_xfail_comment": "crtimes uses APFS birthtime via setattrlist; older backport binaries (3.4.x/3.2.7) drive it differently than current rsync, so the 3.5.0 testsuite's crtimes check fails here. Tolerated -- it passes for a current binary, where this entry is a no-op.", + "_xfail_comment": "Older peers handle APFS birthtime differently. Current rsync passes this test.", "xfail": [ "crtimes" ] }, { - "_comment": "Same host and build as mac2, but the tests' scratch trees live on an HFS+ volume instead of APFS -- runtests.py honours $scratchbase, so only the data under test moves. The setup VERIFIES the mount rather than assuming it: a stale directory or a name collision mounting at 'RsyncHFS 1' would otherwise leave the tests on APFS reporting green, which is exactly how the first version of this target lied. Ownership must be on too: a user-attached image mounts 'noowners', under which every uid/gid and permission check is meaningless -- that alone caused 28 of the 31 failures the first honest run produced.", + "_comment": "Run scratch data on an HFS+ image. The setup verifies the mount and enables ownership so permission tests remain meaningful.", "name": "mac2-hfs", "ssh_host": "runner@mac2", "workflow": "macos-build.yml", @@ -215,10 +207,10 @@ "backup-crossdev-copy", "chmod-temp-dir" ], - "_skip_comment": "The two omitted entries are macOS-wide expected-skips that this target RUNS: the separate volume supplies the cross-device conditions mac2 lacks. (itemize used to XFAIL here, before --link-dest learned to fall back when the filesystem cannot hard-link a symlink.)" + "_skip_comment": "The HFS+ volume supplies the cross-device conditions missing from the APFS target." }, { - "_comment": "The x86-64 Mac (macOS 10.13). The ONLY target that can build the x86-64 md5 assembly -- mac2 is arm64, where configure refuses --enable-md5-asm outright. MacPorts supplies autotools, python3 and the crypto/hash libs the stock 10.13 image lacks, and is not on the non-interactive ssh PATH, so put it there for the whole run. This target keeps the STOCK Apple compiler (clang 10, the 10.13 ceiling), which is what caught #161; it cannot build --enable-roll-simd, because a clang that old rejects configure's target(\"default\") multiversioning probe. That is a compiler-VERSION limit, not a Mach-O one -- see mac-x86-asm below, which builds the same source with MacPorts clang 19 and all three optimizations on.", + "_comment": "macOS 10.13 on x86-64 with Apple Clang 10. This target covers MD5 assembly but cannot compile the SIMD multiversioning probe.", "name": "mac-x86", "ssh_host": "tridge@mac", "workflow": "macos-build.yml", @@ -237,12 +229,15 @@ "expect_skip_extra": [ "symlink-mknod-fakesuper-symlink-race" ], + "unsupported_extra": [ + "symlink_mknod" + ], "expect_skip_omit": [ "sender-remove-source-root-anchor" ] }, { - "_comment": "Same host and OS as mac-x86, built with the SIMD/asm optimizations ON. The only target that exercises --enable-roll-asm/--enable-md5-asm on Mach-O -- the combination Roland Kletzing reported in #160, where an AVX2 over-read (#178) survived two months precisely because nothing built with these flags. Needs MacPorts clang 19 via CC/CXX: Apple clang 10 cannot compile the function-multiversioning probe. Runs after mac-x86, since the harness never runs two targets on one machine at once.", + "_comment": "The same x86-64 Mac built with MacPorts Clang 19, rolling SIMD, rolling assembly and MD5 assembly. This covers the Mach-O combination from issues 160 and 178.", "name": "mac-x86-asm", "ssh_host": "tridge@mac", "workflow": "macos-build.yml", @@ -263,14 +258,17 @@ "expect_skip_extra": [ "symlink-mknod-fakesuper-symlink-race" ], - "_expect_skip_omit_comment": "simd-checksum is a macOS-wide expected skip because simdtest is only built when SIMD is enabled -- which is the whole point of this target, so it RUNS here.", + "unsupported_extra": [ + "symlink_mknod" + ], + "_expect_skip_omit_comment": "simd-checksum runs here because this target enables SIMD.", "expect_skip_omit": [ "sender-remove-source-root-anchor", "simd-checksum" ] }, { - "_comment": "Cygwin: non-root plain user (no sudo), binary is rsync.exe. The workflow installs only the python39 package, so pin python3.9 here too: the box's default python3 (3.12) has a working AF_UNIX socketpair, which makes daemon-stdin-local-socket run where cygwin.txt expects it to skip. Keep python39 current: the xattr tests need the os.getxattr that newer Cygwin builds (3.9.25+) expose.", + "_comment": "Cygwin runs unprivileged with rsync.exe and Python 3.9 to match the workflow capability set. Keep Python 3.9 current enough to expose os.getxattr.", "name": "cygwin", "ssh_host": "win11", "workflow": "cygwin-build.yml", diff --git a/testsuite/fleettest.py b/testsuite/fleettest.py index 9ccc7db3c..17c9d2d7b 100755 --- a/testsuite/fleettest.py +++ b/testsuite/fleettest.py @@ -6,10 +6,9 @@ --use-tcp) in parallel, and prints one report of only the UNEXPECTED results -- a fast local pre-flight for the GitHub CI matrix. -Each target maps to a .github/workflows/*.yml job or matrix lane: the per-target -configure flags mirror that lane, and the pipe-run RSYNC_EXPECT_SKIPPED list is -PARSED from the workflow (not hardcoded). The --use-tcp run never sets an -expected-skip list (matching the workflows), so only test FAILs matter there. +Each target maps to a .github/workflows/*.yml job or matrix lane. Configure +flags, capability profiles and the transitional pipe-run skip oracle are parsed +from that workflow. The --use-tcp run sets neither policy, matching CI. The tcp pass runs only the tests that can reach the daemon transport, because it follows a full pipe pass over the very same build: --use-tcp is observable only @@ -105,6 +104,8 @@ import time from pathlib import Path +from harness import read_requirements + # Set from --skip / --xfail in main(). SKIP_CSV is passed to runtests as # RSYNC_EXCLUDE (tests dropped before running); XFAIL_GLOBS are tolerated # failures (a matching FAIL does not make a cell "not OK"). Both are @@ -131,9 +132,8 @@ # source tree these point at, so it must be run from inside an rsync checkout # or given --repo PATH. REPO = Path.cwd() -# Source tree providing the test suite (runtests.py + testsuite/). Defaults to -# REPO; --testsuite-repo decouples it so one tree is built and another's suite is -# run against the result. +# Source tree providing testsuite/. Defaults to REPO; --testsuite-repo decouples +# it so one tree is built and another's suite runs against the result. TESTSUITE_REPO = REPO WORKFLOWS = TESTSUITE_REPO / ".github" / "workflows" @@ -203,6 +203,8 @@ class Target: # pins -- a pass with no matching workflow step stays unpinned (see # workflow_skip_for). expect_skip_extra: list[str] = dataclasses.field(default_factory=list) + # Capabilities absent on this box which explain expect_skip_extra. + unsupported_extra: list[str] = dataclasses.field(default_factory=list) # ...and the mirror: entries the workflow expects to skip which this target # actually RUNS (a relocated scratch can satisfy a condition the # workflow's host cannot, e.g. a cross-device copy). @@ -301,7 +303,7 @@ def push_argv(target: Target, staging: str) -> list[str]: # --------------------------------------------------------------------------- -# workflow skip-list parsing +# workflow policy parsing # --------------------------------------------------------------------------- def parse_workflow_skip(workflow: str, make_target: str = "check") -> str | None: @@ -326,6 +328,27 @@ def parse_workflow_skip(workflow: str, make_target: str = "check") -> str | None return m.group(1) if m else None +def parse_workflow_profiles(workflow: str, make_target: str = "check") -> str | None: + path = WORKFLOWS / workflow + try: + text = path.read_text() + except OSError: + return None + rx = re.compile(r"RSYNC_TEST_PROFILES=(\S+).*?\s+make\s+" + + re.escape(make_target) + r"'?\s*$", re.M) + match = rx.search(text) + return match.group(1) if match else None + + +def tcp_profiles(workflow: str) -> str | None: + profiles = parse_workflow_profiles(workflow) + if not profiles: + return None + names = [name for name in profiles.split(',') + if name != 'pipe' and not name.startswith('protocol-')] + return ','.join(names) + + def _expand_spec_names(spec: str) -> set[str]: """The test names a workflow's RSYNC_EXPECT_SKIPPED spec resolves to, by reading its @FILE references out of the suite tree. Only used to decide @@ -398,9 +421,9 @@ def workflow_skip_for(t: "Target", make_target: str = "check") -> str | None: def discover_nonroot_tests(testsuite_dir: Path) -> list[str]: """Return the names (without the _test.py suffix) of the tests under - testsuite_dir that declare `fleet_nonroot = True`.""" + testsuite_dir/tests that declare `fleet_nonroot = True`.""" names = [] - for p in sorted(testsuite_dir.glob("*_test.py")): + for p in sorted((testsuite_dir / 'tests').glob('*_test.py')): try: if _NONROOT_RE.search(p.read_text(errors="replace")): names.append(p.name[: -len("_test.py")]) @@ -417,6 +440,24 @@ def _exclude_csv(t: "Target") -> str: return ",".join(parts) +def validate_target_capabilities(target: "Target") -> str | None: + capabilities = set(target.unsupported_extra) + explained = set() + for name in target.expect_skip_extra: + path = TESTSUITE_REPO / 'testsuite' / 'tests' / f'{name}_test.py' + if not path.is_file(): + return f'{target.name}: unknown expect_skip_extra test {name}' + metadata = read_requirements(path) + matches = set(metadata['features'] if metadata else ()) & capabilities + if not matches: + return f'{target.name}: no unsupported_extra capability explains {name}' + explained.update(matches) + unused = capabilities - explained + if unused: + return f'{target.name}: unused unsupported_extra: {", ".join(sorted(unused))}' + return None + + # --------------------------------------------------------------------------- # remote script builders # --------------------------------------------------------------------------- @@ -435,7 +476,7 @@ def build_script(t: Target) -> str: ) -def test_script(t: Target, transport: str, skip_csv: str | None, jobs: int, +def test_script(t: Target, transport: str, skip_csv: str | None, profiles: str | None, jobs: int, protocol: int | None = None, only: list[str] | None = None) -> str: rb = f'--rsync-bin="$PWD/{t.rsync_bin}"' tcp = " --use-tcp" if transport == "tcp" else "" @@ -451,6 +492,10 @@ def test_script(t: Target, transport: str, skip_csv: str | None, jobs: int, excl = _exclude_csv(t) if excl: env += f"RSYNC_EXCLUDE={excl} " + if profiles: + env += f"RSYNC_TEST_PROFILES={profiles} " + if t.unsupported_extra: + env += f"RSYNC_TEST_UNSUPPORTED={','.join(t.unsupported_extra)} " # Named tests (a max_retry re-run) make runtests full_run False, so the # expected-skip list does not apply -- only the named tests' pass/fail matter. names = "" @@ -472,7 +517,7 @@ def test_script(t: Target, transport: str, skip_csv: str | None, jobs: int, narrow_tcp = (transport == "tcp" and not FULL_TCP and not only and "pipe" in TRANSPORTS) only_daemon = " --daemon-tests-only" if narrow_tcp else "" - runtests = (f'{t.python} runtests.py {rb}{tcp}{proto} ' + runtests = (f'{t.python} testsuite/runtests.py {rb}{tcp}{proto} ' f'-j {jobs}{timing}{only_daemon}{names}') # env_prefix (e.g. a brew PATH) must reach the test too: some tests build a # helper binary on the fly (a test may invoke `make`, which needs gawk etc.), @@ -499,7 +544,7 @@ def nonroot_test_script(t: Target, names: list[str]) -> str: _e = _exclude_csv(t) excl = f'RSYNC_EXCLUDE={_e} ' if _e else '' sb = f'scratchbase={shlex.quote(t.scratchbase)} ' if t.scratchbase else '' - runtests = (f'PYTHONDONTWRITEBYTECODE=1 {sb}{excl}{t.python} runtests.py ' + runtests = (f'PYTHONDONTWRITEBYTECODE=1 {sb}{excl}{t.python} testsuite/runtests.py ' f'--rsync-bin="$PWD/{t.rsync_bin}" {" ".join(names)}') # A relocated scratch lives outside builddir, so clearing ./testtmp alone # leaves the prior sudo run's root-owned tree in place and the non-root @@ -675,15 +720,17 @@ def run_target(t: Target, args, staging: str) -> TargetResult: for transport in args.transports: skip_csv = workflow_skip_for(t) if transport == "pipe" else None + profiles = (parse_workflow_profiles(t.workflow) if transport == "pipe" + else tcp_profiles(t.workflow)) jobs = (args.jobs if args.jobs else (t.tcp_jobs if transport == "tcp" else t.pipe_jobs)) - cmd = test_script(t, transport, skip_csv, jobs) + cmd = test_script(t, transport, skip_csv, profiles, jobs) t0 = time.monotonic() r = run_on(t, cmd, timeout=2400) res.timings[transport] = time.monotonic() - t0 tr = parse_transport(transport, r, skip_csv is not None, t.xfail) - retry_failed(t, transport, tr, lambda names, tp=transport: run_on( - t, test_script(t, tp, None, 1, only=names), timeout=1200)) + retry_failed(t, transport, tr, lambda names, tp=transport, pf=profiles: run_on( + t, test_script(t, tp, None, pf, 1, only=names), timeout=1200)) res.transports[transport] = tr log(f"[{t.name}] {transport} done " f"({'ok' if tr.ok else 'ISSUE'})") @@ -698,13 +745,14 @@ def run_target(t: Target, args, staging: str) -> TargetResult: for proto in t.protocols: label = f"proto{proto}" skip_csv = workflow_skip_for(t, f"check{proto}") - cmd = test_script(t, "pipe", skip_csv, jobs, protocol=proto) + profiles = parse_workflow_profiles(t.workflow, f"check{proto}") + cmd = test_script(t, "pipe", skip_csv, profiles, jobs, protocol=proto) t0 = time.monotonic() r = run_on(t, cmd, timeout=2400) res.timings[label] = time.monotonic() - t0 tr = parse_transport(label, r, skip_csv is not None, t.xfail) - retry_failed(t, label, tr, lambda names, pr=proto: run_on( - t, test_script(t, "pipe", None, 1, protocol=pr, only=names), + retry_failed(t, label, tr, lambda names, pr=proto, pf=profiles: run_on( + t, test_script(t, "pipe", None, pf, 1, protocol=pr, only=names), timeout=1200)) res.transports[label] = tr log(f"[{t.name}] {label} done " @@ -770,8 +818,8 @@ def print_report(results: list[TargetResult], args, fleet: list[Target]) -> bool print(f"rsync fleet CI — branch {current_branch()} — {ts}") print(f"source: HEAD run: {args.run_id} " f"transports: {','.join(args.transports)}") - print("(A target's pipe skip-set is only enforced when its workflow sets " - "RSYNC_EXPECT_SKIPPED; otherwise only FAILs matter. The 'nonroot' " + print("(A target's pipe capability profiles and transitional skip-set come " + "from its workflow. The 'nonroot' " "column runs the privilege-sensitive tests as the unprivileged user; " "'-' = N/A.)") print("=" * 64) @@ -1178,7 +1226,7 @@ def main() -> int: "the slowest target") ap.add_argument("--repo", help="rsync source tree to build (default: cwd)") ap.add_argument("--testsuite-repo", - help="rsync tree to take runtests.py + testsuite/ from " + help="rsync tree to take testsuite/ from " "(default: --repo). Build one tree and run another's test " "suite against it, e.g. --repo ../rsync-v3.4 --testsuite-repo .") ap.add_argument("--fleet", help="fleet config JSON (default: ~/.fleettest.json, " @@ -1226,12 +1274,10 @@ def main() -> int: print(f"[backport] excluding {len(names)} test(s) declared in " f"{bp.relative_to(REPO)}") if not args.cleanup: - # The Python test suite (runtests.py + testsuite/) comes from - # TESTSUITE_REPO, so that is where runtests.py must live. The build tree - # (REPO) only has to be a buildable rsync source -- it may be an older - # release whose runtests.py predates the Python suite, or lacks it. - if not (TESTSUITE_REPO / "runtests.py").is_file(): - print(f"{TESTSUITE_REPO} has no runtests.py; run from inside a " + # TESTSUITE_REPO provides testsuite/ so that tree must contain runtests.py. + # REPO only has to be buildable rsync source and may predate this suite. + if not (TESTSUITE_REPO / "testsuite" / "runtests.py").is_file(): + print(f"{TESTSUITE_REPO} has no testsuite/runtests.py; run from inside a " f"checkout or pass --testsuite-repo a tree with the Python " f"test suite", file=sys.stderr) return 2 @@ -1276,6 +1322,12 @@ def main() -> int: return 2 chosen = [by_name[w] for w in want] + for target in chosen: + error = validate_target_capabilities(target) + if error: + print(error, file=sys.stderr) + return 2 + if args.cleanup: # Sweep every -* run dir on the selected targets. NB: this # also removes dirs belonging to runs that are still in progress, so @@ -1326,13 +1378,13 @@ def main() -> int: print(f"git archive failed: {ar.stderr}", file=sys.stderr) return 2 - # --testsuite-repo: overlay another tree's runtests.py + testsuite/ onto + # --testsuite-repo: overlay another tree's testsuite/ onto # the built source (merge, no delete). Build REPO's rsync, but run # TESTSUITE_REPO's suite against it. The leftover .test files from REPO - # are ignored by a Python runtests.py (it globs *_test.py). + # are ignored by a Python runtests.py (it globs tests/*_test.py). if TESTSUITE_REPO != REPO: ov = subprocess.run( - f"git -C {TESTSUITE_REPO} archive HEAD -- runtests.py testsuite " + f"git -C {TESTSUITE_REPO} archive HEAD -- testsuite " f"| tar -x -C {staging}", shell=True, capture_output=True, text=True) if ov.returncode != 0: diff --git a/testsuite/harness/__init__.py b/testsuite/harness/__init__.py new file mode 100644 index 000000000..21cbc3162 --- /dev/null +++ b/testsuite/harness/__init__.py @@ -0,0 +1,11 @@ +from .context import TestContext, run +from .metadata import applies_to_peer, describe, metadata, placeholder_target, read_requirements, requires +from .profile import load_profile, merge_profiles, parse_peer_banner +from .receipt import write_receipt +from .results import Exit, Outcome, TestResult, outcome_of, unsupported, verdict_of + +__all__ = ( + 'Exit', 'Outcome', 'TestContext', 'TestResult', 'applies_to_peer', 'describe', 'load_profile', + 'merge_profiles', 'metadata', 'outcome_of', 'parse_peer_banner', 'placeholder_target', + 'read_requirements', 'requires', 'run', 'unsupported', 'verdict_of', 'write_receipt', +) diff --git a/testsuite/harness/context.py b/testsuite/harness/context.py new file mode 100644 index 000000000..65779956f --- /dev/null +++ b/testsuite/harness/context.py @@ -0,0 +1,30 @@ +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .metadata import describe + + +@dataclass(frozen=True) +class TestContext: + scratch: Path + source: Path + destination: Path + expected: Path + repository: Path + tools: Path + + @classmethod + def from_env(cls): + scratch = Path(os.environ['scratchdir']) + return cls(scratch, scratch / 'from', scratch / 'to', scratch / 'chk', + Path(os.environ['srcdir']), Path(os.environ['TOOLDIR'])) + + +def run(function: Callable) -> None: + if os.environ.get('RSYNC_TEST_DESCRIBE') == '1': + print(json.dumps(describe(function), separators=(',', ':'), sort_keys=True)) + return + function(TestContext.from_env()) diff --git a/testsuite/harness/metadata.py b/testsuite/harness/metadata.py new file mode 100644 index 000000000..d3d7118cb --- /dev/null +++ b/testsuite/harness/metadata.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import ast +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable, Iterable, Optional + + +@dataclass(frozen=True) +class Requirements: + features: tuple[str, ...] = () + protocols: tuple[int, ...] = () + transports: tuple[str, ...] = () + min_peer: Optional[str] = None + root: Optional[bool] = None + parallel: bool = True + cost: str = 'normal' + mutates: tuple[str, ...] = () + tags: tuple[str, ...] = () + + def __post_init__(self): + groups = self.features, self.transports, self.mutates, self.tags + if not all(isinstance(value, str) and value for values in groups for value in values): + raise ValueError('metadata names must be non-empty strings') + if any(type(value) is not int or value <= 0 for value in self.protocols): + raise ValueError('protocols must be positive integers') + if self.transports and set(self.transports) - {'pipe', 'tcp'}: + raise ValueError('invalid transport') + if self.min_peer is not None and (not isinstance(self.min_peer, str) + or not re.fullmatch(r'\d+(?:\.\d+)+', self.min_peer)): + raise ValueError('invalid minimum peer') + if (self.root is not None and not isinstance(self.root, bool)) or not isinstance(self.parallel, bool): + raise ValueError('invalid execution requirement') + if self.cost not in ('normal', 'expensive', 'stress'): + raise ValueError('invalid cost') + + +def _ordered(values: Iterable) -> tuple: + if not isinstance(values, (tuple, list, set, frozenset)): + raise ValueError('metadata collections must be lists, tuples or sets') + try: + return tuple(sorted(set(values))) + except TypeError as error: + raise ValueError('metadata collection values must share a type') from error + + +def _requirements(*, features=(), protocols=(), transports=(), min_peer=None, root=None, + parallel=True, cost='normal', mutates=(), tags=()): + return Requirements(_ordered(features), _ordered(protocols), _ordered(transports), min_peer, + root, parallel, cost, _ordered(mutates), _ordered(tags)) + + +def requires(*, features=(), protocols=(), transports=(), min_peer=None, root=None, + parallel=True, cost='normal', mutates=(), tags=()): + requirements = _requirements(features=features, protocols=protocols, transports=transports, + min_peer=min_peer, root=root, parallel=parallel, cost=cost, + mutates=mutates, tags=tags) + + def decorate(function: Callable) -> Callable: + function.__test_requirements__ = requirements + return function + + return decorate + + +def metadata(*, features=(), protocols=(), transports=(), min_peer=None, root=None, + parallel=True, cost='normal', mutates=(), tags=()): + return _requirements(features=features, protocols=protocols, transports=transports, + min_peer=min_peer, root=root, parallel=parallel, cost=cost, + mutates=mutates, tags=tags) + + +def describe(function: Callable) -> dict: + requirements = getattr(function, '__test_requirements__', Requirements()) + return asdict(requirements) + + +def placeholder_target(path): + path = Path(path) + try: + if path.is_symlink() or path.stat().st_size > 255: + return None + name = path.read_text(encoding='utf-8').strip() + except (OSError, UnicodeError): + return None + if not name.endswith('_test.py') or Path(name).name != name or name == path.name: + return None + target = path.with_name(name) + return target if target.is_file() else None + + +def read_requirements(path) -> Optional[dict]: + path = Path(path) + if path.is_symlink() or placeholder_target(path): + return None + tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path)) + requirements = None + for node in tree.body: + calls = [] + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call): + calls.append(node.value) + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or node.name != 'test': + decorators = () + else: + decorators = node.decorator_list + calls.extend(decorator for decorator in decorators if isinstance(decorator, ast.Call)) + for call in calls: + if not isinstance(call.func, ast.Name): + continue + if call.func.id not in ('metadata', 'requires') or call.args: + continue + values = {item.arg: ast.literal_eval(item.value) for item in call.keywords} + requirements = metadata(**values) + break + if requirements: + break + + features = set(requirements.features if requirements else ()) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + if node.func.id == 'require_tcp': + features.add('tcp') + elif node.func.id == 'require_asan': + features.add('asan') + elif node.func.id == 'setup_chroot_inner': + features.add('root') + elif node.func.id == 'build_patched_rsync': + features.add('native_build') + elif node.func.id == 'test_skipped': + for keyword in node.keywords: + if keyword.arg == 'capability' and isinstance(keyword.value, ast.Constant): + if not isinstance(keyword.value.value, str) or not keyword.value.value: + raise ValueError(f'{path}: invalid capability') + features.add(keyword.value.value) + if not requirements and not features: + return None + values = asdict(requirements or Requirements()) + values['features'] = tuple(sorted(features)) + return values + + +def applies_to_peer(requirements, peer, protocol, transport=None): + if not requirements or 'version-mix' not in requirements['tags']: + return False + if requirements['min_peer']: + current = tuple(int(part) for part in peer.split('.')) + minimum = tuple(int(part) for part in requirements['min_peer'].split('.')) + if current < minimum: + return False + if requirements['protocols'] and protocol not in requirements['protocols']: + return False + if transport and requirements['transports'] and transport not in requirements['transports']: + return False + return True diff --git a/testsuite/harness/profile.py b/testsuite/harness/profile.py new file mode 100644 index 000000000..27c045067 --- /dev/null +++ b/testsuite/harness/profile.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class Profile: + name: str + peer: Optional[str] + protocol: Optional[int] + unsupported: dict + xfail: dict + + +def _strings(path, field, values): + if not isinstance(values, dict): + raise ValueError(f'{path}: invalid {field}') + if not all(isinstance(key, str) and key and isinstance(value, str) and value + for key, value in values.items()): + raise ValueError(f'{path}: invalid {field}') + return dict(values) + + +def load_profile(path, tests): + path = Path(path) + data = json.loads(path.read_text(encoding='utf-8')) + if not isinstance(data, dict): + raise ValueError(f'{path}: profile must be an object') + if set(data) - {'peer', 'protocol', 'unsupported', 'xfail'}: + raise ValueError(f'{path}: unknown profile field') + peer = data.get('peer') + if peer is not None and (not isinstance(peer, str) or not re.fullmatch(r'\d+(?:\.\d+)+', peer)): + raise ValueError(f'{path}: invalid peer') + protocol = data.get('protocol') + if protocol is not None and (type(protocol) is not int or protocol <= 0): + raise ValueError(f'{path}: invalid protocol') + if peer is not None and protocol is None: + raise ValueError(f'{path}: peer profile requires protocol') + if peer is not None and path.stem != f'peer-{peer}': + raise ValueError(f'{path}: peer does not match profile name') + unsupported = _strings(path, 'unsupported', data.get('unsupported', {})) + xfail = _strings(path, 'xfail', data.get('xfail', {})) + unknown = set(xfail) - set(tests) + if unknown: + raise ValueError(f'{path}: unknown tests: {", ".join(sorted(unknown))}') + return Profile(path.stem, peer, protocol, unsupported, xfail) + + +def parse_peer_banner(text): + match = re.search(r'^rsync\s+version\s+(\d+(?:\.\d+)+)(?:-\S+)?\s+protocol\s+version\s+(\d+)\s*$', + text, re.MULTILINE) + if not match: + raise ValueError('invalid rsync version banner') + return match.group(1), int(match.group(2)) + + +def merge_profiles(profiles): + peer = None + protocol = None + unsupported = {} + xfail = {} + for profile in profiles: + if profile.peer: + if peer and peer != profile.peer: + raise ValueError(f'conflicting peers: {peer}, {profile.peer}') + peer = profile.peer + if profile.protocol: + if protocol and protocol != profile.protocol: + raise ValueError(f'conflicting protocols: {protocol}, {profile.protocol}') + protocol = profile.protocol + for capability, reason in profile.unsupported.items(): + if capability in unsupported and unsupported[capability] != reason: + raise ValueError(f'{capability}: conflicting unsupported reasons') + unsupported[capability] = reason + for test, reason in profile.xfail.items(): + if test in xfail and xfail[test] != reason: + raise ValueError(f'{test}: conflicting xfail reasons') + xfail[test] = reason + return peer, protocol, unsupported, xfail diff --git a/testsuite/harness/receipt.py b/testsuite/harness/receipt.py new file mode 100644 index 000000000..26b2138de --- /dev/null +++ b/testsuite/harness/receipt.py @@ -0,0 +1,19 @@ +import json +import os +from pathlib import Path + + +def write_receipt(path, data): + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f'.{target.name}.{os.getpid()}.tmp') + try: + with temporary.open('x', encoding='utf-8') as stream: + json.dump(data, stream, indent=2, sort_keys=True) + stream.write('\n') + os.replace(temporary, target) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass diff --git a/testsuite/harness/results.py b/testsuite/harness/results.py new file mode 100644 index 000000000..12136a971 --- /dev/null +++ b/testsuite/harness/results.py @@ -0,0 +1,81 @@ +import enum +import os +from dataclasses import dataclass +from pathlib import Path + +from exitcodes import Exit + + +class Outcome(str, enum.Enum): + PASS = 'pass' + FAIL = 'fail' + ERROR = 'error' + SKIP = 'skip' + UNSUPPORTED = 'unsupported' + XFAIL = 'xfail' + XPASS = 'xpass' + PROFILE_ERROR = 'profile_error' + + +def outcome_of(exit_code, unsupported=''): + if exit_code == Exit.PASS: + return Outcome.PASS + if exit_code == Exit.ERROR: + return Outcome.ERROR + if exit_code == Exit.SKIP: + return Outcome.UNSUPPORTED if unsupported else Outcome.SKIP + if exit_code == Exit.XFAIL: + return Outcome.XFAIL + return Outcome.FAIL + + +def verdict_of(outcome, expected): + if expected == 'fail' or (expected and expected.startswith('xfail')): + if outcome in (Outcome.FAIL, Outcome.XFAIL): + return Outcome.XFAIL + return Outcome.XPASS if outcome == Outcome.PASS else Outcome.PROFILE_ERROR + if expected and expected.startswith('unsupported'): + return Outcome.UNSUPPORTED if outcome == Outcome.UNSUPPORTED else Outcome.PROFILE_ERROR + if expected == 'skip': + return Outcome.SKIP if outcome == Outcome.SKIP else Outcome.PROFILE_ERROR + if expected == 'pass' and outcome in (Outcome.SKIP, Outcome.UNSUPPORTED, Outcome.XFAIL): + return Outcome.PROFILE_ERROR + return outcome + + +@dataclass +class TestResult: + name: str + exit_code: int + output: str = '' + skip_reason: str = '' + unsupported: str = '' + duration: float = 0.0 + + @property + def outcome(self): + return outcome_of(self.exit_code, self.unsupported) + + def record(self, expected=None): + verdict = verdict_of(self.outcome, expected) + data = { + 'name': self.name, + 'outcome': self.outcome.value, + 'verdict': verdict.value, + 'exit_code': int(self.exit_code), + 'duration_seconds': round(self.duration, 6), + } + if self.skip_reason: + data['reason'] = self.skip_reason + if self.unsupported: + data['unsupported'] = self.unsupported + if expected: + data['expected'] = expected + return data + + +def unsupported(capability: str) -> None: + scratch = Path(os.environ['scratchdir']) + (scratch / 'unsupported').write_text(capability + '\n', encoding='utf-8') + (scratch / 'whyskipped').write_text(capability + '\n', encoding='utf-8') + raise SystemExit(Exit.SKIP) diff --git a/testsuite/profiles/cygwin.json b/testsuite/profiles/cygwin.json new file mode 100644 index 000000000..431f584f6 --- /dev/null +++ b/testsuite/profiles/cygwin.json @@ -0,0 +1,30 @@ +{ + "unsupported": { + "acl": "no POSIX ACL tools", + "backup_metadata": "no backup metadata fixture", + "cross_uid": "no cross-UID fixture", + "dac_restriction": "no Unix DAC restriction", + "darwin": "not Darwin", + "darwin_interpose": "no dyld interposition", + "device_nodes": "no Unix device nodes", + "directory_sgid": "no directory setgid semantics", + "group_auth": "no Linux group-auth fixture", + "interpose": "no native interposition", + "linux_acl": "no Linux ACL semantics", + "linux_interpose": "no Linux interposition", + "linux_xattr": "no Linux xattr semantics", + "native_build": "no reliable patched build", + "noatime": "no O_NOATIME", + "ownership": "no Unix ownership fixture", + "posix_detach": "no POSIX daemon detach", + "proc_fd": "no procfs descriptors", + "protected_regular": "no protected_regular sysctl", + "root_anchor": "no root-anchored source fixture", + "search_only": "no Linux search-only fixture", + "simd": "no SIMD test build", + "symlink_mode": "no symlink chmod", + "unix_listener": "no named Unix listener passing", + "unix_socketpair": "no Unix socketpair", + "user_namespace": "no user namespaces" + } +} diff --git a/testsuite/profiles/linux.json b/testsuite/profiles/linux.json new file mode 100644 index 000000000..dde274438 --- /dev/null +++ b/testsuite/profiles/linux.json @@ -0,0 +1,8 @@ +{ + "unsupported": { + "crtimes": "no creation times", + "darwin": "not Darwin", + "darwin_interpose": "no dyld interposition", + "symlink_mode": "no symlink chmod" + } +} diff --git a/testsuite/profiles/macos.json b/testsuite/profiles/macos.json new file mode 100644 index 000000000..b6a3ae908 --- /dev/null +++ b/testsuite/profiles/macos.json @@ -0,0 +1,24 @@ +{ + "unsupported": { + "backup_metadata": "no backup metadata fixture", + "cross_device": "no writable cross-device fixture", + "directory_sgid": "no directory setgid semantics", + "group_auth": "no Linux group-auth fixture", + "linux_acl": "no Linux ACL semantics", + "linux_interpose": "no Linux interposition", + "linux_xattr": "no Linux xattr semantics", + "noatime": "no O_NOATIME", + "preallocate": "no preallocation support", + "proc_fd": "no procfs descriptors", + "protected_regular": "no protected_regular sysctl", + "raw_filename_bytes": "no raw filename bytes", + "root_anchor": "no root-anchored source fixture", + "search_only": "no Linux search-only fixture", + "setgid_semantics": "setgid differential unavailable", + "simd": "no SIMD test build", + "sparse": "no sparse-file fixture", + "symlink_xattr": "no symlink xattr fixture", + "ungrantable_group": "no ungrantable group", + "user_namespace": "no user namespaces" + } +} diff --git a/testsuite/profiles/non-asan.json b/testsuite/profiles/non-asan.json new file mode 100644 index 000000000..e1191c359 --- /dev/null +++ b/testsuite/profiles/non-asan.json @@ -0,0 +1,5 @@ +{ + "unsupported": { + "asan": "no AddressSanitizer" + } +} diff --git a/testsuite/profiles/nonroot.json b/testsuite/profiles/nonroot.json new file mode 100644 index 000000000..51427360f --- /dev/null +++ b/testsuite/profiles/nonroot.json @@ -0,0 +1,5 @@ +{ + "unsupported": { + "root": "running without root privileges" + } +} diff --git a/testsuite/profiles/peer-2.6.0.json b/testsuite/profiles/peer-2.6.0.json new file mode 100644 index 000000000..af170fd80 --- /dev/null +++ b/testsuite/profiles/peer-2.6.0.json @@ -0,0 +1,28 @@ +{ + "peer": "2.6.0", + "protocol": 27, + "xfail": { + "alt-dest": "peer-feature-gap", + "alt-dest-symlink-race": "pre-hardening", + "batch-mode": "peer-feature-gap", + "chdir-symlink-race": "pre-hardening", + "chmod-option": "peer-feature-gap", + "copy-dest-source-symlink": "pre-hardening", + "daemon": "peer-feature-gap", + "daemon-access": "peer-feature-gap", + "daemon-config": "peer-feature-gap", + "daemon-exec": "peer-feature-gap", + "daemon-filter": "peer-feature-gap", + "daemon-gzip-download": "peer-feature-gap", + "daemon-gzip-upload": "peer-feature-gap", + "daemon-munge": "peer-feature-gap", + "daemon-refuse": "peer-feature-gap", + "daemon-refuse-compress": "peer-feature-gap", + "files-from": "peer-feature-gap", + "sender-flist-symlink-leak": "pre-hardening", + "smoke": "peer-feature-gap", + "symlink-dirlink-basis": "peer-feature-gap", + "variety": "peer-feature-gap", + "variety-symlink-traversal": "peer-feature-gap" + } +} diff --git a/testsuite/profiles/peer-3.0.0.json b/testsuite/profiles/peer-3.0.0.json new file mode 100644 index 000000000..f58dc305b --- /dev/null +++ b/testsuite/profiles/peer-3.0.0.json @@ -0,0 +1,18 @@ +{ + "peer": "3.0.0", + "protocol": 30, + "xfail": { + "alt-dest-symlink-race": "pre-hardening", + "batch-mode": "peer-feature-gap", + "chdir-symlink-race": "pre-hardening", + "chmod-option": "peer-feature-gap", + "copy-dest-source-symlink": "pre-hardening", + "daemon": "peer-feature-gap", + "daemon-config": "peer-feature-gap", + "daemon-filter": "peer-feature-gap", + "daemon-gzip-download": "peer-feature-gap", + "daemon-gzip-upload": "peer-feature-gap", + "daemon-refuse": "peer-feature-gap", + "sender-flist-symlink-leak": "pre-hardening" + } +} diff --git a/testsuite/profiles/peer-3.1.0.json b/testsuite/profiles/peer-3.1.0.json new file mode 100644 index 000000000..5bce813cb --- /dev/null +++ b/testsuite/profiles/peer-3.1.0.json @@ -0,0 +1,15 @@ +{ + "peer": "3.1.0", + "protocol": 31, + "xfail": { + "alt-dest-symlink-race": "pre-hardening", + "chdir-symlink-race": "pre-hardening", + "copy-dest-source-symlink": "pre-hardening", + "daemon": "peer-feature-gap", + "daemon-gzip-download": "peer-feature-gap", + "daemon-gzip-upload": "peer-feature-gap", + "daemon-refuse": "peer-feature-gap", + "daemon-refuse-compress": "peer-feature-gap", + "sender-flist-symlink-leak": "pre-hardening" + } +} diff --git a/testsuite/profiles/peer-3.1.3.json b/testsuite/profiles/peer-3.1.3.json new file mode 100644 index 000000000..5e9d344a3 --- /dev/null +++ b/testsuite/profiles/peer-3.1.3.json @@ -0,0 +1,13 @@ +{ + "peer": "3.1.3", + "protocol": 31, + "xfail": { + "alt-dest-symlink-race": "pre-hardening", + "chdir-symlink-race": "pre-hardening", + "copy-dest-source-symlink": "pre-hardening", + "daemon": "peer-feature-gap", + "daemon-refuse": "peer-feature-gap", + "daemon-refuse-compress": "peer-feature-gap", + "sender-flist-symlink-leak": "pre-hardening" + } +} diff --git a/testsuite/profiles/peer-3.2.0.json b/testsuite/profiles/peer-3.2.0.json new file mode 100644 index 000000000..0b926a1a5 --- /dev/null +++ b/testsuite/profiles/peer-3.2.0.json @@ -0,0 +1,13 @@ +{ + "peer": "3.2.0", + "protocol": 31, + "xfail": { + "alt-dest-symlink-race": "pre-hardening", + "chdir-symlink-race": "pre-hardening", + "chmod-option": "peer-feature-gap", + "copy-dest-source-symlink": "pre-hardening", + "daemon": "peer-feature-gap", + "daemon-refuse-compress": "peer-feature-gap", + "sender-flist-symlink-leak": "pre-hardening" + } +} diff --git a/testsuite/profiles/peer-3.2.7.json b/testsuite/profiles/peer-3.2.7.json new file mode 100644 index 000000000..9975e7e2e --- /dev/null +++ b/testsuite/profiles/peer-3.2.7.json @@ -0,0 +1,10 @@ +{ + "peer": "3.2.7", + "protocol": 31, + "xfail": { + "alt-dest-symlink-race": "pre-hardening", + "chdir-symlink-race": "pre-hardening", + "copy-dest-source-symlink": "pre-hardening", + "sender-flist-symlink-leak": "pre-hardening" + } +} diff --git a/testsuite/profiles/peer-3.3.0.json b/testsuite/profiles/peer-3.3.0.json new file mode 100644 index 000000000..535d66a8f --- /dev/null +++ b/testsuite/profiles/peer-3.3.0.json @@ -0,0 +1,10 @@ +{ + "peer": "3.3.0", + "protocol": 31, + "xfail": { + "alt-dest-symlink-race": "pre-hardening", + "chdir-symlink-race": "pre-hardening", + "copy-dest-source-symlink": "pre-hardening", + "sender-flist-symlink-leak": "pre-hardening" + } +} diff --git a/testsuite/profiles/peer-3.4.0.json b/testsuite/profiles/peer-3.4.0.json new file mode 100644 index 000000000..9a2b84db5 --- /dev/null +++ b/testsuite/profiles/peer-3.4.0.json @@ -0,0 +1,11 @@ +{ + "peer": "3.4.0", + "protocol": 32, + "xfail": { + "alt-dest-symlink-race": "pre-hardening", + "chdir-symlink-race": "pre-hardening", + "copy-dest-source-symlink": "pre-hardening", + "sender-flist-symlink-leak": "pre-hardening", + "symlink-dirlink-basis": "issue-715" + } +} diff --git a/testsuite/profiles/peer-3.4.1.json b/testsuite/profiles/peer-3.4.1.json new file mode 100644 index 000000000..ade711a24 --- /dev/null +++ b/testsuite/profiles/peer-3.4.1.json @@ -0,0 +1,11 @@ +{ + "peer": "3.4.1", + "protocol": 32, + "xfail": { + "alt-dest-symlink-race": "pre-3.4.3", + "chdir-symlink-race": "pre-3.4.3", + "copy-dest-source-symlink": "pre-3.4.3", + "sender-flist-symlink-leak": "pre-3.4.3", + "symlink-dirlink-basis": "issue-715" + } +} diff --git a/testsuite/profiles/pipe.json b/testsuite/profiles/pipe.json new file mode 100644 index 000000000..82f195565 --- /dev/null +++ b/testsuite/profiles/pipe.json @@ -0,0 +1,5 @@ +{ + "unsupported": { + "tcp": "pipe transport" + } +} diff --git a/testsuite/profiles/protocol-29.json b/testsuite/profiles/protocol-29.json new file mode 100644 index 000000000..7c272276e --- /dev/null +++ b/testsuite/profiles/protocol-29.json @@ -0,0 +1,7 @@ +{ + "protocol": 29, + "unsupported": { + "protocol_30": "protocol below 30", + "protocol_33": "protocol below 33" + } +} diff --git a/testsuite/profiles/protocol-30.json b/testsuite/profiles/protocol-30.json new file mode 100644 index 000000000..5f91feac6 --- /dev/null +++ b/testsuite/profiles/protocol-30.json @@ -0,0 +1,6 @@ +{ + "protocol": 30, + "unsupported": { + "protocol_33": "protocol below 33" + } +} diff --git a/testsuite/profiles/root.json b/testsuite/profiles/root.json new file mode 100644 index 000000000..948bdc7f6 --- /dev/null +++ b/testsuite/profiles/root.json @@ -0,0 +1,5 @@ +{ + "unsupported": { + "nonroot": "running as root" + } +} diff --git a/testsuite/profiles/self-peer.json b/testsuite/profiles/self-peer.json new file mode 100644 index 000000000..ce3306ac0 --- /dev/null +++ b/testsuite/profiles/self-peer.json @@ -0,0 +1,5 @@ +{ + "unsupported": { + "legacy_peer": "no historical peer" + } +} diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index a1a1dcfa8..149c375ef 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -196,8 +196,10 @@ def test_fail(msg: str) -> 'None': sys.exit(Exit.FAIL) -def test_skipped(msg: str) -> 'None': +def test_skipped(msg: str, capability: str = None) -> 'None': sys.stderr.write(msg.rstrip() + '\n') + if capability: + (TMPDIR / 'unsupported').write_text(capability + '\n') (TMPDIR / 'whyskipped').write_text(msg.rstrip() + '\n') sys.exit(Exit.SKIP) @@ -875,7 +877,7 @@ def require_tcp(reason: str) -> 'None': pipe equivalent (the fake-proxy listener; the reverse-DNS hostname-ACL daemon test).""" if not USE_TCP: - test_skipped(reason) + test_skipped(reason, capability='tcp') def require_asan(reason: str, which: str = None) -> 'None': @@ -891,10 +893,10 @@ def require_asan(reason: str, which: str = None) -> 'None': stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=15) except Exception: - test_skipped(reason) + test_skipped(reason, capability='asan') return if b'AddressSanitizer' not in r.stderr: - test_skipped(reason) + test_skipped(reason, capability='asan') def rsh_cmd(cmd: str = None, *opts: str) -> str: @@ -2181,12 +2183,13 @@ def run_proxy_probe(port, host, expected): def setup_chroot_inner(name): if get_testuid() != get_rootuid(): - test_skipped("chroot /./ module regression requires root") + test_skipped("chroot /./ module regression requires root", capability='root') if _under_valgrind(): # The daemon's per-connection child chroots into the module, after # which valgrind can no longer create its absolute --log-file %p path # and the child dies (the transfer then resets) -- skip under valgrind. - test_skipped("daemon chroot prevents valgrind from writing its per-process log") + test_skipped("daemon chroot prevents valgrind from writing its per-process log", + capability='chroot') base = SCRATCHDIR / name outer = base / 'outer' inner = outer / 'inner' @@ -2218,13 +2221,15 @@ def build_patched_rsync(name, replacements, append_cflags=None): if sys.platform == 'cygwin' or platform.system().startswith('CYGWIN'): test_skipped(f"{name}: build_patched_rsync is unreliable on Cygwin " "(prebuilt-object staleness / -fno-common relink); the " - "patched-peer fix is validated on the POSIX targets") + "patched-peer fix is validated on the POSIX targets", + capability='native_build') if not (SRCDIR / 'Makefile').is_file(): - test_skipped(f"{name}: needs a configured rsync source tree with a Makefile") + test_skipped(f"{name}: needs a configured rsync source tree with a Makefile", + capability='native_build') if not shutil.which('make'): - test_skipped(f"{name}: make(1) not on PATH") + test_skipped(f"{name}: make(1) not on PATH", capability='native_build') if not shutil.which('gcc') and not shutil.which('cc'): - test_skipped(f"{name}: no C compiler on PATH") + test_skipped(f"{name}: no C compiler on PATH", capability='native_build') work = SCRATCHDIR / name rmtree(work) @@ -2237,7 +2242,8 @@ def build_patched_rsync(name, replacements, append_cflags=None): path = work / relpath text = path.read_text() if old not in text: - test_skipped(f"{name}: could not find patch target in {relpath}: {old!r}") + test_skipped(f"{name}: could not find patch target in {relpath}: {old!r}", + capability='native_build') path.write_text(text.replace(old, new, 1)) # Drop the copied object for this unit. copytree() preserves mtimes, so # on a target whose clock lags the host that pushed the tree -- or one @@ -2265,7 +2271,8 @@ def build_patched_rsync(name, replacements, append_cflags=None): mk = mkpath.read_text() mk2 = re.sub(r'(?m)^(CFLAGS=.*)$', r'\1 ' + append_cflags, mk, count=1) if mk2 == mk: - test_skipped(f"{name}: could not append {append_cflags!r} to CFLAGS in the Makefile") + test_skipped(f"{name}: could not append {append_cflags!r} to CFLAGS in the Makefile", + capability='native_build') mkpath.write_text(mk2) # The copied tree carries prebuilt objects compiled with the ORIGINAL # flags; since the sources aren't newer, make would reuse them and the @@ -2281,7 +2288,8 @@ def build_patched_rsync(name, replacements, append_cflags=None): if build.returncode != 0 or not rsync.is_file() or not os.access(rsync, os.X_OK): test_skipped( f"{name}: patched rsync build failed (rc={build.returncode}). " - "Tail of build output:\n" + '\n'.join(build.stdout.splitlines()[-20:])) + "Tail of build output:\n" + '\n'.join(build.stdout.splitlines()[-20:]), + capability='native_build') return rsync diff --git a/runtests.py b/testsuite/runtests.py similarity index 76% rename from runtests.py rename to testsuite/runtests.py index 4f62c12f6..fbf53065c 100755 --- a/runtests.py +++ b/testsuite/runtests.py @@ -16,7 +16,7 @@ Can be called by 'make check' or directly. Usage: - ./runtests.py [options] [TEST ...] + ./testsuite/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. @@ -26,8 +26,10 @@ import concurrent.futures import fnmatch import glob +import json import math import os +import shutil import signal import subprocess import sys @@ -36,8 +38,11 @@ # 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 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import ( + Exit, Outcome, TestResult, applies_to_peer, load_profile, merge_profiles, parse_peer_banner, + placeholder_target, read_requirements, verdict_of, write_receipt, +) def _race_seconds(text): @@ -89,6 +94,7 @@ def parse_args(): 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('--receipt', metavar='FILE', help='Write a JSON run receipt') 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', @@ -120,13 +126,8 @@ def parse_args(): '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('--profiles', default=None, metavar='LIST', + help='Comma-separated test profiles') 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 ' @@ -140,6 +141,8 @@ def parse_args(): 'stdio-pipe transport, which opens no listening ' 'socket; --use-tcp exposes a loopback port for the ' 'duration of each daemon test.') + p.add_argument('--describe-tests', action='store_true', + help='Print test metadata as JSON and exit') return p.parse_args() @@ -249,9 +252,7 @@ def prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef): 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. +# Python tests live in testsuite/tests/ and use a positive "_test.py" suffix. _PY_TEST_SUFFIX = '_test.py' @@ -269,8 +270,9 @@ def _testbase(path): def collect_tests(suitedir, patterns): """Collect test scripts (_test.py) matching the given patterns.""" + testdir = os.path.join(suitedir, 'tests') if not patterns: - candidates = glob.glob(os.path.join(suitedir, '*' + _PY_TEST_SUFFIX)) + candidates = glob.glob(os.path.join(testdir, '*' + _PY_TEST_SUFFIX)) tests = sorted(p for p in candidates if _is_test_path(p)) else: seen = set() @@ -282,7 +284,7 @@ def collect_tests(suitedir, patterns): else: pats = [pat + _PY_TEST_SUFFIX] for p in pats: - for m in sorted(glob.glob(os.path.join(suitedir, p))): + for m in sorted(glob.glob(os.path.join(testdir, p))): if _is_test_path(m) and m not in seen: seen.add(m) tests.append(m) @@ -316,6 +318,14 @@ def select_daemon_tests(tests): filter must always be "ran too much".""" keep, dropped = [], [] for path in tests: + metadata = read_requirements(path) + if metadata and metadata['transports']: + if 'tcp' not in metadata['transports']: + dropped.append(path) + continue + if 'daemon' in metadata['tags']: + keep.append(path) + continue try: with open(path, errors='replace') as f: text = f.read() @@ -326,31 +336,13 @@ def select_daemon_tests(tests): 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 describe_tests(tests): + descriptions = [] + for path in tests: + metadata = read_requirements(path) + descriptions.append({'name': _testbase(path), 'legacy': metadata is None, + **(metadata or {})}) + return descriptions def expand_skip_spec(spec, srcdir, suitedir): @@ -438,7 +430,7 @@ def die(msg): # 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')): + if not os.path.isfile(os.path.join(suitedir, 'tests', 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 @@ -476,7 +468,7 @@ def read_backport_exclude(tooldir, suitedir): 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')): + if not os.path.isfile(os.path.join(suitedir, 'tests', name + '_test.py')): sys.stderr.write(f'{where}: no such test: {name}\n') sys.exit(Exit.ERROR) names.add(name) @@ -512,17 +504,6 @@ def print_timing_report(durations, outcomes, run_wall, parallel): 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 = [] @@ -536,8 +517,7 @@ def build_rsync_cmd(rsync_bin, args, scratchbase): 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') + supp = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'valgrind.supp') if os.path.exists(supp): vopts += f' --suppressions={supp}' if args.valgrind_opts: @@ -549,17 +529,14 @@ def build_rsync_cmd(rsync_bin, args, scratchbase): 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 binary_identity(path): + result = subprocess.run([path, '--version'], capture_output=True, text=True) + if result.returncode: + raise ValueError(f'cannot determine rsync version from {path}') + try: + return parse_peer_banner(result.stdout) + except ValueError as error: + raise ValueError(f'cannot determine rsync version from {path}') from error def run_one_test(testscript, testbase, scratchdir, base_env, timeout, @@ -575,6 +552,11 @@ def run_one_test(testscript, testbase, scratchdir, base_env, timeout, env = base_env.copy() env['scratchdir'] = scratchdir + target = placeholder_target(testscript) + if target: + testscript = os.path.join(scratchdir, os.path.basename(testscript)) + shutil.copyfile(target, testscript) + # Dispatch by extension: shell tests via /bin/sh -e, Python tests via # the same python3 that's running this runner. if testscript.endswith('.py'): @@ -635,6 +617,7 @@ def run_one_test(testscript, testbase, scratchdir, base_env, timeout, output_parts.append(f'----- {testbase} rsyncd.log ends') skipped_reason = '' + unsupported = '' if result == Exit.PASS: output_parts.append(f'PASS {testbase}') elif result == Exit.SKIP: @@ -644,14 +627,20 @@ def run_one_test(testscript, testbase, scratchdir, base_env, timeout, skipped_reason = f.read().strip() except FileNotFoundError: pass - output_parts.append(f'SKIP {testbase} ({skipped_reason})') + try: + with open(os.path.join(scratchdir, 'unsupported')) as stream: + unsupported = stream.read().strip() + except FileNotFoundError: + pass + label = 'UNSUPPORTED' if unsupported else 'SKIP' + output_parts.append(f'{label} {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) + unsupported, time.monotonic() - started) # Lock for serializing output in parallel mode @@ -670,13 +659,15 @@ def main(): args.expect_skipped = os.environ.get('RSYNC_EXPECT_SKIPPED', 'IGNORE') if args.exclude is None: args.exclude = os.environ.get('RSYNC_EXCLUDE', '') + if args.profiles is None: + args.profiles = os.environ.get('RSYNC_TEST_PROFILES', '') 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 + srcdir = args.srcdir or os.path.dirname(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') @@ -694,6 +685,16 @@ def main(): rsync_bin2 = os.path.abspath(rsync_bin2) suitedir = os.path.join(srcdir, 'testsuite') + if args.describe_tests: + tests = collect_tests(suitedir, args.tests) + excluded = [item.strip() for item in args.exclude.split(',') if item.strip()] + if excluded: + tests = [path for path in tests + if not any(fnmatch.fnmatch(_testbase(path), item) + for item in excluded)] + print(json.dumps(describe_tests(tests), separators=(',', ':'), sort_keys=True)) + return + # 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. @@ -824,6 +825,73 @@ def main(): print(f"Excluding {before - len(tests)} test(s) matching: " f"{', '.join(excl)}") + profiles = [] + all_tests = collect_tests(suitedir, []) + known_tests = {_testbase(test) for test in all_tests} + for item in (value.strip() for value in args.profiles.split(',')): + if not item: + continue + if os.path.sep not in item and (not os.path.altsep or os.path.altsep not in item): + path = os.path.join(suitedir, 'profiles', item if item.endswith('.json') else f'{item}.json') + else: + path = item if os.path.isabs(item) else os.path.join(srcdir, item) + try: + profiles.append(load_profile(path, known_tests)) + except (OSError, ValueError, json.JSONDecodeError) as error: + sys.stderr.write(f'runtests.py: {error}\n') + sys.exit(Exit.ERROR) + try: + profile_peer, profile_protocol, profile_unsupported, profile_xfail = merge_profiles(profiles) + except ValueError as error: + sys.stderr.write(f'runtests.py: {error}\n') + sys.exit(Exit.ERROR) + extra_unsupported = [value.strip() for value in os.environ.get('RSYNC_TEST_UNSUPPORTED', '').split(',') + if value.strip()] + if extra_unsupported and not profiles: + sys.stderr.write('runtests.py: RSYNC_TEST_UNSUPPORTED requires an active profile\n') + sys.exit(Exit.ERROR) + for capability in extra_unsupported: + profile_unsupported.setdefault(capability, 'target-specific absence') + effective_protocol = args.protocol + transport = 'tcp' if args.use_tcp else 'pipe' + if profile_protocol and not profile_peer: + if args.protocol != profile_protocol: + sys.stderr.write(f'runtests.py: profile protocol {profile_protocol} does not match ' + f'{args.protocol or "the default protocol"}\n') + sys.exit(Exit.ERROR) + effective_protocol = profile_protocol + if profile_peer: + try: + _, current_protocol = binary_identity(rsync_bin) + actual_peer, actual_peer_protocol = binary_identity(rsync_bin2) + except ValueError as error: + sys.stderr.write(f'runtests.py: {error}\n') + sys.exit(Exit.ERROR) + if actual_peer != profile_peer: + sys.stderr.write(f'runtests.py: profile peer {profile_peer} does not match {actual_peer}\n') + sys.exit(Exit.ERROR) + if actual_peer_protocol != profile_protocol: + sys.stderr.write(f'runtests.py: profile protocol {profile_protocol} does not match {actual_peer_protocol}\n') + sys.exit(Exit.ERROR) + effective_protocol = min(current_protocol, actual_peer_protocol, + args.protocol if args.protocol is not None else current_protocol) + eligible = {_testbase(test) for test in all_tests + if applies_to_peer(read_requirements(test), profile_peer, profile_protocol)} + invalid = sorted(set(profile_xfail) - eligible) + if invalid: + sys.stderr.write(f'runtests.py: peer deviations do not apply: {", ".join(invalid)}\n') + sys.exit(Exit.ERROR) + selected = [] + for test in tests: + metadata = read_requirements(test) + if not applies_to_peer(metadata, profile_peer, effective_protocol, transport): + continue + selected.append(test) + tests = selected + full_run = False + if not tests: + sys.stderr.write(f'runtests.py: no tests apply to peer {profile_peer}\n') + sys.exit(Exit.ERROR) # 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. @@ -835,28 +903,17 @@ def main(): # 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 + metadata_by_test = {_testbase(test): read_requirements(test) for test in tests} - def mismatch(testbase, actual): - """True if actual outcome disagrees with the manifest expectation.""" - return expect is not None and _cls(expect[testbase]) != _cls(actual) + def mismatch(testbase, actual, unsupported=''): + if actual == 'unsupported': + metadata = metadata_by_test.get(testbase) or {} + return unsupported not in profile_unsupported or unsupported not in metadata.get('features', ()) + if actual == 'skip': + return True + if testbase in profile_xfail: + return actual not in ('fail', 'xfail') + return actual != 'pass' # Record test order for consistent skipped-list output test_order = {_testbase(t): i for i, t in enumerate(tests)} @@ -864,42 +921,48 @@ def mismatch(testbase, actual): passed = 0 failed = 0 skipped = 0 + unsupported_count = 0 xfailed = 0 skipped_list = [] outcomes = {} # testbase -> actual outcome string ('pass'/'skip'/'fail'/'xfail') + unsupported_outcomes = {} durations = {} # testbase -> wall-clock seconds (for --timing) + test_results = {} 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 + nonlocal passed, failed, skipped, unsupported_count, 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: + scratchdir = os.path.join(scratchbase, tr.name) + oc = tr.outcome.value + outcomes[tr.name] = oc + test_results[tr.name] = tr + unsupported_outcomes[tr.name] = tr.unsupported + durations[tr.name] = tr.duration + if tr.exit_code == Exit.PASS: passed += 1 - elif tr.result == Exit.SKIP: - skipped_list.append(tr.testbase) - skipped += 1 - elif tr.result == Exit.XFAIL: + elif tr.exit_code == Exit.SKIP: + skipped_list.append(tr.name) + if tr.unsupported: + unsupported_count += 1 + else: + skipped += 1 + elif tr.exit_code == 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 \ + if tr.exit_code 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) + if profiles: + return mismatch(tr.name, oc, tr.unsupported) + return tr.exit_code not in (Exit.PASS, Exit.SKIP, Exit.XFAIL) run_started = time.monotonic() @@ -969,28 +1032,91 @@ def process_result(tr): print(f' {xfailed} xfailed (expected)') if skipped > 0: print(f' {skipped} skipped') + if unsupported_count > 0: + print(f' {unsupported_count} unsupported') 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. + def expected_outcome(name): + if name in profile_xfail: + return f'xfail:{profile_xfail[name]}' + capability = unsupported_outcomes.get(name, '') + if capability and capability in profile_unsupported: + return f'unsupported:{capability}' + if full_run and args.expect_skipped != 'IGNORE' and name in args.expect_skipped.split(','): + return 'skip' + return 'pass' + + def emit_receipt(exit_code, mismatches=()): + if not args.receipt: + return + ordered = sorted(test_results, key=lambda name: test_order.get(name, 1 << 30)) + records = [] + verdicts = {} + for name in ordered: + record = test_results[name].record(expected_outcome(name) if profiles else None) + if metadata_by_test.get(name): + record['requirements'] = metadata_by_test[name] + records.append(record) + verdicts[record['verdict']] = verdicts.get(record['verdict'], 0) + 1 + data = { + 'schema': 1, + 'run': { + 'binary': rsync_cmd, + 'peer': rsync_peer_cmd, + 'peer_version': profile_peer, + 'profiles': [profile.name for profile in profiles], + 'protocol': effective_protocol, + 'transport': transport, + 'parallel': args.parallel, + 'valgrind': args.valgrind, + 'selected': list(test_order), + }, + 'tests': records, + 'summary': { + 'passed': passed, + 'failed': failed, + 'skipped': skipped, + 'unsupported': unsupported_count, + 'xfailed': xfailed, + 'verdicts': verdicts, + 'valgrind_errors': vg_errors, + 'duration_seconds': round(run_wall, 6), + 'exit_code': exit_code, + 'mismatches': list(mismatches), + }, + } + write_receipt(args.receipt, data) + + if profiles: mismatches = [] - for tb in sorted(expect, key=lambda x: test_order.get(x, 1 << 30)): + expected_tests = {_testbase(test) for test in tests} + for tb in sorted(expected_tests, 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)) + unsupported = unsupported_outcomes.get(tb, '') + if actual == 'notrun' or mismatch(tb, actual, unsupported): + want = expected_outcome(tb) + verdict = Outcome.PROFILE_ERROR if actual == 'notrun' else verdict_of(Outcome(actual), want) + mismatches.append((tb, want, actual, verdict.value)) 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('----- profile mismatches:') + for tb, want, got, verdict in mismatches: + print(f' {verdict.upper()} {tb}: expected {want}, got {got}') + skip_policy_mismatch = False + if full_run and args.expect_skipped != 'IGNORE': + got = set(skipped_list) + want = {name for name in args.expect_skipped.split(',') if name} + skip_policy_mismatch = got != want + print('----- skip-policy comparison:') + print(f' expected: {",".join(sorted(want))}') + print(f' got: {",".join(sorted(got))}') print('-' * 60) - exit_code = len(mismatches) + vg_errors + exit_code = len(mismatches) + int(skip_policy_mismatch) + vg_errors + emit_receipt(exit_code, ({'name': name, 'expected': want, 'actual': got, 'verdict': verdict} + for name, want, got, verdict in mismatches)) print(f'overall result is {exit_code}') sys.exit(exit_code) @@ -1016,6 +1142,7 @@ def process_result(tr): if got != want: exit_code = 1 + emit_receipt(exit_code) print(f'overall result is {exit_code}') sys.exit(exit_code) diff --git a/testsuite/acl-symlink-race_test.py b/testsuite/tests/acl-symlink-race_test.py similarity index 95% rename from testsuite/acl-symlink-race_test.py rename to testsuite/tests/acl-symlink-race_test.py index 039888cba..419f011ec 100644 --- a/testsuite/acl-symlink-race_test.py +++ b/testsuite/tests/acl-symlink-race_test.py @@ -34,9 +34,10 @@ # ---- gates ----------------------------------------------------------------- if platform.system() != 'Linux': - test_skipped("POSIX ACL race test is Linux-only (setfacl/getfacl semantics)") + test_skipped("POSIX ACL race test is Linux-only (setfacl/getfacl semantics)", + capability='linux_acl') if not shutil.which('setfacl') or not shutil.which('getfacl'): - test_skipped("setfacl/getfacl not available") + test_skipped("setfacl/getfacl not available", capability='linux_acl') # Protocol gate FIRST: this test transfers an ACL over the wire, which requires # protocol 30+ regardless of the host's ACL capability. It MUST precede the # ACL_at check below: on a no-xattrat box that check short-circuits to a PASS, @@ -45,10 +46,11 @@ # failing CI on the no-xattrat Ubuntu runners. proto = forced_protocol() if proto is not None and proto < 30: - test_skipped(f"ACL transfer requires protocol 30+ (negotiated {proto})") + test_skipped(f"ACL transfer requires protocol 30+ (negotiated {proto})", + capability='protocol_30') vv = run_rsync('-VV', check=True, capture_output=True) if '"ACLs": true' not in vv.stdout: - test_skipped("rsync built without ACL support") + test_skipped("rsync built without ACL support", capability='linux_acl') if '"ACL_at": true' not in vv.stdout: # No race-safe ACL primitive on this kernel (no *xattrat, pre-6.13 / BSD): # by project policy a received ACL is then applied via the path so --acls @@ -63,7 +65,7 @@ def setfacl(spec, path): if subprocess.run(['setfacl', '-m', spec, str(path)]).returncode != 0: - test_skipped("filesystem has ACLs disabled (setfacl failed)") + test_skipped("filesystem has ACLs disabled (setfacl failed)", capability='linux_acl') def getfacl(path) -> str: diff --git a/testsuite/acls-default_test.py b/testsuite/tests/acls-default_test.py similarity index 93% rename from testsuite/acls-default_test.py rename to testsuite/tests/acls-default_test.py index e780de66d..01c1b5920 100644 --- a/testsuite/acls-default_test.py +++ b/testsuite/tests/acls-default_test.py @@ -18,11 +18,11 @@ vv = run_rsync('-VV', check=True, capture_output=True) if '"ACLs": true' not in vv.stdout: - test_skipped("Rsync is configured without ACL support") + test_skipped("Rsync is configured without ACL support", capability='linux_acl') setfacl_nodef = os.environ.get('setfacl_nodef', 'true') if setfacl_nodef == 'true': - test_skipped("I don't know how to use your setfacl command") + test_skipped("I don't know how to use your setfacl command", capability='linux_acl') if '-k' in setfacl_nodef.split(): seed_opts = ['-dm', 'u::7,g::5,o:5'] @@ -33,7 +33,7 @@ # inherit a known-base; if setfacl rejects this the FS doesn't have ACLs. proc = subprocess.run(['setfacl', *seed_opts, str(SCRATCHDIR)]) if proc.returncode != 0: - test_skipped("Your filesystem has ACLs disabled") + test_skipped("Your filesystem has ACLs disabled", capability='linux_acl') def testit(dirname, default_acl, file_expected, prog_expected): diff --git a/testsuite/acls-depth_test.py b/testsuite/tests/acls-depth_test.py similarity index 89% rename from testsuite/acls-depth_test.py rename to testsuite/tests/acls-depth_test.py index 308be7650..1341067e1 100644 --- a/testsuite/acls-depth_test.py +++ b/testsuite/tests/acls-depth_test.py @@ -19,9 +19,9 @@ vv = run_rsync('-VV', check=True, capture_output=True).stdout if '"ACLs": true' not in vv: - test_skipped("rsync built without ACL support") + test_skipped("rsync built without ACL support", capability='linux_acl') if not (shutil.which('setfacl') and shutil.which('getfacl')): - test_skipped("setfacl/getfacl not available") + test_skipped("setfacl/getfacl not available", capability='linux_acl') src = FROMDIR rmtree(src) @@ -36,7 +36,7 @@ for rel in entries: r = subprocess.run(['setfacl', '-m', 'u:0:r-x', str(src / rel)]) if r.returncode != 0: - test_skipped("filesystem does not support setting ACLs") + test_skipped("filesystem does not support setting ACLs", capability='linux_acl') def getfacl(path): diff --git a/testsuite/acls-unpinnable_test.py b/testsuite/tests/acls-unpinnable_test.py similarity index 92% rename from testsuite/acls-unpinnable_test.py rename to testsuite/tests/acls-unpinnable_test.py index 65e4145b6..b39dcc3d7 100644 --- a/testsuite/acls-unpinnable_test.py +++ b/testsuite/tests/acls-unpinnable_test.py @@ -31,19 +31,21 @@ STALE_UID = 60009 # a different grant pre-seeded on the dest; must be gone if platform.system() != 'Linux': - test_skipped("POSIX ACL test is Linux-only (setfacl/getfacl semantics)") + test_skipped("POSIX ACL test is Linux-only (setfacl/getfacl semantics)", + capability='linux_acl') if not shutil.which('setfacl') or not shutil.which('getfacl'): - test_skipped("setfacl/getfacl not available") + test_skipped("setfacl/getfacl not available", capability='linux_acl') if '"ACLs": true' not in run_rsync('-VV', check=True, capture_output=True).stdout: - test_skipped("rsync built without ACL support") + test_skipped("rsync built without ACL support", capability='linux_acl') proto = forced_protocol() if proto is not None and proto < 30: - test_skipped(f"ACL transfer requires protocol 30+ (negotiated {proto})") + test_skipped(f"ACL transfer requires protocol 30+ (negotiated {proto})", + capability='protocol_30') def setfacl(spec, path): if subprocess.run(['setfacl', '-m', spec, str(path)]).returncode != 0: - test_skipped("filesystem has ACLs disabled (setfacl failed)") + test_skipped("filesystem has ACLs disabled (setfacl failed)", capability='linux_acl') def getfacl(path) -> str: diff --git a/testsuite/acls_test.py b/testsuite/tests/acls_test.py similarity index 91% rename from testsuite/acls_test.py rename to testsuite/tests/acls_test.py index c24d7b00f..e748fe646 100644 --- a/testsuite/acls_test.py +++ b/testsuite/tests/acls_test.py @@ -14,7 +14,7 @@ vv = run_rsync('-VV', check=True, capture_output=True) if '"ACLs": true' not in vv.stdout: - test_skipped("Rsync is configured without ACL support") + test_skipped("Rsync is configured without ACL support", capability='acl') makepath(FROMDIR / 'foo') (FROMDIR / 'file1').write_text("something\n") @@ -36,7 +36,7 @@ def _chmod_plus_a_supported() -> bool: use_chmod_plus_a = setfacl_nodef == 'true' and _chmod_plus_a_supported() if setfacl_nodef == 'true' and not use_chmod_plus_a: - test_skipped("I don't know how to use setfacl or chmod for ACLs") + test_skipped("I don't know how to use setfacl or chmod for ACLs", capability='acl') def _setfacl(*args) -> int: @@ -50,7 +50,7 @@ def _chmod_acl(*args) -> int: if use_chmod_plus_a: if _chmod_acl('+a', 'root allow read,write,execute', str(FROMDIR / 'foo')) != 0: - test_skipped("Your filesystem has ACLs disabled") + test_skipped("Your filesystem has ACLs disabled", capability='acl') _chmod_acl('+a', 'root allow read,execute', str(FROMDIR / 'file1')) _chmod_acl('+a', 'admin allow read', str(FROMDIR / 'file1')) _chmod_acl('+a', 'daemon allow read,write', str(FROMDIR / 'file1')) @@ -60,7 +60,7 @@ def see_acls(paths): return subprocess.check_output(['ls', '-le', *paths], text=True) else: if _setfacl('-m', 'u:0:7', str(FROMDIR / 'foo')) != 0: - test_skipped("Your filesystem has ACLs disabled") + test_skipped("Your filesystem has ACLs disabled", capability='acl') _setfacl('-m', 'g:1:5', str(FROMDIR / 'foo')) _setfacl('-m', 'g:2:1', str(FROMDIR / 'foo')) _setfacl('-m', 'g:0:7', str(FROMDIR / 'foo')) diff --git a/testsuite/alt-dest-deep_test.py b/testsuite/tests/alt-dest-deep_test.py similarity index 100% rename from testsuite/alt-dest-deep_test.py rename to testsuite/tests/alt-dest-deep_test.py diff --git a/testsuite/alt-dest-symlink-race_test.py b/testsuite/tests/alt-dest-symlink-race_test.py similarity index 91% rename from testsuite/alt-dest-symlink-race_test.py rename to testsuite/tests/alt-dest-symlink-race_test.py index fc83c5762..98457c970 100644 --- a/testsuite/alt-dest-symlink-race_test.py +++ b/testsuite/tests/alt-dest-symlink-race_test.py @@ -24,7 +24,9 @@ rsync_argv, get_testuid, get_rootuid, get_rootgid, rmtree, start_test_daemon, test_fail, ) +from harness import metadata +metadata(features={'alt-dest', 'daemon', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', cost='expensive', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'security', 'version-mix'}) DAEMON_PORT = 12882 diff --git a/testsuite/alt-dest_test.py b/testsuite/tests/alt-dest_test.py similarity index 92% rename from testsuite/alt-dest_test.py rename to testsuite/tests/alt-dest_test.py index 6430339b1..e7a594205 100644 --- a/testsuite/alt-dest_test.py +++ b/testsuite/tests/alt-dest_test.py @@ -13,7 +13,9 @@ CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, checkit, hands_setup, rmtree, run_rsync, test_fail, rsync_path_arg, rsh_cmd, ) +from harness import metadata +metadata(features={'alt-dest', 'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process'}, tags={'compatibility', 'transfer', 'version-mix'}) alt1dir = TMPDIR / 'alt1' alt2dir = TMPDIR / 'alt2' diff --git a/testsuite/append-shortsum_test.py b/testsuite/tests/append-shortsum_test.py similarity index 100% rename from testsuite/append-shortsum_test.py rename to testsuite/tests/append-shortsum_test.py diff --git a/testsuite/append_test.py b/testsuite/tests/append_test.py similarity index 100% rename from testsuite/append_test.py rename to testsuite/tests/append_test.py diff --git a/testsuite/atimes_test.py b/testsuite/tests/atimes_test.py similarity index 100% rename from testsuite/atimes_test.py rename to testsuite/tests/atimes_test.py diff --git a/testsuite/authenticate-no-ocloexec-build-regression_test.py b/testsuite/tests/authenticate-no-ocloexec-build-regression_test.py similarity index 97% rename from testsuite/authenticate-no-ocloexec-build-regression_test.py rename to testsuite/tests/authenticate-no-ocloexec-build-regression_test.py index 672fc9262..6070eea34 100644 --- a/testsuite/authenticate-no-ocloexec-build-regression_test.py +++ b/testsuite/tests/authenticate-no-ocloexec-build-regression_test.py @@ -30,7 +30,7 @@ def makefile_vars(repo, names): repo = Path(os.environ.get( - "RSYNC_SOURCE_UNDER_TEST", Path(__file__).resolve().parent.parent)) + "RSYNC_SOURCE_UNDER_TEST", Path(__file__).resolve().parents[2])) source_path = repo / "authenticate.c" config_path = repo / "config.h" if not source_path.exists() or not config_path.exists(): diff --git a/testsuite/backup-acl-xattr-cache_test.py b/testsuite/tests/backup-acl-xattr-cache_test.py similarity index 92% rename from testsuite/backup-acl-xattr-cache_test.py rename to testsuite/tests/backup-acl-xattr-cache_test.py index eacd4e6ef..41c91259f 100644 --- a/testsuite/backup-acl-xattr-cache_test.py +++ b/testsuite/tests/backup-acl-xattr-cache_test.py @@ -23,9 +23,9 @@ import subprocess if not acls_supported(): - test_skipped("ACLs not supported on this filesystem") + test_skipped("ACLs not supported on this filesystem", capability='linux_acl') if not xattrs_supported(): - test_skipped("xattrs not supported on this filesystem") + test_skipped("xattrs not supported on this filesystem", capability='backup_metadata') src = SCRATCHDIR / 'bak-src' dest = SCRATCHDIR / 'bak-dest' @@ -43,7 +43,7 @@ # cache_tmp_acl()/cache_tmp_xattr() when it mkdirs bak/d1 and bak/d2. for d in (dest / 'd1', dest / 'd1' / 'd2'): if not acl_set('u:0:rwx', d): - test_skipped(f"setfacl failed on {d}") + test_skipped(f"setfacl failed on {d}", capability='linux_acl') xattr_set('user.bak-cache', 'v', d) r = subprocess.run( diff --git a/testsuite/backup-crossdev-copy_test.py b/testsuite/tests/backup-crossdev-copy_test.py similarity index 98% rename from testsuite/backup-crossdev-copy_test.py rename to testsuite/tests/backup-crossdev-copy_test.py index 232c82f44..c55e9dbbd 100644 --- a/testsuite/backup-crossdev-copy_test.py +++ b/testsuite/tests/backup-crossdev-copy_test.py @@ -36,7 +36,8 @@ except OSError: continue if TMPFS is None: - test_skipped("no writable cross-device dir (tmpfs) for --backup-dir EXDEV path") + test_skipped("no writable cross-device dir (tmpfs) for --backup-dir EXDEV path", + capability='cross_device') src = FROMDIR dst = SCRATCHDIR / 'bak-xdev-dst' diff --git a/testsuite/backup-deep_test.py b/testsuite/tests/backup-deep_test.py similarity index 100% rename from testsuite/backup-deep_test.py rename to testsuite/tests/backup-deep_test.py diff --git a/testsuite/backup-dir-relative_test.py b/testsuite/tests/backup-dir-relative_test.py similarity index 100% rename from testsuite/backup-dir-relative_test.py rename to testsuite/tests/backup-dir-relative_test.py diff --git a/testsuite/backup-dir-repeated-separator-delete_test.py b/testsuite/tests/backup-dir-repeated-separator-delete_test.py similarity index 100% rename from testsuite/backup-dir-repeated-separator-delete_test.py rename to testsuite/tests/backup-dir-repeated-separator-delete_test.py diff --git a/testsuite/backup-dir-symlink-race_test.py b/testsuite/tests/backup-dir-symlink-race_test.py similarity index 98% rename from testsuite/backup-dir-symlink-race_test.py rename to testsuite/tests/backup-dir-symlink-race_test.py index 1d67e3e74..d47978133 100644 --- a/testsuite/backup-dir-symlink-race_test.py +++ b/testsuite/tests/backup-dir-symlink-race_test.py @@ -40,10 +40,11 @@ # thereby make it "not the operator's own". At any other uid the boundary # cannot be built, so the race would be vacuous. if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') NFILES = 95 diff --git a/testsuite/backup-incremental_test.py b/testsuite/tests/backup-incremental_test.py similarity index 100% rename from testsuite/backup-incremental_test.py rename to testsuite/tests/backup-incremental_test.py diff --git a/testsuite/backup_test.py b/testsuite/tests/backup_test.py similarity index 100% rename from testsuite/backup_test.py rename to testsuite/tests/backup_test.py diff --git a/testsuite/bare-do-open-symlink-race_test.py b/testsuite/tests/bare-do-open-symlink-race_test.py similarity index 95% rename from testsuite/bare-do-open-symlink-race_test.py rename to testsuite/tests/bare-do-open-symlink-race_test.py index f3fc2d5f1..1d2770e6f 100644 --- a/testsuite/bare-do-open-symlink-race_test.py +++ b/testsuite/tests/bare-do-open-symlink-race_test.py @@ -17,7 +17,9 @@ get_rootgid, get_rootuid, get_testuid, rmtree, rsync_argv, start_test_daemon, test_fail, test_skipped, ) +from harness import metadata +metadata(features={'daemon', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', cost='expensive', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'security', 'version-mix'}) DAEMON_PORT = 12884 diff --git a/testsuite/basis-xname-traversal_test.py b/testsuite/tests/basis-xname-traversal_test.py similarity index 100% rename from testsuite/basis-xname-traversal_test.py rename to testsuite/tests/basis-xname-traversal_test.py diff --git a/testsuite/batch-file-symlink_test.py b/testsuite/tests/batch-file-symlink_test.py similarity index 98% rename from testsuite/batch-file-symlink_test.py rename to testsuite/tests/batch-file-symlink_test.py index d169152d3..cff56e7db 100644 --- a/testsuite/batch-file-symlink_test.py +++ b/testsuite/tests/batch-file-symlink_test.py @@ -33,7 +33,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(the attacker simulation)") + "(the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -45,7 +45,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') base = SCRATCHDIR / 'batch' diff --git a/testsuite/batch-mode_test.py b/testsuite/tests/batch-mode_test.py similarity index 92% rename from testsuite/batch-mode_test.py rename to testsuite/tests/batch-mode_test.py index 3c1a68271..df709a9a4 100644 --- a/testsuite/batch-mode_test.py +++ b/testsuite/tests/batch-mode_test.py @@ -13,7 +13,9 @@ build_rsyncd_conf, checkit, hands_setup, rmtree, run_rsync, start_test_daemon, test_fail, ) +from harness import metadata +metadata(features={'batch', 'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'compatibility', 'daemon', 'transfer', 'version-mix'}) DAEMON_PORT = 12874 diff --git a/testsuite/batch-only-remove-source-regression_test.py b/testsuite/tests/batch-only-remove-source-regression_test.py similarity index 100% rename from testsuite/batch-only-remove-source-regression_test.py rename to testsuite/tests/batch-only-remove-source-regression_test.py diff --git a/testsuite/change-shrink_test.py b/testsuite/tests/change-shrink_test.py similarity index 100% rename from testsuite/change-shrink_test.py rename to testsuite/tests/change-shrink_test.py diff --git a/testsuite/change-vanish_test.py b/testsuite/tests/change-vanish_test.py similarity index 100% rename from testsuite/change-vanish_test.py rename to testsuite/tests/change-vanish_test.py diff --git a/testsuite/chdir-symlink-race_test.py b/testsuite/tests/chdir-symlink-race_test.py similarity index 94% rename from testsuite/chdir-symlink-race_test.py rename to testsuite/tests/chdir-symlink-race_test.py index 01cb3f37f..d38ad813f 100644 --- a/testsuite/chdir-symlink-race_test.py +++ b/testsuite/tests/chdir-symlink-race_test.py @@ -17,7 +17,9 @@ make_data_file, rmtree, rsync_argv, start_test_daemon, test_fail, ) +from harness import metadata +metadata(features={'daemon', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', cost='expensive', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'security', 'version-mix'}) DAEMON_PORT = 12885 diff --git a/testsuite/checksum-zero-blocklen_test.py b/testsuite/tests/checksum-zero-blocklen_test.py similarity index 100% rename from testsuite/checksum-zero-blocklen_test.py rename to testsuite/tests/checksum-zero-blocklen_test.py diff --git a/testsuite/chgrp_test.py b/testsuite/tests/chgrp_test.py similarity index 100% rename from testsuite/chgrp_test.py rename to testsuite/tests/chgrp_test.py diff --git a/testsuite/chmod-option_test.py b/testsuite/tests/chmod-option_test.py similarity index 94% rename from testsuite/chmod-option_test.py rename to testsuite/tests/chmod-option_test.py index ad0fc77be..4f076693b 100644 --- a/testsuite/chmod-option_test.py +++ b/testsuite/tests/chmod-option_test.py @@ -14,7 +14,9 @@ build_rsyncd_conf, check_perms, checkit, makepath, rmtree, run_rsync, start_test_daemon, test_fail, ) +from harness import metadata +metadata(features={'chmod', 'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'metadata', 'version-mix'}) DAEMON_PORT = 12875 diff --git a/testsuite/chmod-setid_test.py b/testsuite/tests/chmod-setid_test.py similarity index 100% rename from testsuite/chmod-setid_test.py rename to testsuite/tests/chmod-setid_test.py diff --git a/testsuite/chmod-symlink-race_test.py b/testsuite/tests/chmod-symlink-race_test.py similarity index 100% rename from testsuite/chmod-symlink-race_test.py rename to testsuite/tests/chmod-symlink-race_test.py diff --git a/testsuite/chmod-temp-dir_test.py b/testsuite/tests/chmod-temp-dir_test.py similarity index 98% rename from testsuite/chmod-temp-dir_test.py rename to testsuite/tests/chmod-temp-dir_test.py index 30d9fe20a..eb9609cff 100644 --- a/testsuite/chmod-temp-dir_test.py +++ b/testsuite/tests/chmod-temp-dir_test.py @@ -35,7 +35,7 @@ def _fsdev(path: str) -> str: break if tmpdir2 is None: - test_skipped("Can't find a tmp dir on a different file system") + test_skipped("Can't find a tmp dir on a different file system", capability='cross_device') # Mirror chmod_test.py: set up a varied permission tree on the source. diff --git a/testsuite/chmod_test.py b/testsuite/tests/chmod_test.py similarity index 100% rename from testsuite/chmod_test.py rename to testsuite/tests/chmod_test.py diff --git a/testsuite/chown-fake_test.py b/testsuite/tests/chown-fake_test.py similarity index 100% rename from testsuite/chown-fake_test.py rename to testsuite/tests/chown-fake_test.py diff --git a/testsuite/chown_test.py b/testsuite/tests/chown_test.py similarity index 91% rename from testsuite/chown_test.py rename to testsuite/tests/chown_test.py index 116b97ef0..29cf34687 100644 --- a/testsuite/chown_test.py +++ b/testsuite/tests/chown_test.py @@ -25,7 +25,7 @@ if fake_variant: # --fake-super needs xattrs support (and a way to set them here). if not xattrs_supported(): - test_skipped("Rsync needs xattrs for fake device tests") + test_skipped("Rsync needs xattrs for fake device tests", capability='xattr_runtime') # Augment the RSYNC command and TLS_ARGS so checkit's listing path # treats the xattr-encoded ownership as the file's real ownership. rsyncfns.RSYNC = rsyncfns.RSYNC + ' --fake-super' @@ -65,9 +65,9 @@ def chown_or_fake(path, uid, gid): name2.write_text("This is the other file\n") if not chown_or_fake(name1, 5000, 5002): - test_skipped("Can't chown (probably need root)") + test_skipped("Can't chown (probably need root)", capability='ownership') if not chown_or_fake(name2, 5001, 5003): - test_skipped("Can't chown (probably need root)") + test_skipped("Can't chown (probably need root)", capability='ownership') os.chdir(FROMDIR.parent) checkit(['-aHvv', 'from/', 'to/'], FROMDIR, TODIR) diff --git a/testsuite/chroot-alt-dest-inner-module_test.py b/testsuite/tests/chroot-alt-dest-inner-module_test.py similarity index 100% rename from testsuite/chroot-alt-dest-inner-module_test.py rename to testsuite/tests/chroot-alt-dest-inner-module_test.py diff --git a/testsuite/chroot-basis-forge-inner-module_test.py b/testsuite/tests/chroot-basis-forge-inner-module_test.py similarity index 100% rename from testsuite/chroot-basis-forge-inner-module_test.py rename to testsuite/tests/chroot-basis-forge-inner-module_test.py diff --git a/testsuite/chroot-copy-dest-inner-module_test.py b/testsuite/tests/chroot-copy-dest-inner-module_test.py similarity index 100% rename from testsuite/chroot-copy-dest-inner-module_test.py rename to testsuite/tests/chroot-copy-dest-inner-module_test.py diff --git a/testsuite/chroot-link-dest-inner-module_test.py b/testsuite/tests/chroot-link-dest-inner-module_test.py similarity index 100% rename from testsuite/chroot-link-dest-inner-module_test.py rename to testsuite/tests/chroot-link-dest-inner-module_test.py diff --git a/testsuite/chroot-receiver-write-inner-module_test.py b/testsuite/tests/chroot-receiver-write-inner-module_test.py similarity index 100% rename from testsuite/chroot-receiver-write-inner-module_test.py rename to testsuite/tests/chroot-receiver-write-inner-module_test.py diff --git a/testsuite/chroot-special-inner-module_test.py b/testsuite/tests/chroot-special-inner-module_test.py similarity index 100% rename from testsuite/chroot-special-inner-module_test.py rename to testsuite/tests/chroot-special-inner-module_test.py diff --git a/testsuite/clean-fname-collapse_test.py b/testsuite/tests/clean-fname-collapse_test.py similarity index 100% rename from testsuite/clean-fname-collapse_test.py rename to testsuite/tests/clean-fname-collapse_test.py diff --git a/testsuite/clean-fname-underflow_test.py b/testsuite/tests/clean-fname-underflow_test.py similarity index 100% rename from testsuite/clean-fname-underflow_test.py rename to testsuite/tests/clean-fname-underflow_test.py diff --git a/testsuite/compare_test.py b/testsuite/tests/compare_test.py similarity index 100% rename from testsuite/compare_test.py rename to testsuite/tests/compare_test.py diff --git a/testsuite/compress-options_test.py b/testsuite/tests/compress-options_test.py similarity index 100% rename from testsuite/compress-options_test.py rename to testsuite/tests/compress-options_test.py diff --git a/testsuite/compress-zlib-insert_test.py b/testsuite/tests/compress-zlib-insert_test.py similarity index 100% rename from testsuite/compress-zlib-insert_test.py rename to testsuite/tests/compress-zlib-insert_test.py diff --git a/testsuite/connect-prog-host-quoting_test.py b/testsuite/tests/connect-prog-host-quoting_test.py similarity index 100% rename from testsuite/connect-prog-host-quoting_test.py rename to testsuite/tests/connect-prog-host-quoting_test.py diff --git a/testsuite/connect-prog-nested-singlequote-host-injection_test.py b/testsuite/tests/connect-prog-nested-singlequote-host-injection_test.py similarity index 100% rename from testsuite/connect-prog-nested-singlequote-host-injection_test.py rename to testsuite/tests/connect-prog-nested-singlequote-host-injection_test.py diff --git a/testsuite/contimeout-rsh_test.py b/testsuite/tests/contimeout-rsh_test.py similarity index 100% rename from testsuite/contimeout-rsh_test.py rename to testsuite/tests/contimeout-rsh_test.py diff --git a/testsuite/copy-dest-source-symlink_test.py b/testsuite/tests/copy-dest-source-symlink_test.py similarity index 91% rename from testsuite/copy-dest-source-symlink_test.py rename to testsuite/tests/copy-dest-source-symlink_test.py index d1f445423..9e6fca66e 100644 --- a/testsuite/copy-dest-source-symlink_test.py +++ b/testsuite/tests/copy-dest-source-symlink_test.py @@ -22,7 +22,9 @@ rsync_argv, get_testuid, get_rootuid, get_rootgid, rmtree, start_test_daemon, test_fail, ) +from harness import metadata +metadata(features={'alt-dest', 'daemon', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'security', 'version-mix'}) DAEMON_PORT = 12883 diff --git a/testsuite/copy-dest-symlink-readleak_test.py b/testsuite/tests/copy-dest-symlink-readleak_test.py similarity index 97% rename from testsuite/copy-dest-symlink-readleak_test.py rename to testsuite/tests/copy-dest-symlink-readleak_test.py index 654f8a9bf..9efab8bd4 100644 --- a/testsuite/copy-dest-symlink-readleak_test.py +++ b/testsuite/tests/copy-dest-symlink-readleak_test.py @@ -24,10 +24,11 @@ ) if os.geteuid() != 0: - test_skipped("requires root to plant a copy-dest symlink owned by a non-self uid") + test_skipped("requires root to plant a copy-dest symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') NFILES = 60 PINNED = 1000000000 # identical mtime so the basis is an exact match diff --git a/testsuite/copy-xattrs-symlink-race_test.py b/testsuite/tests/copy-xattrs-symlink-race_test.py similarity index 97% rename from testsuite/copy-xattrs-symlink-race_test.py rename to testsuite/tests/copy-xattrs-symlink-race_test.py index b902a86c5..650dc5732 100644 --- a/testsuite/copy-xattrs-symlink-race_test.py +++ b/testsuite/tests/copy-xattrs-symlink-race_test.py @@ -33,9 +33,10 @@ N = 120 if platform.system() != 'Linux': - test_skipped("parent-flip xattr race is checked on Linux (os.*xattr)") + test_skipped("parent-flip xattr race is checked on Linux (os.*xattr)", + capability='linux_xattr') if not xattrs_supported(): - test_skipped("rsync built without xattr support (or no xattr tooling)") + test_skipped("rsync built without xattr support (or no xattr tooling)", capability='xattr') base = SCRATCHDIR / 'copy-xattrs-race' src = base / 'src' @@ -75,7 +76,7 @@ # (lsetxattr on a missing path is a no-op ENOENT, not an escape). (outside / f'f{i}').write_text('') except OSError as e: - test_skipped(f"filesystem does not support user xattrs ({e})") + test_skipped(f"filesystem does not support user xattrs ({e})", capability='xattr') sub = dest / DEEP / 'sub' # the parent component the attacker flips link = dest / DEEP / '.sublink' # symlink -> outside, swapped in for `sub` diff --git a/testsuite/tests/crtimes_test.py b/testsuite/tests/crtimes_test.py new file mode 100644 index 000000000..ab45da95b --- /dev/null +++ b/testsuite/tests/crtimes_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Creation-time preservation""" + +import datetime +import os + +import rsyncfns +from harness import TestContext, requires, run, unsupported +from rsyncfns import checkit, run_rsync + + +@requires(features={'crtimes'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe'}, mutates={'filesystem'}, tags={'metadata'}) +def test(context: TestContext): + if '"crtimes": true' not in run_rsync('-VV', capture_output=True).stdout: + unsupported('crtimes') + + context.source.mkdir(parents=True) + source = context.source / 'foo' + source.write_text('hiho\n') + + def set_time(path, value): + timestamp = value.timestamp() + os.utime(path, (timestamp, timestamp)) + + set_time(context.source, datetime.datetime(2001, 1, 1, 11, 11, 11)) + set_time(context.source, datetime.datetime(2002, 2, 2, 22, 22, 22)) + set_time(source, datetime.datetime(2001, 11, 11, 11, 11, 11)) + set_time(source, datetime.datetime(2002, 12, 12, 22, 22, 22)) + rsyncfns.TLS_ARGS = '--crtimes' + checkit(['-rtgvvv', '--crtimes', f'{context.source}/', f'{context.destination}/'], context.source, context.destination) + + +if __name__ == '__main__': + run(test) diff --git a/testsuite/cvs-exclude_test.py b/testsuite/tests/cvs-exclude_test.py similarity index 100% rename from testsuite/cvs-exclude_test.py rename to testsuite/tests/cvs-exclude_test.py diff --git a/testsuite/daemon-access-idn_test.py b/testsuite/tests/daemon-access-idn_test.py similarity index 100% rename from testsuite/daemon-access-idn_test.py rename to testsuite/tests/daemon-access-idn_test.py diff --git a/testsuite/daemon-access-ip_test.py b/testsuite/tests/daemon-access-ip_test.py similarity index 100% rename from testsuite/daemon-access-ip_test.py rename to testsuite/tests/daemon-access-ip_test.py diff --git a/testsuite/daemon-access_test.py b/testsuite/tests/daemon-access_test.py similarity index 92% rename from testsuite/daemon-access_test.py rename to testsuite/tests/daemon-access_test.py index fe07a4936..7f2ecc8f9 100644 --- a/testsuite/daemon-access_test.py +++ b/testsuite/tests/daemon-access_test.py @@ -13,6 +13,9 @@ assert_same, make_tree, makepath, rmtree, rsync_argv, run_rsync, start_test_daemon, test_fail, verify_dirs, walk_files, write_daemon_conf, ) +from harness import metadata + +metadata(features={'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'transfer', 'version-mix'}) DAEMON_PORT = 12886 diff --git a/testsuite/daemon-address-family_test.py b/testsuite/tests/daemon-address-family_test.py similarity index 100% rename from testsuite/daemon-address-family_test.py rename to testsuite/tests/daemon-address-family_test.py diff --git a/testsuite/daemon-argv-limit_test.py b/testsuite/tests/daemon-argv-limit_test.py similarity index 100% rename from testsuite/daemon-argv-limit_test.py rename to testsuite/tests/daemon-argv-limit_test.py diff --git a/testsuite/daemon-auth-digest-floor_test.py b/testsuite/tests/daemon-auth-digest-floor_test.py similarity index 100% rename from testsuite/daemon-auth-digest-floor_test.py rename to testsuite/tests/daemon-auth-digest-floor_test.py diff --git a/testsuite/daemon-auth-group_test.py b/testsuite/tests/daemon-auth-group_test.py similarity index 98% rename from testsuite/daemon-auth-group_test.py rename to testsuite/tests/daemon-auth-group_test.py index 1d3820cb9..d89656371 100644 --- a/testsuite/daemon-auth-group_test.py +++ b/testsuite/tests/daemon-auth-group_test.py @@ -30,7 +30,7 @@ # group-name matching differ enough on the BSDs/Solaris/macOS that the test's # fixed expectations don't hold there; it is a Linux daemon-coverage test. if platform.system() != 'Linux': - test_skipped("@group daemon-auth coverage is Linux-specific") + test_skipped("@group daemon-auth coverage is Linux-specific", capability='group_auth') DAEMON_PORT = 12892 os.environ['RSYNC_PASSWORD'] = 'env-fallback-wrong' # never prompt @@ -52,7 +52,7 @@ def pick_user(): U, G = pick_user() if U is None: - test_skipped("no suitable non-root passwd entry for @group auth") + test_skipped("no suitable non-root passwd entry for @group auth", capability='group_auth') # A second user NOT in U's group, for the negative case. def pick_outsider(): @@ -86,7 +86,7 @@ def pick_outsider(): # The non-root receiver child (uid=U) must be able to write its dest, which # needs a cross-uid chown -- i.e. root. Skip cleanly when we can't. if not owners_supported(): - test_skipped("needs chown to set up the non-root-owned dest tree") + test_skipped("needs chown to set up the non-root-owned dest tree", capability='group_auth') os.chown(dest_nonroot, U.pw_uid, U.pw_gid) mods = [ diff --git a/testsuite/daemon-auth-users-comma-only_test.py b/testsuite/tests/daemon-auth-users-comma-only_test.py similarity index 100% rename from testsuite/daemon-auth-users-comma-only_test.py rename to testsuite/tests/daemon-auth-users-comma-only_test.py diff --git a/testsuite/daemon-auth_test.py b/testsuite/tests/daemon-auth_test.py similarity index 92% rename from testsuite/daemon-auth_test.py rename to testsuite/tests/daemon-auth_test.py index 5075f1cbf..d18cc9a25 100644 --- a/testsuite/daemon-auth_test.py +++ b/testsuite/tests/daemon-auth_test.py @@ -15,6 +15,9 @@ make_tree, makepath, rmtree, rsync_argv, start_test_daemon, test_fail, verify_dirs, write_daemon_conf, ) +from harness import metadata + +metadata(features={'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'authentication', 'daemon', 'version-mix'}) DAEMON_PORT = 12888 diff --git a/testsuite/daemon-chroot-acl_test.py b/testsuite/tests/daemon-chroot-acl_test.py similarity index 100% rename from testsuite/daemon-chroot-acl_test.py rename to testsuite/tests/daemon-chroot-acl_test.py diff --git a/testsuite/daemon-chroot-munge-default_test.py b/testsuite/tests/daemon-chroot-munge-default_test.py similarity index 98% rename from testsuite/daemon-chroot-munge-default_test.py rename to testsuite/tests/daemon-chroot-munge-default_test.py index 97feed38c..6a1974647 100644 --- a/testsuite/daemon-chroot-munge-default_test.py +++ b/testsuite/tests/daemon-chroot-munge-default_test.py @@ -104,4 +104,4 @@ def stored_link(module, landing_dir): "-- the use-chroot doc regime split is accurate") else: test_skipped("chroot regimes (B/C) need root; verified the no-chroot regime " - "munges by default") + "munges by default", capability='root') diff --git a/testsuite/daemon-chroot_test.py b/testsuite/tests/daemon-chroot_test.py similarity index 95% rename from testsuite/daemon-chroot_test.py rename to testsuite/tests/daemon-chroot_test.py index 0cea480da..db535240d 100644 --- a/testsuite/daemon-chroot_test.py +++ b/testsuite/tests/daemon-chroot_test.py @@ -33,7 +33,7 @@ ) if get_testuid() != get_rootuid(): - test_skipped("use chroot = yes needs root (CAP_SYS_CHROOT)") + test_skipped("use chroot = yes needs root (CAP_SYS_CHROOT)", capability='root') # Probe chroot() directly so a rootless/seccomp'd container that maps uid 0 # but withholds CAP_SYS_CHROOT skips cleanly instead of failing. @@ -46,7 +46,7 @@ os._exit(1) _, st = os.waitpid(pid, 0) if not (os.WIFEXITED(st) and os.WEXITSTATUS(st) == 0): - test_skipped("chroot(2) not permitted in this environment") + test_skipped("chroot(2) not permitted in this environment", capability='chroot') require_tcp("daemon chroot path needs the real start_daemon socket flow") diff --git a/testsuite/daemon-config-symlink_test.py b/testsuite/tests/daemon-config-symlink_test.py similarity index 97% rename from testsuite/daemon-config-symlink_test.py rename to testsuite/tests/daemon-config-symlink_test.py index 9b7376c79..da9385601 100644 --- a/testsuite/daemon-config-symlink_test.py +++ b/testsuite/tests/daemon-config-symlink_test.py @@ -38,7 +38,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(the attacker simulation)") + "(the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -50,7 +50,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') # Daemon setup diff --git a/testsuite/daemon-config_test.py b/testsuite/tests/daemon-config_test.py similarity index 88% rename from testsuite/daemon-config_test.py rename to testsuite/tests/daemon-config_test.py index b680c73ce..24806e648 100644 --- a/testsuite/daemon-config_test.py +++ b/testsuite/tests/daemon-config_test.py @@ -13,6 +13,9 @@ FROMDIR, SCRATCHDIR, make_tree, rmtree, rsync_argv, start_test_daemon, test_fail, ) +from harness import metadata + +metadata(features={'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'configuration', 'daemon', 'version-mix'}) DAEMON_PORT = 12893 diff --git a/testsuite/daemon-connection-limits_test.py b/testsuite/tests/daemon-connection-limits_test.py similarity index 100% rename from testsuite/daemon-connection-limits_test.py rename to testsuite/tests/daemon-connection-limits_test.py diff --git a/testsuite/daemon-copy-links-symlink_test.py b/testsuite/tests/daemon-copy-links-symlink_test.py similarity index 100% rename from testsuite/daemon-copy-links-symlink_test.py rename to testsuite/tests/daemon-copy-links-symlink_test.py diff --git a/testsuite/daemon-copylinks-intree_test.py b/testsuite/tests/daemon-copylinks-intree_test.py similarity index 100% rename from testsuite/daemon-copylinks-intree_test.py rename to testsuite/tests/daemon-copylinks-intree_test.py diff --git a/testsuite/daemon-copylinks-parent-escape_test.py b/testsuite/tests/daemon-copylinks-parent-escape_test.py similarity index 100% rename from testsuite/daemon-copylinks-parent-escape_test.py rename to testsuite/tests/daemon-copylinks-parent-escape_test.py diff --git a/testsuite/daemon-copylinks-parent-target-regression_test.py b/testsuite/tests/daemon-copylinks-parent-target-regression_test.py similarity index 97% rename from testsuite/daemon-copylinks-parent-target-regression_test.py rename to testsuite/tests/daemon-copylinks-parent-target-regression_test.py index b0cb6fb93..3192ac5c2 100644 --- a/testsuite/daemon-copylinks-parent-target-regression_test.py +++ b/testsuite/tests/daemon-copylinks-parent-target-regression_test.py @@ -16,7 +16,8 @@ # whatever protocol the run selects. _proto = forced_protocol() if _proto is not None and _proto < 30: - test_skipped(f'the stdio_daemon client speaks protocol 30 (forced {_proto})') + test_skipped(f'the stdio_daemon client speaks protocol 30 (forced {_proto})', + capability='protocol_30') PAYLOAD = 'safe-parent-relative-target\n' diff --git a/testsuite/daemon-delete-stats_test.py b/testsuite/tests/daemon-delete-stats_test.py similarity index 100% rename from testsuite/daemon-delete-stats_test.py rename to testsuite/tests/daemon-delete-stats_test.py diff --git a/testsuite/daemon-deny-dns-failopen_test.py b/testsuite/tests/daemon-deny-dns-failopen_test.py similarity index 100% rename from testsuite/daemon-deny-dns-failopen_test.py rename to testsuite/tests/daemon-deny-dns-failopen_test.py diff --git a/testsuite/daemon-dot-file-force-wipe_test.py b/testsuite/tests/daemon-dot-file-force-wipe_test.py similarity index 100% rename from testsuite/daemon-dot-file-force-wipe_test.py rename to testsuite/tests/daemon-dot-file-force-wipe_test.py diff --git a/testsuite/daemon-early-exec-nameconv_test.py b/testsuite/tests/daemon-early-exec-nameconv_test.py similarity index 100% rename from testsuite/daemon-early-exec-nameconv_test.py rename to testsuite/tests/daemon-early-exec-nameconv_test.py diff --git a/testsuite/daemon-exclude-from-outside-module_test.py b/testsuite/tests/daemon-exclude-from-outside-module_test.py similarity index 100% rename from testsuite/daemon-exclude-from-outside-module_test.py rename to testsuite/tests/daemon-exclude-from-outside-module_test.py diff --git a/testsuite/daemon-exclude-namebased_test.py b/testsuite/tests/daemon-exclude-namebased_test.py similarity index 100% rename from testsuite/daemon-exclude-namebased_test.py rename to testsuite/tests/daemon-exclude-namebased_test.py diff --git a/testsuite/daemon-exec-metachar-documented-limit_test.py b/testsuite/tests/daemon-exec-metachar-documented-limit_test.py similarity index 100% rename from testsuite/daemon-exec-metachar-documented-limit_test.py rename to testsuite/tests/daemon-exec-metachar-documented-limit_test.py diff --git a/testsuite/daemon-exec-metachar-refused_test.py b/testsuite/tests/daemon-exec-metachar-refused_test.py similarity index 100% rename from testsuite/daemon-exec-metachar-refused_test.py rename to testsuite/tests/daemon-exec-metachar-refused_test.py diff --git a/testsuite/daemon-exec-rsync-env-shell-escape_test.py b/testsuite/tests/daemon-exec-rsync-env-shell-escape_test.py similarity index 100% rename from testsuite/daemon-exec-rsync-env-shell-escape_test.py rename to testsuite/tests/daemon-exec-rsync-env-shell-escape_test.py diff --git a/testsuite/daemon-exec-second-shell-argument-injection_test.py b/testsuite/tests/daemon-exec-second-shell-argument-injection_test.py similarity index 100% rename from testsuite/daemon-exec-second-shell-argument-injection_test.py rename to testsuite/tests/daemon-exec-second-shell-argument-injection_test.py diff --git a/testsuite/daemon-exec-singlequote-injection_test.py b/testsuite/tests/daemon-exec-singlequote-injection_test.py similarity index 100% rename from testsuite/daemon-exec-singlequote-injection_test.py rename to testsuite/tests/daemon-exec-singlequote-injection_test.py diff --git a/testsuite/daemon-exec_test.py b/testsuite/tests/daemon-exec_test.py similarity index 92% rename from testsuite/daemon-exec_test.py rename to testsuite/tests/daemon-exec_test.py index a0669b730..886002c97 100644 --- a/testsuite/daemon-exec_test.py +++ b/testsuite/tests/daemon-exec_test.py @@ -15,7 +15,9 @@ make_tree, makepath, rmtree, rsync_argv, start_test_daemon, test_fail, write_daemon_conf, ) +from harness import metadata +metadata(features={'daemon', 'exec-hooks'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'process', 'version-mix'}) def wait_for(path, want, secs=5): """Poll for a marker file to contain `want`. post-xfer exec runs on the diff --git a/testsuite/daemon-filter-merge-bypass_test.py b/testsuite/tests/daemon-filter-merge-bypass_test.py similarity index 100% rename from testsuite/daemon-filter-merge-bypass_test.py rename to testsuite/tests/daemon-filter-merge-bypass_test.py diff --git a/testsuite/daemon-filter_test.py b/testsuite/tests/daemon-filter_test.py similarity index 92% rename from testsuite/daemon-filter_test.py rename to testsuite/tests/daemon-filter_test.py index 59566068c..13d1d1587 100644 --- a/testsuite/daemon-filter_test.py +++ b/testsuite/tests/daemon-filter_test.py @@ -14,6 +14,9 @@ assert_mode, assert_not_exists, assert_same, make_tree, makepath, rmtree, rsync_argv, start_test_daemon, test_fail, walk_files, write_daemon_conf, ) +from harness import metadata + +metadata(features={'daemon', 'filters'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'metadata', 'transfer', 'version-mix'}) DAEMON_PORT = 12887 diff --git a/testsuite/daemon-groupmap-wild_test.py b/testsuite/tests/daemon-groupmap-wild_test.py similarity index 100% rename from testsuite/daemon-groupmap-wild_test.py rename to testsuite/tests/daemon-groupmap-wild_test.py diff --git a/testsuite/daemon-gzip-download_test.py b/testsuite/tests/daemon-gzip-download_test.py similarity index 76% rename from testsuite/daemon-gzip-download_test.py rename to testsuite/tests/daemon-gzip-download_test.py index 4550863aa..90e75b7ca 100644 --- a/testsuite/daemon-gzip-download_test.py +++ b/testsuite/tests/daemon-gzip-download_test.py @@ -10,7 +10,9 @@ CHKDIR, FROMDIR, TODIR, build_rsyncd_conf, checkit, hands_setup, run_rsync, start_test_daemon, ) +from harness import metadata +metadata(features={'compression', 'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'transfer', 'version-mix'}) DAEMON_PORT = 12879 diff --git a/testsuite/daemon-gzip-upload_test.py b/testsuite/tests/daemon-gzip-upload_test.py similarity index 76% rename from testsuite/daemon-gzip-upload_test.py rename to testsuite/tests/daemon-gzip-upload_test.py index 64922a143..3d4151a9d 100644 --- a/testsuite/daemon-gzip-upload_test.py +++ b/testsuite/tests/daemon-gzip-upload_test.py @@ -10,7 +10,9 @@ CHKDIR, FROMDIR, TODIR, build_rsyncd_conf, checkit, hands_setup, run_rsync, start_test_daemon, ) +from harness import metadata +metadata(features={'compression', 'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'transfer', 'version-mix'}) DAEMON_PORT = 12880 diff --git a/testsuite/daemon-handshake-timeout_test.py b/testsuite/tests/daemon-handshake-timeout_test.py similarity index 100% rename from testsuite/daemon-handshake-timeout_test.py rename to testsuite/tests/daemon-handshake-timeout_test.py diff --git a/testsuite/daemon-http-proxy_test.py b/testsuite/tests/daemon-http-proxy_test.py similarity index 100% rename from testsuite/daemon-http-proxy_test.py rename to testsuite/tests/daemon-http-proxy_test.py diff --git a/testsuite/daemon-include-maxconn_test.py b/testsuite/tests/daemon-include-maxconn_test.py similarity index 100% rename from testsuite/daemon-include-maxconn_test.py rename to testsuite/tests/daemon-include-maxconn_test.py diff --git a/testsuite/daemon-leaf-type-race-fchmod_test.py b/testsuite/tests/daemon-leaf-type-race-fchmod_test.py similarity index 98% rename from testsuite/daemon-leaf-type-race-fchmod_test.py rename to testsuite/tests/daemon-leaf-type-race-fchmod_test.py index ee2052588..77d209e82 100644 --- a/testsuite/daemon-leaf-type-race-fchmod_test.py +++ b/testsuite/tests/daemon-leaf-type-race-fchmod_test.py @@ -216,12 +216,12 @@ def stop_process(process: subprocess.Popen, timeout: float = 3) -> tuple: if platform.system() != "Darwin": - test_skipped("leaf-type authority proof currently requires Darwin") + test_skipped("leaf-type authority proof currently requires Darwin", capability='darwin') if os.geteuid() == 0: - test_skipped("leaf-type authority proof needs a non-root EACCES trigger") + test_skipped("leaf-type authority proof needs a non-root EACCES trigger", capability='nonroot') sandbox_exec = Path("/usr/bin/sandbox-exec") if not sandbox_exec.is_file(): - test_skipped("/usr/bin/sandbox-exec is unavailable") + test_skipped("/usr/bin/sandbox-exec is unavailable", capability='sandbox_exec') require_tcp("leaf-type race needs an injected real TCP daemon") claim_ports(PORT) @@ -308,7 +308,7 @@ def stop_process(process: subprocess.Popen, timeout: float = 3) -> tuple: racer_stdout, racer_stderr = stop_process(racer) combined_stderr = racer_stderr or "" if "sandbox_apply: Operation not permitted" in combined_stderr: - test_skipped("enclosing sandbox forbids nested sandbox-exec") + test_skipped("enclosing sandbox forbids nested sandbox-exec", capability='sandbox_exec') test_fail( "sandbox racer retained chmod authority: " f"preflight={preflight!r} stdout={racer_stdout!r}" diff --git a/testsuite/daemon-link-dest-escape_test.py b/testsuite/tests/daemon-link-dest-escape_test.py similarity index 100% rename from testsuite/daemon-link-dest-escape_test.py rename to testsuite/tests/daemon-link-dest-escape_test.py diff --git a/testsuite/daemon-module-chdir-symlink_test.py b/testsuite/tests/daemon-module-chdir-symlink_test.py similarity index 97% rename from testsuite/daemon-module-chdir-symlink_test.py rename to testsuite/tests/daemon-module-chdir-symlink_test.py index 66b2663e5..da72d77b1 100644 --- a/testsuite/daemon-module-chdir-symlink_test.py +++ b/testsuite/tests/daemon-module-chdir-symlink_test.py @@ -41,7 +41,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(the attacker simulation)") + "(the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -53,7 +53,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') base = SCRATCHDIR / 'modchdir' diff --git a/testsuite/daemon-module-options_test.py b/testsuite/tests/daemon-module-options_test.py similarity index 100% rename from testsuite/daemon-module-options_test.py rename to testsuite/tests/daemon-module-options_test.py diff --git a/testsuite/daemon-module-private-parent_test.py b/testsuite/tests/daemon-module-private-parent_test.py similarity index 98% rename from testsuite/daemon-module-private-parent_test.py rename to testsuite/tests/daemon-module-private-parent_test.py index 599d37d49..780810c04 100644 --- a/testsuite/daemon-module-private-parent_test.py +++ b/testsuite/tests/daemon-module-private-parent_test.py @@ -30,7 +30,7 @@ if os.geteuid() != 0: test_skipped("requires root (a 0700 parent the served uid cannot traverse, " - "and a daemon that drops to an unprivileged uid)") + "and a daemon that drops to an unprivileged uid)", capability='root') UNPRIV = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -42,7 +42,7 @@ except KeyError: continue if UNPRIV is None: - test_skipped("no unprivileged uid available to drop the daemon to") + test_skipped("no unprivileged uid available to drop the daemon to", capability='root') base = SCRATCHDIR / 'priv-parent' rmtree(base) diff --git a/testsuite/daemon-munge_test.py b/testsuite/tests/daemon-munge_test.py similarity index 87% rename from testsuite/daemon-munge_test.py rename to testsuite/tests/daemon-munge_test.py index 05fdb4695..c9aa4d950 100644 --- a/testsuite/daemon-munge_test.py +++ b/testsuite/tests/daemon-munge_test.py @@ -15,6 +15,9 @@ assert_is_symlink, make_tree, makepath, rmtree, rsync_argv, start_test_daemon, test_fail, write_daemon_conf, ) +from harness import metadata + +metadata(features={'daemon', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'security', 'version-mix'}) DAEMON_PORT = 12890 diff --git a/testsuite/daemon-namecvt-empty-response_test.py b/testsuite/tests/daemon-namecvt-empty-response_test.py similarity index 97% rename from testsuite/daemon-namecvt-empty-response_test.py rename to testsuite/tests/daemon-namecvt-empty-response_test.py index 2967afba9..1fad2760c 100644 --- a/testsuite/daemon-namecvt-empty-response_test.py +++ b/testsuite/tests/daemon-namecvt-empty-response_test.py @@ -58,12 +58,13 @@ if not xattrs_supported(): test_skipped("namecvt-empty-response test requires xattr support to " - "read fake-super metadata") + "read fake-super metadata", capability='xattr_runtime') if not hasattr(os, 'getxattr'): # CPython exposes os.getxattr only on Linux; the OS (e.g. Cygwin) may have # xattrs yet no Python binding to read the fake-super %stat metadata. - test_skipped("this Python build lacks os.getxattr to read fake-super metadata") + test_skipped("this Python build lacks os.getxattr to read fake-super metadata", + capability='xattr_runtime') my_uid = os.getuid() my_gid = os.getgid() @@ -78,7 +79,7 @@ # only test must be added to that step (and declare `fleet_nonroot = True` # for the fleet harness's non-root pass) or it will silently never run in CI. test_skipped("namecvt-empty-response test must run as a non-root user " - "(sender uid/gid must differ from 0 to expose the bug)") + "(sender uid/gid must differ from 0 to expose the bug)", capability='nonroot') # -- stub name-converter that always returns empty ------------------------- @@ -97,7 +98,7 @@ mod = SCRATCHDIR / 'recvmod' -src = SCRATCHDIR / 'srcdir' # NOT 'src' -- runtests.py:155 plants +src = SCRATCHDIR / 'srcdir' # NOT 'src' because runtests.py plants that link # a /src symlink to SRCDIR for # every test, so writing files under it # would land in the repo working tree. diff --git a/testsuite/daemon-namecvt-newline-token_test.py b/testsuite/tests/daemon-namecvt-newline-token_test.py similarity index 100% rename from testsuite/daemon-namecvt-newline-token_test.py rename to testsuite/tests/daemon-namecvt-newline-token_test.py diff --git a/testsuite/daemon-path-root-read_test.py b/testsuite/tests/daemon-path-root-read_test.py similarity index 100% rename from testsuite/daemon-path-root-read_test.py rename to testsuite/tests/daemon-path-root-read_test.py diff --git a/testsuite/daemon-path-rsync-var_test.py b/testsuite/tests/daemon-path-rsync-var_test.py similarity index 100% rename from testsuite/daemon-path-rsync-var_test.py rename to testsuite/tests/daemon-path-rsync-var_test.py diff --git a/testsuite/daemon-proxy-protocol_test.py b/testsuite/tests/daemon-proxy-protocol_test.py similarity index 100% rename from testsuite/daemon-proxy-protocol_test.py rename to testsuite/tests/daemon-proxy-protocol_test.py diff --git a/testsuite/daemon-refuse-compress-threads-alias_test.py b/testsuite/tests/daemon-refuse-compress-threads-alias_test.py similarity index 100% rename from testsuite/daemon-refuse-compress-threads-alias_test.py rename to testsuite/tests/daemon-refuse-compress-threads-alias_test.py diff --git a/testsuite/daemon-refuse-compress_test.py b/testsuite/tests/daemon-refuse-compress_test.py similarity index 85% rename from testsuite/daemon-refuse-compress_test.py rename to testsuite/tests/daemon-refuse-compress_test.py index 83af17294..2a58241c3 100644 --- a/testsuite/daemon-refuse-compress_test.py +++ b/testsuite/tests/daemon-refuse-compress_test.py @@ -12,7 +12,9 @@ build_rsyncd_conf, checkit, hands_setup, rmtree, rsync_argv, run_rsync, start_test_daemon, test_fail, ) +from harness import metadata +metadata(features={'compression', 'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'configuration', 'daemon', 'version-mix'}) DAEMON_PORT = 12876 diff --git a/testsuite/daemon-refuse-delete-alias_test.py b/testsuite/tests/daemon-refuse-delete-alias_test.py similarity index 100% rename from testsuite/daemon-refuse-delete-alias_test.py rename to testsuite/tests/daemon-refuse-delete-alias_test.py diff --git a/testsuite/daemon-refuse_test.py b/testsuite/tests/daemon-refuse_test.py similarity index 91% rename from testsuite/daemon-refuse_test.py rename to testsuite/tests/daemon-refuse_test.py index 054f28099..91d3309ed 100644 --- a/testsuite/daemon-refuse_test.py +++ b/testsuite/tests/daemon-refuse_test.py @@ -14,6 +14,9 @@ make_tree, makepath, rmtree, rsync_argv, run_rsync, start_test_daemon, test_fail, verify_dirs, write_daemon_conf, ) +from harness import metadata + +metadata(features={'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'configuration', 'daemon', 'version-mix'}) DAEMON_PORT = 12891 diff --git a/testsuite/daemon-scan-cwd-desync_test.py b/testsuite/tests/daemon-scan-cwd-desync_test.py similarity index 100% rename from testsuite/daemon-scan-cwd-desync_test.py rename to testsuite/tests/daemon-scan-cwd-desync_test.py diff --git a/testsuite/daemon-scan-dir-escape_test.py b/testsuite/tests/daemon-scan-dir-escape_test.py similarity index 100% rename from testsuite/daemon-scan-dir-escape_test.py rename to testsuite/tests/daemon-scan-dir-escape_test.py diff --git a/testsuite/daemon-secrets-file-symlink_test.py b/testsuite/tests/daemon-secrets-file-symlink_test.py similarity index 98% rename from testsuite/daemon-secrets-file-symlink_test.py rename to testsuite/tests/daemon-secrets-file-symlink_test.py index 1a2a2c614..ea276bcee 100644 --- a/testsuite/daemon-secrets-file-symlink_test.py +++ b/testsuite/tests/daemon-secrets-file-symlink_test.py @@ -41,7 +41,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(the attacker simulation)") + "(the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -53,7 +53,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') # Prevent rsync's interactive getpass() fallback if no password is read. os.environ['RSYNC_PASSWORD'] = 'env-fallback-wrong' diff --git a/testsuite/daemon-size-arg-overflow_test.py b/testsuite/tests/daemon-size-arg-overflow_test.py similarity index 100% rename from testsuite/daemon-size-arg-overflow_test.py rename to testsuite/tests/daemon-size-arg-overflow_test.py diff --git a/testsuite/daemon-standalone-detach_test.py b/testsuite/tests/daemon-standalone-detach_test.py similarity index 98% rename from testsuite/daemon-standalone-detach_test.py rename to testsuite/tests/daemon-standalone-detach_test.py index 4f7bfb843..a74bc3a8e 100644 --- a/testsuite/daemon-standalone-detach_test.py +++ b/testsuite/tests/daemon-standalone-detach_test.py @@ -33,7 +33,8 @@ # disconnected Windows process the harness can't reap, so it lingers as an orphan # squatting its port and poisoning later TCP daemon tests. Skip on cygwin. if platform.system().startswith('CYGWIN'): - test_skipped("a detached daemon orphans on cygwin's Windows process model") + test_skipped("a detached daemon orphans on cygwin's Windows process model", + capability='posix_detach') PORT = 19877 require_tcp("standalone detaching daemon opens a real loopback listener; " diff --git a/testsuite/daemon-stdin-inetd_test.py b/testsuite/tests/daemon-stdin-inetd_test.py similarity index 100% rename from testsuite/daemon-stdin-inetd_test.py rename to testsuite/tests/daemon-stdin-inetd_test.py diff --git a/testsuite/daemon-stdin-local-socket_test.py b/testsuite/tests/daemon-stdin-local-socket_test.py similarity index 89% rename from testsuite/daemon-stdin-local-socket_test.py rename to testsuite/tests/daemon-stdin-local-socket_test.py index fc240ad54..294931e0e 100644 --- a/testsuite/daemon-stdin-local-socket_test.py +++ b/testsuite/tests/daemon-stdin-local-socket_test.py @@ -9,7 +9,7 @@ ) if not hasattr(socket, 'AF_UNIX') or not hasattr(socket, 'socketpair'): - test_skipped('Unix-domain socket pairs are unavailable') + test_skipped('Unix-domain socket pairs are unavailable', capability='unix_socketpair') base = SCRATCHDIR / 'daemon-stdin-local-socket' rmtree(base) @@ -23,7 +23,7 @@ try: parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) except (OSError, ValueError) as e: - test_skipped(f'Unix-domain socket pairs are unavailable: {e}') + test_skipped(f'Unix-domain socket pairs are unavailable: {e}', capability='unix_socketpair') try: # ADB shell without a PTY similarly presents a local socket as fd 0. The # local socket must not be mistaken for an inetd connection. diff --git a/testsuite/daemon-stdin-unix-listener_test.py b/testsuite/tests/daemon-stdin-unix-listener_test.py similarity index 93% rename from testsuite/daemon-stdin-unix-listener_test.py rename to testsuite/tests/daemon-stdin-unix-listener_test.py index 3c18f57ea..c934522df 100644 --- a/testsuite/daemon-stdin-unix-listener_test.py +++ b/testsuite/tests/daemon-stdin-unix-listener_test.py @@ -13,9 +13,10 @@ ) if not hasattr(socket, 'AF_UNIX'): - test_skipped('Unix-domain sockets are unavailable') + test_skipped('Unix-domain sockets are unavailable', capability='unix_listener') if sys.platform == 'cygwin': - test_skipped('Cygwin cannot pass a named Unix listener as daemon stdin') + test_skipped('Cygwin cannot pass a named Unix listener as daemon stdin', + capability='unix_listener') base = SCRATCHDIR / 'daemon-stdin-unix-listener' rmtree(base) @@ -39,7 +40,7 @@ def check_listener(address, label): client.connect(address) child, _ = listener.accept() except (OSError, ValueError) as e: - test_skipped(f'{label} Unix listener is unavailable: {e}') + test_skipped(f'{label} Unix listener is unavailable: {e}', capability='unix_listener') proc = subprocess.Popen( rsync_argv('--daemon', '--no-detach', f'--config={conf}', diff --git a/testsuite/daemon-strict-modes-matrix_test.py b/testsuite/tests/daemon-strict-modes-matrix_test.py similarity index 100% rename from testsuite/daemon-strict-modes-matrix_test.py rename to testsuite/tests/daemon-strict-modes-matrix_test.py diff --git a/testsuite/daemon-subdir-climb-symlink_test.py b/testsuite/tests/daemon-subdir-climb-symlink_test.py similarity index 100% rename from testsuite/daemon-subdir-climb-symlink_test.py rename to testsuite/tests/daemon-subdir-climb-symlink_test.py diff --git a/testsuite/daemon-symlink-escape-matrix_test.py b/testsuite/tests/daemon-symlink-escape-matrix_test.py similarity index 99% rename from testsuite/daemon-symlink-escape-matrix_test.py rename to testsuite/tests/daemon-symlink-escape-matrix_test.py index 2865c518a..3effa721f 100644 --- a/testsuite/daemon-symlink-escape-matrix_test.py +++ b/testsuite/tests/daemon-symlink-escape-matrix_test.py @@ -71,7 +71,7 @@ # The 3.2.7 oracle: prefer a --rsync-bin2 peer if it differs from the build under # test, else the in-tree static binary. None -> degrade to the static contract. -_repo = Path(__file__).resolve().parent.parent +_repo = Path(__file__).resolve().parents[2] ORACLE_BIN = None if RSYNC_PEER != RSYNC: ORACLE_BIN = RSYNC_PEER diff --git a/testsuite/daemon-unix-socket-atfd_test.py b/testsuite/tests/daemon-unix-socket-atfd_test.py similarity index 100% rename from testsuite/daemon-unix-socket-atfd_test.py rename to testsuite/tests/daemon-unix-socket-atfd_test.py diff --git a/testsuite/daemon-zstd-thread-exhaustion_test.py b/testsuite/tests/daemon-zstd-thread-exhaustion_test.py similarity index 100% rename from testsuite/daemon-zstd-thread-exhaustion_test.py rename to testsuite/tests/daemon-zstd-thread-exhaustion_test.py diff --git a/testsuite/daemon_test.py b/testsuite/tests/daemon_test.py similarity index 95% rename from testsuite/daemon_test.py rename to testsuite/tests/daemon_test.py index 3b1adda4e..dff09c645 100644 --- a/testsuite/daemon_test.py +++ b/testsuite/tests/daemon_test.py @@ -18,7 +18,9 @@ build_rsyncd_conf, get_rootuid, get_testuid, makepath, rsync_argv, run_rsync, start_test_daemon, test_fail, rsh_cmd, ) +from harness import metadata +metadata(features={'daemon'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'compatibility', 'daemon', 'version-mix'}) DAEMON_PORT = 12877 diff --git a/testsuite/deep-path_test.py b/testsuite/tests/deep-path_test.py similarity index 100% rename from testsuite/deep-path_test.py rename to testsuite/tests/deep-path_test.py diff --git a/testsuite/delay-updates-deep_test.py b/testsuite/tests/delay-updates-deep_test.py similarity index 100% rename from testsuite/delay-updates-deep_test.py rename to testsuite/tests/delay-updates-deep_test.py diff --git a/testsuite/delay-updates_test.py b/testsuite/tests/delay-updates_test.py similarity index 100% rename from testsuite/delay-updates_test.py rename to testsuite/tests/delay-updates_test.py diff --git a/testsuite/delete-deep_test.py b/testsuite/tests/delete-deep_test.py similarity index 100% rename from testsuite/delete-deep_test.py rename to testsuite/tests/delete-deep_test.py diff --git a/testsuite/delete-missing-args-files-from_test.py b/testsuite/tests/delete-missing-args-files-from_test.py similarity index 100% rename from testsuite/delete-missing-args-files-from_test.py rename to testsuite/tests/delete-missing-args-files-from_test.py diff --git a/testsuite/delete_test.py b/testsuite/tests/delete_test.py similarity index 100% rename from testsuite/delete_test.py rename to testsuite/tests/delete_test.py diff --git a/testsuite/dest-symlinked-dir_test.py b/testsuite/tests/dest-symlinked-dir_test.py similarity index 100% rename from testsuite/dest-symlinked-dir_test.py rename to testsuite/tests/dest-symlinked-dir_test.py diff --git a/testsuite/devices-fake_test.py b/testsuite/tests/devices-fake_test.py similarity index 100% rename from testsuite/devices-fake_test.py rename to testsuite/tests/devices-fake_test.py diff --git a/testsuite/devices_test.py b/testsuite/tests/devices_test.py similarity index 89% rename from testsuite/devices_test.py rename to testsuite/tests/devices_test.py index db4da0b19..5880a24fa 100644 --- a/testsuite/devices_test.py +++ b/testsuite/tests/devices_test.py @@ -23,7 +23,7 @@ if fake_variant: if not xattrs_supported(): - test_skipped("Rsync needs xattrs for fake device tests") + test_skipped("Rsync needs xattrs for fake device tests", capability='xattr_runtime') rsyncfns.RSYNC = rsyncfns.RSYNC + ' --fake-super' rsyncfns.TLS_ARGS = (rsyncfns.TLS_ARGS + ' --fake-super').strip() @@ -50,7 +50,7 @@ def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool: if fakeroot_path and os.access(fakeroot_path, os.X_OK): print("Let's try re-running the script under fakeroot...") os.execv(fakeroot_path, [fakeroot_path, sys.executable, __file__]) - test_skipped("Rsync needs root/fakeroot for device tests") + test_skipped("Rsync needs root/fakeroot for device tests", capability='device_nodes') def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool: try: @@ -72,17 +72,17 @@ def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool: TODIR.mkdir(parents=True, exist_ok=True) if not make_special(FROMDIR / 'char', 'c', 41, 67): - test_skipped("Can't create char device node") + test_skipped("Can't create char device node", capability='device_nodes') if not make_special(FROMDIR / 'char2', 'c', 42, 68): - test_skipped("Can't create char device node") + test_skipped("Can't create char device node", capability='device_nodes') if not make_special(FROMDIR / 'char3', 'c', 42, 69): - test_skipped("Can't create char device node") + test_skipped("Can't create char device node", capability='device_nodes') if not make_special(FROMDIR / 'block', 'b', 42, 69): - test_skipped("Can't create block device node") + test_skipped("Can't create block device node", capability='device_nodes') if not make_special(FROMDIR / 'block2', 'b', 42, 73): - test_skipped("Can't create block device node") + test_skipped("Can't create block device node", capability='device_nodes') if not make_special(FROMDIR / 'block3', 'b', 105, 73): - test_skipped("Can't create block device node") + test_skipped("Can't create block device node", capability='device_nodes') if can_hlink_special: try: @@ -96,7 +96,7 @@ def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool: print("Skipping hard-linked device test...") if not make_special(FROMDIR / 'fifo', 'p'): - test_skipped("Can't run mkfifo") + test_skipped("Can't run mkfifo", capability='device_nodes') # Match block/block2 timestamps so the diff doesn't drift. ref = (FROMDIR / 'block').stat() diff --git a/testsuite/dir-sgid_test.py b/testsuite/tests/dir-sgid_test.py similarity index 93% rename from testsuite/dir-sgid_test.py rename to testsuite/tests/dir-sgid_test.py index bfa735ae8..74338fedc 100644 --- a/testsuite/dir-sgid_test.py +++ b/testsuite/tests/dir-sgid_test.py @@ -70,7 +70,7 @@ def testit(dirname, dirperms, file_expected, prog_expected, dir_expected, setgid out = subprocess.run(['getfacl', str(src_dir)], capture_output=True, text=True) if 'default:user::' in out.stdout: - test_skipped("The default ACL mode interferes with this test") + test_skipped("The default ACL mode interferes with this test", capability='directory_sgid') except FileNotFoundError: pass # No getfacl -- proceed. @@ -80,16 +80,17 @@ def testit(dirname, dirperms, file_expected, prog_expected, dir_expected, setgid try: subprocess.run(['chmod', 'u=rwx,g=rw,g+s,o=r', str(src_dir)], check=True) except subprocess.CalledProcessError: - test_skipped("Can't chmod") + test_skipped("Can't chmod", capability='directory_sgid') os.chmod(SCRATCHDIR / 'file', 0o664) os.chmod(SCRATCHDIR / 'program', 0o775) if not (os.stat(src_dir).st_mode & 0o2000): - test_skipped("The directory setgid bit vanished!") + test_skipped("The directory setgid bit vanished!", capability='directory_sgid') (src_dir / 'blah').mkdir() if not (os.stat(src_dir / 'blah').st_mode & 0o2000): - test_skipped("Your filesystem doesn't use directory setgid; maybe it's BSD.") + test_skipped("Your filesystem doesn't use directory setgid; maybe it's BSD.", + capability='directory_sgid') testit('setgid-off', 0o700, 'rw-------', 'rwx------', 'rwx------', setgid=False) testit('setgid-on', 'u=rwx,g=rw,g+s,o-rwx', 'rw-------', 'rwx------', 'rwx--S---', diff --git a/testsuite/dirs_test.py b/testsuite/tests/dirs_test.py similarity index 100% rename from testsuite/dirs_test.py rename to testsuite/tests/dirs_test.py diff --git a/testsuite/duplicates_test.py b/testsuite/tests/duplicates_test.py similarity index 100% rename from testsuite/duplicates_test.py rename to testsuite/tests/duplicates_test.py diff --git a/testsuite/early-input-symlink_test.py b/testsuite/tests/early-input-symlink_test.py similarity index 98% rename from testsuite/early-input-symlink_test.py rename to testsuite/tests/early-input-symlink_test.py index 65fb0fbf5..1719452b9 100644 --- a/testsuite/early-input-symlink_test.py +++ b/testsuite/tests/early-input-symlink_test.py @@ -39,7 +39,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(the attacker simulation)") + "(the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -51,7 +51,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') src = FROMDIR rmtree(src) diff --git a/testsuite/exclude-implied-trailing-backslash_test.py b/testsuite/tests/exclude-implied-trailing-backslash_test.py similarity index 100% rename from testsuite/exclude-implied-trailing-backslash_test.py rename to testsuite/tests/exclude-implied-trailing-backslash_test.py diff --git a/testsuite/exclude-lsh_test.py b/testsuite/tests/exclude-lsh_test.py similarity index 100% rename from testsuite/exclude-lsh_test.py rename to testsuite/tests/exclude-lsh_test.py diff --git a/testsuite/exclude_test.py b/testsuite/tests/exclude_test.py similarity index 97% rename from testsuite/exclude_test.py rename to testsuite/tests/exclude_test.py index a35901c2f..81b8eb4a3 100644 --- a/testsuite/exclude_test.py +++ b/testsuite/tests/exclude_test.py @@ -19,7 +19,9 @@ all_plus, allspace, dots, checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail, rsync_path_arg, rsh_cmd, ) +from harness import metadata +metadata(features={'filters', 'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'environment', 'filesystem', 'process'}, tags={'compatibility', 'transfer', 'version-mix'}) os.environ['CVSIGNORE'] = '*.junk' diff --git a/testsuite/excludefrom-symlink_test.py b/testsuite/tests/excludefrom-symlink_test.py similarity index 97% rename from testsuite/excludefrom-symlink_test.py rename to testsuite/tests/excludefrom-symlink_test.py index 00bb95dd4..b1fb30f39 100644 --- a/testsuite/excludefrom-symlink_test.py +++ b/testsuite/tests/excludefrom-symlink_test.py @@ -34,7 +34,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(the attacker simulation)") + "(the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -46,7 +46,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') base = SCRATCHDIR / 'excludefrom' diff --git a/testsuite/executability_test.py b/testsuite/tests/executability_test.py similarity index 100% rename from testsuite/executability_test.py rename to testsuite/tests/executability_test.py diff --git a/testsuite/fake-super-acl-xattr_test.py b/testsuite/tests/fake-super-acl-xattr_test.py similarity index 92% rename from testsuite/fake-super-acl-xattr_test.py rename to testsuite/tests/fake-super-acl-xattr_test.py index 097323670..35819ffb4 100644 --- a/testsuite/fake-super-acl-xattr_test.py +++ b/testsuite/tests/fake-super-acl-xattr_test.py @@ -34,12 +34,13 @@ # the prefix is `rsync.` and the access path uses setfacl/os.getxattr that don't # exist or differ (macOS, the BSDs, Cygwin). This test targets that Linux path. if platform.system() != 'Linux': - test_skipped("fake-super ACL xattrs use the Linux user.rsync.* namespace") + test_skipped("fake-super ACL xattrs use the Linux user.rsync.* namespace", + capability='linux_xattr') if not acls_supported(): - test_skipped("ACLs not supported on this filesystem") + test_skipped("ACLs not supported on this filesystem", capability='acl') if not xattrs_supported(): - test_skipped("xattrs not supported on this filesystem") + test_skipped("xattrs not supported on this filesystem", capability='xattr') src = FROMDIR dst = SCRATCHDIR / 'fakesuper-dst' @@ -59,11 +60,11 @@ r = subprocess.run(['setfacl', '-m', f'u:{os.getuid()}:rwx', str(src / 'f')], capture_output=True, text=True) if r.returncode != 0: - test_skipped(f"setfacl on file failed: {r.stderr!r}") + test_skipped(f"setfacl on file failed: {r.stderr!r}", capability='acl') r = subprocess.run(['setfacl', '-d', '-m', f'u:{os.getuid()}:rwx', str(sd)], capture_output=True, text=True) if r.returncode != 0: - test_skipped(f"setfacl -d on dir failed: {r.stderr!r}") + test_skipped(f"setfacl -d on dir failed: {r.stderr!r}", capability='acl') def has_xattr(path, name): diff --git a/testsuite/fake-super-backup-fifo-regression_test.py b/testsuite/tests/fake-super-backup-fifo-regression_test.py similarity index 98% rename from testsuite/fake-super-backup-fifo-regression_test.py rename to testsuite/tests/fake-super-backup-fifo-regression_test.py index d99030816..c71ccd7c0 100644 --- a/testsuite/fake-super-backup-fifo-regression_test.py +++ b/testsuite/tests/fake-super-backup-fifo-regression_test.py @@ -13,7 +13,8 @@ system = platform.system() if system not in ('Darwin', 'Linux'): - test_skipped('deterministic fast-path failure needs dyld/ELF interposing') + test_skipped('deterministic fast-path failure needs dyld/ELF interposing', + capability='interpose') if not xattrs_supported(): test_skipped('fake-super FIFO setup requires xattr support') @@ -168,7 +169,7 @@ [*build_args, '-o', str(hook_lib), str(hook_src)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if build.returncode != 0: - test_skipped(f'cannot build dyld interposer: {build.stdout!r}') + test_skipped(f'cannot build dyld interposer: {build.stdout!r}', capability='interpose') seed = subprocess.run( rsync_argv('-rtI', '--specials', '--fake-super', f'{src}/', f'{mod}/'), diff --git a/testsuite/file-to-file-mkpath-dry-run_test.py b/testsuite/tests/file-to-file-mkpath-dry-run_test.py similarity index 100% rename from testsuite/file-to-file-mkpath-dry-run_test.py rename to testsuite/tests/file-to-file-mkpath-dry-run_test.py diff --git a/testsuite/files-from-depth_test.py b/testsuite/tests/files-from-depth_test.py similarity index 100% rename from testsuite/files-from-depth_test.py rename to testsuite/tests/files-from-depth_test.py diff --git a/testsuite/files-from-leak_test.py b/testsuite/tests/files-from-leak_test.py similarity index 97% rename from testsuite/files-from-leak_test.py rename to testsuite/tests/files-from-leak_test.py index b310860e4..42296adb0 100644 --- a/testsuite/files-from-leak_test.py +++ b/testsuite/tests/files-from-leak_test.py @@ -19,11 +19,12 @@ PORT = 13333 if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') SECRET = "NO_ONE_SHOULD_README\n" NFILES = 80 @@ -169,7 +170,8 @@ def root_owned_backup_symlink(): # flipper (Linux); on platforms that fall back to the non-atomic flipper the # window may never be hit within the budget. Can't mount the attack here, so # there's nothing to assert -- skip rather than fail. - test_skipped("race did not produce a root-owned backup symlink (no atomic flipper?)") + test_skipped("race did not produce a root-owned backup symlink (no atomic flipper?)", + capability='cross_uid') # Non-vacuous GREEN precondition: the symlink we exploit must genuinely target a # secret OUTSIDE the module, so marker-absence proves module-root confinement diff --git a/testsuite/files-from-path-clamp_test.py b/testsuite/tests/files-from-path-clamp_test.py similarity index 100% rename from testsuite/files-from-path-clamp_test.py rename to testsuite/tests/files-from-path-clamp_test.py diff --git a/testsuite/files-from_test.py b/testsuite/tests/files-from_test.py similarity index 87% rename from testsuite/files-from_test.py rename to testsuite/tests/files-from_test.py index faab43b9a..5ed60d476 100644 --- a/testsuite/files-from_test.py +++ b/testsuite/tests/files-from_test.py @@ -9,7 +9,9 @@ CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR, checkit, hands_setup, rmtree, run_rsync, rsync_path_arg, rsh_cmd, ) +from harness import metadata +metadata(features={'files-from', 'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process'}, tags={'compatibility', 'transfer', 'version-mix'}) SSH = rsh_cmd() diff --git a/testsuite/filter-depth_test.py b/testsuite/tests/filter-depth_test.py similarity index 100% rename from testsuite/filter-depth_test.py rename to testsuite/tests/filter-depth_test.py diff --git a/testsuite/filter-leak_test.py b/testsuite/tests/filter-leak_test.py similarity index 97% rename from testsuite/filter-leak_test.py rename to testsuite/tests/filter-leak_test.py index 9931937f5..df4208495 100644 --- a/testsuite/filter-leak_test.py +++ b/testsuite/tests/filter-leak_test.py @@ -20,11 +20,12 @@ PORT = 13333 if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') SECRET = "NO_ONE_SHOULD_README\n" NFILES = 80 @@ -171,7 +172,8 @@ def root_owned_backup_symlink(): # flipper (Linux); on platforms that fall back to the non-atomic flipper the # window may never be hit within the budget. Can't mount the attack here, so # there's nothing to assert -- skip rather than fail. - test_skipped("race did not produce a root-owned backup symlink (no atomic flipper?)") + test_skipped("race did not produce a root-owned backup symlink (no atomic flipper?)", + capability='cross_uid') # Non-vacuous GREEN precondition: the symlink we exploit must genuinely target a # secret OUTSIDE the module, so marker-absence proves module-root confinement diff --git a/testsuite/filter-merge-content-echo_test.py b/testsuite/tests/filter-merge-content-echo_test.py similarity index 100% rename from testsuite/filter-merge-content-echo_test.py rename to testsuite/tests/filter-merge-content-echo_test.py diff --git a/testsuite/filter-merge-recursion_test.py b/testsuite/tests/filter-merge-recursion_test.py similarity index 100% rename from testsuite/filter-merge-recursion_test.py rename to testsuite/tests/filter-merge-recursion_test.py diff --git a/testsuite/filter-merge-symlink_test.py b/testsuite/tests/filter-merge-symlink_test.py similarity index 98% rename from testsuite/filter-merge-symlink_test.py rename to testsuite/tests/filter-merge-symlink_test.py index 4f3dc145c..60675e245 100644 --- a/testsuite/filter-merge-symlink_test.py +++ b/testsuite/tests/filter-merge-symlink_test.py @@ -40,7 +40,8 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(simulates the 'root runs `rsync -a /home /backup`' case)") + "(simulates the 'root runs `rsync -a /home /backup`' case)", + capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -52,7 +53,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') base = SCRATCHDIR / 'filtermerge' diff --git a/testsuite/fuzzy-basis_test.py b/testsuite/tests/fuzzy-basis_test.py similarity index 100% rename from testsuite/fuzzy-basis_test.py rename to testsuite/tests/fuzzy-basis_test.py diff --git a/testsuite/fuzzy_test.py b/testsuite/tests/fuzzy_test.py similarity index 100% rename from testsuite/fuzzy_test.py rename to testsuite/tests/fuzzy_test.py diff --git a/testsuite/git-set-file-times-python-compat-regression_test.py b/testsuite/tests/git-set-file-times-python-compat-regression_test.py similarity index 98% rename from testsuite/git-set-file-times-python-compat-regression_test.py rename to testsuite/tests/git-set-file-times-python-compat-regression_test.py index 1c70d480d..678d7dd21 100644 --- a/testsuite/git-set-file-times-python-compat-regression_test.py +++ b/testsuite/tests/git-set-file-times-python-compat-regression_test.py @@ -7,7 +7,7 @@ from pathlib import Path -source_dir = Path(os.environ.get("srcdir", Path(__file__).resolve().parents[1])) +source_dir = Path(os.environ.get("srcdir", Path(__file__).resolve().parents[2])) script = source_dir / "support" / "git-set-file-times" proc = subprocess.run( [sys.executable, str(script), "--help"], diff --git a/testsuite/growing-file_test.py b/testsuite/tests/growing-file_test.py similarity index 100% rename from testsuite/growing-file_test.py rename to testsuite/tests/growing-file_test.py diff --git a/testsuite/hands_test.py b/testsuite/tests/hands_test.py similarity index 100% rename from testsuite/hands_test.py rename to testsuite/tests/hands_test.py diff --git a/testsuite/hardlinks-deep_test.py b/testsuite/tests/hardlinks-deep_test.py similarity index 100% rename from testsuite/hardlinks-deep_test.py rename to testsuite/tests/hardlinks-deep_test.py diff --git a/testsuite/hardlinks_test.py b/testsuite/tests/hardlinks_test.py similarity index 93% rename from testsuite/hardlinks_test.py rename to testsuite/tests/hardlinks_test.py index 728264f01..331cc68c3 100644 --- a/testsuite/hardlinks_test.py +++ b/testsuite/tests/hardlinks_test.py @@ -15,7 +15,9 @@ CHKDIR, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SRCDIR, TODIR, checkit, makepath, rsync_argv, test_fail, test_skipped, rsync_path_arg, rsh_cmd, ) +from harness import metadata +metadata(features={'hardlinks', 'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process'}, tags={'compatibility', 'metadata', 'transfer', 'version-mix'}) SSH = rsh_cmd() diff --git a/testsuite/hashsearch-chain_test.py b/testsuite/tests/hashsearch-chain_test.py similarity index 100% rename from testsuite/hashsearch-chain_test.py rename to testsuite/tests/hashsearch-chain_test.py diff --git a/testsuite/hashtable-overflow_test.py b/testsuite/tests/hashtable-overflow_test.py similarity index 100% rename from testsuite/hashtable-overflow_test.py rename to testsuite/tests/hashtable-overflow_test.py diff --git a/testsuite/highfd-hang_test.py b/testsuite/tests/highfd-hang_test.py similarity index 100% rename from testsuite/highfd-hang_test.py rename to testsuite/tests/highfd-hang_test.py diff --git a/testsuite/iconv_test.py b/testsuite/tests/iconv_test.py similarity index 97% rename from testsuite/iconv_test.py rename to testsuite/tests/iconv_test.py index 758952bac..dfa54ea83 100644 --- a/testsuite/iconv_test.py +++ b/testsuite/tests/iconv_test.py @@ -47,9 +47,9 @@ def forced_protocol(command): if not iconv_works(RSYNC, 'client'): - test_skipped('rsync under test does not provide working --iconv support') + test_skipped('rsync under test does not provide working --iconv support', capability='iconv') if RSYNC_PEER != RSYNC and not iconv_works(RSYNC_PEER, 'peer'): - test_skipped('selected peer does not provide working --iconv support') + test_skipped('selected peer does not provide working --iconv support', capability='iconv') def raw_path(directory, name): @@ -101,9 +101,11 @@ def invoke(*args, env=None, expected=0): preserves_bytes = b'probe-\xe9' in os.listdir(os.fsencode(base)) os.unlink(probe) if not preserves_bytes: - test_skipped('filesystem does not preserve ISO-8859-1 filename bytes') + test_skipped('filesystem does not preserve ISO-8859-1 filename bytes', + capability='raw_filename_bytes') except OSError as exc: - test_skipped(f'filesystem cannot represent ISO-8859-1 filename bytes: {exc}') + test_skipped(f'filesystem cannot represent ISO-8859-1 filename bytes: {exc}', + capability='raw_filename_bytes') ssh = rsh_cmd() diff --git a/testsuite/idn_test.py b/testsuite/tests/idn_test.py similarity index 100% rename from testsuite/idn_test.py rename to testsuite/tests/idn_test.py diff --git a/testsuite/ignore-missing-args_test.py b/testsuite/tests/ignore-missing-args_test.py similarity index 100% rename from testsuite/ignore-missing-args_test.py rename to testsuite/tests/ignore-missing-args_test.py diff --git a/testsuite/inband-modname-leak_test.py b/testsuite/tests/inband-modname-leak_test.py similarity index 100% rename from testsuite/inband-modname-leak_test.py rename to testsuite/tests/inband-modname-leak_test.py diff --git a/testsuite/inplace_test.py b/testsuite/tests/inplace_test.py similarity index 100% rename from testsuite/inplace_test.py rename to testsuite/tests/inplace_test.py diff --git a/testsuite/insecure-links-admin-optout_test.py b/testsuite/tests/insecure-links-admin-optout_test.py similarity index 97% rename from testsuite/insecure-links-admin-optout_test.py rename to testsuite/tests/insecure-links-admin-optout_test.py index 44492c7fa..5a7127544 100644 --- a/testsuite/insecure-links-admin-optout_test.py +++ b/testsuite/tests/insecure-links-admin-optout_test.py @@ -32,10 +32,11 @@ MARKER = "OUT-OF-MODULE-SECRET\n" if os.geteuid() != 0: - test_skipped("requires root to plant a foreign-owned symlink and serve as root") + test_skipped("requires root to plant a foreign-owned symlink and serve as root", + capability='cross_uid') ATT = find_attacker_uid() if ATT is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') if not rsync_supports('--copy-dirlinks'): test_skipped("rsync lacks --copy-dirlinks") diff --git a/testsuite/install-strip_test.py b/testsuite/tests/install-strip_test.py similarity index 100% rename from testsuite/install-strip_test.py rename to testsuite/tests/install-strip_test.py diff --git a/testsuite/io-noop-flood-recursion_test.py b/testsuite/tests/io-noop-flood-recursion_test.py similarity index 100% rename from testsuite/io-noop-flood-recursion_test.py rename to testsuite/tests/io-noop-flood-recursion_test.py diff --git a/testsuite/io-nosend-flood-recursion_test.py b/testsuite/tests/io-nosend-flood-recursion_test.py similarity index 100% rename from testsuite/io-nosend-flood-recursion_test.py rename to testsuite/tests/io-nosend-flood-recursion_test.py diff --git a/testsuite/io-readargs-argv-nullwrite_test.py b/testsuite/tests/io-readargs-argv-nullwrite_test.py similarity index 100% rename from testsuite/io-readargs-argv-nullwrite_test.py rename to testsuite/tests/io-readargs-argv-nullwrite_test.py diff --git a/testsuite/itemize_test.py b/testsuite/tests/itemize_test.py similarity index 100% rename from testsuite/itemize_test.py rename to testsuite/tests/itemize_test.py diff --git a/testsuite/iwildmatch-fold_test.py b/testsuite/tests/iwildmatch-fold_test.py similarity index 100% rename from testsuite/iwildmatch-fold_test.py rename to testsuite/tests/iwildmatch-fold_test.py diff --git a/testsuite/keep-dirlinks-rule_test.py b/testsuite/tests/keep-dirlinks-rule_test.py similarity index 100% rename from testsuite/keep-dirlinks-rule_test.py rename to testsuite/tests/keep-dirlinks-rule_test.py diff --git a/testsuite/keep-dirlinks-symlinked-dest_test.py b/testsuite/tests/keep-dirlinks-symlinked-dest_test.py similarity index 100% rename from testsuite/keep-dirlinks-symlinked-dest_test.py rename to testsuite/tests/keep-dirlinks-symlinked-dest_test.py diff --git a/testsuite/ki58-log-format-percent_test.py b/testsuite/tests/ki58-log-format-percent_test.py similarity index 100% rename from testsuite/ki58-log-format-percent_test.py rename to testsuite/tests/ki58-log-format-percent_test.py diff --git a/testsuite/ki62-io-error-mask_test.py b/testsuite/tests/ki62-io-error-mask_test.py similarity index 99% rename from testsuite/ki62-io-error-mask_test.py rename to testsuite/tests/ki62-io-error-mask_test.py index 32a16dd5b..99acffb8d 100644 --- a/testsuite/ki62-io-error-mask_test.py +++ b/testsuite/tests/ki62-io-error-mask_test.py @@ -70,7 +70,7 @@ # pipe mode the daemon is forked off the client and is not directly killable, # so skip rather than run an inconclusive test. if not USE_TCP: - test_skipped("needs --use-tcp to kill the daemon (sender) mid-transfer") + test_skipped("needs --use-tcp to kill the daemon (sender) mid-transfer", capability='tcp') conf = build_rsyncd_conf() diff --git a/testsuite/ki72-safe-links-backup_test.py b/testsuite/tests/ki72-safe-links-backup_test.py similarity index 100% rename from testsuite/ki72-safe-links-backup_test.py rename to testsuite/tests/ki72-safe-links-backup_test.py diff --git a/testsuite/ki73-cvs-clear-list_test.py b/testsuite/tests/ki73-cvs-clear-list_test.py similarity index 100% rename from testsuite/ki73-cvs-clear-list_test.py rename to testsuite/tests/ki73-cvs-clear-list_test.py diff --git a/testsuite/link-dest-module-escape_test.py b/testsuite/tests/link-dest-module-escape_test.py similarity index 100% rename from testsuite/link-dest-module-escape_test.py rename to testsuite/tests/link-dest-module-escape_test.py diff --git a/testsuite/link-dest-pathroot_test.py b/testsuite/tests/link-dest-pathroot_test.py similarity index 100% rename from testsuite/link-dest-pathroot_test.py rename to testsuite/tests/link-dest-pathroot_test.py diff --git a/testsuite/link-dest-relative-basis_test.py b/testsuite/tests/link-dest-relative-basis_test.py similarity index 100% rename from testsuite/link-dest-relative-basis_test.py rename to testsuite/tests/link-dest-relative-basis_test.py diff --git a/testsuite/link-dest-symlink-enotsup_test.py b/testsuite/tests/link-dest-symlink-enotsup_test.py similarity index 95% rename from testsuite/link-dest-symlink-enotsup_test.py rename to testsuite/tests/link-dest-symlink-enotsup_test.py index 864a81f24..a40daace0 100644 --- a/testsuite/link-dest-symlink-enotsup_test.py +++ b/testsuite/tests/link-dest-symlink-enotsup_test.py @@ -25,12 +25,12 @@ ) if platform.system() != 'Linux': - test_skipped('the LD_PRELOAD linkat hook is Linux-only') + test_skipped('the LD_PRELOAD linkat hook is Linux-only', capability='linux_interpose') vv = run_rsync('-VV', check=True, capture_output=True).stdout if '"hardlink_symlinks": true' not in vv: test_skipped('build cannot hard-link symlinks, so it already falls back ' - 'at compile time and this arm is unreachable') + 'at compile time and this arm is unreachable', capability='linux_interpose') hook_code = r''' #define _GNU_SOURCE @@ -133,7 +133,8 @@ ['cc', '-shared', '-fPIC', '-o', str(hook_lib), str(hook_src), '-ldl'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if build.returncode != 0: - test_skipped(f'cannot build the LD_PRELOAD linkat hook: {build.stdout!r}') + test_skipped(f'cannot build the LD_PRELOAD linkat hook: {build.stdout!r}', + capability='linux_interpose') def run(dest, refuse, args=('-a',)): @@ -152,7 +153,7 @@ def run(dest, refuse, args=('-a',)): f'output={r.stdout!r}') if not marker.exists(): test_skipped(f'the LD_PRELOAD hook never fired (rc={r.returncode}, ' - f'output={r.stdout!r})') + f'output={r.stdout!r})', capability='linux_interpose') return r diff --git a/testsuite/links_test.py b/testsuite/tests/links_test.py similarity index 100% rename from testsuite/links_test.py rename to testsuite/tests/links_test.py diff --git a/testsuite/log-control-chars_test.py b/testsuite/tests/log-control-chars_test.py similarity index 100% rename from testsuite/log-control-chars_test.py rename to testsuite/tests/log-control-chars_test.py diff --git a/testsuite/log-file-symlink_test.py b/testsuite/tests/log-file-symlink_test.py similarity index 97% rename from testsuite/log-file-symlink_test.py rename to testsuite/tests/log-file-symlink_test.py index a290ef3c0..2f31a526c 100644 --- a/testsuite/log-file-symlink_test.py +++ b/testsuite/tests/log-file-symlink_test.py @@ -39,7 +39,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-root, " - "non-self uid (the attacker simulation)") + "non-self uid (the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -51,7 +51,7 @@ continue if NOBODY_UID is None or NOBODY_UID == 0 or NOBODY_UID == os.geteuid(): test_skipped("no untrusted-uid user available for cross-uid plant " - "(tried nobody, nfsnobody, daemon)") + "(tried nobody, nfsnobody, daemon)", capability='cross_uid') base = SCRATCHDIR / 'logfile' diff --git a/testsuite/longdir_test.py b/testsuite/tests/longdir_test.py similarity index 100% rename from testsuite/longdir_test.py rename to testsuite/tests/longdir_test.py diff --git a/testsuite/macos-setgid-ordinary-mode-regression_test.py b/testsuite/tests/macos-setgid-ordinary-mode-regression_test.py similarity index 90% rename from testsuite/macos-setgid-ordinary-mode-regression_test.py rename to testsuite/tests/macos-setgid-ordinary-mode-regression_test.py index 167373e28..ba4e3954f 100644 --- a/testsuite/macos-setgid-ordinary-mode-regression_test.py +++ b/testsuite/tests/macos-setgid-ordinary-mode-regression_test.py @@ -22,7 +22,7 @@ if platform.system() != "Darwin": - test_skipped("macOS-specific fchmodat setgid behavior") + test_skipped("macOS-specific fchmodat setgid behavior", capability='darwin') AT_FDCWD = -2 AT_SYMLINK_NOFOLLOW = 0x0020 @@ -70,12 +70,14 @@ def _scratch_with_ungrantable_group(): base = _scratch_with_ungrantable_group() if base is None: test_skipped("no scratch parent whose group this user cannot grant " - "(running as root, or every candidate group is granted)") + "(running as root or every candidate group is granted)", + capability='ungrantable_group') mprobe = base / "mprobe" mprobe.mkdir(mode=0o2750) if mode(mprobe) != 0o750: - test_skipped("mkdir does not apply ordinary bits while clearing setgid") + test_skipped("mkdir does not apply ordinary bits while clearing setgid", + capability='setgid_semantics') faprobe = base / "fchmodat-probe" faprobe.mkdir(mode=0o700) @@ -83,7 +85,8 @@ def _scratch_with_ungrantable_group(): frc = libc.fchmodat(AT_FDCWD, os.fsencode(faprobe), 0o2750, AT_SYMLINK_NOFOLLOW) if frc == 0 or ctypes.get_errno() != errno.EPERM or mode(faprobe) != 0o700: - test_skipped("fchmodat no-follow does not expose the macOS differential") + test_skipped("fchmodat no-follow does not expose the macOS differential", + capability='setgid_semantics') fdprobe = base / "fchmod-probe" fdprobe.mkdir(mode=0o700) @@ -93,7 +96,8 @@ def _scratch_with_ungrantable_group(): finally: os.close(fd) if mode(fdprobe) != 0o750: - test_skipped("descriptor chmod does not preserve the legacy mkdir semantics") + test_skipped("descriptor chmod does not preserve the legacy mkdir semantics", + capability='setgid_semantics') src = base / "src" dest = base / "dest" diff --git a/testsuite/malicious-dot-dir-delete-scope_test.py b/testsuite/tests/malicious-dot-dir-delete-scope_test.py similarity index 100% rename from testsuite/malicious-dot-dir-delete-scope_test.py rename to testsuite/tests/malicious-dot-dir-delete-scope_test.py diff --git a/testsuite/malicious-dot-file-delete-scope_test.py b/testsuite/tests/malicious-dot-file-delete-scope_test.py similarity index 100% rename from testsuite/malicious-dot-file-delete-scope_test.py rename to testsuite/tests/malicious-dot-file-delete-scope_test.py diff --git a/testsuite/malicious-sender-delete-scope_test.py b/testsuite/tests/malicious-sender-delete-scope_test.py similarity index 100% rename from testsuite/malicious-sender-delete-scope_test.py rename to testsuite/tests/malicious-sender-delete-scope_test.py diff --git a/testsuite/malicious-server-partial-basis-symlink-overwrite_test.py b/testsuite/tests/malicious-server-partial-basis-symlink-overwrite_test.py similarity index 100% rename from testsuite/malicious-server-partial-basis-symlink-overwrite_test.py rename to testsuite/tests/malicious-server-partial-basis-symlink-overwrite_test.py diff --git a/testsuite/match-append-empty-nullmap_test.py b/testsuite/tests/match-append-empty-nullmap_test.py similarity index 100% rename from testsuite/match-append-empty-nullmap_test.py rename to testsuite/tests/match-append-empty-nullmap_test.py diff --git a/testsuite/match-want-i-nolen_test.py b/testsuite/tests/match-want-i-nolen_test.py similarity index 100% rename from testsuite/match-want-i-nolen_test.py rename to testsuite/tests/match-want-i-nolen_test.py diff --git a/testsuite/max-alloc-zero_test.py b/testsuite/tests/max-alloc-zero_test.py similarity index 100% rename from testsuite/max-alloc-zero_test.py rename to testsuite/tests/max-alloc-zero_test.py diff --git a/testsuite/merge_test.py b/testsuite/tests/merge_test.py similarity index 100% rename from testsuite/merge_test.py rename to testsuite/tests/merge_test.py diff --git a/testsuite/metadata-depth_test.py b/testsuite/tests/metadata-depth_test.py similarity index 100% rename from testsuite/metadata-depth_test.py rename to testsuite/tests/metadata-depth_test.py diff --git a/testsuite/tests/metadata_test.py b/testsuite/tests/metadata_test.py new file mode 100644 index 000000000..08ad4c847 --- /dev/null +++ b/testsuite/tests/metadata_test.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Test metadata schema""" + +from harness import TestContext, applies_to_peer, metadata, placeholder_target, read_requirements, requires, run +from rsyncfns import test_fail + + +@requires(protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe'}, min_peer='2.6.0', tags={'harness'}) +def test(context: TestContext): + metadata = read_requirements(context.repository / 'testsuite' / 'tests' / 'smoke_test.py') + if metadata['min_peer'] != '2.6.0' or metadata['transports'] != ('pipe', 'tcp'): + test_fail('smoke metadata was not discovered') + if not applies_to_peer(metadata, '2.6.0', 27, 'pipe'): + test_fail('matching peer metadata was rejected') + if (applies_to_peer(metadata, '2.5.0', 27, 'pipe') + or applies_to_peer(metadata, '2.6.0', 26, 'pipe') + or applies_to_peer(metadata, '2.6.0', 27, 'socket')): + test_fail('inapplicable peer metadata was accepted') + + module = context.scratch / 'module_test.py' + module.write_text("from harness import metadata\nmetadata(features={'acl'}, tags={'version-mix'})\n") + if read_requirements(module)['features'] != ('acl',): + test_fail('module metadata was not discovered') + + alias = context.scratch / 'alias_test.py' + alias.symlink_to(module.name) + if read_requirements(alias) is not None: + test_fail('link alias inherited target metadata') + + placeholder = context.scratch / 'placeholder_test.py' + placeholder.write_text(module.name) + if placeholder_target(placeholder) != module or read_requirements(placeholder) is not None: + test_fail('link placeholder was not isolated') + + inferred = context.scratch / 'inferred_test.py' + inferred.write_text("require_tcp('tcp')\nrequire_asan('asan')\n" + "test_skipped('root', capability='nonroot')\n") + if read_requirements(inferred)['features'] != ('asan', 'nonroot', 'tcp'): + test_fail('capability metadata was not inferred') + + invalid_capability = context.scratch / 'invalid_capability_test.py' + invalid_capability.write_text("test_skipped('bad', capability=1)\n") + try: + read_requirements(invalid_capability) + except ValueError: + pass + else: + test_fail('invalid capability metadata was accepted') + + invalid = ( + {'features': {''}}, {'features': 'acl'}, {'features': {'acl', 1}}, + {'protocols': {0}}, {'protocols': {True}}, + {'transports': {'socket'}}, {'min_peer': 'three'}, {'min_peer': True}, + {'root': 1}, {'parallel': 1}, {'cost': 'slow'}, + ) + for values in invalid: + try: + requires(**values) + except ValueError: + continue + test_fail(f'invalid metadata was accepted: {values}') + try: + metadata(unknown=True) + except TypeError: + pass + else: + test_fail('unknown metadata field was accepted') + + +if __name__ == '__main__': + run(test) diff --git a/testsuite/misc-coverage_test.py b/testsuite/tests/misc-coverage_test.py similarity index 100% rename from testsuite/misc-coverage_test.py rename to testsuite/tests/misc-coverage_test.py diff --git a/testsuite/missing_test.py b/testsuite/tests/missing_test.py similarity index 100% rename from testsuite/missing_test.py rename to testsuite/tests/missing_test.py diff --git a/testsuite/mkpath_test.py b/testsuite/tests/mkpath_test.py similarity index 100% rename from testsuite/mkpath_test.py rename to testsuite/tests/mkpath_test.py diff --git a/testsuite/msg-io-timeout-overflow_test.py b/testsuite/tests/msg-io-timeout-overflow_test.py similarity index 100% rename from testsuite/msg-io-timeout-overflow_test.py rename to testsuite/tests/msg-io-timeout-overflow_test.py diff --git a/testsuite/msg-io-timeout-zero_test.py b/testsuite/tests/msg-io-timeout-zero_test.py similarity index 100% rename from testsuite/msg-io-timeout-zero_test.py rename to testsuite/tests/msg-io-timeout-zero_test.py diff --git a/testsuite/nested-socket-specials_test.py b/testsuite/tests/nested-socket-specials_test.py similarity index 100% rename from testsuite/nested-socket-specials_test.py rename to testsuite/tests/nested-socket-specials_test.py diff --git a/testsuite/no-implied-dirs-symlink_test.py b/testsuite/tests/no-implied-dirs-symlink_test.py similarity index 100% rename from testsuite/no-implied-dirs-symlink_test.py rename to testsuite/tests/no-implied-dirs-symlink_test.py diff --git a/testsuite/nondaemon-symlink-race_test.py b/testsuite/tests/nondaemon-symlink-race_test.py similarity index 97% rename from testsuite/nondaemon-symlink-race_test.py rename to testsuite/tests/nondaemon-symlink-race_test.py index 0c732fee2..b75f85e3a 100644 --- a/testsuite/nondaemon-symlink-race_test.py +++ b/testsuite/tests/nondaemon-symlink-race_test.py @@ -34,10 +34,11 @@ # operator's OWN backup dir) and refuses a foreign-owned one, so the escape # vector is an ATTACKER-owned backup-dir symlink with rsync run as root. if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') # A non-daemon receiver resolves its write paths on held dirfds with O_NOFOLLOW diff --git a/testsuite/nonroot-restrictive-perms_test.py b/testsuite/tests/nonroot-restrictive-perms_test.py similarity index 97% rename from testsuite/nonroot-restrictive-perms_test.py rename to testsuite/tests/nonroot-restrictive-perms_test.py index 4fce24101..5640a304d 100644 --- a/testsuite/nonroot-restrictive-perms_test.py +++ b/testsuite/tests/nonroot-restrictive-perms_test.py @@ -28,11 +28,11 @@ DAEMON_PORT = 12893 if not owners_supported(): - test_skipped("needs chown to set up a non-root-owned dest tree") + test_skipped("needs chown to set up a non-root-owned dest tree", capability='ownership') U = next((p for p in pwd.getpwall() if p.pw_uid != 0), None) if U is None: - test_skipped("no non-root passwd entry") + test_skipped("no non-root passwd entry", capability='ownership') src = SCRATCHDIR / 'nrp-src' dest = SCRATCHDIR / 'nrp-dest' diff --git a/testsuite/old-dirs_test.py b/testsuite/tests/old-dirs_test.py similarity index 84% rename from testsuite/old-dirs_test.py rename to testsuite/tests/old-dirs_test.py index 00fc8fec2..5eddd2676 100644 --- a/testsuite/old-dirs_test.py +++ b/testsuite/tests/old-dirs_test.py @@ -5,7 +5,9 @@ SCRATCHDIR, makepath, rmtree, rsync_path_arg, rsh_cmd, run_rsync, test_fail, ) +from harness import metadata +metadata(features={'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process'}, tags={'compatibility', 'transfer', 'version-mix'}) base = SCRATCHDIR / 'old-dirs' src = base / 'src' diff --git a/testsuite/omit-times_test.py b/testsuite/tests/omit-times_test.py similarity index 100% rename from testsuite/omit-times_test.py rename to testsuite/tests/omit-times_test.py diff --git a/testsuite/open-noatime_test.py b/testsuite/tests/open-noatime_test.py similarity index 91% rename from testsuite/open-noatime_test.py rename to testsuite/tests/open-noatime_test.py index fd267ec54..95d25880d 100644 --- a/testsuite/open-noatime_test.py +++ b/testsuite/tests/open-noatime_test.py @@ -17,11 +17,11 @@ vv = run_rsync('-VV', check=True, capture_output=True) if '"atimes": true' not in vv.stdout: - test_skipped("Rsync is configured without atimes support") + test_skipped("Rsync is configured without atimes support", capability='noatime') # O_NOATIME is Linux-specific. if platform.system() != 'Linux': - test_skipped("O_NOATIME is only supported on Linux") + test_skipped("O_NOATIME is only supported on Linux", capability='noatime') FROMDIR.mkdir(parents=True, exist_ok=True) foo = FROMDIR / 'foo' diff --git a/testsuite/operator-path-backup-dir-daemon_test.py b/testsuite/tests/operator-path-backup-dir-daemon_test.py similarity index 100% rename from testsuite/operator-path-backup-dir-daemon_test.py rename to testsuite/tests/operator-path-backup-dir-daemon_test.py diff --git a/testsuite/operator-path-backup-dir-exclude-daemon_test.py b/testsuite/tests/operator-path-backup-dir-exclude-daemon_test.py similarity index 100% rename from testsuite/operator-path-backup-dir-exclude-daemon_test.py rename to testsuite/tests/operator-path-backup-dir-exclude-daemon_test.py diff --git a/testsuite/operator-path-backup-dir_test.py b/testsuite/tests/operator-path-backup-dir_test.py similarity index 100% rename from testsuite/operator-path-backup-dir_test.py rename to testsuite/tests/operator-path-backup-dir_test.py diff --git a/testsuite/operator-path-backup-rmdir_test.py b/testsuite/tests/operator-path-backup-rmdir_test.py similarity index 97% rename from testsuite/operator-path-backup-rmdir_test.py rename to testsuite/tests/operator-path-backup-rmdir_test.py index 5e889f61a..f664c68e3 100644 --- a/testsuite/operator-path-backup-rmdir_test.py +++ b/testsuite/tests/operator-path-backup-rmdir_test.py @@ -18,11 +18,12 @@ ) if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') # Many files widen the per-file backup window so the flipper has more chances to # land the parent swap during a backup rmdir. diff --git a/testsuite/operator-path-backup-symlink_test.py b/testsuite/tests/operator-path-backup-symlink_test.py similarity index 97% rename from testsuite/operator-path-backup-symlink_test.py rename to testsuite/tests/operator-path-backup-symlink_test.py index 6501d0a82..f8afb1c4f 100644 --- a/testsuite/operator-path-backup-symlink_test.py +++ b/testsuite/tests/operator-path-backup-symlink_test.py @@ -17,11 +17,12 @@ ) if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') # Many files widen rsync's per-file backup window so the background flipper has # more chances to land the parent swap during a backup operation. diff --git a/testsuite/operator-path-compare-dest_test.py b/testsuite/tests/operator-path-compare-dest_test.py similarity index 100% rename from testsuite/operator-path-compare-dest_test.py rename to testsuite/tests/operator-path-compare-dest_test.py diff --git a/testsuite/operator-path-copy-dest_test.py b/testsuite/tests/operator-path-copy-dest_test.py similarity index 100% rename from testsuite/operator-path-copy-dest_test.py rename to testsuite/tests/operator-path-copy-dest_test.py diff --git a/testsuite/operator-path-dir-daemon-inmodule_test.py b/testsuite/tests/operator-path-dir-daemon-inmodule_test.py similarity index 100% rename from testsuite/operator-path-dir-daemon-inmodule_test.py rename to testsuite/tests/operator-path-dir-daemon-inmodule_test.py diff --git a/testsuite/operator-path-dir-daemon-leaf_test.py b/testsuite/tests/operator-path-dir-daemon-leaf_test.py similarity index 100% rename from testsuite/operator-path-dir-daemon-leaf_test.py rename to testsuite/tests/operator-path-dir-daemon-leaf_test.py diff --git a/testsuite/operator-path-dir-daemon-mkdir_test.py b/testsuite/tests/operator-path-dir-daemon-mkdir_test.py similarity index 100% rename from testsuite/operator-path-dir-daemon-mkdir_test.py rename to testsuite/tests/operator-path-dir-daemon-mkdir_test.py diff --git a/testsuite/operator-path-dir-daemon-outside_test.py b/testsuite/tests/operator-path-dir-daemon-outside_test.py similarity index 100% rename from testsuite/operator-path-dir-daemon-outside_test.py rename to testsuite/tests/operator-path-dir-daemon-outside_test.py diff --git a/testsuite/operator-path-files-from_test.py b/testsuite/tests/operator-path-files-from_test.py similarity index 100% rename from testsuite/operator-path-files-from_test.py rename to testsuite/tests/operator-path-files-from_test.py diff --git a/testsuite/operator-path-inplace-backup-dir_test.py b/testsuite/tests/operator-path-inplace-backup-dir_test.py similarity index 100% rename from testsuite/operator-path-inplace-backup-dir_test.py rename to testsuite/tests/operator-path-inplace-backup-dir_test.py diff --git a/testsuite/operator-path-insecure-links-daemon_test.py b/testsuite/tests/operator-path-insecure-links-daemon_test.py similarity index 97% rename from testsuite/operator-path-insecure-links-daemon_test.py rename to testsuite/tests/operator-path-insecure-links-daemon_test.py index 66eb0b6da..e31d1231c 100644 --- a/testsuite/operator-path-insecure-links-daemon_test.py +++ b/testsuite/tests/operator-path-insecure-links-daemon_test.py @@ -29,10 +29,10 @@ CANARY = "audit_daemon_optout_canary" if os.geteuid() != 0: - test_skipped("requires root to plant a symlink owned by a non-self uid") + test_skipped("requires root to plant a symlink owned by a non-self uid", capability='cross_uid') ATT = find_attacker_uid() if ATT is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') base = SCRATCHDIR / 'daemonoptout' rmtree(base) diff --git a/testsuite/operator-path-insecure-links-refused_test.py b/testsuite/tests/operator-path-insecure-links-refused_test.py similarity index 100% rename from testsuite/operator-path-insecure-links-refused_test.py rename to testsuite/tests/operator-path-insecure-links-refused_test.py diff --git a/testsuite/operator-path-link-dest_test.py b/testsuite/tests/operator-path-link-dest_test.py similarity index 100% rename from testsuite/operator-path-link-dest_test.py rename to testsuite/tests/operator-path-link-dest_test.py diff --git a/testsuite/operator-path-log-file_test.py b/testsuite/tests/operator-path-log-file_test.py similarity index 100% rename from testsuite/operator-path-log-file_test.py rename to testsuite/tests/operator-path-log-file_test.py diff --git a/testsuite/operator-path-partial-dir-daemon_test.py b/testsuite/tests/operator-path-partial-dir-daemon_test.py similarity index 100% rename from testsuite/operator-path-partial-dir-daemon_test.py rename to testsuite/tests/operator-path-partial-dir-daemon_test.py diff --git a/testsuite/operator-path-partial-dir-exclude-daemon_test.py b/testsuite/tests/operator-path-partial-dir-exclude-daemon_test.py similarity index 100% rename from testsuite/operator-path-partial-dir-exclude-daemon_test.py rename to testsuite/tests/operator-path-partial-dir-exclude-daemon_test.py diff --git a/testsuite/operator-path-partial-dir_test.py b/testsuite/tests/operator-path-partial-dir_test.py similarity index 100% rename from testsuite/operator-path-partial-dir_test.py rename to testsuite/tests/operator-path-partial-dir_test.py diff --git a/testsuite/operator-path-temp-dir_test.py b/testsuite/tests/operator-path-temp-dir_test.py similarity index 100% rename from testsuite/operator-path-temp-dir_test.py rename to testsuite/tests/operator-path-temp-dir_test.py diff --git a/testsuite/operator-path-traversal-backup-dir-daemon_test.py b/testsuite/tests/operator-path-traversal-backup-dir-daemon_test.py similarity index 100% rename from testsuite/operator-path-traversal-backup-dir-daemon_test.py rename to testsuite/tests/operator-path-traversal-backup-dir-daemon_test.py diff --git a/testsuite/operator-path-traversal-dest-dir-daemon_test.py b/testsuite/tests/operator-path-traversal-dest-dir-daemon_test.py similarity index 100% rename from testsuite/operator-path-traversal-dest-dir-daemon_test.py rename to testsuite/tests/operator-path-traversal-dest-dir-daemon_test.py diff --git a/testsuite/operator-path-traversal-partial-dir-daemon_test.py b/testsuite/tests/operator-path-traversal-partial-dir-daemon_test.py similarity index 100% rename from testsuite/operator-path-traversal-partial-dir-daemon_test.py rename to testsuite/tests/operator-path-traversal-partial-dir-daemon_test.py diff --git a/testsuite/operator-path-write-batch_test.py b/testsuite/tests/operator-path-write-batch_test.py similarity index 100% rename from testsuite/operator-path-write-batch_test.py rename to testsuite/tests/operator-path-write-batch_test.py diff --git a/testsuite/output-control-chars_test.py b/testsuite/tests/output-control-chars_test.py similarity index 100% rename from testsuite/output-control-chars_test.py rename to testsuite/tests/output-control-chars_test.py diff --git a/testsuite/output-options_test.py b/testsuite/tests/output-options_test.py similarity index 100% rename from testsuite/output-options_test.py rename to testsuite/tests/output-options_test.py diff --git a/testsuite/ownership-depth_test.py b/testsuite/tests/ownership-depth_test.py similarity index 100% rename from testsuite/ownership-depth_test.py rename to testsuite/tests/ownership-depth_test.py diff --git a/testsuite/partial-dir-abs-delta_test.py b/testsuite/tests/partial-dir-abs-delta_test.py similarity index 100% rename from testsuite/partial-dir-abs-delta_test.py rename to testsuite/tests/partial-dir-abs-delta_test.py diff --git a/testsuite/partial-protected-regular-retry-linux_test.py b/testsuite/tests/partial-protected-regular-retry-linux_test.py similarity index 98% rename from testsuite/partial-protected-regular-retry-linux_test.py rename to testsuite/tests/partial-protected-regular-retry-linux_test.py index 0cb80c2fd..89be4eeef 100644 --- a/testsuite/partial-protected-regular-retry-linux_test.py +++ b/testsuite/tests/partial-protected-regular-retry-linux_test.py @@ -26,14 +26,16 @@ if platform.system() != 'Linux': - test_skipped('LD_PRELOAD partial EACCES recovery hook is Linux-only') + test_skipped('LD_PRELOAD partial EACCES recovery hook is Linux-only', + capability='linux_interpose') # one_inplace staging needs inplace_partial, which is negotiated through the # protocol-30 CF_INPLACE_PARTIAL_DIR capability flag. Below that the receiver # uses the ordinary tmpfile path and the recovery under test never runs. _proto = forced_protocol() if _proto is not None and _proto < 30: - test_skipped(f'one-inplace partial staging needs protocol >= 30 (forced {_proto})') + test_skipped(f'one-inplace partial staging needs protocol >= 30 (forced {_proto})', + capability='protocol_30') hook_code = r''' #define _GNU_SOURCE @@ -449,7 +451,8 @@ def run(): ['cc', '-shared', '-fPIC', '-o', str(hook_lib), str(hook_src), '-ldl'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if build.returncode != 0: - test_skipped(f'cannot build LD_PRELOAD partial retry hook: {build.stdout!r}') + test_skipped(f'cannot build LD_PRELOAD partial retry hook: {build.stdout!r}', + capability='linux_interpose') run_env = os.environ.copy() run_env.update({ @@ -483,7 +486,7 @@ def run(): test_fail(f'rsync died by signal {-result.returncode} under the ' f'LD_PRELOAD hook ({ctx})') if not markers['load'].exists(): - test_skipped(f'LD_PRELOAD hook was not loaded ({ctx})') + test_skipped(f'LD_PRELOAD hook was not loaded ({ctx})', capability='linux_interpose') if not markers['eacces'].exists(): test_fail('positive control failed: receiver did not open the existing ' f'partial file with O_CREAT ({ctx})') diff --git a/testsuite/partial-protected-regular-retry-policy_test.py b/testsuite/tests/partial-protected-regular-retry-policy_test.py similarity index 99% rename from testsuite/partial-protected-regular-retry-policy_test.py rename to testsuite/tests/partial-protected-regular-retry-policy_test.py index a6843cb7b..bd34634b9 100644 --- a/testsuite/partial-protected-regular-retry-policy_test.py +++ b/testsuite/tests/partial-protected-regular-retry-policy_test.py @@ -20,7 +20,8 @@ if platform.system() != 'Darwin': - test_skipped('deterministic partial EACCES recovery uses dyld interposing') + test_skipped('deterministic partial EACCES recovery uses dyld interposing', + capability='darwin_interpose') base = SCRATCHDIR / 'partial-protected-regular-retry-policy' rmtree(base) diff --git a/testsuite/partial_nowrite_test.py b/testsuite/tests/partial_nowrite_test.py similarity index 100% rename from testsuite/partial_nowrite_test.py rename to testsuite/tests/partial_nowrite_test.py diff --git a/testsuite/partial_test.py b/testsuite/tests/partial_test.py similarity index 100% rename from testsuite/partial_test.py rename to testsuite/tests/partial_test.py diff --git a/testsuite/password-file-symlink_test.py b/testsuite/tests/password-file-symlink_test.py similarity index 98% rename from testsuite/password-file-symlink_test.py rename to testsuite/tests/password-file-symlink_test.py index 596035f33..e04942a08 100644 --- a/testsuite/password-file-symlink_test.py +++ b/testsuite/tests/password-file-symlink_test.py @@ -39,7 +39,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(the attacker simulation)") + "(the attacker simulation)", capability='cross_uid') NOBODY_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -51,7 +51,7 @@ except KeyError: continue if NOBODY_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') # Match daemon-auth_test.py: prevent rsync's interactive getpass() fallback # if no password is read. diff --git a/testsuite/peer-legacy-implied-delete-scope_test.py b/testsuite/tests/peer-legacy-implied-delete-scope_test.py similarity index 100% rename from testsuite/peer-legacy-implied-delete-scope_test.py rename to testsuite/tests/peer-legacy-implied-delete-scope_test.py diff --git a/testsuite/preallocate_test.py b/testsuite/tests/preallocate_test.py similarity index 98% rename from testsuite/preallocate_test.py rename to testsuite/tests/preallocate_test.py index bba87c7c0..1ba0a641a 100644 --- a/testsuite/preallocate_test.py +++ b/testsuite/tests/preallocate_test.py @@ -36,7 +36,7 @@ def allocated(path): (src / 'probe').write_text("x\n") if run_rsync('-a', '--preallocate', f'{src}/', f'{TODIR}/', check=False, capture_output=True).returncode != 0: - test_skipped("--preallocate not supported on this platform") + test_skipped("--preallocate not supported on this platform", capability='preallocate') def punch_frees(offset, length, size): """True where punching [offset, offset+length) out of a `size`-byte file diff --git a/testsuite/tests/profile_test.py b/testsuite/tests/profile_test.py new file mode 100644 index 000000000..7ef1f7196 --- /dev/null +++ b/testsuite/tests/profile_test.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +import json + +from harness import TestContext, applies_to_peer, load_profile, merge_profiles, parse_peer_banner, read_requirements, requires, run +from rsyncfns import test_fail + + +@requires(protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe'}, min_peer='2.6.0', tags={'harness'}) +def test(context: TestContext): + test_paths = list((context.repository / 'testsuite' / 'tests').glob('*_test.py')) + requirements = {path.name[:-len('_test.py')]: read_requirements(path) for path in test_paths} + tests = set(requirements) + paths = sorted((context.repository / 'testsuite' / 'profiles').glob('*.json')) + profiles = [load_profile(path, tests) for path in paths] + if not any(profile.peer for profile in profiles) or not any(profile.unsupported for profile in profiles): + test_fail('profile set is incomplete') + features = {feature for values in requirements.values() if values for feature in values['features']} + for profile in profiles: + unknown = set(profile.unsupported) - features + if unknown: + test_fail(f'{profile.name} permits unknown capabilities: {", ".join(sorted(unknown))}') + peers = {profile.peer for profile in profiles if profile.peer} + archived = {path.name[len('rsync_'):] for path in (context.repository / 'old_versions').glob('rsync_*')} + if archived and peers != archived: + test_fail('peer profiles do not match archived binaries') + by_name = {profile.name: profile for profile in profiles} + environment = [by_name[name] for name in ('linux', 'non-asan', 'pipe', 'root', 'self-peer')] + peer, protocol, unsupported, xfail = merge_profiles(environment) + if peer or protocol or not unsupported or xfail: + test_fail('platform profile composition failed') + for profile in profiles: + if profile.peer and any(not applies_to_peer(requirements[name], profile.peer, profile.protocol) + for name in profile.xfail): + test_fail(f'{profile.name} has an inapplicable deviation') + if parse_peer_banner('rsync version 3.5.1-gabc protocol version 33') != ('3.5.1', 33): + test_fail('version banner was not parsed') + + def reject(name, data): + path = context.scratch / name + path.write_text(json.dumps(data)) + try: + load_profile(path, tests) + except ValueError: + return + test_fail(f'{name} was accepted') + + reject('field.json', {'unknown': {}}) + reject('object.json', []) + reject('peer.json', {'peer': ''}) + reject('peer-format.json', {'peer': 'current', 'protocol': 33}) + reject('missing-protocol.json', {'peer': '3.4.1'}) + reject('wrong-name.json', {'peer': '3.4.1', 'protocol': 32}) + reject('protocol.json', {'protocol': True}) + reject('unsupported.json', {'unsupported': {'acl': ''}}) + reject('xfail.json', {'xfail': {'missing-test': 'issue-1'}}) + + +if __name__ == '__main__': + run(test) diff --git a/testsuite/protected-regular_test.py b/testsuite/tests/protected-regular_test.py similarity index 91% rename from testsuite/protected-regular_test.py rename to testsuite/tests/protected-regular_test.py index 4094534b2..c4d774f81 100644 --- a/testsuite/protected-regular_test.py +++ b/testsuite/tests/protected-regular_test.py @@ -17,14 +17,15 @@ pr_path = Path('/proc/sys/fs/protected_regular') if not pr_path.is_file(): - test_skipped("Can't find protected_regular setting (only available on Linux)") + test_skipped("Can't find protected_regular setting (only available on Linux)", + capability='protected_regular') try: pr_lvl = pr_path.read_text().strip() except OSError: - test_skipped("Can't check if fs.protected_regular is enabled") + test_skipped("Can't check if fs.protected_regular is enabled", capability='protected_regular') if pr_lvl == '0': - test_skipped("fs.protected_regular is not enabled") + test_skipped("fs.protected_regular is not enabled", capability='protected_regular') workdir = TMPDIR / 'files' workdir.mkdir(parents=True, exist_ok=True) @@ -59,7 +60,7 @@ def _chown_5001(path: Path) -> bool: timeout=5, ) except subprocess.TimeoutExpired: - test_skipped("Can't chown (unshare probe timed out)") + test_skipped("Can't chown (unshare probe timed out)", capability='protected_regular') if probe.returncode == 0: print("Re-running under unshare with UID mapping...") env = os.environ.copy() @@ -71,7 +72,7 @@ def _chown_5001(path: Path) -> bool: sys.executable, __file__], env, ) - test_skipped("Can't chown (need root or unshare with uidmap)") + test_skipped("Can't chown (need root or unshare with uidmap)", capability='protected_regular') print(f"Contents of {workdir}:") subprocess.run(['ls', '-al', str(workdir)]) diff --git a/testsuite/proto-cleared-dirflist_test.py b/testsuite/tests/proto-cleared-dirflist_test.py similarity index 100% rename from testsuite/proto-cleared-dirflist_test.py rename to testsuite/tests/proto-cleared-dirflist_test.py diff --git a/testsuite/proto-cleared-ndx_test.py b/testsuite/tests/proto-cleared-ndx_test.py similarity index 100% rename from testsuite/proto-cleared-ndx_test.py rename to testsuite/tests/proto-cleared-ndx_test.py diff --git a/testsuite/proto-hlink-flag-oob_test.py b/testsuite/tests/proto-hlink-flag-oob_test.py similarity index 100% rename from testsuite/proto-hlink-flag-oob_test.py rename to testsuite/tests/proto-hlink-flag-oob_test.py diff --git a/testsuite/proto-hlink-gnum_test.py b/testsuite/tests/proto-hlink-gnum_test.py similarity index 100% rename from testsuite/proto-hlink-gnum_test.py rename to testsuite/tests/proto-hlink-gnum_test.py diff --git a/testsuite/proto-msg-info-assert_test.py b/testsuite/tests/proto-msg-info-assert_test.py similarity index 100% rename from testsuite/proto-msg-info-assert_test.py rename to testsuite/tests/proto-msg-info-assert_test.py diff --git a/testsuite/proto-parent-ndx-empty-dirflist_test.py b/testsuite/tests/proto-parent-ndx-empty-dirflist_test.py similarity index 100% rename from testsuite/proto-parent-ndx-empty-dirflist_test.py rename to testsuite/tests/proto-parent-ndx-empty-dirflist_test.py diff --git a/testsuite/proto-sender-selftest_test.py b/testsuite/tests/proto-sender-selftest_test.py similarity index 100% rename from testsuite/proto-sender-selftest_test.py rename to testsuite/tests/proto-sender-selftest_test.py diff --git a/testsuite/proto-subflist-freed_test.py b/testsuite/tests/proto-subflist-freed_test.py similarity index 100% rename from testsuite/proto-subflist-freed_test.py rename to testsuite/tests/proto-subflist-freed_test.py diff --git a/testsuite/proxy-connect-request-too-long_test.py b/testsuite/tests/proxy-connect-request-too-long_test.py similarity index 100% rename from testsuite/proxy-connect-request-too-long_test.py rename to testsuite/tests/proxy-connect-request-too-long_test.py diff --git a/testsuite/proxy-host-crlf_test.py b/testsuite/tests/proxy-host-crlf_test.py similarity index 100% rename from testsuite/proxy-host-crlf_test.py rename to testsuite/tests/proxy-host-crlf_test.py diff --git a/testsuite/proxy-protocol-trusted-peer_test.py b/testsuite/tests/proxy-protocol-trusted-peer_test.py similarity index 100% rename from testsuite/proxy-protocol-trusted-peer_test.py rename to testsuite/tests/proxy-protocol-trusted-peer_test.py diff --git a/testsuite/proxy-response-header-too-long_test.py b/testsuite/tests/proxy-response-header-too-long_test.py similarity index 100% rename from testsuite/proxy-response-header-too-long_test.py rename to testsuite/tests/proxy-response-header-too-long_test.py diff --git a/testsuite/proxy-response-line-too-long_test.py b/testsuite/tests/proxy-response-line-too-long_test.py similarity index 100% rename from testsuite/proxy-response-line-too-long_test.py rename to testsuite/tests/proxy-response-line-too-long_test.py diff --git a/testsuite/prune-empty-dirs_test.py b/testsuite/tests/prune-empty-dirs_test.py similarity index 100% rename from testsuite/prune-empty-dirs_test.py rename to testsuite/tests/prune-empty-dirs_test.py diff --git a/testsuite/pseudo-paths-daemon_test.py b/testsuite/tests/pseudo-paths-daemon_test.py similarity index 98% rename from testsuite/pseudo-paths-daemon_test.py rename to testsuite/tests/pseudo-paths-daemon_test.py index f6d2d869e..f63a72ce6 100644 --- a/testsuite/pseudo-paths-daemon_test.py +++ b/testsuite/tests/pseudo-paths-daemon_test.py @@ -13,12 +13,12 @@ from rsyncfns import makepath, rmtree, rsync_argv, test_fail, test_skipped if not sys.platform.startswith('linux'): - test_skipped('Namespace daemon testing is a Linux-specific feature') + test_skipped('Namespace daemon testing is a Linux-specific feature', capability='proc_fd') raise SystemExit(0) bash = shutil.which('bash') if not bash: - test_skipped('bash is not installed') + test_skipped('bash is not installed', capability='proc_fd') raise SystemExit(0) # runtests.py exports POSIXLY_CORRECT=1, and bash before 5.1 disables process @@ -26,7 +26,7 @@ bash_env = {k: v for k, v in os.environ.items() if k != 'POSIXLY_CORRECT'} probe_bash = subprocess.run([bash, '-c', 'echo "probe" > >(cat > /dev/null)'], env=bash_env, capture_output=True) if probe_bash.returncode != 0: - test_skipped('bash process substitution is not supported on this system') + test_skipped('bash process substitution is not supported on this system', capability='proc_fd') raise SystemExit(0) def kill_daemon(proc): diff --git a/testsuite/pseudo-paths_test.py b/testsuite/tests/pseudo-paths_test.py similarity index 99% rename from testsuite/pseudo-paths_test.py rename to testsuite/tests/pseudo-paths_test.py index 2c0a5b25c..ee474ee7f 100644 --- a/testsuite/pseudo-paths_test.py +++ b/testsuite/tests/pseudo-paths_test.py @@ -12,14 +12,14 @@ SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail, test_skipped, ) if not sys.platform.startswith('linux'): - test_skipped('Kernel pseudo-path string is a Linux-specific procfs feature') + test_skipped('Kernel pseudo-path string is a Linux-specific procfs feature', capability='proc_fd') raise SystemExit(0) # We require bash specifically because standard POSIX /bin/sh does not # guarantee support for >(...) process substitution syntax. bash = shutil.which('bash') if bash is None: - test_skipped('bash is unavailable, cannot test process substitution') + test_skipped('bash is unavailable, cannot test process substitution', capability='proc_fd') # runtests.py exports POSIXLY_CORRECT=1, and bash before 5.1 disables process # substitution in POSIX mode -- the very syntax bash is required for here. @@ -31,7 +31,7 @@ env=bash_env, ) if probe.returncode != 0: - test_skipped('bash process substitution is not supported on this system') + test_skipped('bash process substitution is not supported on this system', capability='proc_fd') base = Path(SCRATCHDIR / 'rsync-pseudo-path').resolve() src = base / 'src' diff --git a/testsuite/read-batch-pipe_test.py b/testsuite/tests/read-batch-pipe_test.py similarity index 95% rename from testsuite/read-batch-pipe_test.py rename to testsuite/tests/read-batch-pipe_test.py index 505678a7e..1a00bf50c 100644 --- a/testsuite/read-batch-pipe_test.py +++ b/testsuite/tests/read-batch-pipe_test.py @@ -13,11 +13,11 @@ # We require bash specifically because standard POSIX /bin/sh does not # guarantee support for <(...) process substitution syntax. if not sys.platform.startswith('linux'): - test_skipped('This test requires Linux platform') + test_skipped('This test requires Linux platform', capability='proc_fd') bash = shutil.which('bash') if bash is None: - test_skipped('bash is unavailable, cannot test process substitution') + test_skipped('bash is unavailable, cannot test process substitution', capability='proc_fd') # runtests.py exports POSIXLY_CORRECT=1, and bash before 5.1 disables process # substitution in POSIX mode -- the very syntax bash is required for here. @@ -29,7 +29,7 @@ capture_output=True) if probe.returncode != 0: - test_skipped('bash process substitution is not supported on this system') + test_skipped('bash process substitution is not supported on this system', capability='proc_fd') base = Path(SCRATCHDIR / 'rsync-batch-fifo') src = base / 'src' diff --git a/testsuite/readonly-partial-abort-mode-regression_test.py b/testsuite/tests/readonly-partial-abort-mode-regression_test.py similarity index 97% rename from testsuite/readonly-partial-abort-mode-regression_test.py rename to testsuite/tests/readonly-partial-abort-mode-regression_test.py index 92eac9479..05cc31e26 100644 --- a/testsuite/readonly-partial-abort-mode-regression_test.py +++ b/testsuite/tests/readonly-partial-abort-mode-regression_test.py @@ -16,7 +16,7 @@ import sys import rsync_proto as rp -from rsyncfns import SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail +from rsyncfns import SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail, test_skipped CF_INPLACE_PARTIAL_DIR = 1 << 6 @@ -101,8 +101,7 @@ def fake_sender(basis_type): if os.geteuid() == 0: - print("SKIP: root bypasses the read-only output-file precondition") - sys.exit(77) + test_skipped("root bypasses the read-only output-file precondition", capability='nonroot') base = SCRATCHDIR / "readonly-partial-abort-mode-regression" rmtree(base) diff --git a/testsuite/tests/receipt_test.py b/testsuite/tests/receipt_test.py new file mode 100644 index 000000000..2beb30f9b --- /dev/null +++ b/testsuite/tests/receipt_test.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Runner receipt contract""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +from harness import Exit, TestContext, parse_peer_banner, requires, run +from rsyncfns import RSYNC, split_rsync_cmd, test_fail + + +@requires(features={'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe'}, min_peer='2.6.0', mutates={'filesystem', 'process'}, tags={'harness'}) +def test(context: TestContext): + receipt = context.scratch / 'run.json' + env = os.environ.copy() + env['scratchbase'] = str(context.scratch / 'nested') + binary = next(part for part in split_rsync_cmd(RSYNC) if Path(part).is_file()) + banner = subprocess.run([binary, '--version'], capture_output=True, text=True, check=True).stdout + peer, protocol = parse_peer_banner(banner) + profile = context.scratch / f'peer-{peer}.json' + command = [sys.executable, str(context.repository / 'testsuite' / 'runtests.py'), 'smoke', + f'--rsync-bin={binary}', f'--rsync-bin2={binary}', f'--tooldir={context.tools}', + f'--srcdir={context.repository}', f'--profiles={profile}', f'--receipt={receipt}'] + profile.write_text(json.dumps({'peer': peer, 'protocol': protocol + 1})) + mismatch = subprocess.run(command, env=env, capture_output=True, text=True) + if mismatch.returncode != Exit.ERROR or 'profile protocol' not in mismatch.stderr: + test_fail('peer protocol mismatch was not rejected') + profile.write_text(json.dumps({'peer': peer, 'protocol': protocol, + 'xfail': {'alt-dest': 'test-fixture'}})) + result = subprocess.run(command, env=env, capture_output=True, text=True) + if result.returncode: + test_fail(f'nested runner failed: {result.stdout}{result.stderr}') + data = json.loads(receipt.read_text()) + if (data['summary']['exit_code'] or data['run']['selected'] != ['smoke'] + or data['run']['peer_version'] != peer or data['run']['protocol'] != protocol + or data['run']['profiles'] != [f'peer-{peer}'] + or data['summary']['verdicts'] != {'pass': 1}): + test_fail('receipt summary or selection is wrong') + if (data['tests'][0]['name'] != 'smoke' or data['tests'][0]['outcome'] != 'pass' + or data['tests'][0]['expected'] != 'pass'): + test_fail('receipt test result is wrong') + + +if __name__ == '__main__': + run(test) diff --git a/testsuite/recv-discard-nullderef_test.py b/testsuite/tests/recv-discard-nullderef_test.py similarity index 97% rename from testsuite/recv-discard-nullderef_test.py rename to testsuite/tests/recv-discard-nullderef_test.py index f7d0ee43d..73d6778dc 100755 --- a/testsuite/recv-discard-nullderef_test.py +++ b/testsuite/tests/recv-discard-nullderef_test.py @@ -46,7 +46,7 @@ if get_testuid() == get_rootuid(): test_skipped("root bypasses DAC: the unwritable dest dir wouldn't make " "the receiver's mkstemp fail, so the discard path (and the " - "bug) is never reached") + "bug) is never reached", capability='nonroot') os.chdir(TMPDIR) @@ -85,7 +85,8 @@ os.unlink(_probe) os.chmod(BASISDIR, 0o755) test_skipped("destination dir is writable despite chmod 0555 " - "(CAP_DAC_OVERRIDE?); cannot force the receiver discard path") + "(CAP_DAC_OVERRIDE?); cannot force the receiver discard path", + capability='dac_restriction') except OSError: pass # EACCES -- good, the precondition is enforced diff --git a/testsuite/recv-generator-acl-leak_test.py b/testsuite/tests/recv-generator-acl-leak_test.py similarity index 100% rename from testsuite/recv-generator-acl-leak_test.py rename to testsuite/tests/recv-generator-acl-leak_test.py diff --git a/testsuite/relative-content_test.py b/testsuite/tests/relative-content_test.py similarity index 100% rename from testsuite/relative-content_test.py rename to testsuite/tests/relative-content_test.py diff --git a/testsuite/relative-implied-symlink_test.py b/testsuite/tests/relative-implied-symlink_test.py similarity index 100% rename from testsuite/relative-implied-symlink_test.py rename to testsuite/tests/relative-implied-symlink_test.py diff --git a/testsuite/relative-implied_test.py b/testsuite/tests/relative-implied_test.py similarity index 100% rename from testsuite/relative-implied_test.py rename to testsuite/tests/relative-implied_test.py diff --git a/testsuite/relative-mkpath-dir-symlink_test.py b/testsuite/tests/relative-mkpath-dir-symlink_test.py similarity index 100% rename from testsuite/relative-mkpath-dir-symlink_test.py rename to testsuite/tests/relative-mkpath-dir-symlink_test.py diff --git a/testsuite/relative-mkpath-symlink_test.py b/testsuite/tests/relative-mkpath-symlink_test.py similarity index 100% rename from testsuite/relative-mkpath-symlink_test.py rename to testsuite/tests/relative-mkpath-symlink_test.py diff --git a/testsuite/relative-source-ancestor_test.py b/testsuite/tests/relative-source-ancestor_test.py similarity index 100% rename from testsuite/relative-source-ancestor_test.py rename to testsuite/tests/relative-source-ancestor_test.py diff --git a/testsuite/relative-symlinked-parent-dotdot_test.py b/testsuite/tests/relative-symlinked-parent-dotdot_test.py similarity index 100% rename from testsuite/relative-symlinked-parent-dotdot_test.py rename to testsuite/tests/relative-symlinked-parent-dotdot_test.py diff --git a/testsuite/relative-symlinked-parent_test.py b/testsuite/tests/relative-symlinked-parent_test.py similarity index 100% rename from testsuite/relative-symlinked-parent_test.py rename to testsuite/tests/relative-symlinked-parent_test.py diff --git a/testsuite/relative_test.py b/testsuite/tests/relative_test.py similarity index 100% rename from testsuite/relative_test.py rename to testsuite/tests/relative_test.py diff --git a/testsuite/remote-logging_test.py b/testsuite/tests/remote-logging_test.py similarity index 100% rename from testsuite/remote-logging_test.py rename to testsuite/tests/remote-logging_test.py diff --git a/testsuite/remote-shell-newline-escaping_test.py b/testsuite/tests/remote-shell-newline-escaping_test.py similarity index 100% rename from testsuite/remote-shell-newline-escaping_test.py rename to testsuite/tests/remote-shell-newline-escaping_test.py diff --git a/testsuite/rename-fullpath-symlink-race_test.py b/testsuite/tests/rename-fullpath-symlink-race_test.py similarity index 100% rename from testsuite/rename-fullpath-symlink-race_test.py rename to testsuite/tests/rename-fullpath-symlink-race_test.py diff --git a/testsuite/rename-mixed-parent-escape-poc_test.py b/testsuite/tests/rename-mixed-parent-escape-poc_test.py similarity index 100% rename from testsuite/rename-mixed-parent-escape-poc_test.py rename to testsuite/tests/rename-mixed-parent-escape-poc_test.py diff --git a/testsuite/rename-mixed-parent-symlink-race_test.py b/testsuite/tests/rename-mixed-parent-symlink-race_test.py similarity index 100% rename from testsuite/rename-mixed-parent-symlink-race_test.py rename to testsuite/tests/rename-mixed-parent-symlink-race_test.py diff --git a/testsuite/rename-mixed-parent-transfer_test.py b/testsuite/tests/rename-mixed-parent-transfer_test.py similarity index 98% rename from testsuite/rename-mixed-parent-transfer_test.py rename to testsuite/tests/rename-mixed-parent-transfer_test.py index a766ab17c..8b564cd39 100644 --- a/testsuite/rename-mixed-parent-transfer_test.py +++ b/testsuite/tests/rename-mixed-parent-transfer_test.py @@ -46,10 +46,11 @@ # Operator paths follow a uid0/euid-owned symlink and refuse a foreign-owned one, # so the escape vector is an ATTACKER-owned backup-dir symlink and a root daemon. if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') DAEMON_PORT = 12903 diff --git a/testsuite/tests/results_test.py b/testsuite/tests/results_test.py new file mode 100644 index 000000000..ef10270c7 --- /dev/null +++ b/testsuite/tests/results_test.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Result normalisation and receipt writing""" + +import json + +from harness import Exit, Outcome, TestContext, TestResult, requires, run, verdict_of, write_receipt +from rsyncfns import test_fail + + +@requires(protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe'}, min_peer='2.6.0', tags={'harness'}) +def test(context: TestContext): + cases = ( + (Exit.PASS, '', Outcome.PASS), (Exit.FAIL, '', Outcome.FAIL), + (Exit.ERROR, '', Outcome.ERROR), (Exit.SKIP, '', Outcome.SKIP), + (Exit.SKIP, 'acl', Outcome.UNSUPPORTED), (Exit.XFAIL, '', Outcome.XFAIL), + ) + for exit_code, unsupported, expected in cases: + result = TestResult('case', exit_code, unsupported=unsupported) + if result.outcome != expected: + test_fail(f'{exit_code} normalised as {result.outcome}') + + verdicts = ( + (Outcome.PASS, 'xfail:issue-1', Outcome.XPASS), + (Outcome.FAIL, 'xfail:issue-1', Outcome.XFAIL), + (Outcome.UNSUPPORTED, 'pass', Outcome.PROFILE_ERROR), + (Outcome.UNSUPPORTED, 'unsupported:acl', Outcome.UNSUPPORTED), + ) + for outcome, expected, verdict in verdicts: + if verdict_of(outcome, expected) != verdict: + test_fail(f'{outcome} against {expected} produced the wrong verdict') + if TestResult('case', Exit.SKIP, unsupported='acl').record()['verdict'] != 'unsupported': + test_fail('unprofiled unsupported result was reclassified') + + path = context.scratch / 'receipt.json' + write_receipt(path, {'schema': 1, 'tests': [TestResult('case', Exit.PASS).record('pass')]}) + data = json.loads(path.read_text()) + if data['tests'][0]['outcome'] != 'pass' or list(context.scratch.glob('.*.tmp')): + test_fail('receipt write was incomplete') + + +if __name__ == '__main__': + run(test) diff --git a/testsuite/reverse-daemon-delta_test.py b/testsuite/tests/reverse-daemon-delta_test.py similarity index 95% rename from testsuite/reverse-daemon-delta_test.py rename to testsuite/tests/reverse-daemon-delta_test.py index a2d57c7f8..5c557d154 100644 --- a/testsuite/reverse-daemon-delta_test.py +++ b/testsuite/tests/reverse-daemon-delta_test.py @@ -30,6 +30,9 @@ FROMDIR, RSYNC, RSYNC_PEER, TMPDIR, build_rsyncd_conf, makepath, make_data_file, start_test_daemon, test_fail, split_rsync_cmd, ) +from harness import metadata + +metadata(features={'compression', 'daemon', 'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'compatibility', 'daemon', 'transfer', 'version-mix'}) DAEMON_PORT = 12894 FILESIZE = 512 * 1024 # big enough that delta savings are unambiguous diff --git a/testsuite/rrsync-alt-dest-inband-pivot_test.py b/testsuite/tests/rrsync-alt-dest-inband-pivot_test.py similarity index 100% rename from testsuite/rrsync-alt-dest-inband-pivot_test.py rename to testsuite/tests/rrsync-alt-dest-inband-pivot_test.py diff --git a/testsuite/rrsync-archive-mode_test.py b/testsuite/tests/rrsync-archive-mode_test.py similarity index 100% rename from testsuite/rrsync-archive-mode_test.py rename to testsuite/tests/rrsync-archive-mode_test.py diff --git a/testsuite/rrsync-backup-dir-inband-pivot_test.py b/testsuite/tests/rrsync-backup-dir-inband-pivot_test.py similarity index 100% rename from testsuite/rrsync-backup-dir-inband-pivot_test.py rename to testsuite/tests/rrsync-backup-dir-inband-pivot_test.py diff --git a/testsuite/rrsync-copy-unsafe-links-denied_test.py b/testsuite/tests/rrsync-copy-unsafe-links-denied_test.py similarity index 100% rename from testsuite/rrsync-copy-unsafe-links-denied_test.py rename to testsuite/tests/rrsync-copy-unsafe-links-denied_test.py diff --git a/testsuite/rrsync-debug-denied_test.py b/testsuite/tests/rrsync-debug-denied_test.py similarity index 100% rename from testsuite/rrsync-debug-denied_test.py rename to testsuite/tests/rrsync-debug-denied_test.py diff --git a/testsuite/rrsync-files-from-stdin_test.py b/testsuite/tests/rrsync-files-from-stdin_test.py similarity index 100% rename from testsuite/rrsync-files-from-stdin_test.py rename to testsuite/tests/rrsync-files-from-stdin_test.py diff --git a/testsuite/rrsync-logfile-symlink_test.py b/testsuite/tests/rrsync-logfile-symlink_test.py similarity index 100% rename from testsuite/rrsync-logfile-symlink_test.py rename to testsuite/tests/rrsync-logfile-symlink_test.py diff --git a/testsuite/rrsync-merge-file-confine_test.py b/testsuite/tests/rrsync-merge-file-confine_test.py similarity index 100% rename from testsuite/rrsync-merge-file-confine_test.py rename to testsuite/tests/rrsync-merge-file-confine_test.py diff --git a/testsuite/rrsync-no-overwrite-backup-collision_test.py b/testsuite/tests/rrsync-no-overwrite-backup-collision_test.py similarity index 100% rename from testsuite/rrsync-no-overwrite-backup-collision_test.py rename to testsuite/tests/rrsync-no-overwrite-backup-collision_test.py diff --git a/testsuite/rrsync-no-overwrite-delay-updates_test.py b/testsuite/tests/rrsync-no-overwrite-delay-updates_test.py similarity index 100% rename from testsuite/rrsync-no-overwrite-delay-updates_test.py rename to testsuite/tests/rrsync-no-overwrite-delay-updates_test.py diff --git a/testsuite/rrsync-no-overwrite-logfile_test.py b/testsuite/tests/rrsync-no-overwrite-logfile_test.py similarity index 100% rename from testsuite/rrsync-no-overwrite-logfile_test.py rename to testsuite/tests/rrsync-no-overwrite-logfile_test.py diff --git a/testsuite/rrsync-no-overwrite-partial-dir_test.py b/testsuite/tests/rrsync-no-overwrite-partial-dir_test.py similarity index 100% rename from testsuite/rrsync-no-overwrite-partial-dir_test.py rename to testsuite/tests/rrsync-no-overwrite-partial-dir_test.py diff --git a/testsuite/rrsync-pull-arg-shapes_test.py b/testsuite/tests/rrsync-pull-arg-shapes_test.py similarity index 100% rename from testsuite/rrsync-pull-arg-shapes_test.py rename to testsuite/tests/rrsync-pull-arg-shapes_test.py diff --git a/testsuite/rrsync-pull-delivers-content_test.py b/testsuite/tests/rrsync-pull-delivers-content_test.py similarity index 100% rename from testsuite/rrsync-pull-delivers-content_test.py rename to testsuite/tests/rrsync-pull-delivers-content_test.py diff --git a/testsuite/rrsync-root-relative-paths_test.py b/testsuite/tests/rrsync-root-relative-paths_test.py similarity index 100% rename from testsuite/rrsync-root-relative-paths_test.py rename to testsuite/tests/rrsync-root-relative-paths_test.py diff --git a/testsuite/rrsync-sender-leaf-flip_test.py b/testsuite/tests/rrsync-sender-leaf-flip_test.py similarity index 99% rename from testsuite/rrsync-sender-leaf-flip_test.py rename to testsuite/tests/rrsync-sender-leaf-flip_test.py index e228fcce8..cba69b407 100644 --- a/testsuite/rrsync-sender-leaf-flip_test.py +++ b/testsuite/tests/rrsync-sender-leaf-flip_test.py @@ -43,7 +43,7 @@ if not proc_self_fd_pins(): test_skipped("rrsync's realpath-vs-exec inode-pin needs /proc/self/fd " - "(Linux); unhardened fallback elsewhere by design") + "(Linux); unhardened fallback elsewhere by design", capability='proc_fd') MARKER = 'AUDIT_RRSYNC_LEAF_FLIP_MARKER_DO_NOT_DISCLOSE' diff --git a/testsuite/rrsync-sender-parent-pin_test.py b/testsuite/tests/rrsync-sender-parent-pin_test.py similarity index 98% rename from testsuite/rrsync-sender-parent-pin_test.py rename to testsuite/tests/rrsync-sender-parent-pin_test.py index a1a09fec8..cadccb65c 100644 --- a/testsuite/rrsync-sender-parent-pin_test.py +++ b/testsuite/tests/rrsync-sender-parent-pin_test.py @@ -39,7 +39,7 @@ if not proc_self_fd_pins(): test_skipped("rrsync's inode pin needs /proc/self/fd (Linux); the " - "unhardened fallback elsewhere is by design") + "unhardened fallback elsewhere is by design", capability='proc_fd') MARKER = 'AUDIT_PARENT_PIN_MARKER_DO_NOT_DISCLOSE' diff --git a/testsuite/rrsync-specials-denied_test.py b/testsuite/tests/rrsync-specials-denied_test.py similarity index 100% rename from testsuite/rrsync-specials-denied_test.py rename to testsuite/tests/rrsync-specials-denied_test.py diff --git a/testsuite/rrsync-symlink_test.py b/testsuite/tests/rrsync-symlink_test.py similarity index 99% rename from testsuite/rrsync-symlink_test.py rename to testsuite/tests/rrsync-symlink_test.py index e69d295df..4952831f8 100644 --- a/testsuite/rrsync-symlink_test.py +++ b/testsuite/tests/rrsync-symlink_test.py @@ -76,7 +76,7 @@ # closed and this test cannot pass -- skip rather than report the intended gap. if not proc_self_fd_pins(): test_skipped("rrsync's realpath-vs-exec inode-pin needs /proc/self/fd " - "(Linux); unhardened fallback elsewhere by design") + "(Linux); unhardened fallback elsewhere by design", capability='proc_fd') MARKER = b"AUDIT_RRSYNC_RACE_LEAK_MARKER_DO_NOT_DISCLOSE" diff --git a/testsuite/rrsync-userns-procfs_test.py b/testsuite/tests/rrsync-userns-procfs_test.py similarity index 90% rename from testsuite/rrsync-userns-procfs_test.py rename to testsuite/tests/rrsync-userns-procfs_test.py index 7bd1cdb06..96c9315fa 100644 --- a/testsuite/rrsync-userns-procfs_test.py +++ b/testsuite/tests/rrsync-userns-procfs_test.py @@ -13,20 +13,20 @@ if not sys.platform.startswith('linux'): - test_skipped('rrsync-userns-procfs is Linux-specific') + test_skipped('rrsync-userns-procfs is Linux-specific', capability='user_namespace') if not os.environ.get('RSYNC_USERNS_PROCFS'): unshare = shutil.which('unshare') if unshare is None: - test_skipped('unshare is unavailable') + test_skipped('unshare is unavailable', capability='user_namespace') env = os.environ.copy() env['RSYNC_USERNS_PROCFS'] = '1' launch_dir = Path(tempfile.mkdtemp(prefix='rsync-userns-launch-')) launch_dir.chmod(0o755) - testdir = Path(__file__).resolve().parent - child_test = launch_dir / Path(__file__).name - for source in (Path(__file__), testdir / 'rsyncfns.py', - testdir / 'exitcodes.py'): + testfile = Path(__file__).resolve() + suitedir = testfile.parent.parent + child_test = launch_dir / testfile.name + for source in (testfile, suitedir / 'rsyncfns.py', suitedir / 'exitcodes.py'): shutil.copy2(source, launch_dir / source.name) rsync_cmd = shlex.split(env['RSYNC']) for i, arg in enumerate(rsync_cmd): @@ -44,7 +44,8 @@ if os.geteuid() == 0: setpriv = shutil.which('setpriv') if setpriv is None: - test_skipped('setpriv is unavailable for the root-run testsuite') + test_skipped('setpriv is unavailable for the root-run testsuite', + capability='user_namespace') launcher = [setpriv, '--reuid=65534', '--regid=65534', '--clear-groups'] unshare_argv = [unshare, '--user', '--map-root-user', '--mount', '--pid', '--fork', '--mount-proc'] @@ -76,7 +77,8 @@ proc_uid = os.lstat('/proc/self').st_uid if proc_uid in (0, os.geteuid()): - test_skipped('/proc/self does not expose an overflow uid in this namespace') + test_skipped('/proc/self does not expose an overflow uid in this namespace', + capability='user_namespace') base = Path(tempfile.mkdtemp(prefix='rsync-userns-procfs-')) root = base / 'root' diff --git a/testsuite/rrsync-write-only-files-from_test.py b/testsuite/tests/rrsync-write-only-files-from_test.py similarity index 100% rename from testsuite/rrsync-write-only-files-from_test.py rename to testsuite/tests/rrsync-write-only-files-from_test.py diff --git a/testsuite/rsync-ssl-hostname-validation_test.py b/testsuite/tests/rsync-ssl-hostname-validation_test.py similarity index 100% rename from testsuite/rsync-ssl-hostname-validation_test.py rename to testsuite/tests/rsync-ssl-hostname-validation_test.py diff --git a/testsuite/rsync-ssl-openssl-hostname-check_test.py b/testsuite/tests/rsync-ssl-openssl-hostname-check_test.py similarity index 100% rename from testsuite/rsync-ssl-openssl-hostname-check_test.py rename to testsuite/tests/rsync-ssl-openssl-hostname-check_test.py diff --git a/testsuite/rsync-ssl-stunnel-ca-required_test.py b/testsuite/tests/rsync-ssl-stunnel-ca-required_test.py similarity index 100% rename from testsuite/rsync-ssl-stunnel-ca-required_test.py rename to testsuite/tests/rsync-ssl-stunnel-ca-required_test.py diff --git a/testsuite/rsync-ssl-stunnel-hostname-check_test.py b/testsuite/tests/rsync-ssl-stunnel-hostname-check_test.py similarity index 100% rename from testsuite/rsync-ssl-stunnel-hostname-check_test.py rename to testsuite/tests/rsync-ssl-stunnel-hostname-check_test.py diff --git a/testsuite/rsync-ssl-type-option_test.py b/testsuite/tests/rsync-ssl-type-option_test.py similarity index 100% rename from testsuite/rsync-ssl-type-option_test.py rename to testsuite/tests/rsync-ssl-type-option_test.py diff --git a/testsuite/safe-arg-leak_test.py b/testsuite/tests/safe-arg-leak_test.py similarity index 100% rename from testsuite/safe-arg-leak_test.py rename to testsuite/tests/safe-arg-leak_test.py diff --git a/testsuite/safe-links-absolute-intree_test.py b/testsuite/tests/safe-links-absolute-intree_test.py similarity index 100% rename from testsuite/safe-links-absolute-intree_test.py rename to testsuite/tests/safe-links-absolute-intree_test.py diff --git a/testsuite/safe-links-unsafe-def_test.py b/testsuite/tests/safe-links-unsafe-def_test.py similarity index 100% rename from testsuite/safe-links-unsafe-def_test.py rename to testsuite/tests/safe-links-unsafe-def_test.py diff --git a/testsuite/safe-links_test.py b/testsuite/tests/safe-links_test.py similarity index 100% rename from testsuite/safe-links_test.py rename to testsuite/tests/safe-links_test.py diff --git a/testsuite/scanner-argv-bounds_test.py b/testsuite/tests/scanner-argv-bounds_test.py similarity index 100% rename from testsuite/scanner-argv-bounds_test.py rename to testsuite/tests/scanner-argv-bounds_test.py diff --git a/testsuite/scanner-batch-flag-mismatch_test.py b/testsuite/tests/scanner-batch-flag-mismatch_test.py similarity index 90% rename from testsuite/scanner-batch-flag-mismatch_test.py rename to testsuite/tests/scanner-batch-flag-mismatch_test.py index 58be95146..611f90b09 100644 --- a/testsuite/scanner-batch-flag-mismatch_test.py +++ b/testsuite/tests/scanner-batch-flag-mismatch_test.py @@ -12,11 +12,11 @@ ) if not xattrs_supported(): - test_skipped("needs xattr support to write an -X batch") + test_skipped("needs xattr support to write an -X batch", capability='xattr_runtime') proto = forced_protocol() if proto is not None and proto < 30: - test_skipped("xattrs (-X) need protocol 30+") + test_skipped("xattrs (-X) need protocol 30+", capability='protocol_30') src = FROMDIR rmtree(src) @@ -31,7 +31,8 @@ try: xattr_set('user.m', 'v', f) except Exception as e: - test_skipped(f"cannot set a user xattr on a regular file here: {e}") + test_skipped(f"cannot set a user xattr on a regular file here: {e}", + capability='xattr_runtime') batch = SCRATCHDIR / 'xbatch' wb_dest = SCRATCHDIR / 'wb-dest' diff --git a/testsuite/scanner-daemon-log-checksum_test.py b/testsuite/tests/scanner-daemon-log-checksum_test.py similarity index 100% rename from testsuite/scanner-daemon-log-checksum_test.py rename to testsuite/tests/scanner-daemon-log-checksum_test.py diff --git a/testsuite/scanner-delete-delay-overread_test.py b/testsuite/tests/scanner-delete-delay-overread_test.py similarity index 100% rename from testsuite/scanner-delete-delay-overread_test.py rename to testsuite/tests/scanner-delete-delay-overread_test.py diff --git a/testsuite/search-only-destination_test.py b/testsuite/tests/search-only-destination_test.py similarity index 92% rename from testsuite/search-only-destination_test.py rename to testsuite/tests/search-only-destination_test.py index 46108e2f5..bb76a0a7a 100644 --- a/testsuite/search-only-destination_test.py +++ b/testsuite/tests/search-only-destination_test.py @@ -15,13 +15,13 @@ from rsyncfns import SCRATCHDIR, rmtree, rsync_argv, test_fail, test_skipped if not sys.platform.startswith('linux'): - test_skipped('search-only-destination is Linux-specific') + test_skipped('search-only-destination is Linux-specific', capability='search_only') launcher = [] if os.geteuid() == 0: setpriv = shutil.which('setpriv') if setpriv is None: - test_skipped('setpriv is unavailable for the root-run testsuite') + test_skipped('setpriv is unavailable for the root-run testsuite', capability='search_only') launcher = [setpriv, '--reuid=65534', '--regid=65534', '--clear-groups'] external_base = os.geteuid() == 0 @@ -51,7 +51,8 @@ stderr=subprocess.DEVNULL, ) if probe.returncode == 0: - test_skipped('filesystem does not enforce the search-only test mode') + test_skipped('filesystem does not enforce the search-only test mode', + capability='search_only') if probe.returncode != 1: test_fail(f'search-only permission probe failed with exit {probe.returncode}') diff --git a/testsuite/search-only-held-dirfd_test.py b/testsuite/tests/search-only-held-dirfd_test.py similarity index 97% rename from testsuite/search-only-held-dirfd_test.py rename to testsuite/tests/search-only-held-dirfd_test.py index e4da46fbf..c3a2013ad 100644 --- a/testsuite/search-only-held-dirfd_test.py +++ b/testsuite/tests/search-only-held-dirfd_test.py @@ -19,13 +19,13 @@ ) if not sys.platform.startswith('linux'): - test_skipped('search-only held-dirfd coverage is Linux-specific') + test_skipped('search-only held-dirfd coverage is Linux-specific', capability='search_only') launcher = [] if os.geteuid() == 0: setpriv = shutil.which('setpriv') if setpriv is None: - test_skipped('setpriv is unavailable for the root-run testsuite') + test_skipped('setpriv is unavailable for the root-run testsuite', capability='search_only') launcher = [setpriv, '--reuid=65534', '--regid=65534', '--clear-groups'] external_base = os.geteuid() == 0 @@ -81,7 +81,7 @@ def permission_probe(path, flag, expected, label): if proc.returncode != expected: test_skipped( f'filesystem does not enforce {label}: test {flag} returned ' - f'{proc.returncode}, expected {expected}' + f'{proc.returncode}, expected {expected}', capability='search_only' ) diff --git a/testsuite/secure-relpath-validation_test.py b/testsuite/tests/secure-relpath-validation_test.py similarity index 100% rename from testsuite/secure-relpath-validation_test.py rename to testsuite/tests/secure-relpath-validation_test.py diff --git a/testsuite/sender-flist-symlink-leak_test.py b/testsuite/tests/sender-flist-symlink-leak_test.py similarity index 92% rename from testsuite/sender-flist-symlink-leak_test.py rename to testsuite/tests/sender-flist-symlink-leak_test.py index 7b85395a1..11a0312a3 100644 --- a/testsuite/sender-flist-symlink-leak_test.py +++ b/testsuite/tests/sender-flist-symlink-leak_test.py @@ -18,7 +18,9 @@ rsync_argv, get_testuid, get_rootuid, get_rootgid, rmtree, start_test_daemon, test_fail, ) +from harness import metadata +metadata(features={'daemon', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process', 'socket'}, tags={'daemon', 'security', 'version-mix'}) DAEMON_PORT = 12881 diff --git a/testsuite/sender-readlink-atfd_test.py b/testsuite/tests/sender-readlink-atfd_test.py similarity index 100% rename from testsuite/sender-readlink-atfd_test.py rename to testsuite/tests/sender-readlink-atfd_test.py diff --git a/testsuite/sender-remove-source-relative-anchor_test.py b/testsuite/tests/sender-remove-source-relative-anchor_test.py similarity index 100% rename from testsuite/sender-remove-source-relative-anchor_test.py rename to testsuite/tests/sender-remove-source-relative-anchor_test.py diff --git a/testsuite/sender-remove-source-root-anchor_test.py b/testsuite/tests/sender-remove-source-root-anchor_test.py similarity index 97% rename from testsuite/sender-remove-source-root-anchor_test.py rename to testsuite/tests/sender-remove-source-root-anchor_test.py index 61bfae681..a03f4cb9e 100644 --- a/testsuite/sender-remove-source-root-anchor_test.py +++ b/testsuite/tests/sender-remove-source-root-anchor_test.py @@ -23,7 +23,7 @@ ) if os.geteuid() != 0: - test_skipped('needs root to place a transfer source directly under /') + test_skipped('needs root to place a transfer source directly under /', capability='root_anchor') SOURCE_DATA = b'REAL-SOURCE-CONTENT' DECOY_DATA = b'CWD-DECOY-CONTENT!!' @@ -43,7 +43,7 @@ root_src.write_bytes(SOURCE_DATA) except OSError as e: # e.g. macOS's sealed system volume: / is read-only even for root. - test_skipped(f'cannot create a transfer source under /: {e}') + test_skipped(f'cannot create a transfer source under /: {e}', capability='root_anchor') decoy.write_bytes(DECOY_DATA) # Same size and mtime, so the sender's "has it changed?" guard passes and # the decoy is a credible removal target. Nanosecond precision matters: diff --git a/testsuite/sender-remove-source-secure_test.py b/testsuite/tests/sender-remove-source-secure_test.py similarity index 100% rename from testsuite/sender-remove-source-secure_test.py rename to testsuite/tests/sender-remove-source-secure_test.py diff --git a/testsuite/sender-scan-dir-escape_test.py b/testsuite/tests/sender-scan-dir-escape_test.py similarity index 100% rename from testsuite/sender-scan-dir-escape_test.py rename to testsuite/tests/sender-scan-dir-escape_test.py diff --git a/testsuite/simd-checksum_test.py b/testsuite/tests/simd-checksum_test.py similarity index 87% rename from testsuite/simd-checksum_test.py rename to testsuite/tests/simd-checksum_test.py index bae98d1fd..58c7ce756 100644 --- a/testsuite/simd-checksum_test.py +++ b/testsuite/tests/simd-checksum_test.py @@ -13,7 +13,7 @@ simdtest = TOOLDIR / 'simdtest' if not (simdtest.is_file() and os.access(simdtest, os.X_OK)): - test_skipped("simdtest not built (SIMD not available)") + test_skipped("simdtest not built (SIMD not available)", capability='simd') proc = subprocess.run([str(simdtest)]) if proc.returncode != 0: diff --git a/testsuite/size-filter_test.py b/testsuite/tests/size-filter_test.py similarity index 100% rename from testsuite/size-filter_test.py rename to testsuite/tests/size-filter_test.py diff --git a/testsuite/skiplist-spec_test.py b/testsuite/tests/skiplist-spec_test.py similarity index 73% rename from testsuite/skiplist-spec_test.py rename to testsuite/tests/skiplist-spec_test.py index 2fe0dde28..ef4ae4b80 100644 --- a/testsuite/skiplist-spec_test.py +++ b/testsuite/tests/skiplist-spec_test.py @@ -16,7 +16,7 @@ # which would otherwise re-prefix them. SRC = Path(SRCDIR).resolve() -spec = importlib.util.spec_from_file_location('runtests', SRC / 'runtests.py') +spec = importlib.util.spec_from_file_location('runtests', SRC / 'testsuite' / 'runtests.py') runtests = importlib.util.module_from_spec(spec) spec.loader.exec_module(runtests) @@ -45,10 +45,11 @@ def write(name, body): # A stand-in suite whose entries make each malformed name *look* real, so that # removing the guard for it cannot be masked by the "no such test" check. FAKE = SCRATCHDIR / 'fakesuite' -FAKE.mkdir(exist_ok=True) -(FAKE / 'acls sparse_test.py').write_text('') # if the one-name-per-line -(FAKE / 'foo,bar_test.py').write_text('') # ... comma and -(FAKE / 'adir_test.py').mkdir(exist_ok=True) # ... regular-file guards went +FAKE_TESTS = FAKE / 'tests' +FAKE_TESTS.mkdir(parents=True, exist_ok=True) +(FAKE_TESTS / 'acls sparse_test.py').write_text('') # if the one-name-per-line +(FAKE_TESTS / 'foo,bar_test.py').write_text('') # ... comma and +(FAKE_TESTS / 'adir_test.py').mkdir(exist_ok=True) # ... regular-file guards went # --- a bad spec must be a hard error, never a silently smaller expectation -- # @@ -91,6 +92,12 @@ def write(name, body): if got != want: test_fail(f'{what}: expected {want!r}, got {got!r}') +backport = SCRATCHDIR / 'backport' +(backport / 'testsuite' / 'skiplist').mkdir(parents=True) +(backport / 'testsuite' / 'skiplist' / 'backport.txt').write_text('acls\n') +if runtests.read_backport_exclude(str(backport), SUITE) != {'acls'}: + test_fail('backport exclusions did not resolve the moved test directory') + # A relative @FILE resolves against srcdir, not the cwd: `make check` in an # out-of-tree build directory would otherwise not find the lists. srcdir is # itself relative under `make installcheck` (--srcdir=../src), so check a @@ -130,6 +137,9 @@ def write(name, body): # Every workflow reference must expand, and every committed list must be # referenced by some workflow -- a list nothing points at silently stops # being enforced. +test_paths = runtests.collect_tests(SUITE, []) +known_tests = {runtests._testbase(path) for path in test_paths} +requirements = {runtests._testbase(path): runtests.read_requirements(path) for path in test_paths} wf = sorted((SRC / '.github' / 'workflows').glob('*.yml')) refs, referenced = 0, set() for path in wf: @@ -144,12 +154,43 @@ def write(name, body): for tok in arg.split(','): if tok.startswith('@'): referenced.add(Path(tok[1:]).name) + if 'RSYNC_TEST_PROFILES=' not in line: + test_fail(f'{path.name}: skip policy has no capability profiles') + profile_arg = line.split('RSYNC_TEST_PROFILES=', 1)[1].split()[0] + profiles = [ + runtests.load_profile(SRC / 'testsuite' / 'profiles' / f'{name}.json', known_tests) + for name in profile_arg.split(',') + ] + _, _, unsupported, _ = runtests.merge_profiles(profiles) + permitted = {name for name, metadata in requirements.items() + if metadata and set(metadata['features']) & set(unsupported)} + expected = set(got.split(',')) + if permitted != expected: + missing = ','.join(sorted(expected - permitted)) or '-' + extra = ','.join(sorted(permitted - expected)) or '-' + test_fail(f'{path.name}: profile policy differs: missing={missing} extra={extra}') if wf: if refs == 0: test_fail('no workflow references RSYNC_EXPECT_SKIPPED any more') orphans = sorted({p.name for p in lists} - referenced) if orphans: test_fail(f'skip lists no workflow references: {", ".join(orphans)}') + for path in wf: + lines = path.read_text().splitlines() + base = next((line for line in lines + if 'RSYNC_EXPECT_SKIPPED=' in line + and (line.rstrip().endswith('make check') or line.rstrip().endswith("make check'"))), None) + if not base: + continue + tcp = next((line for line in lines if '--use-tcp' in line), None) + if not tcp or 'RSYNC_TEST_PROFILES=' not in tcp: + test_fail(f'{path.name}: TCP pass has no capability profiles') + base_names = base.split('RSYNC_TEST_PROFILES=', 1)[1].split()[0].split(',') + expected_tcp = [name for name in base_names + if name != 'pipe' and not name.startswith('protocol-')] + tcp_names = tcp.split('RSYNC_TEST_PROFILES=', 1)[1].split()[0].split(',') + if tcp_names != expected_tcp: + test_fail(f'{path.name}: TCP profiles differ from pipe profiles') # '-name' removals. fleettest emits these for a host that can genuinely run a # test its platform list expects to skip; the name being dropped lives inside an # @FILE, so no composer can subtract it before runtests expands the spec. diff --git a/testsuite/tests/smoke_test.py b/testsuite/tests/smoke_test.py new file mode 100644 index 000000000..20aa2495b --- /dev/null +++ b/testsuite/tests/smoke_test.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Foundational CLI and remote-shell transfer coverage""" + +import os +import subprocess + +from harness import TestContext, requires, run +from rsyncfns import checkit, rsync_argv, rsync_path_arg, rsh_cmd, run_rsync, test_fail + + +@requires(features={'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'environment', 'filesystem'}, tags={'compatibility', 'smoke', 'transfer', 'version-mix'}) +def test(context: TestContext): + source_root = context.source + destination = context.destination + os.environ['RSYNC_RSH'] = rsh_cmd() + for option in ('--version', '--info=help', '--debug=help'): + if run_rsync(option, check=False).returncode: + test_fail(f'{option} failed') + + name = 'A weird)name' + source = source_root / name + source.mkdir(parents=True) + + def append(text): + with open(source / 'file', 'a') as stream: + stream.write(text + '\n') + + def copy(options, source_host, destination_host): + checkit([*options, f'--rsync-path={rsync_path_arg()}', f'{source_host}{source}/', + f'{destination_host}{destination / name}'], source_root, destination) + + append('local') + checkit(['-ai', f'{source_root}/', f'{destination}/'], source_root, destination) + for text, options, source_host, destination_host in ( + ('pull', ['-ai'], 'lh:', ''), ('push', ['-ai'], '', 'lh:'), + ('secluded-pull', ['-ais'], 'lh:', ''), ('secluded-push', ['-ais'], '', 'lh:'), + ): + append(text) + copy(options, source_host, destination_host) + + for name in ('one', 'two'): + (source_root / name).touch() + for options, overrides in ((('--old-args',), {}), ((), {'RSYNC_OLD_ARGS': '1'})): + for name in ('one', 'two'): + (destination / name).unlink(missing_ok=True) + env = os.environ.copy() + env.update(overrides) + subprocess.run(rsync_argv('-ai', *options, f'--rsync-path={rsync_path_arg()}', + 'lh:one two', f'{destination}/'), cwd=source_root, env=env, check=True) + if not all((destination / name).is_file() for name in ('one', 'two')): + test_fail('old argument handling failed') + + +if __name__ == '__main__': + run(test) diff --git a/testsuite/source-change-size-continues_test.py b/testsuite/tests/source-change-size-continues_test.py similarity index 95% rename from testsuite/source-change-size-continues_test.py rename to testsuite/tests/source-change-size-continues_test.py index 314784a9d..580a80513 100644 --- a/testsuite/source-change-size-continues_test.py +++ b/testsuite/tests/source-change-size-continues_test.py @@ -136,10 +136,11 @@ def compiler(): def build_shim(): if not sys.platform.startswith('linux'): test_skipped('source-size-change regression uses LD_PRELOAD and ' - '/proc/self/fd to synchronise the source read') + '/proc/self/fd to synchronise the source read', capability='linux_interpose') cc = compiler() if not cc: - test_skipped('no C compiler available to build the source-read shim') + test_skipped('no C compiler available to build the source-read shim', + capability='linux_interpose') cfile = SCRATCHDIR / 'source_shrink_read.c' sofile = SCRATCHDIR / 'source_shrink_read.so' @@ -150,13 +151,14 @@ def build_shim(): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if proc.returncode != 0 or not sofile.is_file(): test_skipped('could not build the source-read shim:\n' - + (proc.stdout or '').strip()) + + (proc.stdout or '').strip(), capability='linux_interpose') return sofile first = split_rsync_cmd(RSYNC)[0] if RSYNC else '' if os.path.basename(first) == 'valgrind': - test_skipped('LD_PRELOAD read hook is not meaningful under valgrind') + test_skipped('LD_PRELOAD read hook is not meaningful under valgrind', + capability='linux_interpose') base = SCRATCHDIR / 'source-change-size' src = base / 'src' @@ -189,7 +191,7 @@ def build_shim(): if not load_mark.exists(): test_skipped('source-read shim was not exercised; rsync may be static or ' - 'the platform ignored LD_PRELOAD') + 'the platform ignored LD_PRELOAD', capability='linux_interpose') if not mark.exists(): test_fail('source-read shim loaded but did not target the changing source ' 'file') diff --git a/testsuite/sparse_test.py b/testsuite/tests/sparse_test.py similarity index 98% rename from testsuite/sparse_test.py rename to testsuite/tests/sparse_test.py index 898ed8c96..49a48c42b 100644 --- a/testsuite/sparse_test.py +++ b/testsuite/tests/sparse_test.py @@ -42,7 +42,7 @@ def allocated(path): # Confirm the source filesystem actually made a sparse file; otherwise the # whole premise (and any dest comparison) is meaningless here. if allocated(src / deep) >= SIZE: - test_skipped("source filesystem did not create a sparse file") + test_skipped("source filesystem did not create a sparse file", capability='sparse') # --- with -S the hole is preserved at the destination ----------------------- run_rsync('-a', '-S', f'{src}/', f'{TODIR}/') diff --git a/testsuite/ssh-basic_test.py b/testsuite/tests/ssh-basic_test.py similarity index 91% rename from testsuite/ssh-basic_test.py rename to testsuite/tests/ssh-basic_test.py index 08c9b88cc..ba323b48e 100644 --- a/testsuite/ssh-basic_test.py +++ b/testsuite/tests/ssh-basic_test.py @@ -18,7 +18,9 @@ checkit, hands_setup, runtest, test_skipped, rsync_path_arg, rsh_cmd, split_rsync_cmd, ) +from harness import metadata +metadata(features={'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'environment', 'filesystem', 'process'}, tags={'compatibility', 'transfer', 'version-mix'}) SSH = rsh_cmd() diff --git a/testsuite/stop-time_test.py b/testsuite/tests/stop-time_test.py similarity index 100% rename from testsuite/stop-time_test.py rename to testsuite/tests/stop-time_test.py diff --git a/testsuite/strict-basis_test.py b/testsuite/tests/strict-basis_test.py similarity index 100% rename from testsuite/strict-basis_test.py rename to testsuite/tests/strict-basis_test.py diff --git a/testsuite/symlink-dest-backupdir_test.py b/testsuite/tests/symlink-dest-backupdir_test.py similarity index 97% rename from testsuite/symlink-dest-backupdir_test.py rename to testsuite/tests/symlink-dest-backupdir_test.py index 26ca00ca3..a051697f5 100644 --- a/testsuite/symlink-dest-backupdir_test.py +++ b/testsuite/tests/symlink-dest-backupdir_test.py @@ -28,10 +28,11 @@ # dir) and refuse a foreign-owned one, so the escape vector is an ATTACKER-owned # backup-dir symlink with rsync run as root. if os.geteuid() != 0: - test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a backup-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') NFILES = 8 diff --git a/testsuite/symlink-dirlink-basis_test.py b/testsuite/tests/symlink-dirlink-basis_test.py similarity index 96% rename from testsuite/symlink-dirlink-basis_test.py rename to testsuite/tests/symlink-dirlink-basis_test.py index c059a2102..055ca9345 100644 --- a/testsuite/symlink-dirlink-basis_test.py +++ b/testsuite/tests/symlink-dirlink-basis_test.py @@ -20,7 +20,9 @@ RSYNC, SCRATCHDIR, RSYNC_PEER, SRCDIR, TMPDIR, make_data_file, rsync_argv, test_fail, rsync_path_arg, rsh_cmd, ) +from harness import metadata +metadata(features={'remote-shell', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', mutates={'filesystem', 'process'}, tags={'compatibility', 'security', 'transfer', 'version-mix'}) # The secure resolver follows an in-tree directory symlink (the basis dir) on # every platform: the per-component walk readlinks the symlink and walks its diff --git a/testsuite/symlink-exclude-chdir-alias_test.py b/testsuite/tests/symlink-exclude-chdir-alias_test.py similarity index 100% rename from testsuite/symlink-exclude-chdir-alias_test.py rename to testsuite/tests/symlink-exclude-chdir-alias_test.py diff --git a/testsuite/symlink-exclude-component_test.py b/testsuite/tests/symlink-exclude-component_test.py similarity index 100% rename from testsuite/symlink-exclude-component_test.py rename to testsuite/tests/symlink-exclude-component_test.py diff --git a/testsuite/symlink-exclude-deep_test.py b/testsuite/tests/symlink-exclude-deep_test.py similarity index 100% rename from testsuite/symlink-exclude-deep_test.py rename to testsuite/tests/symlink-exclude-deep_test.py diff --git a/testsuite/symlink-exclude-leaf_test.py b/testsuite/tests/symlink-exclude-leaf_test.py similarity index 100% rename from testsuite/symlink-exclude-leaf_test.py rename to testsuite/tests/symlink-exclude-leaf_test.py diff --git a/testsuite/symlink-exclude-meta_test.py b/testsuite/tests/symlink-exclude-meta_test.py similarity index 100% rename from testsuite/symlink-exclude-meta_test.py rename to testsuite/tests/symlink-exclude-meta_test.py diff --git a/testsuite/symlink-exclude-xattr_test.py b/testsuite/tests/symlink-exclude-xattr_test.py similarity index 91% rename from testsuite/symlink-exclude-xattr_test.py rename to testsuite/tests/symlink-exclude-xattr_test.py index c46a9070a..e5d6f9e6f 100644 --- a/testsuite/symlink-exclude-xattr_test.py +++ b/testsuite/tests/symlink-exclude-xattr_test.py @@ -13,11 +13,12 @@ ) if not xattrs_supported() or not hasattr(os, 'getxattr') or not hasattr(os, 'setxattr'): - test_skipped("user xattrs not available") + test_skipped("user xattrs not available", capability='symlink_xattr') proto = forced_protocol() if proto is not None and proto < 30: - test_skipped(f"xattr (-X) transfer requires protocol 30+ (negotiated {proto})") + test_skipped(f"xattr (-X) transfer requires protocol 30+ (negotiated {proto})", + capability='protocol_30') DAEMON_PORT = 12992 XNAME = 'user.rsync.marker' @@ -43,7 +44,7 @@ def getx(p): try: os.setxattr(str(victim), XNAME, b'ORIGINAL') except OSError: - test_skipped("cannot set user xattr on this filesystem") + test_skipped("cannot set user xattr on this filesystem", capability='symlink_xattr') os.symlink('pub/', mod / 'blink2') # in-tree dir symlink -> pub/ src = base / 'src' diff --git a/testsuite/symlink-exclude_test.py b/testsuite/tests/symlink-exclude_test.py similarity index 100% rename from testsuite/symlink-exclude_test.py rename to testsuite/tests/symlink-exclude_test.py diff --git a/testsuite/symlink-ignore_test.py b/testsuite/tests/symlink-ignore_test.py similarity index 100% rename from testsuite/symlink-ignore_test.py rename to testsuite/tests/symlink-ignore_test.py diff --git a/testsuite/symlink-mknod-fakesuper-symlink-race_test.py b/testsuite/tests/symlink-mknod-fakesuper-symlink-race_test.py similarity index 98% rename from testsuite/symlink-mknod-fakesuper-symlink-race_test.py rename to testsuite/tests/symlink-mknod-fakesuper-symlink-race_test.py index 7affd31c2..a89e87f06 100644 --- a/testsuite/symlink-mknod-fakesuper-symlink-race_test.py +++ b/testsuite/tests/symlink-mknod-fakesuper-symlink-race_test.py @@ -29,7 +29,7 @@ def run_helper(args, label): proc = subprocess.run([str(TOOLDIR / 't_symlink_secure'), *args]) if proc.returncode == 77: - test_skipped(f"t_symlink_secure {label} skipped") + test_skipped(f"t_symlink_secure {label} skipped", capability='symlink_mknod') if proc.returncode != 0: test_fail(f"t_symlink_secure {label} reported failures (see stderr above)") diff --git a/testsuite/symlink-race-dest_test.py b/testsuite/tests/symlink-race-dest_test.py similarity index 97% rename from testsuite/symlink-race-dest_test.py rename to testsuite/tests/symlink-race-dest_test.py index 7b500dd8d..612abeb36 100644 --- a/testsuite/symlink-race-dest_test.py +++ b/testsuite/tests/symlink-race-dest_test.py @@ -22,7 +22,8 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " - "(simulates 'root runs rsync' over an unprivileged user's dir)") + "(simulates 'root runs rsync' over an unprivileged user's dir)", + capability='cross_uid') ATT_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -34,7 +35,7 @@ except KeyError: continue if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') # --- attack: dest/sub is an ATTACKER-owned symlink (absolute path) ------------ base = SCRATCHDIR / 'destchdir' diff --git a/testsuite/symlink-race-relative-dest_test.py b/testsuite/tests/symlink-race-relative-dest_test.py similarity index 96% rename from testsuite/symlink-race-relative-dest_test.py rename to testsuite/tests/symlink-race-relative-dest_test.py index cf704352b..3bcca34a2 100644 --- a/testsuite/symlink-race-relative-dest_test.py +++ b/testsuite/tests/symlink-race-relative-dest_test.py @@ -24,7 +24,7 @@ if os.geteuid() != 0: test_skipped("requires root to plant a symlink owned by a non-self uid " "(simulates 'root runs rsync -a src/ dest/' over an " - "unprivileged user's directory)") + "unprivileged user's directory)", capability='cross_uid') ATT_UID = None for name in ('nobody', 'nfsnobody', 'daemon'): @@ -36,7 +36,7 @@ except KeyError: continue if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') def run(cwd): diff --git a/testsuite/symlink-race-source_test.py b/testsuite/tests/symlink-race-source_test.py similarity index 100% rename from testsuite/symlink-race-source_test.py rename to testsuite/tests/symlink-race-source_test.py diff --git a/testsuite/symlink-unix-perms_test.py b/testsuite/tests/symlink-unix-perms_test.py similarity index 96% rename from testsuite/symlink-unix-perms_test.py rename to testsuite/tests/symlink-unix-perms_test.py index 9827a6bf2..c29ffebac 100644 --- a/testsuite/symlink-unix-perms_test.py +++ b/testsuite/tests/symlink-unix-perms_test.py @@ -51,11 +51,11 @@ os.chmod('symlink', stat.S_IREAD + stat.S_IXOTH, follow_symlinks=False) except NotImplementedError: - test_skipped(notimplemented) + test_skipped(notimplemented, capability='symlink_mode') except OSError as e: if (e.errno == errno.ENOTSUP): - test_skipped(notsupported) + test_skipped(notsupported, capability='symlink_mode') test_fail('OS' + error + f'{e}') except Exception as e: diff --git a/testsuite/temp-dir-symlink-injection_test.py b/testsuite/tests/temp-dir-symlink-injection_test.py similarity index 98% rename from testsuite/temp-dir-symlink-injection_test.py rename to testsuite/tests/temp-dir-symlink-injection_test.py index f86fd94f5..69611a619 100644 --- a/testsuite/temp-dir-symlink-injection_test.py +++ b/testsuite/tests/temp-dir-symlink-injection_test.py @@ -28,10 +28,11 @@ ) if os.geteuid() != 0: - test_skipped("requires root to plant a temp-dir symlink owned by a non-self uid") + test_skipped("requires root to plant a temp-dir symlink owned by a non-self uid", + capability='cross_uid') ATT_UID = find_attacker_uid() if ATT_UID is None: - test_skipped("no untrusted-uid user available for cross-uid plant") + test_skipped("no untrusted-uid user available for cross-uid plant", capability='cross_uid') NFILES = 40 PINNED = 1000000000 diff --git a/testsuite/temp-dir_test.py b/testsuite/tests/temp-dir_test.py similarity index 100% rename from testsuite/temp-dir_test.py rename to testsuite/tests/temp-dir_test.py diff --git a/testsuite/trimslash_test.py b/testsuite/tests/trimslash_test.py similarity index 100% rename from testsuite/trimslash_test.py rename to testsuite/tests/trimslash_test.py diff --git a/testsuite/uidlist-id0-name-leak_test.py b/testsuite/tests/uidlist-id0-name-leak_test.py similarity index 100% rename from testsuite/uidlist-id0-name-leak_test.py rename to testsuite/tests/uidlist-id0-name-leak_test.py diff --git a/testsuite/unsafe-byname_test.py b/testsuite/tests/unsafe-byname_test.py similarity index 100% rename from testsuite/unsafe-byname_test.py rename to testsuite/tests/unsafe-byname_test.py diff --git a/testsuite/unsafe-links_test.py b/testsuite/tests/unsafe-links_test.py similarity index 100% rename from testsuite/unsafe-links_test.py rename to testsuite/tests/unsafe-links_test.py diff --git a/testsuite/update_test.py b/testsuite/tests/update_test.py similarity index 100% rename from testsuite/update_test.py rename to testsuite/tests/update_test.py diff --git a/testsuite/variety-symlink-traversal_test.py b/testsuite/tests/variety-symlink-traversal_test.py similarity index 95% rename from testsuite/variety-symlink-traversal_test.py rename to testsuite/tests/variety-symlink-traversal_test.py index 9d37b3d42..d1dc1427b 100644 --- a/testsuite/variety-symlink-traversal_test.py +++ b/testsuite/tests/variety-symlink-traversal_test.py @@ -28,6 +28,9 @@ xattrs_supported, acls_supported, devices_supported, owners_supported, test_skipped, test_fail, split_rsync_cmd, rsh_cmd, ) +from harness import metadata + +metadata(features={'remote-shell', 'symlink'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', cost='expensive', mutates={'filesystem', 'process'}, tags={'compatibility', 'security', 'transfer', 'version-mix'}) SSH = rsh_cmd() WITH_X = xattrs_supported() @@ -156,7 +159,7 @@ def case_receiver_intermediate(): if split_rsync_cmd(RSYNC) == split_rsync_cmd(RSYNC_PEER): test_skipped("no old peer selected (RSYNC_PEER == RSYNC); nothing to " - "compare for symlink-traversal divergence") + "compare for symlink-traversal divergence", capability='legacy_peer') case_sender_root() case_sender_intermediate() diff --git a/testsuite/variety_test.py b/testsuite/tests/variety_test.py similarity index 96% rename from testsuite/variety_test.py rename to testsuite/tests/variety_test.py index dfea53a37..af0600e5e 100644 --- a/testsuite/variety_test.py +++ b/testsuite/tests/variety_test.py @@ -9,7 +9,7 @@ # # Peer-agnostic: RSYNC is the current binary, RSYNC_PEER the --rsync-bin2 peer. # With no peer (RSYNC_PEER == RSYNC) it degrades to a self-consistency check. -# Enabled first for the 3.2.7 peer via testsuite/expect/rsync_3.2.7.expect. +# Version-mix applicability is declared below # # ORACLE: for each (transport, direction, option-profile) the ALL-OLD pairing is # the reference ("what 3.2.7 does"); the new->old, old->new and new->new pairings @@ -36,6 +36,9 @@ write_daemon_conf, start_test_daemon, test_fail, split_rsync_cmd, rsh_cmd, ) +from harness import metadata + +metadata(features={'daemon', 'filesystem-specials', 'remote-shell'}, protocols={27, 28, 29, 30, 31, 32, 33}, transports={'pipe', 'tcp'}, min_peer='2.6.0', cost='expensive', mutates={'filesystem', 'process', 'socket'}, tags={'compatibility', 'metadata', 'transfer', 'version-mix'}) SSH = rsh_cmd() DAEMON_PORT_OLD = 12931 diff --git a/testsuite/wildmatch_test.py b/testsuite/tests/wildmatch_test.py similarity index 100% rename from testsuite/wildmatch_test.py rename to testsuite/tests/wildmatch_test.py diff --git a/testsuite/write-batch-filter-injection_test.py b/testsuite/tests/write-batch-filter-injection_test.py similarity index 100% rename from testsuite/write-batch-filter-injection_test.py rename to testsuite/tests/write-batch-filter-injection_test.py diff --git a/testsuite/write-batch-quoting_test.py b/testsuite/tests/write-batch-quoting_test.py similarity index 100% rename from testsuite/write-batch-quoting_test.py rename to testsuite/tests/write-batch-quoting_test.py diff --git a/testsuite/write-touched-blocks_test.py b/testsuite/tests/write-touched-blocks_test.py similarity index 98% rename from testsuite/write-touched-blocks_test.py rename to testsuite/tests/write-touched-blocks_test.py index 0e58571ee..174ca9971 100644 --- a/testsuite/write-touched-blocks_test.py +++ b/testsuite/tests/write-touched-blocks_test.py @@ -14,7 +14,8 @@ if arg.startswith('--protocol='): prot_version = int(arg.split('=')[1]) if prot_version < 33: - test_skipped(f"Skipping write-touched-blocks: feature requires protocol 33, but CI forced {prot_version}") + test_skipped(f"Skipping write-touched-blocks: feature requires protocol 33 but CI forced {prot_version}", + capability='protocol_33') src = FROMDIR makepath(src) diff --git a/testsuite/xattr-wire-cap_test.py b/testsuite/tests/xattr-wire-cap_test.py similarity index 96% rename from testsuite/xattr-wire-cap_test.py rename to testsuite/tests/xattr-wire-cap_test.py index f1fc33101..9e30a10c5 100644 --- a/testsuite/xattr-wire-cap_test.py +++ b/testsuite/tests/xattr-wire-cap_test.py @@ -28,10 +28,12 @@ import rsync_proto as rp if not xattrs_supported(): - test_skipped("xattr-wire-cap requires rsync and filesystem xattr support") + test_skipped("xattr-wire-cap requires rsync and filesystem xattr support", + capability='xattr_wire') proto = forced_protocol() if proto is not None and proto < 30: - test_skipped(f"xattr-wire-cap requires protocol 30+ (xattrs unsupported at negotiated {proto})") + test_skipped(f"xattr-wire-cap requires protocol 30+ (xattrs unsupported at negotiated {proto})", + capability='protocol_30') PORT = 12970 require_tcp("the pure-Python daemon needs a real TCP socket; run with --use-tcp") diff --git a/testsuite/xattrs-depth_test.py b/testsuite/tests/xattrs-depth_test.py similarity index 93% rename from testsuite/xattrs-depth_test.py rename to testsuite/tests/xattrs-depth_test.py index c090286c6..d166e87c8 100644 --- a/testsuite/xattrs-depth_test.py +++ b/testsuite/tests/xattrs-depth_test.py @@ -17,7 +17,8 @@ ) if not xattrs_supported(): - test_skipped("rsync built without xattr support (or no xattr tooling here)") + test_skipped("rsync built without xattr support (or no xattr tooling here)", + capability='xattr_runtime') src = FROMDIR rmtree(src) @@ -32,7 +33,8 @@ for i, rel in enumerate(entries): xattr_set('depth', f'value-{i}-{rel}', str(rel)) except OSError as e: - test_skipped(f"unable to set an xattr on this filesystem: {e}") + test_skipped(f"unable to set an xattr on this filesystem: {e}", + capability='xattr_runtime') want = xattr_dump(*[str(r) for r in entries]) diff --git a/testsuite/xattrs-hlink_test.py b/testsuite/tests/xattrs-hlink_test.py similarity index 100% rename from testsuite/xattrs-hlink_test.py rename to testsuite/tests/xattrs-hlink_test.py diff --git a/testsuite/xattrs_test.py b/testsuite/tests/xattrs_test.py similarity index 98% rename from testsuite/xattrs_test.py rename to testsuite/tests/xattrs_test.py index 18e317f52..277241e96 100644 --- a/testsuite/xattrs_test.py +++ b/testsuite/tests/xattrs_test.py @@ -20,7 +20,7 @@ if not xattrs_supported(): test_skipped("Rsync is configured without xattr support (or no xattr " - "tooling on this platform)") + "tooling on this platform)", capability='xattr_runtime') script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__) @@ -60,7 +60,7 @@ try: xset('foo', 'foo', 'file0') except OSError: - test_skipped("Unable to set an xattr") + test_skipped("Unable to set an xattr", capability='xattr_runtime') xset('bar', 'bar', 'file0') xset('short', 'this is short', 'file1') diff --git a/testsuite/xrsync_test.py b/testsuite/tests/xrsync_test.py similarity index 100% rename from testsuite/xrsync_test.py rename to testsuite/tests/xrsync_test.py From 1ee9bc75a6202d9f4e3e88203422fde2812db712 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 9 Oct 2026 12:54:22 +1000 Subject: [PATCH 21/27] docs: refresh testsuite documentation --- old_versions/README.md | 108 +++------ testsuite/COVERAGE.md | 415 +++++++++++++++++------------------ testsuite/README.md | 365 ++++++++---------------------- testsuite/skiplist/README.md | 156 ++++--------- 4 files changed, 371 insertions(+), 673 deletions(-) diff --git a/old_versions/README.md b/old_versions/README.md index a5ce3a5b6..d7653c18a 100644 --- a/old_versions/README.md +++ b/old_versions/README.md @@ -2,86 +2,50 @@ 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. +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:** `testsuite/runtests.py --rsync-bin2=...` runs the current code against one of these as the daemon or remote-shell peer. CI (`.github/workflows/ubuntu-version-mix.yml`) exercises every binary against its peer profile in `testsuite/profiles/`. -## Adding a version +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 regeneration +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 and Debian Buster era, 2018 +rsync_3.2.0 | 3.2.0 | 31 | 2020; zstd, lz4 and xxhash negotiation added +rsync_3.2.7 | 3.2.7 | 31 | 2022 +rsync_3.3.0 | 3.3.0 | 31 | 2024 +rsync_3.4.0 | 3.4.0 | 32 | 2025 +rsync_3.4.1 | 3.4.1 | 32 | 2025 + +These are every `x.y.0` release from 2.6.0 (2004) onward plus a few point releases. 2.6.0 is the practical floor: older tags need progressively more porting to build on a current toolchain. +All built `--disable-openssl` and with `_FORTIFY_SOURCE` disabled (see below); xxhash/zstd/lz4 are compiled in where the version supports them. + +## Adding a version ```bash ./build_static.sh 3.2.7 # uses git tag v3.2.7 ./build_static.sh 3.0.9 v3.0.9 # explicit tag if naming differs ``` -The script checks out the tag into a throwaway `git worktree`, applies the -minimal patches needed to compile old sources on a modern toolchain, links -statically, verifies the result is static and reports the requested version, -then installs `rsync_` here and removes the worktree. +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 and verifies the result. It 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`). +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. +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 such as the `qsort` comparator and `pool->bomb` - 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 or daemon - which made 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 such as 2.6.0** ship `configure.in`, not a generated `configure`. The script runs `autoheader`/`autoconf` to generate it, after neutralizing the `AC_CHECK_FUNCS(fn,,AC_LIBOBJ(lib/...))` fallbacks for `inet_ntop`/`inet_pton`/`getaddrinfo`/`getnameinfo` - modern autoconf emits broken shell for those never-taken branches (the funcs exist in glibc). It also generates `proto.h` (no make rule in that era) and stubs the vendored `lib/addrinfo.h` the tag dropped (modern glibc supplies `struct addrinfo`). All guarded so they no-op on 3.x. + +Newer versions may need fewer or different tweaks; if a build fails, the script prints the first compiler errors from its log. diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 096947394..5cb2c3520 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -1,214 +1,201 @@ -# 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 | old-dirs | `~` | — | ✓ both mixed-version remote directions | -| -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 | ownership-depth | Y | — | ✓ client uid/gid mapping | -| --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 | preallocate | Y | — | ✓ allocation and delta-update paths | -| --fsync | — | — | — | ✗ | -| -B, --block-size | hashsearch-chain, compress-zlib-insert, preallocate | Y | — | ✓ | -| --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 | ignore-missing-args | Y | — | ✓ direct, remote-shell and `--files-from` sources | -| --delete-missing-args | delete-missing-args-files-from | `~` | — | ✓ with `--files-from` | - -### 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 | iconv | — | — | ✓ deletion proceeds after sender I/O error | - -### 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 | output-options, daemon-module-options | ✓ output selection, routing, MOTD suppression and line buffering | -| -M, --remote-option / --log-file / --log-file-format | remote-logging | ✓ client and remote-shell sender/receiver logs | - -### 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-address-family, daemon-standalone-detach | ✓ explicit IPv4/IPv6 and config-derived binding | -| --password-file | daemon-auth*new* | ✓ | -| --early-input / daemon `early exec` | daemon-early-exec-nameconv, early-input-symlink | ✓ data delivery and confined input path | -| --sockopts | daemon-module-options | ✓ client and daemon socket options | -| --blocking-io | — | ✗ | -| --timeout / --contimeout | daemon-handshake-timeout, msg-io-timeout-{zero,overflow}, contimeout-rsh | ✓ precedence, bounds and remote-shell daemon path | -| -4/-6, --ipv4/--ipv6 | daemon-address-family | ✓ IPv4 and IPv6 daemon binding and client connection | -| --stop-after / --stop-at | stop-time | ✓ future, past and duration parsing | -| --bwlimit | partial*new* (used, not asserted) | `~` | -| --copy-as | — | ✗ root-gated | -| --iconv | iconv | ✓ filenames, arguments, file lists, protocol 30+ symlink targets and invalid input; macOS raw-byte fixture unavailable | -| -s/--secluded-args, --old-args, --trust-sender | iconv (-s); default arg protection | `~` secluded filename conversion asserted; old/trust not isolated | - ---- - -## 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 | daemon-early-exec-nameconv | ✓ environment and `--early-input` bytes | -| 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 | daemon-early-exec-nameconv, daemon-namecvt-* | ✓ `numeric ids = no` conversion path | -| fake super | chown-fake, daemon-namecvt-empty-response | ✓ client and daemon parameter | -| timeout | daemon-handshake-timeout | ✓ handshake and transfer precedence, including zero | -| max connections / lock file | daemon-include-maxconn, daemon-connection-limits | ✓ sequential reuse, concurrent refusal and release | -| temp dir / open noatime / ignore errors / ignore nonreadable | iconv (ignore errors) | `~` ignore errors asserted; remaining parameters uncovered | -| charset | iconv | ✓ module charset overrides the client remote charset | -| name converter | daemon-early-exec-nameconv, daemon-namecvt-* | ✓ success, empty and malformed responses | -| dont compress | daemon-module-options | `~` configured but compression choice is not asserted | -| uid / gid / daemon uid / daemon gid / daemon chroot | build_rsyncd_conf (uid/gid when root), daemon-chroot-acl | `~` root-gated | -| motd file | daemon-module-options | ✓ banner content asserted | -| socket options | daemon-module-options | `~` live socket path; kernel effects are not introspected | -| pid file / port / address | daemon-standalone-detach, daemon-address-family | ✓ configuration and CLI binding paths | -| proxy protocol | daemon-proxy-protocol, proxy-protocol-trusted-peer | ✓ enabled, disabled and trusted-peer policy | -| listen backlog / syslog facility / syslog tag | — | ✗ | - ---- - -## Known gaps worth a future pass -* 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` and `-x/--one-file-system` have no dedicated test yet. -* `--fsync`, `--blocking-io`, daemon charset and daemon listen/syslog settings - remain unisolated. +# Test coverage + +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 | hands, delete-deep | deep | N/A | Covered +-R, --relative | relative, 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 | dirs | 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 | unsafe-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 | files-from-leak, 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 | deep | outside | Covered for skip, copy and hard-link 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, merge | 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 | compare | deep | N/A | Covered for same-metadata content changes +-I, --ignore-times | compare | deep | N/A | Covered +--size-only | compare | deep | N/A | Covered +-@, --modify-window | compare | deep | N/A | Covered +--checksum-choice, --checksum-seed | compress-options | deep | N/A | Covered for advertised algorithms +-z, --compress | daemon-gzip-download, daemon-gzip-upload, 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 tests | Covered with nested paths +read only | daemon-access, daemon | Covered +write only | daemon-access | Covered +list | daemon-access, daemon | 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 | 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, 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 | Partial: configured but not asserted +max verbosity | daemon | Partial +comment | daemon, 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-include-maxconn, 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..6aca52497 100644 --- a/testsuite/README.md +++ b/testsuite/README.md @@ -1,304 +1,113 @@ # 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/` contains the test scripts. Test names end in `_test.py` +- `testsuite/runtests.py` discovers and runs tests +- `testsuite/rsyncfns.py` contains the legacy test helpers +- `testsuite/harness/` contains metadata, profile, result and receipt support +- `testsuite/profiles/` records platform capabilities and peer deviations +- `testsuite/skiplist/` contains the temporary expected-skip lists +- `testsuite/fleettest.py` runs the suite across the maintainer fleet +- `testsuite/abdiff.py` compares two rsync versions over the same transfers +- [COVERAGE.md](COVERAGE.md) records option and daemon-parameter coverage + +Some tests also use C helpers built with rsync. ## 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 - - [m] - path = {mod} - read only = yes - """) - ``` - -## Running the tests - -### Via make - -Run from the build directory: - -- **`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. - -### Via runtests.py directly - -`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`): +A regression test should assert the behaviour being fixed. A final source and destination comparison can miss the actual bug. + +Function-based tests use one `@requires(...)` decorator. Existing module tests use one top-level `metadata(...)` call while they are migrated. Declare any capability passed to `test_skipped()` and use `require_tcp()` or `require_asan()` for those checks. + +Use `TestContext` or the existing `rsyncfns.py` paths 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. +## 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 +make check CHECK_J=1 +make check29 +make check30 +make check-progs +make installcheck +make coverage-all +``` +The runner accepts names and shell patterns: ```sh -cp testsuite/fleettest.json.example testsuite/fleettest.json # then edit -# (or symlink it, or point elsewhere with --fleet PATH) +./testsuite/runtests.py +./testsuite/runtests.py chmod-temp-dir +./testsuite/runtests.py 'xattr*' ``` -The config is looked up in order: `~/.fleettest.json` first, then -`testsuite/fleettest.json`, unless overridden with `--fleet PATH`. +The main controls are `-j`, `--rsync-bin`, `--rsync-bin2`, `--protocol`, `--profiles`, `--use-tcp`, `--race-timeout`, `--receipt`, `--describe-tests` and `--valgrind`. Run `./testsuite/runtests.py --help` for the full list. + +## TCP daemon mode + +`--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. + +## Results and profiles + +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 -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`. +Exit codes are 0 for pass, 1 for fail, 2 for error, 77 for skip and 78 for expected failure. -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. +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. -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. +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. -Run it from inside a checkout (it builds the current directory's HEAD; use -`--repo PATH` for another tree): +CI also compares the exact skipped-test list while profiles are being rolled out. See [skiplist/README.md](skiplist/README.md). +## Scratch data and requirements + +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. + +## Fleet testing + +`testsuite/fleettest.py` builds the committed revision on configured hosts and runs the same pipe, TCP, protocol and non-root lanes used by CI. Configuration is read from `~/.fleettest.json` then `testsuite/fleettest.json` or from the path passed to `--fleet`. + +Start with the checked-in example: +```sh +cp testsuite/fleettest.json.example testsuite/fleettest.json +``` + +Common commands: ```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 +python3 testsuite/fleettest.py +python3 testsuite/fleettest.py --list +python3 testsuite/fleettest.py --targets freebsd,netbsd +python3 testsuite/fleettest.py --keep-on-fail ``` -`--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. - -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. - -`--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. - -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). - -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`. - -## Differential regression hunting (abdiff.py) - -`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`. - -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. - -Run it from the build directory (so `./rsync` and `old_versions/` resolve): +Target-specific skip allowances require a matching unsupported capability. Non-root and protocol passes are declared in the target entry. Do not point TCP fleet runs at shared hosts. + +## Differential testing +`testsuite/abdiff.py` 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 +testsuite/abdiff.py +testsuite/abdiff.py --sweep all -j5 +testsuite/abdiff.py --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_