From d67faeb4a0b28500cebf8aa20dcfa595e95ec6ee Mon Sep 17 00:00:00 2001 From: pterror Date: Mon, 1 Jun 2026 00:47:08 +1000 Subject: [PATCH 1/4] test+ci: invariant/transport-equivalence tests, fuzz scaffold, sanitizer/static-analysis CI --- .github/ubsan-suppressions.txt | 23 + .github/workflows/analysis-codeql.yml | 51 ++ .github/workflows/analysis-cppcheck.yml | 65 +++ .github/workflows/analysis-gcc-fanalyzer.yml | 79 +++ .github/workflows/analysis-scan-build.yml | 70 +++ .github/workflows/hardened-build.yml | 75 +++ .github/workflows/sanitizers-asan-ubsan.yml | 92 ++++ Makefile.in | 10 + configure.ac | 18 + fuzz/.gitignore | 18 + fuzz/Makefile | 76 +++ fuzz/README.md | 146 +++++ fuzz/corpus/fuzz_io/ff_stress | 1 + fuzz/corpus/fuzz_io/mixed_varints | Bin 0 -> 28 bytes fuzz/corpus/fuzz_io/sumhead_blength_max | Bin 0 -> 17 bytes fuzz/corpus/fuzz_io/sumhead_blength_over | Bin 0 -> 17 bytes fuzz/corpus/fuzz_io/sumhead_count_huge | Bin 0 -> 17 bytes fuzz/corpus/fuzz_io/sumhead_count_neg | Bin 0 -> 17 bytes fuzz/corpus/fuzz_io/sumhead_remainder_over | Bin 0 -> 17 bytes fuzz/corpus/fuzz_io/sumhead_s2_over | Bin 0 -> 17 bytes fuzz/corpus/fuzz_io/sumhead_valid_p20 | Bin 0 -> 13 bytes fuzz/corpus/fuzz_io/sumhead_valid_p30 | Bin 0 -> 23 bytes fuzz/corpus/fuzz_io/sumhead_zero | Bin 0 -> 17 bytes fuzz/corpus/fuzz_token/lit_chunk_max | Bin 0 -> 32776 bytes fuzz/corpus/fuzz_token/lit_end | Bin 0 -> 4 bytes fuzz/corpus/fuzz_token/lit_huge | 1 + fuzz/corpus/fuzz_token/lit_over | Bin 0 -> 4 bytes fuzz/corpus/fuzz_token/lit_small | Bin 0 -> 13 bytes fuzz/corpus/fuzz_token/lit_trunc | Bin 0 -> 7 bytes fuzz/corpus/fuzz_token/match_token | 1 + fuzz/fuzz_flist.c | 49 ++ fuzz/fuzz_io.c | 126 +++++ fuzz/fuzz_token.c | 85 +++ fuzz/fuzz_xattrs.c | 46 ++ fuzz/run-regression.sh | 50 ++ fuzz/stubs.c | 176 ++++++ testsuite/content-fidelity_test.py | 139 +++++ testsuite/delete-backup-invariants_test.py | 399 ++++++++++++++ testsuite/equiv_fns.py | 537 +++++++++++++++++++ testsuite/idempotence_test.py | 168 ++++++ testsuite/link-dest-equiv_test.py | 193 +++++++ testsuite/link-dest-variants_test.py | 184 +++++++ testsuite/metadata-fidelity_test.py | 297 ++++++++++ testsuite/transport-equiv-meta_test.py | 278 ++++++++++ xattrs.c | 9 +- 45 files changed, 3459 insertions(+), 3 deletions(-) create mode 100644 .github/ubsan-suppressions.txt create mode 100644 .github/workflows/analysis-codeql.yml create mode 100644 .github/workflows/analysis-cppcheck.yml create mode 100644 .github/workflows/analysis-gcc-fanalyzer.yml create mode 100644 .github/workflows/analysis-scan-build.yml create mode 100644 .github/workflows/hardened-build.yml create mode 100644 .github/workflows/sanitizers-asan-ubsan.yml create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Makefile create mode 100644 fuzz/README.md create mode 100644 fuzz/corpus/fuzz_io/ff_stress create mode 100644 fuzz/corpus/fuzz_io/mixed_varints create mode 100644 fuzz/corpus/fuzz_io/sumhead_blength_max create mode 100644 fuzz/corpus/fuzz_io/sumhead_blength_over create mode 100644 fuzz/corpus/fuzz_io/sumhead_count_huge create mode 100644 fuzz/corpus/fuzz_io/sumhead_count_neg create mode 100644 fuzz/corpus/fuzz_io/sumhead_remainder_over create mode 100644 fuzz/corpus/fuzz_io/sumhead_s2_over create mode 100644 fuzz/corpus/fuzz_io/sumhead_valid_p20 create mode 100644 fuzz/corpus/fuzz_io/sumhead_valid_p30 create mode 100644 fuzz/corpus/fuzz_io/sumhead_zero create mode 100644 fuzz/corpus/fuzz_token/lit_chunk_max create mode 100644 fuzz/corpus/fuzz_token/lit_end create mode 100644 fuzz/corpus/fuzz_token/lit_huge create mode 100644 fuzz/corpus/fuzz_token/lit_over create mode 100644 fuzz/corpus/fuzz_token/lit_small create mode 100644 fuzz/corpus/fuzz_token/lit_trunc create mode 100644 fuzz/corpus/fuzz_token/match_token create mode 100644 fuzz/fuzz_flist.c create mode 100644 fuzz/fuzz_io.c create mode 100644 fuzz/fuzz_token.c create mode 100644 fuzz/fuzz_xattrs.c create mode 100755 fuzz/run-regression.sh create mode 100644 fuzz/stubs.c create mode 100644 testsuite/content-fidelity_test.py create mode 100644 testsuite/delete-backup-invariants_test.py create mode 100644 testsuite/equiv_fns.py create mode 100644 testsuite/idempotence_test.py create mode 100644 testsuite/link-dest-equiv_test.py create mode 100644 testsuite/link-dest-variants_test.py create mode 100644 testsuite/metadata-fidelity_test.py create mode 100644 testsuite/transport-equiv-meta_test.py diff --git a/.github/ubsan-suppressions.txt b/.github/ubsan-suppressions.txt new file mode 100644 index 000000000..6a22b08a4 --- /dev/null +++ b/.github/ubsan-suppressions.txt @@ -0,0 +1,23 @@ +# UBSan runtime suppressions for rsync. +# +# These entries suppress intentional, architecture-safe UB that we do NOT want +# to fix in source. Every other UBSan check class runs FULLY. +# +# Format: : +# "alignment" fires when a misaligned pointer dereference is detected. +# +# byteorder.h -- deliberate unaligned 32/64-bit access on the !CAREFUL_ALIGNMENT +# (x86/amd64) fast path. The union-pun approach is intentional; the header's own +# comment documents this. CAREFUL_ALIGNMENT=1 selects a byte-shuffle fallback for +# strict-alignment architectures; the CI runner is x86-64 so the fast path is +# compiled. Suppressed at runtime rather than disabled class-wide so that any NEW +# misaligned access outside byteorder.h is still caught. +alignment:IVALu +alignment:SIVALu +alignment:IVAL64 +alignment:SIVAL64 +# log.c log_delete() -- intentional pool-style allocation: new_array0(char,...) + +# pointer-arithmetic offset then cast to struct file_struct*. Same x86 alignment- +# tolerance assumption as byteorder.h; not a memory-safety bug. +alignment:log_delete +alignment:log_formatted diff --git a/.github/workflows/analysis-codeql.yml b/.github/workflows/analysis-codeql.yml new file mode 100644 index 000000000..76bf1199b --- /dev/null +++ b/.github/workflows/analysis-codeql.yml @@ -0,0 +1,51 @@ +name: CodeQL (cpp) + +# GitHub CodeQL static analysis for C/C++. Uses a MANUAL build (not autobuild) +# because rsync needs ./prepare-source to generate sources before configure/make, +# which autobuild can miss. +# +# GATING: NON-gating by default -- CodeQL surfaces results in the repository's +# Security tab and as PR code-scanning annotations rather than failing this job. +# (Branch-protection "code scanning results" rules are the proper place to gate +# on CodeQL, configured in repo settings, not here.) CI-ONLY: no source/flag +# change; the build uses --disable-md2man like every other CI job. + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: '17 5 * * 1' + workflow_dispatch: + +jobs: + analyze: + name: CodeQL analyze (cpp) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: prep + run: | + sudo apt-get update + sudo apt-get install -y acl libacl1-dev attr libattr1-dev \ + liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: cpp + - name: Manual build + run: | + ./prepare-source + ./configure --disable-md2man + make + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:cpp" diff --git a/.github/workflows/analysis-cppcheck.yml b/.github/workflows/analysis-cppcheck.yml new file mode 100644 index 000000000..0c925fa96 --- /dev/null +++ b/.github/workflows/analysis-cppcheck.yml @@ -0,0 +1,65 @@ +name: Static analysis (cppcheck) + +# cppcheck baseline scan. GATING: NON-gating -- `--error-exitcode=0` means +# cppcheck never fails the job; it produces a baseline report artifact + a +# job-summary count. No local baseline (cppcheck is not in the dev shell), so +# this stays non-gating until a CI baseline is triaged. CI-ONLY: no source/flag +# change, cppcheck only reads the sources. + +on: + push: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/analysis-cppcheck.yml' + pull_request: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/analysis-cppcheck.yml' + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +jobs: + cppcheck: + runs-on: ubuntu-latest + name: cppcheck + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: prep + run: | + sudo apt-get update + sudo apt-get install -y cppcheck + - name: prepare-source + # Generate config/derived sources so cppcheck sees the real tree. + run: ./prepare-source || true + - name: cppcheck + run: | + set -o pipefail + cppcheck --enable=warning,portability --error-exitcode=0 \ + --inline-suppr --std=c11 \ + --suppress=missingInclude --suppress=missingIncludeSystem \ + -i zlib -i popt \ + . 2>&1 | tee cppcheck.log + - name: report + if: always() + run: | + { + echo "## cppcheck (baseline, non-gating)" + n=$(grep -c "): " cppcheck.log || true) + echo "Findings: ${n:-0}" + echo '```' + grep -oE '\[[a-zA-Z]+\]$' cppcheck.log | sort | uniq -c | sort -rn | head -20 || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: upload report + if: always() + uses: actions/upload-artifact@v4 + with: + retention-days: 45 + name: cppcheck-log + path: cppcheck.log + if-no-files-found: ignore diff --git a/.github/workflows/analysis-gcc-fanalyzer.yml b/.github/workflows/analysis-gcc-fanalyzer.yml new file mode 100644 index 000000000..9ee5affe8 --- /dev/null +++ b/.github/workflows/analysis-gcc-fanalyzer.yml @@ -0,0 +1,79 @@ +name: Static analysis (gcc -fanalyzer) + +# GCC's built-in static analyzer, enabled purely via env CFLAGS passthrough +# (configure.ac preserves CFLAGS -- the same mechanism --enable-coverage uses). +# +# GATING: NON-gating. On HEAD this is clean (0 warnings locally with gcc 15), +# but -fanalyzer's findings vary across gcc versions and configure paths and are +# prone to false positives, so a finding produces an artifact + job-summary note +# rather than failing the build. The build step is continue-on-error. +# +# CI-ONLY: no default flags change; -fanalyzer is injected only into this job's +# environment. No source or Makefile change. + +on: + push: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/analysis-gcc-fanalyzer.yml' + pull_request: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/analysis-gcc-fanalyzer.yml' + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + +jobs: + fanalyzer: + runs-on: ubuntu-latest + name: gcc -fanalyzer + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: prep + run: | + sudo apt-get update + sudo apt-get install -y gcc acl libacl1-dev attr libattr1-dev \ + liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl + - name: prepare-source + run: ./prepare-source + - name: configure + # -fanalyzer needs an optimized build to be effective; -O2 also matches the + # flags that surface the most analyzer paths. + env: + CFLAGS: "-fanalyzer -O2" + run: ./configure --disable-md2man + - name: make + id: build + continue-on-error: true + run: | + set -o pipefail + make 2>&1 | tee fanalyzer.log + # Fail this (non-gating) step if any analyzer warning was emitted, so the + # report step can flag it; continue-on-error keeps the workflow green. + ! grep -q "warning:" fanalyzer.log + - name: report + if: always() + run: | + { + echo "## gcc -fanalyzer" + n=$(grep -c "warning:" fanalyzer.log || true) + echo "Analyzer warnings: ${n:-0}" + if [ "${n:-0}" != "0" ]; then + echo '```' + grep -oE '\[-W[a-z0-9-]+\]' fanalyzer.log | sort | uniq -c | sort -rn + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + - name: upload log + if: always() + uses: actions/upload-artifact@v4 + with: + retention-days: 45 + name: fanalyzer-log + path: fanalyzer.log + if-no-files-found: ignore diff --git a/.github/workflows/analysis-scan-build.yml b/.github/workflows/analysis-scan-build.yml new file mode 100644 index 000000000..b7bea7249 --- /dev/null +++ b/.github/workflows/analysis-scan-build.yml @@ -0,0 +1,70 @@ +name: Static analysis (clang scan-build) + +# Clang static analyzer over a full ./configure && make build. +# +# GATING: NON-gating. scan-build's analyzer is prone to false positives and no +# local baseline could be established (the nix-packaged scan-build cannot find +# ccc-analyzer). The job runs `scan-build --status-bugs make`, which exits +# non-zero when bugs are found, but the step is wrapped in continue-on-error so +# a finding does NOT fail the workflow -- it surfaces as an uploaded HTML report +# + a job-summary note. Promote to gating only after a clean CI baseline exists. +# +# CI-ONLY: this changes no default build flags and no source. The analyzer wraps +# the normal configure/make; exotic-platform builds are unaffected. + +on: + push: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/analysis-scan-build.yml' + pull_request: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/analysis-scan-build.yml' + schedule: + - cron: '17 7 * * *' + workflow_dispatch: + +jobs: + scan-build: + runs-on: ubuntu-latest + name: clang scan-build + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: prep + run: | + sudo apt-get update + sudo apt-get install -y clang clang-tools acl libacl1-dev attr libattr1-dev \ + liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl + - name: prepare-source + run: ./prepare-source + - name: scan-build configure + run: scan-build -o scan-build-report ./configure --disable-md2man + - name: scan-build make + id: scan + continue-on-error: true + run: scan-build -o scan-build-report --status-bugs make + - name: report + if: always() + run: | + { + echo "## clang scan-build" + if [ "${{ steps.scan.outcome }}" = "success" ]; then + echo "No analyzer bugs reported." + else + echo "scan-build reported findings (non-gating baseline). See the" + echo "**scan-build-report** artifact for the HTML report." + fi + } >> "$GITHUB_STEP_SUMMARY" + - name: upload report + if: always() + uses: actions/upload-artifact@v4 + with: + retention-days: 45 + name: scan-build-report + path: scan-build-report + if-no-files-found: ignore diff --git a/.github/workflows/hardened-build.yml b/.github/workflows/hardened-build.yml new file mode 100644 index 000000000..740652170 --- /dev/null +++ b/.github/workflows/hardened-build.yml @@ -0,0 +1,75 @@ +name: Hardened build (-Werror) + +# Compile the whole tree under hardened, warning-fatal flags to surface latent +# warnings. +# +# GATING: GATING. Verified clean on HEAD locally -- 58 translation units, 0 +# warnings/errors -- so a NEW warning under these flags MUST fail CI. -Werror is +# fatal ONLY in this job; it does not affect default builds. +# +# CI-ONLY: flags are injected via env CFLAGS passthrough. No default-flag change, +# no source/Makefile change -- exotic platforms are untouched. +# +# IMPORTANT mechanic: -Werror is applied at MAKE time, NOT at configure time. +# autoconf feature-detection probes legitimately emit warnings; with -Werror in +# CFLAGS during ./configure those probes "fail", so configure mis-detects e.g. +# struct addrinfo / sockaddr_storage as absent and emits colliding fallback +# definitions (redefinition errors in lib/addrinfo.h). So: configure with the +# hardened flags MINUS -Werror, then `make CFLAGS=`. +# The make override must re-add -DHAVE_CONFIG_H because overriding CFLAGS on the +# make line replaces autoconf's substituted @CFLAGS@ (which carried it); the +# .c.o rule keeps -I./-I$(srcdir) but not the substituted CFLAGS. +# _FORTIFY_SOURCE=2 requires optimization, so -O2 is included. + +on: + push: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/hardened-build.yml' + pull_request: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/hardened-build.yml' + schedule: + - cron: '17 2 * * *' + workflow_dispatch: + +jobs: + hardened: + runs-on: ubuntu-latest + name: hardened -Werror (${{ matrix.cc }}) + strategy: + fail-fast: false + matrix: + cc: [ gcc, clang ] + env: + # Flags WITHOUT -Werror, used for ./configure so feature probes pass. + CONFIGURE_CFLAGS: >- + -Wall -Wextra -Wformat -Wformat-security + -D_FORTIFY_SOURCE=2 -fstack-protector-strong -O2 + # Full hardened set WITH -Werror, applied at make time only. + MAKE_CFLAGS: >- + -Wall -Wextra -Werror -Wformat -Wformat-security + -D_FORTIFY_SOURCE=2 -fstack-protector-strong -O2 -DHAVE_CONFIG_H + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: prep + run: | + sudo apt-get update + sudo apt-get install -y ${{ matrix.cc }} acl libacl1-dev attr libattr1-dev \ + liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl + - name: prepare-source + run: ./prepare-source + - name: configure (hardened, no -Werror) + env: + CC: ${{ matrix.cc }} + CFLAGS: ${{ env.CONFIGURE_CFLAGS }} + run: ./configure --disable-md2man + - name: make (-Werror) + run: make CFLAGS="${MAKE_CFLAGS}" + - name: info + run: ./rsync --version diff --git a/.github/workflows/sanitizers-asan-ubsan.yml b/.github/workflows/sanitizers-asan-ubsan.yml new file mode 100644 index 000000000..1a99c9e1a --- /dev/null +++ b/.github/workflows/sanitizers-asan-ubsan.yml @@ -0,0 +1,92 @@ +name: Sanitizers (ASan + UBSan) + +# Address + Undefined-Behavior sanitizer build, exercised by the full test +# suite, the TCP-bound equivalence matrix, and the WS2 fuzz regression. +# +# GATING: GATING. Verified clean on HEAD locally: +# make check -> 99 passed, 6 skipped, 0 failed +# make check-equiv -> 1 passed (real loopback rsyncd, --use-tcp) +# fuzz regression -> 829041 runs, 0 crashes +# A new ASan/UBSan error (heap/stack overflow, use-after-free, UB) MUST fail CI. +# +# UBSan runs FULL (all check classes enabled, including pointer-overflow, +# nonnull-attribute, null, alignment). The two zero-count edge cases in xattrs.c +# are FIXED in source so UBSan is silent there. The deliberate unaligned-access +# sites (byteorder.h IVALu/SIVALu/IVAL64/SIVAL64 on !CAREFUL_ALIGNMENT x86, and +# log_delete/log_formatted pool-allocated file_struct) are suppressed at runtime +# via .github/ubsan-suppressions.txt -- this is a narrow per-function suppression, +# not a class-wide -fno-sanitize, so NEW misaligned accesses elsewhere are caught. +# ASan itself is left fully enabled. +# +# CI-ONLY: sanitizers are injected via env CFLAGS passthrough (the mechanism +# configure.ac already supports for --enable-coverage). No default build flags +# and no source/Makefile change -- exotic-platform builds are untouched. + +on: + push: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/sanitizers-asan-ubsan.yml' + pull_request: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/sanitizers-asan-ubsan.yml' + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + +env: + # Halt on the first sanitizer error and dump a stack so the failing step is + # actionable. detect_leaks=0: rsync's short-lived processes intentionally let + # the OS reclaim some allocations; LeakSanitizer noise is out of scope here. + # suppressions= silences the intentional unaligned-access sites in byteorder.h + # and log.c; all other UBSan checks remain fully active. The path is absolute + # so it resolves correctly when rsync child processes cd into test scratch dirs. + UBSAN_OPTIONS: suppressions=${{ github.workspace }}/.github/ubsan-suppressions.txt:halt_on_error=1:print_stacktrace=1 + ASAN_OPTIONS: halt_on_error=1:abort_on_error=1:detect_leaks=0 + +jobs: + asan-ubsan: + runs-on: ubuntu-latest + name: ASan + UBSan + check + check-equiv + fuzz + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: prep + run: | + sudo apt-get update + sudo apt-get install -y clang acl libacl1-dev attr libattr1-dev \ + liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl + - name: prepare-source + run: ./prepare-source + - name: configure (sanitizers) + env: + CC: clang + # fuzzer-no-link instruments the rsync wire-parser objects for the fuzz + # regression below while staying a normal (non-libFuzzer) rsync binary. + CFLAGS: >- + -g -O1 -fno-omit-frame-pointer + -fsanitize=address,undefined,fuzzer-no-link + run: ./configure --disable-md2man + - name: make + run: make + - name: info + run: ./rsync --version + # `make check` runs the suite over the secure stdio-pipe transport. Tests that + # need privileges or absent features SKIP cleanly (exit 77) and do NOT fail the + # job -- the runner distinguishes skip (77) from fail. No sudo here so the + # ASAN/UBSAN env propagates to the test rsync processes unchanged. + - name: make check (ASan/UBSan) + run: make check + # The equivalence matrix with a GUARANTEED real loopback rsyncd (--use-tcp). + - name: make check-equiv (ASan/UBSan, TCP) + run: make check-equiv + # WS2 fuzz regression: bounded corpus replay + short top-up under the same + # sanitizers; exits non-zero on any crash. + - name: fuzz regression (ASan/UBSan) + env: + FUZZ_MAX_TIME: "30" + run: make -C fuzz regression diff --git a/Makefile.in b/Makefile.in index bde2c5897..437ec7fde 100644 --- a/Makefile.in +++ b/Makefile.in @@ -363,6 +363,16 @@ check29: all $(CHECK_PROGS) $(CHECK_SYMLINKS) check30: all $(CHECK_PROGS) $(CHECK_SYMLINKS) $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) --protocol=30 +# Transport-equivalence matrix with a GUARANTEED real bound socket. +# Unlike plain `make check` (which runs the *-equiv tests with the TCP-daemon +# leg skipped via require_tcp/SkipLeg), this runs them with --use-tcp so the +# daemon legs bind a real 127.0.0.1 rsyncd -- socket-bound coverage is not +# optional here. Scoped to the *-equiv tests so the loopback ports are claimed +# for the equivalence matrix only. +.PHONY: check-equiv +check-equiv: all $(CHECK_PROGS) $(CHECK_SYMLINKS) + $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) --use-tcp '*-equiv' + # Whole-suite gcov coverage report (HTML, with branch + decision coverage). # Requires a build configured with --enable-coverage and the `gcovr` tool # (pip install gcovr). Runs the suite in parallel (COVERAGE_J, default CHECK_J): diff --git a/configure.ac b/configure.ac index 4faab5fcb..967dc14f4 100644 --- a/configure.ac +++ b/configure.ac @@ -96,6 +96,24 @@ if test x"$enable_coverage" = x"yes"; then [Flush gcov counters at exit_cleanup: rsync's children exit via _exit(), which bypasses the gcov atexit handler, so without this no .gcda is written for the receiver/generator/daemon-worker processes.]) fi +dnl Hardened build. Appends defensive compiler flags (FORTIFY_SOURCE, stack +dnl protector, format-string warnings). Defaults OFF: a plain ./configure is +dnl unchanged, so exotic platforms are unaffected. NOTE: -Werror is deliberately +dnl NOT added here -- these flags run BEFORE autoconf's feature-detection probes +dnl (struct addrinfo, sockaddr_storage, ...), and -Werror would make a probe's +dnl benign warning look like a failure, causing mis-detection and colliding +dnl fallback definitions. _FORTIFY_SOURCE=2 needs optimization, so -O2 is added +dnl only when the user has not already requested an optimization level. +AC_ARG_ENABLE(hardened, + AS_HELP_STRING([--enable-hardened],[build with hardening flags (FORTIFY_SOURCE, stack protector, format-security)])) +if test x"$enable_hardened" = x"yes"; then + case " $CFLAGS " in + *\ -O*) ;; dnl user already set an -O level; respect it + *) CFLAGS="$CFLAGS -O2" ;; + esac + CFLAGS="$CFLAGS -Wall -Wextra -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 -fstack-protector-strong" +fi + dnl openat2(RESOLVE_BENEATH) is used on Linux 5.6+ for the secure resolver. dnl --disable-openat2 forces the portable per-component O_NOFOLLOW fallback to dnl run as the primary resolver on ordinary Linux, so that tier is exercised diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 000000000..5fffa8f17 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,18 @@ +# Build artifacts (harness binaries live at the fuzz/ root; anchor with a +# leading slash so the names do not also match corpus// directories) +*.o +/fuzz_io +/fuzz_flist +/fuzz_token +/fuzz_xattrs + +# libFuzzer crash / leak reproducers (a real find should be MINIMIZED and +# committed under corpus// with a descriptive name, not left here) +crash-* +leak-* +timeout-* +oom-* + +# libFuzzer-generated corpus entries (40-hex-char names). The committed seed +# corpus uses descriptive names; generated growth is not tracked. +corpus/*/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* diff --git a/fuzz/Makefile b/fuzz/Makefile new file mode 100644 index 000000000..66a20d1dc --- /dev/null +++ b/fuzz/Makefile @@ -0,0 +1,76 @@ +# fuzz/Makefile - libFuzzer harnesses for rsync's UNTRUSTED-PEER wire parsers. +# +# This is the ONLY build-system addition in the fuzzing workstream. The +# top-level Makefile.in is untouched. The wire-parser objects (io.o, token.o, +# ...) are produced by the normal rsync build, which must have been configured +# with the campaign sanitizer CFLAGS so those objects are instrumented: +# +# CFLAGS="-g -O1 -fsanitize=fuzzer-no-link,address,undefined -fno-omit-frame-pointer" \ +# CC=clang ./configure --disable-md2man +# make io.o token.o # (configure preserves env CFLAGS) +# +# Then build the harnesses: +# +# make -C fuzz # builds every harness +# make -C fuzz fuzz_io # one harness +# +# Run regression mode (bounded, exits non-zero on crash): +# +# make -C fuzz regression +# ./fuzz/run-regression.sh # equivalent standalone runner +# +# Inside the nix shell, prefix with: +# nix develop path:$HOME/git/rsync --command bash -c 'make -C fuzz ...' + +CC ?= clang +RSYNC_DIR := .. + +# Harnesses LINK with -fsanitize=fuzzer (provides main + the coverage runtime); +# address,undefined match the instrumentation baked into the rsync objects. +FUZZ_SAN := -fsanitize=fuzzer,address,undefined +# Compiling our own harness/stub TUs: instrument them too, but no libFuzzer main. +HARNESS_SAN := -fsanitize=fuzzer-no-link,address,undefined -fno-omit-frame-pointer + +CPPFLAGS := -I$(RSYNC_DIR) -I$(RSYNC_DIR)/zlib -DHAVE_CONFIG_H +HARNESS_CFLAGS := -g -O1 -std=gnu23 $(HARNESS_SAN) $(CPPFLAGS) + +# Each harness links the named rsync object(s) + the shared stubs. +# io.o is enough for fuzz_io; token.o additionally needs io.o + zlib + (because +# token.o references them even on the CPRES_NONE path) liblz4 / libzstd. +IO_OBJS := $(RSYNC_DIR)/io.o +# rsync bundles its own zlib objects (used by recv_deflated_token); link them +# even though fuzz_token currently drives only the CPRES_NONE/simple path, so +# the object resolves all references. +ZLIB_OBJS := $(RSYNC_DIR)/zlib/deflate.o $(RSYNC_DIR)/zlib/inffast.o \ + $(RSYNC_DIR)/zlib/inflate.o $(RSYNC_DIR)/zlib/inftrees.o \ + $(RSYNC_DIR)/zlib/trees.o $(RSYNC_DIR)/zlib/zutil.o \ + $(RSYNC_DIR)/zlib/adler32.o $(RSYNC_DIR)/zlib/compress.o \ + $(RSYNC_DIR)/zlib/crc32.o +TOKEN_OBJS := $(RSYNC_DIR)/token.o $(RSYNC_DIR)/io.o $(ZLIB_OBJS) +TOKEN_LIBS := -llz4 -lzstd + +HARNESSES := fuzz_io fuzz_token + +.PHONY: all clean regression +all: $(HARNESSES) + +stubs.o: stubs.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +fuzz_io.o: fuzz_io.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +fuzz_io: fuzz_io.o stubs.o $(IO_OBJS) + $(CC) $(FUZZ_SAN) $^ -o $@ + +fuzz_token.o: fuzz_token.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +fuzz_token: fuzz_token.o stubs.o $(TOKEN_OBJS) + $(CC) $(FUZZ_SAN) $^ -o $@ $(TOKEN_LIBS) + +regression: all + ./run-regression.sh + +clean: + rm -f $(HARNESSES) *.o crash-* leak-* timeout-* oom-* diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 000000000..d2f92cf63 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,146 @@ +# Fuzzing harnesses (Workstream 2) + +libFuzzer harnesses for rsync's **UNTRUSTED-PEER wire parsers**, built so the +existing `read_*_bounded` / `MAX_WIRE_*` guards are actually exercised under +ASan + UBSan. A guard that correctly rejects a hostile value unwinds cleanly; a +genuine out-of-bounds access trips the sanitizer *before* any guard runs — so a +crash here means a real parser bug, not a harness artifact. + +## Targets + +| Harness | Surface | Status | +|----------------|-------------------------------------------|--------| +| `fuzz_io` | `io.c` primitives: `read_sum_head`, `read_varint`/`read_varlong`/`read_longint`/`read_vstring`, `read_int_bounded`/`read_varint_bounded`/`read_varint_size` | **working** | +| `fuzz_token` | `token.c` `recv_token` → `simple_recv_token` (CPRES_NONE literal-token path; the `i > CHUNK_SIZE` guard) | **working** | +| `fuzz_flist` | `flist.c` `recv_file_entry` | staged stub (see header notes) | +| `fuzz_xattrs` | `xattrs.c` `receive_xattr` | staged stub (see header notes) | + +The two stubs are no-op `LLVMFuzzerTestOneInput`s with a detailed header +documenting the exact target region, the required global-init contract, and the +dependency wall that puts a *hygienic* harness out of WS2 budget. They are not +wired into the Makefile and never run in regression. + +## Toolchain + +clang (libFuzzer) + ASan/UBSan + compiler-rt, gcc, make, python3 are all +provided by the project nix shell: + +```sh +nix develop path:$HOME/git/rsync --command bash -c '' +``` + +clang is **not** committed into the repo (the fork stays upstreamable). + +## Build + +The wire-parser objects are produced by the *normal* rsync build, configured +with the campaign sanitizer CFLAGS so those objects are instrumented (configure +preserves env `CFLAGS` — **no `Makefile.in` change is needed**): + +```sh +nix develop path:$HOME/git/rsync --command bash -c ' + ./prepare-source && + CFLAGS="-g -O1 -fsanitize=fuzzer-no-link,address,undefined -fno-omit-frame-pointer" \ + CC=clang ./configure --disable-md2man && + make io.o token.o \ + zlib/inflate.o zlib/inftrees.o zlib/inffast.o zlib/zutil.o \ + zlib/adler32.o zlib/deflate.o zlib/trees.o zlib/compress.o zlib/crc32.o && + make -C fuzz' +``` + +`make -C fuzz` builds every working harness (one `-fsanitize=fuzzer` link rule +each — the **only** build-system addition in this workstream). `make -C fuzz +fuzz_io` builds just one. + +## Run / regression mode (what WS3 CI calls) + +```sh +nix develop path:$HOME/git/rsync --command ./fuzz/run-regression.sh +``` + +`run-regression.sh` (equivalently `make -C fuzz regression`) builds each working +harness, deterministically replays its committed seed corpus (`-runs=0`), then +does a **bounded** top-up fuzz run (`-max_total_time`, default 30s) seeded from +that corpus. It **exits non-zero on any crash**. Knobs: `FUZZ_MAX_TIME`, +`FUZZ_TARGETS`. + +## Linking / stubbing strategy + +rsync is not a library, so each harness links a curated set of *unmodified* +rsync `.o` files plus `fuzz/stubs.c`. **No tracked rsync source is modified.** + +- **`fuzz_io`** = `fuzz_io.o` + `stubs.o` + `io.o`. +- **`fuzz_token`** = `fuzz_token.o` + `stubs.o` + `token.o` + `io.o` + + rsync's bundled `zlib/*.o` + `-llz4 -lzstd` (token.o references LZ4/ZSTD + symbols even though the CPRES_NONE path never calls them; the system libs + resolve them). + +`fuzz/stubs.c` supplies everything the objects reference that we do not want to +drag in: + +- **`_exit_cleanup` / `_out_of_memory` / `_overflow_exit` → `longjmp`** back to + the harness. This is the load-bearing shim: a wire-range guard calls + `exit_cleanup(RERR_*)` on a malformed value; we turn that into a clean unwind + so a *correctly rejected* input is not a crash. A real memory bug trips + ASan/UBSan *before* the guard, preserving oracle fidelity. +- **Logging no-ops**: `rprintf`, `rsyserr`, `rwrite`, `who_am_i`, `do_big_num`. +- **A self-contained `my_alloc`** (honours `max_alloc`, returns NULL on the + `file==NULL` over-limit path that `EXPAND_ITEM_LIST` relies on) so ASan tracks + every wire-driven allocation. +- **Real zero-filled `info_levels` / `debug_levels`** arrays (the `INFO_GTE` / + `DEBUG_GTE` macros index them directly; NULL would crash). +- **Default-valued globals** referenced by the objects (`stats`, `am_*`, + `io_error`, `do_compression`, `module_id`, …) and **no-op shims** for + functions only reached on code paths the parsers never enter + (`match_hard_links`, `successful_send`, `glob_expand`, `recv_file_list`, …). + +To rediscover the exact symbol set after a rebase: +`nm -u io.o | sed 's/.* U //' | sort` (and likewise for `token.o`); anything not +in libc is either a default global or a no-op shim in `stubs.c`. + +## Global-init contract + +Per `reference.md` Part 3.5, the harness controls the minimal global state so a +crash is a real parser bug: + +- `protocol_version` — `fuzz_io` derives it per-input from byte 0 (cycling + 20/26/29/30/31) to cover the `proto<27`/`<30`/`>=30` branches of + `read_sum_head` and the `varint30` width choices; `fuzz_token` pins 30. +- `xfer_sum_len` — `fuzz_io` derives it per-input (4..32); bounds `s2length` and + feeds the multiply-overflow guard in `read_sum_head`. +- `do_compression = CPRES_NONE` in `fuzz_token` (selects `simple_recv_token`). +- The iobuf is left at its default (`.in_fd = -1`). That is the whole trick: + `read_buf(f, …)` takes its `f != iobuf.in_fd` fast path straight to + `safe_read(f, …)`, so the readers pull bytes directly from a fd we control — + no multiplexing, no msg framing — with the bytes 100% attacker-chosen. + +The fuzz buffer reaches the readers via a **pipe**: write all bytes, close the +write end; reads drain the buffer then hit EOF, which `read_buf` turns into +`whine_about_eof → exit_cleanup → longjmp`. "Ran out of bytes" is therefore a +clean unwind, never a crash. + +## Seed corpus + +`corpus//` holds descriptively-named seeds (hand-built valid, boundary, +and over-range cases). libFuzzer-generated entries (40-hex-char names) are +git-ignored; a genuine crash repro should be minimized and committed under +`corpus//` with a descriptive name. + +- **`fuzz_io`** (byte 0 selects protocol/sum_len): valid sum_head (proto 30 and + proto 20), empty (count 0), `blength` exactly at `MAX_BLOCK_SIZE` and one + over, negative count, huge count (multiply-overflow guard), `s2length` over + `xfer_sum_len`, `remainder > blength`, a mixed varint+vstring stream, and an + all-`0xff` stress case (drives the `read_longint` 64-bit sentinel). +- **`fuzz_token`**: a small literal token, a zero/negative (match) token, a + literal at exactly `CHUNK_SIZE`, one over `CHUNK_SIZE` (the guard), a huge + length, and a truncated literal (EOF unwind). + +Lengths/encodings are little-endian `read_int` and follow the exact wire layout +of `read_sum_head` / `simple_recv_token`. + +## Verification result + +`fuzz_io` and `fuzz_token` both **build and run clean** under ASan+UBSan over +their seed corpora plus millions of generated inputs. No crashes were found in +the io.c or token.c (simple path) wire guards — i.e. the existing bounds hold up +under fuzzing. diff --git a/fuzz/corpus/fuzz_io/ff_stress b/fuzz/corpus/fuzz_io/ff_stress new file mode 100644 index 000000000..f6a7916bf --- /dev/null +++ b/fuzz/corpus/fuzz_io/ff_stress @@ -0,0 +1 @@ +ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ \ No newline at end of file diff --git a/fuzz/corpus/fuzz_io/mixed_varints b/fuzz/corpus/fuzz_io/mixed_varints new file mode 100644 index 0000000000000000000000000000000000000000..e8c6562bd09269d1ea8a321c2bfb524817151695 GIT binary patch literal 28 Ucmb1QKm#lSKmpeB{Gyx`01AHsTmS$7 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_io/sumhead_blength_max b/fuzz/corpus/fuzz_io/sumhead_blength_max new file mode 100644 index 0000000000000000000000000000000000000000..faa06c096250c75b0e31ec05d1d09ade34733970 GIT binary patch literal 17 Scmb1QWB>ss1_3b5zyJUOZvZX; literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_io/sumhead_blength_over b/fuzz/corpus/fuzz_io/sumhead_blength_over new file mode 100644 index 0000000000000000000000000000000000000000..59336fdf3e96ca077b58b93f39c4c185f92bd57d GIT binary patch literal 17 Ucmb1QWME)mWMEYwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj KFkrxdfe#FaNC1HV literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_token/lit_end b/fuzz/corpus/fuzz_token/lit_end new file mode 100644 index 0000000000000000000000000000000000000000..593f4708db84ac8fd0f5cc47c634f38c013fe9e4 GIT binary patch literal 4 LcmZQzU|;|M00aO5 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_token/lit_huge b/fuzz/corpus/fuzz_token/lit_huge new file mode 100644 index 000000000..59a2022ec --- /dev/null +++ b/fuzz/corpus/fuzz_token/lit_huge @@ -0,0 +1 @@ +ÿÿÿ \ No newline at end of file diff --git a/fuzz/corpus/fuzz_token/lit_over b/fuzz/corpus/fuzz_token/lit_over new file mode 100644 index 0000000000000000000000000000000000000000..009d73a31973e2082917509b8596bb343d4265ab GIT binary patch literal 4 LcmZQj0N(KN04FQV) literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_token/match_token b/fuzz/corpus/fuzz_token/match_token new file mode 100644 index 000000000..7a557ea6e --- /dev/null +++ b/fuzz/corpus/fuzz_token/match_token @@ -0,0 +1 @@ +ùÿÿÿ \ No newline at end of file diff --git a/fuzz/fuzz_flist.c b/fuzz/fuzz_flist.c new file mode 100644 index 000000000..8ee3cf356 --- /dev/null +++ b/fuzz/fuzz_flist.c @@ -0,0 +1,49 @@ +/* + * fuzz_flist.c - STAGED STUB (not yet wired into fuzz/Makefile). + * + * Intended target: recv_file_entry() (flist.c ~682-1169) - the sender->receiver + * file-list entry stream, with the stateful lastname/thisname reconstruction + * (reference.md Part 3.2) and the symlink-target path (line ~1131/1164). Guards + * of interest: the l2 >= MAXPATHLEN - l1 overflow check (line 724) and the + * linkname_len bounds (line 941). + * + * WHY THIS IS A STUB (honest blocker, not laziness): + * recv_file_entry is NOT self-contained the way io.c's primitives are. The + * flist.o object has ~167 undefined references that recv_file_entry's call + * graph actually reaches, spanning subsystems we would have to either link + * (dragging un-instrumented complexity + their own transitive deps) or stub + * so extensively that we risk masking the very bugs we want to find: + * - uid/gid mapping (add_uid, add_gid, uid_to_user, ...) + * - path utilities (clean_fname, sanitize_path, + * count_dir_elements, push/pop_local_filters) + * - filtering (check_filter, name_is_excluded, filter_list) + * - checksums (file_checksum, csum_len_for_type) + * - acls / xattrs (get_acl, get_xattr, ...) + * - the flist pool allocator + flist_expand + * Doing this hygienically is a workstream of its own. Within the WS2 budget, + * io.c (proven) and token.c (CPRES_NONE path, proven) were prioritized. + * + * REQUIRED GLOBAL INIT when this is completed (reference.md Part 3.5): + * protocol_version (>=30 for varint30 widths), preserve_links/devices/specials, + * sender_symlink_iconv = NULL, munge_symlinks = 0, sanitize_paths = 0, + * uid_ndx/gid_ndx/acls_ndx/xattrs_ndx (drive which optional fields are read), + * file_extra_cnt + the *_extra index globals (control F_* slot layout), and a + * real struct file_list with an alloc pool (flist->files / flist->pool). + * Crucially, recv_file_entry is STATEFUL: its static lastname[] persists, so a + * useful harness must feed a SEQUENCE of entries (with/without XMIT_SAME_NAME) + * per input - reset/re-init the statics between inputs is not possible from + * outside, so accept the documented cross-input coupling as fuzz_token does. + * + * Strategy to finish (sketch): link flist.o + io.o + util1.o/util2.o + uidlist.o + * + exclude.o + hashtable.o + the flist pool, stub only the leaf I/O syscalls + * and logging, and assert ASan stays the oracle (guards => longjmp unwind). + */ + +#include +#include + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + (void)data; (void)size; + return 0; /* no-op until the link surface above is built out */ +} diff --git a/fuzz/fuzz_io.c b/fuzz/fuzz_io.c new file mode 100644 index 000000000..7c6c5e663 --- /dev/null +++ b/fuzz/fuzz_io.c @@ -0,0 +1,126 @@ +/* + * fuzz_io.c - libFuzzer harness for rsync's io.c wire-reading primitives. + * + * Targets (all UNTRUSTED-PEER parsers, reference.md Part 3.1): + * read_sum_head, read_varint, read_varlong, read_longint, read_vstring, + * read_int_bounded, read_varint_bounded, read_varint_size, read_int, read_byte. + * + * PLUMBING (the load-bearing trick): + * io.c's read_buf(f, buf, len) has a fast path: + * if (f != iobuf.in_fd) { safe_read(f, buf, len); ... return; } + * i.e. when the fd is NOT the registered multiplexed input fd, every reader + * pulls bytes straight from that fd via safe_read()/read(2) - no iobuf, no + * multiplexing, no msg framing. We exploit that: iobuf stays at its default + * ({.in_fd = -1}), and we hand the readers a fd backed by the fuzz buffer. + * So real parser logic + real guards run, with bytes 100% attacker-chosen. + * + * The fuzz buffer is delivered through a pipe: write all bytes, close the + * write end => reads drain the buffer then hit EOF (read()==0). On EOF + * safe_read returns short, read_buf calls whine_about_eof()->exit_cleanup(), + * which our stub turns into a longjmp back here. So "ran out of bytes" is a + * clean unwind, never a crash. + * + * ORACLE FIDELITY: + * A correctly-rejected hostile value (over-range count/length/etc.) calls + * exit_cleanup() AFTER the guard => clean longjmp, not a finding. A genuine + * OOB read/write or UB happens during the read itself and trips ASan/UBSan + * BEFORE any guard => real finding. The stubs never mask memory errors. + * + * GLOBAL INIT CONTRACT (reference.md Part 3.5): + * protocol_version, xfer_sum_len, and the log-level arrays are provided by + * fuzz/stubs.c. The first byte of each input selects protocol_version and + * xfer_sum_len so a single corpus exercises the proto<27 / <30 / >=30 + * branches of read_sum_head and the varint30 width choices. + */ + +#include "rsync.h" +#include +#include +#include + +/* From io.c (unmodified object under test). */ +extern int32 read_int(int f); +extern int32 read_varint(int f); +extern int64 read_varlong(int f, uchar min_bytes); +extern int64 read_longint(int f); +extern int read_vstring(int f, char *buf, int bufsize); +extern int32 read_int_bounded(int f, int32 lo, int32 hi, const char *what); +extern int32 read_varint_bounded(int f, int32 lo, int32 hi, const char *what); +extern size_t read_varint_size(int f, size_t max, const char *what); +extern uchar read_byte(int f); +extern void read_sum_head(int f, struct sum_struct *sum); + +/* From fuzz/stubs.c */ +extern jmp_buf fuzz_unwind_env; +extern int fuzz_unwind_armed; +extern int protocol_version; +extern int xfer_sum_len; + +/* Open a read fd backed by the given bytes: write to a pipe, close write end. */ +static int fd_from_bytes(const uint8_t *data, size_t size) +{ + int fds[2]; + if (pipe(fds) != 0) + return -1; + /* For inputs larger than the pipe buffer we'd block on write; cap the + * payload to a generous bound (parsers never legitimately need more in + * one call than this, and the corpus stays small/fast). */ + size_t off = 0; + /* Make the write end non-blocking so an over-large input can't deadlock; + * we simply stop feeding once the pipe is full - the reader hits EOF + * after consuming what fit, which is fine for fuzzing. */ + fcntl(fds[1], F_SETFL, O_NONBLOCK); + while (off < size) { + ssize_t n = write(fds[1], data + off, size - off); + if (n <= 0) + break; + off += (size_t)n; + } + close(fds[1]); + return fds[0]; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + if (size < 1) + return 0; + + /* Byte 0 chooses protocol + digest length to cover all branches. */ + uint8_t sel = data[0]; + static const int protos[] = { 20, 26, 29, 30, 31 }; + protocol_version = protos[(sel >> 1) % 5]; + /* xfer_sum_len in a realistic range (4..32); read_sum_head bounds s2length + * against it, and the multiply-overflow guard uses it. */ + xfer_sum_len = 4 + (sel & 0x1f); + if (xfer_sum_len > 32) + xfer_sum_len = 32; + + data++; size--; + + int f = fd_from_bytes(data, size); + if (f < 0) + return 0; + + fuzz_unwind_armed = 1; + if (setjmp(fuzz_unwind_env) == 0) { + /* Drive a sequence of readers off the same byte stream. Order is + * arbitrary; whichever consumes the bytes first wins, the rest hit + * EOF and longjmp out. read_sum_head is the headline target. */ + struct sum_struct sum; + read_sum_head(f, &sum); + + (void)read_varint(f); + (void)read_varlong(f, 3); + (void)read_longint(f); + (void)read_int_bounded(f, -1000, 1000, "fuzz int"); + (void)read_varint_bounded(f, 0, 0x7fffffff, "fuzz varint"); + (void)read_varint_size(f, MAXPATHLEN, "fuzz size"); + + char vbuf[MAXPATHLEN]; + (void)read_vstring(f, vbuf, sizeof vbuf); + } + fuzz_unwind_armed = 0; + + close(f); + return 0; +} diff --git a/fuzz/fuzz_token.c b/fuzz/fuzz_token.c new file mode 100644 index 000000000..8bbb2f432 --- /dev/null +++ b/fuzz/fuzz_token.c @@ -0,0 +1,85 @@ +/* + * fuzz_token.c - libFuzzer harness for rsync's token/delta decode (token.c). + * + * Target: recv_token() with do_compression == CPRES_NONE, i.e. + * simple_recv_token() (reference.md Part 3.3, lines 282-311). Headline guard: + * the "i > CHUNK_SIZE" length check (token.c ~line 298) that stops a hostile + * peer from driving read_buf() past the static CHUNK_SIZE literal buffer. + * + * Plumbing: identical fd trick to fuzz_io - bytes arrive via a pipe and the + * io.c readers take the non-iobuf safe_read() fast path. recv_token pulls a + * 4-byte length via read_int then a literal run via read_buf. + * + * STATE CAVEAT (documented, not a defect): simple_recv_token keeps a file-local + * `residue`/`buf`. We drive recv_token in a loop until it returns <= 0 (a + * clean chunk boundary where residue==0), so most iterations leave residue==0. + * If an iteration unwinds mid-run (EOF/guard longjmp), residue can carry into + * the next input; that cannot cause a FALSE crash (the i>CHUNK_SIZE guard and + * read_buf's own length still bound every access) - it only adds harmless + * cross-input coupling. The CPRES_ZLIB (recv_deflated_token) path is NOT wired + * here precisely because its zlib stream state is not externally resettable + * between iterations; see fuzz/README.md. + * + * Globals: do_compression set per-input; LZ4/ZSTD entry points are stubbed in + * fuzz/stubs.c (never reached under CPRES_NONE) so the object links. + */ + +#include "rsync.h" +#include +#include +#include + +extern int32 recv_token(int f, char **data); +extern int do_compression; + +extern jmp_buf fuzz_unwind_env; +extern int fuzz_unwind_armed; +extern int protocol_version; + +static int fd_from_bytes(const uint8_t *data, size_t size) +{ + int fds[2]; + if (pipe(fds) != 0) + return -1; + fcntl(fds[1], F_SETFL, O_NONBLOCK); + size_t off = 0; + while (off < size) { + ssize_t n = write(fds[1], data + off, size - off); + if (n <= 0) + break; + off += (size_t)n; + } + close(fds[1]); + return fds[0]; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + if (size < 1) + return 0; + + protocol_version = 30; + do_compression = CPRES_NONE; /* simple_recv_token path */ + + int f = fd_from_bytes(data, size); + if (f < 0) + return 0; + + fuzz_unwind_armed = 1; + if (setjmp(fuzz_unwind_env) == 0) { + char *out; + int32 n; + /* Consume the whole stream as a sequence of literal tokens until a + * clean end (<=0) or EOF longjmp. Bounded loop guards against a + * pathological 0-length spin. */ + for (int i = 0; i < 1 << 20; i++) { + n = recv_token(f, &out); + if (n <= 0) + break; + } + } + fuzz_unwind_armed = 0; + + close(f); + return 0; +} diff --git a/fuzz/fuzz_xattrs.c b/fuzz/fuzz_xattrs.c new file mode 100644 index 000000000..e33dcf3ea --- /dev/null +++ b/fuzz/fuzz_xattrs.c @@ -0,0 +1,46 @@ +/* + * fuzz_xattrs.c - STAGED STUB (not yet wired into fuzz/Makefile). + * + * Intended target: receive_xattr() (xattrs.c ~771-877) - the per-file xattr + * wire decode (reference.md Part 3.4). The actual fuzz-worthy region is the + * bounded-read loop, lines 780-820: + * ndx = read_varint(f) (guard 782) + * count = read_varint_bounded(f, 0, MAX_WIRE_XATTR_COUNT) (793) + * name_len = read_varint_size(f, MAX_WIRE_XATTR_NAMELEN) (802) + * datum_len = read_varint_size(f, MAX_WIRE_XATTR_DATALEN) (803) + * overflow guard: SIZE_MAX - dget_len < extra_len || ... (806) + * read_buf(name), trailing-'\0' check (810-814) + * read_buf(datum / abbrev checksum) (815-819) + * + * WHY THIS IS A STUB (honest blocker): + * receive_xattr is one function: after the parse loop it unconditionally calls + * rsync_xal_store(), which reaches xattr_lookup_hash() -> sum_init/sum_update/ + * sum_end (the checksum subsystem: openssl/xxhash/md*, ~all of checksum.o) and + * hashtable_create/hashtable_find (hashtable.o). It also calls f_name() and, + * if saw_xattr_filter, name_is_excluded() (exclude.o + filter state). We cannot + * exercise the bounded-read region without also linking that storage tail. + * Linking checksum.o pulls a large, mostly-irrelevant surface; stubbing + * sum_*/hashtable_* risks masking an OOB that occurs in the abbreviated-datum + * (XSTATE_ABBREV) checksum copy at line 819. Out of WS2 budget after io + token. + * + * REQUIRED GLOBAL INIT when completed (reference.md Part 3.5): + * protocol_version, xfer_sum_len, xattr_sum_len (used by the abbrev-datum + * branch, line 819), preserve_xattrs = 2, saw_xattr_filter = 0 (to skip the + * exclude.o path), am_root, file_extra_cnt + xattrs_ndx (for F_XATTR slot), + * and rsync_xal_l / rsync_xal_h reset to empty between inputs (statics: + * document the cross-input coupling as in fuzz_token). + * + * Strategy to finish (sketch): link xattrs.o + io.o + hashtable.o + checksum.o + * (+ its crypto deps) OR provide faithful sum_*/hashtable_* shims that still + * let ASan see every wire-driven allocation and the line-819 abbrev copy. + * Set saw_xattr_filter = 0 so name_is_excluded()/exclude.o is never reached. + */ + +#include +#include + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + (void)data; (void)size; + return 0; /* no-op until the storage-tail link surface is built out */ +} diff --git a/fuzz/run-regression.sh b/fuzz/run-regression.sh new file mode 100755 index 000000000..8f967b95e --- /dev/null +++ b/fuzz/run-regression.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# fuzz/run-regression.sh - CI regression mode (Workstream 3 calls this). +# +# Builds every harness and replays its committed seed corpus under +# ASan/UBSan for a BOUNDED time. Exits NON-ZERO on the first crash so CI +# fails on any regression. It does NOT fuzz open-endedly; it is a fast, +# deterministic gate (libFuzzer in corpus-replay mode + a short top-up run). +# +# Run inside the nix shell that provides clang/libFuzzer: +# nix develop path:$HOME/git/rsync --command ./fuzz/run-regression.sh +# +# Env knobs: +# FUZZ_MAX_TIME seconds of top-up fuzzing per target after replay (default 30) +# FUZZ_TARGETS space-separated subset of targets (default: all built) + +set -eu + +cd "$(dirname "$0")" + +MAX_TIME="${FUZZ_MAX_TIME:-30}" +TARGETS="${FUZZ_TARGETS:-fuzz_io}" + +# Ensure the rsync wire-parser objects exist & are sanitizer-instrumented. +# (CI is expected to have configured with the campaign CFLAGS already.) +make -C .. io.o >/dev/null + +make all + +rc=0 +for t in $TARGETS; do + echo "=== regression: $t ===" + corpus="corpus/$t" + mkdir -p "$corpus" + # 1) Deterministic replay of every committed seed (runs=0 => just the corpus). + if ! ./"$t" -runs=0 "$corpus"; then + echo "FAIL: $t crashed replaying seed corpus" >&2 + rc=1 + continue + fi + # 2) Bounded top-up fuzz seeded from the corpus; any crash file => fail. + if ! ./"$t" -max_total_time="$MAX_TIME" -print_final_stats=0 "$corpus"; then + echo "FAIL: $t crashed during bounded fuzz run" >&2 + rc=1 + fi +done + +if [ "$rc" -eq 0 ]; then + echo "All fuzz regression targets clean." +fi +exit "$rc" diff --git a/fuzz/stubs.c b/fuzz/stubs.c new file mode 100644 index 000000000..384beffe8 --- /dev/null +++ b/fuzz/stubs.c @@ -0,0 +1,176 @@ +/* + * fuzz/stubs.c - minimal external symbols required to link rsync wire-parser + * object files (io.o, token.o, ...) into a standalone libFuzzer harness. + * + * Strategy (see fuzz/README.md "Linking / stubbing strategy"): + * - The object under test (io.o etc.) is compiled UNMODIFIED from rsync's + * own sources with the campaign sanitizer CFLAGS. + * - Everything io.o references that we do NOT want to drag in (logging, + * cleanup, the rest of rsync's translation units) is supplied here. + * - The single most important stub is _exit_cleanup(): rsync's wire-range + * guards call exit_cleanup(RERR_*) on a malformed/over-range value. In the + * real program that terminates the process; in the fuzzer we longjmp back + * to the harness so a *correctly rejected* hostile input is NOT counted as + * a crash. A genuine memory bug still trips ASan/UBSan BEFORE any guard + * fires, so this preserves oracle fidelity: guard-hit => clean unwind, + * real OOB => sanitizer abort. + * + * No rsync source file is modified by this workstream; all shims live here. + */ + +#include "rsync.h" +#include + +/* Harness sets this up; exit_cleanup / out_of_memory / overflow_exit unwind to it. */ +jmp_buf fuzz_unwind_env; +int fuzz_unwind_armed; + +/* ------- functions io.o (and friends) call that we shim ------- */ + +NORETURN void _exit_cleanup(int code, const char *file, int line) +{ + (void)code; (void)file; (void)line; + if (fuzz_unwind_armed) + longjmp(fuzz_unwind_env, 1); + /* Not armed: abort loudly rather than silently mis-behaving. */ + _exit(99); +} + +NORETURN void _out_of_memory(const char *msg, const char *file, int line) +{ + (void)msg; (void)file; (void)line; + if (fuzz_unwind_armed) + longjmp(fuzz_unwind_env, 2); + _exit(99); +} + +NORETURN void _overflow_exit(const char *msg, const char *file, int line) +{ + (void)msg; (void)file; (void)line; + if (fuzz_unwind_armed) + longjmp(fuzz_unwind_env, 3); + _exit(99); +} + +void rprintf(enum logcode code, const char *format, ...) { (void)code; (void)format; } +void rsyserr(enum logcode code, int errcode, const char *format, ...) +{ (void)code; (void)errcode; (void)format; } +void rwrite(enum logcode code, const char *buf, int len, int is_utf8) +{ (void)code; (void)buf; (void)len; (void)is_utf8; } + +const char *who_am_i(void) { return "fuzz"; } + +char *do_big_num(int64 num, int human_flag, const char *fract) +{ + static char buf[32]; + (void)human_flag; (void)fract; + snprintf(buf, sizeof buf, "%lld", (long long)num); + return buf; +} + +int msleep(int t) { (void)t; return 0; } + +/* my_alloc: a self-contained allocator so ASan tracks every wire-driven + * allocation. Mirrors rsync's semantics closely enough for the parsers: + * honours max_alloc, returns NULL when file==NULL on over-limit (callers like + * EXPAND_ITEM_LIST rely on that), zero-fills on the calloc sentinel. */ +char *do_calloc = "42"; +extern size_t max_alloc; + +void *my_alloc(void *ptr, size_t num, size_t size, const char *file, int line) +{ + (void)line; + if (size && num >= max_alloc / size) { + if (!file) + return NULL; + _exit_cleanup(RERR_MALLOC, file, line); + } + if (!ptr || ptr == do_calloc) + return calloc(num ? num : 1, size ? size : 1); + return realloc(ptr, num * size); +} + +/* ------- global state io.o references; defaults are fine for the parsers ------- */ + +struct stats stats; +size_t max_alloc = 1u << 30; /* 1 GiB cap so over-range counts still get rejected by guards */ + +int protocol_version = PROTOCOL_VERSION; +int xfer_sum_len = 16; /* MD5-ish default; harness may override */ +int file_extra_cnt = 0; + +int am_server = 0, am_sender = 0, am_generator = 0, am_receiver = 0, am_root = 0; +int local_server = 0, daemon_connection = 0; +int inc_recurse = 0; +int io_error = 0; +int io_timeout = 0; +int batch_fd = -1; +int eol_nulls = 0; +int read_batch = 0; +int list_only = 0; +int protect_args = 0; +int checksum_seed = 0; +int flist_eof = 0; +int compat_flags = 0; +int file_total = 0; +int file_old_total = 0; +int preserve_hard_links = 0; +int remove_source_files = 0; +int extra_flist_sending_enabled = 0; +int msgs2stderr = 0; +int flush_ok_after_signal = 0; +int bwlimit = 0; +size_t bwlimit_writemax = 0; +int stop_at_utime = 0; + +/* INFO_GTE / DEBUG_GTE index these directly, so they must be real zero arrays + * (all log verbosity off => parser hot path, no rprintf side effects). */ +short info_levels[COUNT_INFO]; +short debug_levels[COUNT_DEBUG]; + +struct file_list *cur_flist = NULL; + +/* ------- functions io.o references but the parser paths never reach ------- */ + +void check_for_finished_files(int itemizing, enum logcode code, int check_redo) +{ (void)itemizing; (void)code; (void)check_redo; } + +struct file_list *flist_for_ndx(int ndx, const char *fatal_error_msg) +{ (void)ndx; (void)fatal_error_msg; return NULL; } + +struct file_list *recv_file_list(int f, int dir_ndx) { (void)f; (void)dir_ndx; return NULL; } +void send_extra_file_list(int f, int at_least) { (void)f; (void)at_least; } + +int flist_ndx_pop(flist_ndx_list *lp) { (void)lp; return -1; } +void flist_ndx_push(flist_ndx_list *lp, int ndx) { (void)lp; (void)ndx; } + +void log_delete(const char *fname, int mode) { (void)fname; (void)mode; } +void match_hard_links(struct file_list *flist) { (void)flist; } +void successful_send(int ndx) { (void)ndx; } +int glob_expand(const char *arg, char ***argv_p, int *argc_p, int *maxargs_p) +{ (void)arg; (void)argv_p; (void)argc_p; (void)maxargs_p; return 0; } +void glob_expand_module(char *base1, char *arg, char ***argv_p, int *argc_p, int *maxargs_p) +{ (void)base1; (void)arg; (void)argv_p; (void)argc_p; (void)maxargs_p; } + +void add_implied_include(const char *arg, int skip_daemon_module) { (void)arg; (void)skip_daemon_module; } +void free_implied_include_partial_string(void) {} +void implied_include_partial_string(const char *s_start, const char *s_end) { (void)s_start; (void)s_end; } + +int iconvbufs(iconv_t ic, xbuf *in, xbuf *out, int flags) +{ (void)ic; (void)in; (void)out; (void)flags; return 0; } +iconv_t ic_send = (iconv_t)-1; +iconv_t ic_recv = (iconv_t)-1; + +char *filesfrom_convert = NULL; + +/* ------- token.c (compression) globals/shims ------- */ +/* do_compression is set per-input by fuzz_token; CPRES_NONE => simple path. */ +int do_compression = 0; +int do_compression_level = 0; +int do_compression_threads = 0; +int module_id = -1; +char *skip_compress = NULL; + +char *lp_dont_compress(int module_id_) { (void)module_id_; return NULL; } +char *map_ptr(struct map_struct *map, OFF_T offset, int32 len) +{ (void)map; (void)offset; (void)len; return NULL; } diff --git a/testsuite/content-fidelity_test.py b/testsuite/content-fidelity_test.py new file mode 100644 index 000000000..17eca656d --- /dev/null +++ b/testsuite/content-fidelity_test.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Workstream-1 invariant group A -- content fidelity. + +These tests assert properties the spec + source promise INDEPENDENTLY of any +known bug: + + A1: after a transfer, the destination bytes are exactly the source bytes. + receiver.c receive_data() (~478-483) computes a whole-file checksum and + returns 0 (failure) on mismatch, so a completed transfer that rsync + reports as successful must be byte-correct. We assert byte-correctness + of the result ONLY -- never a specific digest algorithm, whose strength + is protocol-dependent. + + A2: quick-check semantics. generator.c quick_check_ok() (~623-646): + - default (size+mtime): a dest with matching size+mtime is SKIPPED + even if its content differs; + - --size-only: matching size alone SKIPS; + - -c/--checksum (always_checksum): content is compared, so a same + size+mtime-but-different-content dest is RE-SENT; + - -I/--ignore-times: the mtime fast-path is forced off, so the file + is RE-SENT regardless of size+mtime. + We assert each documented behavior by checking whether the dest content + was (or was not) overwritten with the source content. +""" + +import os + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_same, make_data_file, makepath, rmtree, run_rsync, test_fail, +) + + +# -------------------------------------------------------------------------- +# A1 -- post-transfer whole-file content is byte-correct. +# -------------------------------------------------------------------------- +# A mix of sizes (including 0 and a multi-block file) and both whole-file and +# delta paths. For the delta path we seed the destination with a slightly +# different prior version so rsync must reconstruct via block matching, then +# verify the reconstructed bytes equal the source exactly. + +rmtree(FROMDIR) +rmtree(TODIR) +makepath(FROMDIR) + +sizes = { + 'empty': 0, + 'tiny': 3, + 'oneblock': 700, + 'multiblock': 300000, +} +for name, sz in sizes.items(): + make_data_file(FROMDIR / name, sz) + +# Whole-file transfer (-W forces no delta): every dest byte must equal source. +run_rsync('-aW', f'{FROMDIR}/', f'{TODIR}/') +for name in sizes: + assert_same(FROMDIR / name, TODIR / name, label=f'A1 whole-file {name}') + +# Delta transfer: pre-seed the dest with a perturbed copy of the multiblock +# file, then change the source and re-sync WITHOUT -W so the delta algorithm +# runs. The reconstructed file must still be byte-identical to the new source. +rmtree(TODIR) +makepath(TODIR) +# Seed dest from an OLDER source content. +make_data_file(TODIR / 'multiblock', 300000) +# Now make the real source a fresh, different multiblock file. +make_data_file(FROMDIR / 'multiblock', 305000) +run_rsync('-a', '--no-whole-file', f'{FROMDIR}/multiblock', f'{TODIR}/multiblock') +assert_same(FROMDIR / 'multiblock', TODIR / 'multiblock', + label='A1 delta reconstruct') + + +# -------------------------------------------------------------------------- +# A2 -- quick-check semantics across default / --size-only / -c / -I. +# -------------------------------------------------------------------------- +# Build a destination file that has IDENTICAL size and mtime to the source but +# DIFFERENT content. quick_check_ok() must skip it by default and under +# --size-only, but re-send it under -c and under -I. + +A2 = SCRATCHDIR / 'a2' + + +def _setup_same_size_mtime_diff_content(): + """Create src/f and dst/f: same size, same mtime, different bytes.""" + rmtree(A2) + src = A2 / 'src' + dst = A2 / 'dst' + makepath(src, dst) + # Same length, different content. Fixed bytes (not urandom) so the two + # differ deterministically while sharing an exact size. + (src / 'f').write_bytes(b'A' * 4096) + (dst / 'f').write_bytes(b'B' * 4096) + # Force identical mtime (and atime) on both, to the nanosecond. + st = os.stat(src / 'f') + os.utime(dst / 'f', ns=(st.st_atime_ns, st.st_mtime_ns)) + # Sanity: sizes equal, contents differ, mtimes equal. + assert os.stat(src / 'f').st_size == os.stat(dst / 'f').st_size + if (src / 'f').read_bytes() == (dst / 'f').read_bytes(): + test_fail('A2 setup: src and dst content unexpectedly equal') + return src, dst + + +def _dst_matches_src(src, dst) -> bool: + return (src / 'f').read_bytes() == (dst / 'f').read_bytes() + + +# Default quick-check: size+mtime match -> SKIP -> dst keeps its OWN content. +src, dst = _setup_same_size_mtime_diff_content() +run_rsync('-a', f'{src}/', f'{dst}/') +if _dst_matches_src(src, dst): + test_fail('A2 default: dest with same size+mtime+different content was ' + 're-sent, but quick-check should have SKIPPED it') + +# --size-only: size match alone -> SKIP -> dst keeps its own content. +src, dst = _setup_same_size_mtime_diff_content() +run_rsync('-a', '--size-only', f'{src}/', f'{dst}/') +if _dst_matches_src(src, dst): + test_fail('A2 --size-only: dest with same size was re-sent, but ' + '--size-only should have SKIPPED it') + +# -c / --checksum: content is compared -> mismatch -> RE-SEND -> dst == src. +src, dst = _setup_same_size_mtime_diff_content() +run_rsync('-ac', f'{src}/', f'{dst}/') +if not _dst_matches_src(src, dst): + test_fail('A2 -c: dest with same size+mtime but different content was ' + 'NOT re-sent, but --checksum must compare content and re-send') +assert_same(src / 'f', dst / 'f', label='A2 -c result') + +# -I / --ignore-times: mtime fast-path forced off -> RE-SEND -> dst == src. +src, dst = _setup_same_size_mtime_diff_content() +run_rsync('-aI', f'{src}/', f'{dst}/') +if not _dst_matches_src(src, dst): + test_fail('A2 -I: dest with same size+mtime but different content was ' + 'NOT re-sent, but --ignore-times must force a re-send') +assert_same(src / 'f', dst / 'f', label='A2 -I result') + +print('content-fidelity: A1 byte-correctness + A2 quick-check semantics ' + 'verified') diff --git a/testsuite/delete-backup-invariants_test.py b/testsuite/delete-backup-invariants_test.py new file mode 100644 index 000000000..2cb5244e0 --- /dev/null +++ b/testsuite/delete-backup-invariants_test.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +"""Workstream-1 invariant group D -- deletion and backup. + +Derived from rsync.1.md + generator.c delete_in_dir, delete.c, backup.c. + + D1: --delete only removes within recursed directories. A sibling dir that + is NOT recursed into is left untouched. We assert the deletion bound: + an extra file inside the recursed tree is removed; an extra inside a + dir the transfer never descends into survives. + + D2: (prime transport-equivalence case) a sender-side IO error SUPPRESSES + all deletion unless --ignore-errors. generator.c delete_in_dir ~297: + `if (io_error & IOERR_GENERAL && !ignore_errors) ... skip deletion`. + io_error is WIRE-PROPAGATED, so this must behave identically local vs + daemon. Mechanism: an UNREADABLE SOURCE DIRECTORY (mode 000) makes the + sender's opendir() fail during the flist scan -- which runs BEFORE + deletion -- setting io_error. (An unreadable regular file fails only + later in send_files, AFTER --delete-during has already deleted, so it + does NOT reliably suppress; the unreadable-directory mechanism is the + one that sets io_error pre-deletion.) We use --delete-before so the + suppression is DETERMINISTIC: with --delete-during/-delay the top- + level dir is deleted before the scan even reaches the unreadable + subdir that sets io_error, so a shallow extra races the error; + --delete-before (and -after) finish the io_error-setting scan before + any deletion, which is the regime that actually exercises the + delete_in_dir guard (verified on HEAD: -before/-after suppress, + -during/-delay race). We assert: + * --delete-before alone -> NO deletions (extra survives) + * --delete-before --ignore-errors -> deletions proceed (extra gone) + and that both behaviors are IDENTICAL across local and daemon legs. + + max-delete: --max-delete=N deletes at most N files (delete.c ~156). We + assert the cap holds: with N extras > limit, exactly limit are removed. + + D4: delete timing default is protocol/version dependent + (--delete-during/-before/-after). We assert the FINAL deletion SET is + identical across every explicit timing variant, NEVER mid-transfer + ordering. + + backup: --backup renames the existing dst before overwrite (backup.c + make_backup). We assert the backup file (with ~ suffix, and with + --backup-dir) holds the OLD content while the dst holds the NEW + content. +""" + +import os +import subprocess + +import rsyncfns +from rsyncfns import ( + SCRATCHDIR, makepath, rmtree, run_rsync, test_fail, +) +from equiv_fns import ( + SkipLeg, _bin_argv, _join_slash, _start_daemon_for_bin, write_daemon_conf, +) + +ROOT = SCRATCHDIR / 'dgroup' +rmtree(ROOT) +makepath(ROOT) + + +def read_file(p): + return p.read_text() + + +# -------------------------------------------------------------------------- +# D1 -- --delete only removes within recursed directories. +# -------------------------------------------------------------------------- +# Transfer src/keep/ (a single subdir) into dst/keep/. The dst ALSO has a +# sibling dst/untouched/ that the transfer never names. --delete may remove +# the extra inside dst/keep/ but MUST NOT touch dst/untouched/. +d1 = ROOT / 'd1' +src = d1 / 'src' +dst = d1 / 'dst' +makepath(src / 'keep', dst / 'keep', dst / 'untouched') +(src / 'keep' / 'f.txt').write_text('kept content\n') +(dst / 'keep' / 'extra_in_recursed.txt').write_text('should be deleted\n') +(dst / 'untouched' / 'extra_in_sibling.txt').write_text('must survive\n') + +# Transfer ONLY the keep subtree (src/keep/ -> dst/keep/). dst/untouched is +# outside the transfer's namespace and is never recursed into. +run_rsync('-a', '--delete', f'{src}/keep/', f'{dst}/keep/') +if (dst / 'keep' / 'extra_in_recursed.txt').exists(): + test_fail('D1 VIOLATION: extra file inside the recursed dir was NOT ' + 'deleted; --delete should remove it.') +if not (dst / 'untouched' / 'extra_in_sibling.txt').exists(): + test_fail('D1 VIOLATION: a file in a sibling dir the transfer never ' + 'recursed into was deleted. --delete must be bounded to the ' + 'recursed tree.') + + +# -------------------------------------------------------------------------- +# D2 -- sender IO error suppresses deletion (transport-equivalent). +# -------------------------------------------------------------------------- +# Mechanism (see module docstring): an unreadable SOURCE DIRECTORY makes the +# sender's opendir fail during the flist scan -> io_error set BEFORE deletion. +def d2_setup(work, *, ignore_errors): + """Populate a work dir for a D2 leg. + + Layout (work is the daemon module root): + work/src/good/a.txt (transfers fine) + work/src/blocked/ (mode 000 -> opendir fails on sender) + work/dst/good/a.txt + work/dst/extra_to_delete.txt (the deletion probe) + Returns the dst path. + """ + rmtree(work) + src = work / 'src' + dst = work / 'dst' + makepath(src / 'good', src / 'blocked', dst / 'good') + (src / 'good' / 'a.txt').write_text('transferable body\n') + (src / 'blocked' / 'inner.txt').write_text('cannot be read\n') + (dst / 'extra_to_delete.txt').write_text('deletion probe\n') + os.chmod(src / 'blocked', 0o000) + return dst + + +def run_d2_leg(transport, *, ignore_errors, port): + """Run the D2 scenario over one transport. Returns whether the extra + deletion-probe file SURVIVED (True == deletion suppressed). + + Each daemon leg gets a DISTINCT port: the test framework leaves prior + test daemons alive until process exit (atexit kill), so reusing a port + would let a stale daemon -- still serving the previous leg's module path + -- answer the connection and silently produce the wrong result. + """ + rsync_bin = rsyncfns.RSYNC + work = ROOT / f'd2-{transport}-{"ign" if ignore_errors else "plain"}' + dst = d2_setup(work, ignore_errors=ignore_errors) + src = _join_slash(work, 'src/') + opts = ['-a', '--delete-before'] + if ignore_errors: + opts.append('--ignore-errors') + base = _bin_argv(rsync_bin) + opts + + try: + if transport == 'local': + argv = base + [src, f'{dst}/'] + elif transport in ('daemon_pipe', 'daemon_tcp'): + use_tcp = (transport == 'daemon_tcp') + if use_tcp and not rsyncfns.USE_TCP: + raise SkipLeg('daemon_tcp needs --use-tcp') + tag = f'd2-{transport}-{"ign" if ignore_errors else "plain"}' + conf = write_daemon_conf( + [('equiv', {'path': str(work), 'read only': 'no'})], + globals={'pid file': str(work / 'rsyncd.pid')}, + name=f'{tag}.conf', + ) + if use_tcp: + # A prior daemon_pipe leg sets RSYNC_CONNECT_PROG, which would + # hijack this TCP client into the stale pipe daemon. Clear it. + os.environ.pop('RSYNC_CONNECT_PROG', None) + prefix = _start_daemon_for_bin(rsync_bin, conf, port, use_tcp=use_tcp) + argv = base + [src, f'{prefix}equiv/dst/'] + else: + raise ValueError(transport) + # Restore perms after the run regardless of outcome so cleanup works. + proc = subprocess.run(argv, capture_output=True, text=True) + # A sender IO error yields exit 23 (partial); that is expected here. + if proc.returncode not in (0, 23): + test_fail(f'[D2/{transport}] unexpected exit {proc.returncode}: ' + f'{" ".join(argv)}\n{proc.stderr}') + survived = (dst / 'extra_to_delete.txt').exists() + return survived + finally: + # Restore perms on every dir under the work tree so the scratch dir + # can be cleaned (the transfer may have created a mode-000 'blocked' + # dir in the dst). Walk bottom-up, chmod 0o755 any unreadable dir. + for dirpath, dirnames, _ in os.walk(work): + for d in dirnames: + p = os.path.join(dirpath, d) + try: + os.chmod(p, 0o755) + except OSError: + pass + + +# Run both modes across local + daemon_pipe (+ daemon_tcp under --use-tcp). +d2_transports = ['local', 'daemon_pipe'] +if rsyncfns.USE_TCP: + d2_transports.append('daemon_tcp') + +# D2 relies on chmoding a source dir to 0o000 to force an opendir IO-error. +# Under euid==0 (real root or fakeroot), DAC is bypassed: the mode-000 dir is +# still readable, the IO-error never fires, and the "suppressed" leg falsely +# looks like a VIOLATION. Skip D2 cleanly (print a notice and leave +# suppress_results/ignore_results empty so the cross-transport checks below +# are also skipped) rather than false-failing under fakeroot/root. The rest +# of the test (max-delete, D4, backup, D7) continues unaffected. +_d2_root_skip = (os.geteuid() == 0) +if _d2_root_skip: + print('D2 SKIPPED: euid==0 -- DAC bypass means mode-000 dir trick cannot ' + 'force an opendir IO-error under root; D2 is not verifiable here.') + +suppress_results = {} +ignore_results = {} +_next_port = [12893] +def _port(): + p = _next_port[0] + _next_port[0] += 1 + return p +if not _d2_root_skip: + for t in d2_transports: + try: + suppress_results[t] = run_d2_leg(t, ignore_errors=False, port=_port()) + ignore_results[t] = run_d2_leg(t, ignore_errors=True, port=_port()) + except SkipLeg as e: + print(f'[D2/{t}] skipped: {e}') + + if not suppress_results: + test_fail('D2: no transport legs ran') + +# Per-leg invariant: --delete alone suppresses (survived), --ignore-errors +# proceeds (deleted). +for t in suppress_results: + if not suppress_results[t]: + test_fail(f'[D2/{t}] VIOLATION: a sender IO error did NOT suppress ' + f'deletion. The deletion probe was removed under --delete ' + f'alone; delete_in_dir must skip deletion when io_error is ' + f'set (this protects against destructive deletion on a ' + f'broken/partial transfer).') + if ignore_results[t]: + test_fail(f'[D2/{t}] VIOLATION: --ignore-errors did NOT re-enable ' + f'deletion; the deletion probe survived. With ' + f'--ignore-errors, io_error must not suppress deletion.') + +# Transport-equivalence: every leg must agree on BOTH behaviors. io_error is +# wire-propagated, so a daemon leg that disagreed with local would be a +# silent transport divergence. (Skipped under root; suppress_results is empty.) +if not _d2_root_skip: + if len(set(suppress_results.values())) != 1: + test_fail(f'D2 TRANSPORT DIVERGENCE: IO-error deletion suppression ' + f'differs across transports: {suppress_results}') + if len(set(ignore_results.values())) != 1: + test_fail(f'D2 TRANSPORT DIVERGENCE: --ignore-errors deletion behavior ' + f'differs across transports: {ignore_results}') + + +# -------------------------------------------------------------------------- +# max-delete -- at most N files removed. +# -------------------------------------------------------------------------- +md = ROOT / 'maxdelete' +src = md / 'src' +dst = md / 'dst' +makepath(src, dst) +(src / 'keep.txt').write_text('keep\n') +N_EXTRA = 5 +LIMIT = 2 +for i in range(N_EXTRA): + (dst / f'extra{i}.txt').write_text('x\n') +# rsync exits 25 when the max-delete limit stops further deletions. +run_rsync('-a', '--delete', f'--max-delete={LIMIT}', f'{src}/', f'{dst}/', + check=False) +remaining = sorted(dst.glob('extra*')) +if len(remaining) != N_EXTRA - LIMIT: + test_fail(f'max-delete VIOLATION: with --max-delete={LIMIT} and ' + f'{N_EXTRA} extras, expected {N_EXTRA - LIMIT} survivors, ' + f'found {len(remaining)}: {remaining}. The cap must bound ' + f'deletions at N.') + + +# -------------------------------------------------------------------------- +# D4 -- delete-timing variants yield the SAME final deletion set. +# -------------------------------------------------------------------------- +# Assert FINAL STATE only, never mid-transfer ordering. +d4src = ROOT / 'd4src' +makepath(d4src / 'sub') +(d4src / 'a.txt').write_text('a\n') +(d4src / 'sub' / 'b.txt').write_text('b\n') + +EXTRAS = ['extra_top.txt', 'sub/extra_deep.txt'] +final_sets = {} +for variant in ('--delete-before', '--delete-during', '--delete-delay', + '--delete-after'): + d4dst = ROOT / f'd4dst-{variant.strip("-")}' + rmtree(d4dst) + makepath(d4dst / 'sub') + for e in EXTRAS: + (d4dst / e).write_text('garbage\n') + run_rsync('-a', '--delete', variant, f'{d4src}/', f'{d4dst}/') + # Record the surviving extras (the complement of the deletion set). + survivors = {e for e in EXTRAS if (d4dst / e).exists()} + final_sets[variant] = survivors + +distinct = {frozenset(s) for s in final_sets.values()} +if len(distinct) != 1: + test_fail(f'D4 VIOLATION: delete-timing variants produced DIFFERENT ' + f'final deletion sets: {final_sets}. The timing must not ' + f'change the final state, only when deletion happens.') +if distinct != {frozenset()}: + test_fail(f'D4 VIOLATION: some extras were NOT deleted: {final_sets}') + + +# -------------------------------------------------------------------------- +# backup -- old content preserved in backup, new content in dst. +# -------------------------------------------------------------------------- +# Plain --backup (~ suffix). +bk = ROOT / 'backup' +src = bk / 'src' +dst = bk / 'dst' +makepath(src, dst) +(src / 'f.txt').write_text('NEW content -- distinct length\n') +(dst / 'f.txt').write_text('OLD content\n') +# Give the source a clearly newer mtime so the quick-check (size+mtime) sees +# a change and re-sends; the existing dst is then backed up before overwrite. +os.utime(src / 'f.txt', (10_000_000_000, 10_000_000_000)) +run_rsync('-a', '--backup', f'{src}/', f'{dst}/') +if read_file(dst / 'f.txt') != 'NEW content -- distinct length\n': + test_fail('backup VIOLATION: dst does not hold the NEW content after ' + '--backup overwrite.') +bak = dst / 'f.txt~' +if not bak.exists(): + test_fail('backup VIOLATION: expected backup file f.txt~ was not created.') +if read_file(bak) != 'OLD content\n': + test_fail(f'backup VIOLATION: backup file does not hold the OLD content; ' + f'got {read_file(bak)!r}. make_backup must rename the existing ' + f'dst before overwrite.') + +# --backup-dir: backup lands under the backup dir, holding the OLD content. +bkd = ROOT / 'backupdir' +src = bkd / 'src' +dst = bkd / 'dst' +bdir = bkd / 'bak' +makepath(src, dst, bdir) +(src / 'f.txt').write_text('NEW2 content -- distinct length\n') +(dst / 'f.txt').write_text('OLD2 content\n') +os.utime(src / 'f.txt', (10_000_000_000, 10_000_000_000)) +run_rsync('-a', '--backup', f'--backup-dir={bdir}', f'{src}/', f'{dst}/') +if read_file(dst / 'f.txt') != 'NEW2 content -- distinct length\n': + test_fail('backup-dir VIOLATION: dst does not hold the NEW content.') +bdfile = bdir / 'f.txt' +if not bdfile.exists(): + test_fail('backup-dir VIOLATION: backup not placed in --backup-dir.') +if read_file(bdfile) != 'OLD2 content\n': + test_fail(f'backup-dir VIOLATION: backup-dir copy does not hold the OLD ' + f'content; got {read_file(bdfile)!r}.') + +# -------------------------------------------------------------------------- +# D7 -- daemon backup-dir confinement (TCP-daemon only). +# -------------------------------------------------------------------------- +# A daemon sanitizes --backup-dir: a leading slash is replaced by the module +# path, so an absolute --backup-dir=/escape is rooted INSIDE the module +# (rsyncd.conf.5.md ~241). The client cannot make the daemon write backups +# outside the module via an absolute path. We push to a module with --backup +# --backup-dir=/escape and assert the backup landed at /escape, NOT +# at the real filesystem /escape. Needs a bound socket; cleanly skips when +# --use-tcp is not set. +_d2_status = ('D2 skipped (root/fakeroot)' if _d2_root_skip + else f'D2 legs: suppress={suppress_results}, ignore={ignore_results}') +if not rsyncfns.USE_TCP: + print('delete-backup-invariants: D7 (daemon backup-dir confinement) ' + f'skipped (needs --use-tcp). D1/max-delete/D4/backup verified. ' + f'{_d2_status}') +else: + d7 = ROOT / 'd7' + module_root = d7 / 'module' + src = d7 / 'src' + rmtree(d7) + makepath(module_root, src) + # Seed the module with an existing file that will be overwritten (and so + # backed up) by the push. + (module_root / 'f.txt').write_text('OLD daemon content\n') + (src / 'f.txt').write_text('NEW daemon content -- distinct length\n') + os.utime(src / 'f.txt', (10_000_000_000, 10_000_000_000)) + + conf = write_daemon_conf( + [('bk', {'path': str(module_root), 'read only': 'no'})], + globals={'pid file': str(d7 / 'rsyncd.pid')}, + name='d7-backupconf.conf', + ) + os.environ.pop('RSYNC_CONNECT_PROG', None) + prefix = _start_daemon_for_bin(rsyncfns.RSYNC, conf, 12899, use_tcp=True) + + # Sentinel: ensure /escape (real fs root) is not writable garbage we'd + # mistake for success; we never expect anything to be created there. + argv = _bin_argv(rsyncfns.RSYNC) + [ + '-a', '--backup', '--backup-dir=/escape', + f'{src}/', f'{prefix}bk/', + ] + proc = subprocess.run(argv, capture_output=True, text=True) + if proc.returncode not in (0, 23): + test_fail(f'[D7] push exited {proc.returncode}: {" ".join(argv)}\n' + f'{proc.stderr}') + + # The new content must be in the module. + if read_file(module_root / 'f.txt') != 'NEW daemon content -- distinct length\n': + test_fail('D7: module dst does not hold the NEW content.') + # The backup of the OLD content must be CONFINED to the module: the + # leading-slash path /escape is rooted at /escape. + confined = module_root / 'escape' / 'f.txt' + if not confined.exists(): + test_fail('D7 VIOLATION: --backup-dir=/escape backup was not found at ' + f'/escape ({confined}). The daemon must sanitize a ' + 'leading-slash backup-dir to be module-rooted.') + if read_file(confined) != 'OLD daemon content\n': + test_fail(f'D7 VIOLATION: confined backup does not hold the OLD ' + f'content; got {read_file(confined)!r}.') + print('delete-backup-invariants: D1/max-delete/D4/backup + D7 (daemon ' + f'backup-dir confinement, TCP) verified. {_d2_status}') diff --git a/testsuite/equiv_fns.py b/testsuite/equiv_fns.py new file mode 100644 index 000000000..21c126411 --- /dev/null +++ b/testsuite/equiv_fns.py @@ -0,0 +1,537 @@ +"""Transport-equivalence harness for rsync tests. + +This module runs a single transfer *scenario* across rsync's four transports +and structurally diffs the resulting destination trees, partitioning every +difference into "must be byte-equal" vs "may differ only by a documented +mapping" (uid/gid/ACL-id/xattr-namespace, tolerated when unprivileged). + +The four transports: + + * ``local`` -- a plain local rsync (src and dst are both paths). + * ``ssh`` -- a remote-shell transfer via support/lsh.sh + (``-e lsh localhost:DEST`` with ``--rsync-path``). + * ``daemon_pipe`` -- an rsync:// daemon reached over a private stdio pipe + (RSYNC_CONNECT_PROG; opens no listening socket). + * ``daemon_tcp`` -- an rsync:// daemon bound to a real 127.0.0.1 socket. + Only runs under ``--use-tcp`` (see ``require_tcp``); + degrades to a clean skip otherwise. + +Black-box driving +----------------- +Every transport is driven through a single ``rsync_bin`` parameter that +defaults to the RSYNC env command but accepts an arbitrary rsync binary +path. The daemon side is launched from the *same* ``rsync_bin``, so the +whole matrix can be pointed at, e.g., a v3.4.2 vs a v3.4.3 binary without +touching the comparison logic. This is what the later proof-of-oracle uses +to demonstrate that #915 regressed link-dest-over-daemon between releases. + +The comparison logic (``capture_tree`` / ``diff_trees`` / ``partition_diffs``) +never sees the binary; it works purely off the on-disk result, so it is +independent of how the binary under test was built. +""" + +from __future__ import annotations + +import os +import shlex +import stat +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path + +import rsyncfns +from rsyncfns import ( + RSYNC, SCRATCHDIR, SRCDIR, + claim_ports, require_tcp, rmtree, test_fail, write_daemon_conf, +) + + +LSH = str(SRCDIR / 'support' / 'lsh.sh') + +# The four transports we assert equivalence across. ``daemon_tcp`` is the +# only one that needs a real listening socket; the rest work under plain +# ``make check``. +TRANSPORTS = ('local', 'ssh', 'daemon_pipe', 'daemon_tcp') + + +# -------------------------------------------------------------------------- +# Privilege model +# -------------------------------------------------------------------------- + +def am_root() -> bool: + return os.geteuid() == 0 + + +def numeric_ids_only() -> bool: + """True when we can expect uid/gid to be reproduced verbatim. + + Owner is only preserved when running as root (``-o`` is a no-op for an + unprivileged client, and a non-root daemon cannot set uid/gid at all -- + write_daemon_conf() comments out the uid/gid lines off-root). When this + is False, an owner/group divergence is the *documented mapping*, not a + defect, and partition_diffs() tolerates it. + """ + return am_root() + + +# -------------------------------------------------------------------------- +# Black-box rsync invocation (independent of the RSYNC env binary) +# -------------------------------------------------------------------------- + +def _bin_argv(rsync_bin: str) -> list: + """argv prefix for an arbitrary rsync binary command. + + ``rsync_bin`` may itself be multi-word (e.g. 'valgrind ... rsync' or a + binary plus '--protocol=N'); shlex-split it so subprocess gets a real + argv. Defaults are handled by the callers passing RSYNC. + """ + return shlex.split(rsync_bin) + + +@dataclass +class RunResult: + transport: str + returncode: int + stdout: str + stderr: str + argv: list + + +# -------------------------------------------------------------------------- +# Per-transport daemon plumbing, parametrized by rsync_bin +# -------------------------------------------------------------------------- + +def _start_daemon_for_bin(rsync_bin: str, conf_path: Path, port: int, + *, use_tcp: bool) -> str: + """Bring up a daemon running ``rsync_bin`` and return the URL prefix. + + Mirrors rsyncfns.start_test_daemon but launches the *given* binary so an + external rsync can be driven black-box. In pipe mode this sets + RSYNC_CONNECT_PROG (no socket); in TCP mode it spawns a real loopback + rsyncd via the rsyncfns kill-on-exit machinery. + """ + if use_tcp: + # A prior daemon_pipe leg in the same process sets RSYNC_CONNECT_PROG, + # which would hijack this TCP client into the stale pipe daemon (the + # client prefers the connect prog over a real socket). Clear it so the + # TCP leg actually uses the bound socket. + os.environ.pop('RSYNC_CONNECT_PROG', None) + claim_ports(port) + argv = _bin_argv(rsync_bin) + [ + '--daemon', '--no-detach', + '--address=127.0.0.1', + f'--port={port}', + f'--config={conf_path}', + ] + proc = subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + preexec_fn=rsyncfns._set_pdeathsig, + ) + import atexit + atexit.register(rsyncfns._stop_rsyncd, proc) + + deadline = time.monotonic() + 10 + last_err = None + import socket as _socket + while time.monotonic() < deadline: + if proc.poll() is not None: + test_fail(f"daemon ({rsync_bin}) exited before listening on " + f"port {port} (status={proc.returncode})") + try: + with _socket.create_connection(('127.0.0.1', port), timeout=0.5): + return f'rsync://localhost:{port}/' + except OSError as e: + last_err = e + time.sleep(0.05) + rsyncfns._stop_rsyncd(proc) + test_fail(f"daemon ({rsync_bin}) never listened on 127.0.0.1:{port}: " + f"{last_err}") + # Pipe mode: the client forks the daemon over a private stdio pipe. + os.environ['RSYNC_CONNECT_PROG'] = f'{rsync_bin} --config={conf_path} --daemon' + return 'rsync://localhost/' + + +# -------------------------------------------------------------------------- +# Scenario runner +# -------------------------------------------------------------------------- + +@dataclass +class Scenario: + """A transfer to replay across transports. + + ``opts`` : rsync options (NOT including src/dst), e.g. ['-aH', '--delete']. + ``rel_src`` : source path relative to the per-transport work dir; trailing + slash semantics are preserved. + ``rel_dst`` : destination path relative to the per-transport work dir. + ``setup`` : callable(workdir: Path) -> None, populates the work dir + (source tree, any link-dest/compare-dest basis dirs, an + existing destination to be --deleted into, etc.). Run fresh + per transport so each leg starts from identical inputs. + ``module_subpath`` : for daemon transports, the path *inside* the module + that maps to ``rel_dst`` (the module is rooted at the work + dir). Defaults to ``rel_dst``. + ``expect_returncode`` : the rsync exit status this scenario is expected to + produce on EVERY transport leg. Defaults to 0. Scenarios that + legitimately partial-transfer (e.g. ``--delete`` racing a + vanished source, or some ``--link-dest`` setups) set this to + 23 explicitly so a *new* partial-transfer regression in a + scenario that should return 0 is no longer silently tolerated. + """ + opts: list + rel_src: str + rel_dst: str + setup: object + extra_opts_for: dict = field(default_factory=dict) + expect_returncode: int = 0 + + +def _work_dir(transport: str) -> Path: + d = SCRATCHDIR / f'equiv-{transport}' + rmtree(d) + d.mkdir(parents=True) + return d + + +def _join_slash(base: Path, rel: str) -> str: + """Join base/rel preserving a trailing slash on rel (rsync-significant).""" + s = str(base / rel) + if rel.endswith('/') and not s.endswith('/'): + s += '/' + return s + + +def run_scenario(scenario: Scenario, transport: str, *, + rsync_bin: str = None, port: int = 12890) -> tuple: + """Run ``scenario`` over ``transport`` against ``rsync_bin``. + + Returns ``(dest_dir: Path, RunResult)``. The destination tree is left on + disk for capture_tree(). ``rsync_bin`` defaults to the RSYNC env command. + + For ``daemon_tcp`` the caller is responsible for having gated on + require_tcp(); this function will still skip the leg cleanly if invoked + without --use-tcp by raising SkipLeg. + """ + if rsync_bin is None: + rsync_bin = RSYNC + + work = _work_dir(transport) + scenario.setup(work) + + src = _join_slash(work, scenario.rel_src) + dst_dir = work / scenario.rel_dst.rstrip('/') + extra = scenario.extra_opts_for.get(transport, []) + base_argv = _bin_argv(rsync_bin) + list(scenario.opts) + list(extra) + + if transport == 'local': + dst = _join_slash(work, scenario.rel_dst) + argv = base_argv + [src, dst] + + elif transport == 'ssh': + dst = _join_slash(work, scenario.rel_dst) + argv = base_argv + ['-e', LSH, f'--rsync-path={rsync_bin}', + src, f'localhost:{dst}'] + + elif transport in ('daemon_pipe', 'daemon_tcp'): + use_tcp = (transport == 'daemon_tcp') + if use_tcp and not rsyncfns.USE_TCP: + raise SkipLeg('daemon_tcp needs --use-tcp') + # Module rooted at the work dir; the daemon writes into it via a + # module-relative path. read-only=no so a push can land. + conf = write_daemon_conf( + [('equiv', {'path': str(work), 'read only': 'no'})], + name=f'equiv-{transport}.conf', + ) + prefix = _start_daemon_for_bin(rsync_bin, conf, port, use_tcp=use_tcp) + module_dst = scenario.rel_dst + url = f'{prefix}equiv/{module_dst}' + argv = base_argv + [src, url] + else: + raise ValueError(f'unknown transport {transport!r}') + + proc = subprocess.run(argv, capture_output=True, text=True) + return dst_dir, RunResult(transport, proc.returncode, + proc.stdout, proc.stderr, argv) + + +class SkipLeg(Exception): + """Raised to skip a single transport leg (e.g. TCP without --use-tcp).""" + + +# -------------------------------------------------------------------------- +# Structural tree capture +# -------------------------------------------------------------------------- + +@dataclass +class FileEntry: + path: str # relative path within the captured root + ftype: str # 'file' | 'dir' | 'symlink' | 'other' + size: int + mode: int # st_mode & 0o7777 + mtime: int # whole seconds + mtime_nsec: int # nanosecond remainder + linktarget: str # symlink target, or '' for non-symlinks + uid: int + gid: int + content_sha: str # sha256 of regular-file content, or '' otherwise + ino_key: int # (dev, ino) collapsed into a group id; see capture_tree + + +@dataclass +class Tree: + root: Path + entries: dict # path -> FileEntry + ino_groups: dict # ino_key -> sorted list of paths + deletion_set: set = field(default_factory=set) + + +def _sha256(path: Path) -> str: + import hashlib + h = hashlib.sha256() + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(1 << 16), b''): + h.update(chunk) + return h.hexdigest() + + +def capture_tree(root) -> Tree: + """Walk ``root`` with os.lstat/os.scandir and record per-file structure. + + Inode grouping: files that share a (st_dev, st_ino) get the same + ``ino_key`` and are listed together in ``ino_groups`` -- this is what the + hardlink / link-dest assertions read. We key on (dev, ino) so a link-dest + that lands on a different filesystem can never be mistaken for a shared + inode. + """ + root = Path(root) + entries: dict = {} + devino_to_group: dict = {} + ino_groups: dict = {} + next_group = [0] + + def group_for(st) -> int: + key = (st.st_dev, st.st_ino) + if key not in devino_to_group: + devino_to_group[key] = next_group[0] + next_group[0] += 1 + return devino_to_group[key] + + def walk(d: Path, relbase: str): + with os.scandir(d) as it: + for de in sorted(it, key=lambda e: e.name): + rel = de.name if not relbase else f'{relbase}/{de.name}' + st = os.lstat(de.path) + m = st.st_mode + if stat.S_ISDIR(m): + ftype, target, content = 'dir', '', '' + entries[rel] = _entry(rel, ftype, st, target, content, + group_for(st)) + walk(Path(de.path), rel) + elif stat.S_ISLNK(m): + target = os.readlink(de.path) + entries[rel] = _entry(rel, 'symlink', st, target, '', + group_for(st)) + elif stat.S_ISREG(m): + content = _sha256(Path(de.path)) + entries[rel] = _entry(rel, 'file', st, '', content, + group_for(st)) + else: + entries[rel] = _entry(rel, 'other', st, '', '', + group_for(st)) + + if root.exists(): + walk(root, '') + + for rel, e in entries.items(): + ino_groups.setdefault(e.ino_key, []).append(rel) + for k in ino_groups: + ino_groups[k].sort() + + return Tree(root=root, entries=entries, ino_groups=ino_groups) + + +def _entry(rel, ftype, st, target, content, group) -> FileEntry: + nsec = getattr(st, 'st_mtime_ns', int(st.st_mtime * 1e9)) % 1_000_000_000 + return FileEntry( + path=rel, + ftype=ftype, + size=st.st_size if ftype != 'dir' else 0, + mode=stat.S_IMODE(st.st_mode), + mtime=int(st.st_mtime), + mtime_nsec=nsec, + linktarget=target, + uid=st.st_uid, + gid=st.st_gid, + content_sha=content, + ino_key=group, + ) + + +# -------------------------------------------------------------------------- +# Structural diff + partition +# -------------------------------------------------------------------------- + +# Fields that must be byte-equal across every transport. +STRICT_FIELDS = ('ftype', 'size', 'mode', 'mtime', 'mtime_nsec', + 'linktarget', 'content_sha') +# Fields that may legitimately differ by a documented mapping when +# unprivileged (asserted equal only when numeric_ids_only()). +MAPPED_FIELDS = ('uid', 'gid') + + +@dataclass +class Diff: + path: str + field: str + a: object + b: object + + +def diff_trees(a: Tree, b: Tree) -> dict: + """Compare two captured trees field by field. + + Returns a dict with: + 'only_in_a' / 'only_in_b' : paths present in exactly one tree + 'strict' : list[Diff] over STRICT_FIELDS + 'mapped' : list[Diff] over MAPPED_FIELDS + 'ino_group_mismatch' : list of human-readable strings where the + inode-grouping partition differs + """ + out = { + 'only_in_a': sorted(set(a.entries) - set(b.entries)), + 'only_in_b': sorted(set(b.entries) - set(a.entries)), + 'strict': [], + 'mapped': [], + 'ino_group_mismatch': [], + } + for path in sorted(set(a.entries) & set(b.entries)): + ea, eb = a.entries[path], b.entries[path] + for f in STRICT_FIELDS: + va, vb = getattr(ea, f), getattr(eb, f) + if va == vb: + continue + # Directory mtime nanoseconds: rsync's default --modify-window=0 + # compares directory times at whole-second granularity (rsync.1.md), + # so the nanosecond remainder of a directory's mtime is not + # preserved unless -@ -1 / --modify-window=-1 is given to enable + # strict sub-second comparison. Additionally, protocol-30 drops + # nsec in the wire encoding for directories. The whole-second dir + # mtime is still strict; only the nsec remainder goes to 'mapped'. + if f == 'mtime_nsec' and ea.ftype == 'dir': + out['mapped'].append(Diff(path, 'dir_mtime_nsec', va, vb)) + continue + out['strict'].append(Diff(path, f, va, vb)) + for f in MAPPED_FIELDS: + va, vb = getattr(ea, f), getattr(eb, f) + if va != vb: + out['mapped'].append(Diff(path, f, va, vb)) + + # Inode grouping equivalence: build, for each tree, the partition of + # shared-inode sets (groups of size > 1 are the meaningful ones). + def shared_sets(t: Tree) -> set: + return frozenset( + frozenset(paths) for paths in t.ino_groups.values() + if len(paths) > 1 + ) + sa, sb = shared_sets(a), shared_sets(b) + if sa != sb: + out['ino_group_mismatch'].append( + f'shared-inode partitions differ: {sorted(map(sorted, sa))} ' + f'!= {sorted(map(sorted, sb))}' + ) + return out + + +def partition_diffs(diff: dict) -> tuple: + """Split a diff_trees() result into (fatal, tolerated) lists of strings. + + Fatal: + * any membership difference (only_in_a / only_in_b) -- the deletion set + and file set must match across transports; + * any STRICT_FIELDS difference; + * any inode-grouping mismatch. + Tolerated (only when NOT numeric_ids_only()): + * uid/gid differences, the documented owner-mapping divergence on an + unprivileged daemon. When running as root these are promoted to fatal. + """ + fatal, tolerated = [], [] + for p in diff['only_in_a']: + fatal.append(f'present only in A: {p}') + for p in diff['only_in_b']: + fatal.append(f'present only in B: {p}') + for d in diff['strict']: + fatal.append(f'{d.path}: {d.field} {d.a!r} != {d.b!r}') + for d in diff['ino_group_mismatch']: + fatal.append(d) + for d in diff['mapped']: + msg = f'{d.path}: {d.field} {d.a!r} != {d.b!r}' + if d.field in MAPPED_FIELDS: + # uid/gid: the owner mapping. Tolerated only when unprivileged; + # promoted to fatal under root, where owner MUST be preserved. + if numeric_ids_only(): + fatal.append(msg + ' (root: owner must be preserved)') + else: + tolerated.append(msg + ' (unprivileged owner-mapping, tolerated)') + else: + # Non-owner documented mapping (e.g. directory mtime nsec): always + # tolerated, never owner-dependent. + tolerated.append(msg + ' (documented transport non-equivalence)') + return fatal, tolerated + + +# -------------------------------------------------------------------------- +# High-level equivalence assertion +# -------------------------------------------------------------------------- + +def run_matrix(scenario: Scenario, *, rsync_bin: str = None, + port: int = 12890, transports=TRANSPORTS) -> dict: + """Run ``scenario`` across all transports, returning {transport: Tree}. + + Legs that cannot run (daemon_tcp without --use-tcp) are recorded as None + and skipped, not failed. + """ + trees: dict = {} + for t in transports: + try: + dst_dir, res = run_scenario(scenario, t, rsync_bin=rsync_bin, + port=port) + except SkipLeg: + trees[t] = None + continue + if res.returncode != scenario.expect_returncode: + test_fail(f'[{t}] rsync exited {res.returncode}, expected ' + f'{scenario.expect_returncode}: ' + f'{" ".join(res.argv)}\n{res.stderr}') + trees[t] = capture_tree(dst_dir) + return trees + + +def assert_equivalent(trees: dict, *, reference: str = 'local') -> list: + """Assert all present trees are equivalent to the reference transport. + + Fatal diffs call test_fail(). Returns the list of tolerated-diff strings + (for the caller to log). Skipped legs (None) are ignored. + """ + if trees.get(reference) is None: + # Reference itself skipped (shouldn't happen for 'local'); pick the + # first present tree as reference. + present = [t for t, v in trees.items() if v is not None] + if not present: + test_fail('no transport legs ran') + reference = present[0] + + ref_tree = trees[reference] + all_tolerated: list = [] + for t, tree in trees.items(): + if tree is None or t == reference: + continue + diff = diff_trees(ref_tree, tree) + fatal, tolerated = partition_diffs(diff) + all_tolerated += [f'[{reference} vs {t}] {m}' for m in tolerated] + if fatal: + detail = '\n '.join(fatal) + test_fail(f'transport divergence {reference} vs {t}:\n {detail}') + return all_tolerated diff --git a/testsuite/idempotence_test.py b/testsuite/idempotence_test.py new file mode 100644 index 000000000..3448bbc12 --- /dev/null +++ b/testsuite/idempotence_test.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Workstream-1 invariant group E -- idempotence / round-trip. + + E1: a second identical `rsync -a` run transfers nothing. With -i the + itemized-change output for an already-synced tree must be empty (modulo + the documented dir-time restamping precision -- a directory line whose + only change is a sub-second time is not a real transfer). We assert no + file/content/metadata item is emitted on the second leg. + + E2: sync A->B, then reverse-sync B->A, is a no-op on the second leg -- the + reverse transfer itemizes nothing and leaves both trees structurally + equivalent. Diffs are PARTITIONED via equiv_fns.partition_diffs so an + unprivileged uid/gid mapping is tolerated rather than false-failing. + +Both legs reuse equiv_fns.capture_tree/diff_trees/partition_diffs for the +structural comparison rather than reinventing tree-walking. +""" + +import os + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + make_tree, rmtree, run_rsync, test_fail, +) +from equiv_fns import capture_tree, diff_trees, partition_diffs + + +# An itemized line is a "real transfer" unless it is purely a directory whose +# only changed attribute is a sub-second time. rsync emits dir lines like +# "cd+++++++++ d/" on creation and ".d..t...... d/" for a pure time restamp. +# We treat a line as a real change unless it is a '.d' line with no change +# code other than 't' (time) / '.' in the attribute field -- i.e. a dir whose +# content/perms/owner did NOT change. Everything else is a genuine transfer. +def _real_changes(itemized: str): + real = [] + for line in itemized.splitlines(): + line = line.rstrip() + if not line: + continue + # Itemized change strings are 11 chars (YXcstpoguax) then a space then + # the name. Anything that isn't an itemized line (warnings, etc.) is + # conservatively treated as a real change so we never hide a problem. + if len(line) < 12 or line[11] != ' ': + real.append(line) + continue + code = line[:11] + update, ftype = code[0], code[1] + attrs = code[2:] + # A directory whose only "change" is a timestamp (or nothing) is the + # documented dir-time restamp, not a transfer. update char is '.' (no + # data/checksum change) and ftype is 'd'. + if update == '.' and ftype == 'd': + non_time = attrs.replace('t', '.').replace('T', '.') + if set(non_time) <= {'.', '+'}: + continue # pure dir-time restamp: tolerated + real.append(line) + return real + + +def _structural_fatal(tree_a, tree_b): + """diff_trees(tree_a, tree_b), partition it, and return the FATAL strings. + + Beyond equiv_fns.partition_diffs (which tolerates dir-time nsec and the + unprivileged uid/gid mapping), we also tolerate a SYMLINK's mtime_nsec: + Linux preserves symlink sub-second mtime via utimensat(AT_SYMLINK_NOFOLLOW), + but platforms such as macOS use lutimes(3) which has only whole-second + resolution, so the nanosecond remainder of a symlink's mtime may not survive + a round-trip on those systems. The whole-second symlink mtime is still + enforced as strict via the 'mtime' field. + + Membership ('only_in_*') diffs are KEPT fatal: every caller here compares + two trees that an idempotent/round-trip sync is supposed to have made + set-identical (FROMDIR vs TODIR after a sync; A before vs after a reverse + leg; A vs B after a round-trip). A file present in one tree but absent in + the other is therefore a real divergence -- a dropped or spuriously-created + entry -- not an artifact of walking two unrelated roots, so it MUST surface. + """ + diff = diff_trees(tree_a, tree_b) + sym_nsec = { + d.path for d in diff['strict'] + if d.field == 'mtime_nsec' + and tree_a.entries.get(d.path) + and tree_a.entries[d.path].ftype == 'symlink' + } + fatal, _tolerated = partition_diffs(diff) + out = [] + for m in fatal: + if any(m.startswith(f'{p}: mtime_nsec ') for p in sym_nsec): + continue + out.append(m) + return out + + +def _itemized_second_leg(src, dst, *opts): + """Run rsync twice; return the real-change lines of the SECOND run.""" + run_rsync('-a', *opts, f'{src}/', f'{dst}/') + out = run_rsync('-ai', *opts, f'{src}/', f'{dst}/', + check=True, capture_output=True).stdout + return _real_changes(out), out + + +# -------------------------------------------------------------------------- +# E1 -- a second identical `rsync -a` run transfers nothing. +# -------------------------------------------------------------------------- +# A representative tree: nested dirs, regular files at depth, a hard-link pair, +# and a symlink -- so the idempotence claim covers every entry kind. +rmtree(FROMDIR) +rmtree(TODIR) +make_tree(FROMDIR, depth=3, data=True) +os.link(FROMDIR / 'f0', FROMDIR / 'f0_hl') +os.symlink('f0', FROMDIR / 'sl') + +real, raw = _itemized_second_leg(FROMDIR, TODIR, '-H') +if real: + test_fail('E1: a second identical -aH run transferred items (expected ' + 'none beyond tolerated dir-time restamps):\n ' + + '\n '.join(real) + + f'\n--- full itemized output ---\n{raw}') + +# Structural confirmation: src and dst trees are equivalent after the no-op +# (partitioned so an unprivileged owner mapping and symlink-time sub-second +# precision are tolerated, not fatal). +fatal = _structural_fatal(capture_tree(FROMDIR), capture_tree(TODIR)) +if fatal: + test_fail('E1: src and dst trees diverge after idempotent sync:\n ' + + '\n '.join(fatal)) + + +# -------------------------------------------------------------------------- +# E2 -- A->B then reverse B->A is a no-op on the reverse leg. +# -------------------------------------------------------------------------- +# Build A, sync to B, then reverse-sync B back to A. The reverse leg must +# itemize nothing (beyond tolerated dir-time restamps) and leave A unchanged. +A = SCRATCHDIR / 'rt_a' +B = SCRATCHDIR / 'rt_b' +rmtree(A) +rmtree(B) +make_tree(A, depth=3, data=True) +os.link(A / 'f0', A / 'f0_hl') +os.symlink('f0', A / 'sl') + +# Forward leg: A -> B. +run_rsync('-aH', f'{A}/', f'{B}/') + +# Snapshot A before the reverse leg so we can prove the reverse changed nothing. +a_before = capture_tree(A) + +# Reverse leg with -i: B -> A must transfer nothing. +out = run_rsync('-aHi', f'{B}/', f'{A}/', check=True, + capture_output=True).stdout +real = _real_changes(out) +if real: + test_fail('E2: reverse-sync B->A transferred items (expected a no-op ' + 'beyond tolerated dir-time restamps):\n ' + '\n '.join(real) + + f'\n--- full itemized output ---\n{out}') + +# A must be byte/metadata-identical before vs after the reverse leg. +fatal = _structural_fatal(a_before, capture_tree(A)) +if fatal: + test_fail('E2: the reverse leg mutated A:\n ' + '\n '.join(fatal)) + +# And A and B must be equivalent (partitioned for owner mapping + precision). +fatal = _structural_fatal(capture_tree(A), capture_tree(B)) +if fatal: + test_fail('E2: A and B diverge after round-trip:\n ' + '\n '.join(fatal)) + +print('idempotence: E1 second -a run is a no-op; E2 A->B->A reverse leg is a ' + 'no-op (dir-time precision tolerated, owner mapping partitioned)') diff --git a/testsuite/link-dest-equiv_test.py b/testsuite/link-dest-equiv_test.py new file mode 100644 index 000000000..d7e5f5f04 --- /dev/null +++ b/testsuite/link-dest-equiv_test.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""--link-dest transport-equivalence test (regression guard for #915). + +Asserts that ``rsync -a --link-dest=BASIS`` hard-links unchanged files into +the destination *identically* across all four transports: local, ssh (via +support/lsh.sh), a pipe-mode rsync:// daemon, and -- under --use-tcp -- a +real loopback-bound rsync:// daemon. + +This is the exact regression that shipped as #915: ``--link-dest`` silently +stopped hard-linking across a daemon, so a "incremental" backup over rsyncd +re-copied every byte and shared no inodes with the basis. The bug is a +*silent* divergence: the transfer still succeeds, the tree still verifies +byte-for-byte, only the inode-sharing (and thus disk usage) regresses. A +content/tree diff alone cannot see it; the inode-grouping partition can. + +Privilege branch (plan C2): with ``-o`` (preserve owner, implied by ``-a``) +an unprivileged daemon receiver cannot reproduce a foreign owner, which +makes unchanged_attrs() return false and legitimately suppresses the hard +link. Here every file is owned by the test user on both ends, so ``-o`` is +satisfiable and linking MUST happen on every leg; we still partition any +uid/gid difference as a documented mapping rather than a failure when +unprivileged, so the test never false-positives on owner mapping. + +Path note: a daemon sanitizes ``--link-dest`` against the module root, so +the basis must be expressed as a module-relative path on the daemon legs +(``/basis``) and as the real on-disk path on the local/ssh legs. Both spell +the same directory; ``extra_opts_for`` carries the per-transport spelling. +""" + +import os +import subprocess + +import rsyncfns +from rsyncfns import RSYNC, SCRATCHDIR, USE_TCP, rmtree, test_fail +from equiv_fns import ( + LSH, TRANSPORTS, SkipLeg, + _bin_argv, _join_slash, _start_daemon_for_bin, + capture_tree, diff_trees, numeric_ids_only, partition_diffs, + write_daemon_conf, +) + +DAEMON_PORT = 12890 + +# Skip the TCP leg cleanly when no real socket is available, but still run +# local + ssh + daemon_pipe under plain `make check`. +transports = list(TRANSPORTS) +if not USE_TCP: + transports = [t for t in transports if t != 'daemon_tcp'] + + +def build_fixture(): + """Create ONE shared src/ + basis/ used by every transport leg. + + Sharing a single source is what makes cross-transport mtime/nsec + comparison meaningful: each leg copies from the *same* bytes and times, + so any absolute mtime divergence in a dst is a real transport defect, not + an artifact of two separate fixture creations. The basis is a + byte-identical, identical-mtime (incl. nsec) copy so every file is a + valid --link-dest candidate (quick_check_ok size+mtime match). + + Layout (root == the daemon module root): + /src/{a.txt,b.txt,sub/c.txt} + /basis/{a.txt,b.txt,sub/c.txt} (link-dest source) + // (each leg's dst, created later) + """ + root = SCRATCHDIR / 'equiv-fixture' + rmtree(root) + src = root / 'src' + basis = root / 'basis' + (src / 'sub').mkdir(parents=True) + (basis / 'sub').mkdir(parents=True) + + def seed(p, text): + sp = src / p + sp.write_text(text) + bp = basis / p + bp.write_text(text) + st = os.stat(sp) + os.utime(bp, ns=(st.st_atime_ns, st.st_mtime_ns)) + + seed('a.txt', 'hello world content\n' * 4) + seed('b.txt', 'second file body here\n' * 4) + seed('sub/c.txt', 'nested candidate file\n' * 4) + return root + + +def run_link_dest_leg(root, transport, *, rsync_bin=None, port=DAEMON_PORT): + """Run the link-dest scenario over one transport against the shared + fixture ``root``, returning (dst_tree, basis_tree). + + The destination is ``//`` so daemon legs (module rooted + at ``root``) can reach both the dst and the basis. ``--link-dest`` is + spelled as the real path for local/ssh and module-relative (``/basis``) + for the daemon legs, since a daemon sanitizes the option against the + module root. + """ + if rsync_bin is None: + rsync_bin = RSYNC + if transport == 'daemon_tcp' and not rsyncfns.USE_TCP: + raise SkipLeg('daemon_tcp needs --use-tcp') + + basis = root / 'basis' + dst = root / transport + rmtree(dst) + dst.mkdir(parents=True) + src = _join_slash(root, 'src/') + base_argv = _bin_argv(rsync_bin) + ['-a'] + + if transport == 'local': + argv = base_argv + [f'--link-dest={basis}', src, f'{dst}/'] + elif transport == 'ssh': + argv = base_argv + ['-e', LSH, f'--rsync-path={rsync_bin}', + f'--link-dest={basis}', + src, f'localhost:{dst}/'] + elif transport in ('daemon_pipe', 'daemon_tcp'): + use_tcp = (transport == 'daemon_tcp') + conf = write_daemon_conf( + [('equiv', {'path': str(root), 'read only': 'no'})], + name=f'equiv-{transport}.conf', + ) + prefix = _start_daemon_for_bin(rsync_bin, conf, port, use_tcp=use_tcp) + argv = base_argv + ['--link-dest=/basis', src, f'{prefix}equiv/{transport}/'] + else: + raise ValueError(transport) + + proc = subprocess.run(argv, capture_output=True, text=True) + if proc.returncode not in (0, 23): + test_fail(f'[{transport}] rsync exited {proc.returncode}: ' + f'{" ".join(argv)}\n{proc.stderr}') + return capture_tree(dst), capture_tree(basis) + + +root = build_fixture() +results = {} +for t in transports: + try: + dst_tree, basis_tree = run_link_dest_leg(root, t) + except SkipLeg as e: + print(f'[{t}] skipped: {e}') + continue + results[t] = (dst_tree, basis_tree) + +if not results: + test_fail('no transport legs ran') + +# 1) Tree-structural equivalence across legs (content/mode/mtime/...), +# partitioning owner mapping. Reference = local if present. +trees = {t: dst for t, (dst, _b) in results.items()} +ref = 'local' if 'local' in trees else next(iter(trees)) +ref_tree = trees[ref] +tolerated_all = [] +for t, tree in trees.items(): + if t == ref: + continue + diff = diff_trees(ref_tree, tree) + fatal, tolerated = partition_diffs(diff) + tolerated_all += [f'[{ref} vs {t}] {m}' for m in tolerated] + if fatal: + test_fail(f'tree divergence {ref} vs {t}:\n ' + '\n '.join(fatal)) + +# 2) The load-bearing assertion: every leg's destination must SHARE +# inodes with its basis (SRC ⊆ DST grouping -- each linked file's dst +# inode equals the basis inode). This is what #915 broke over a daemon. +rel_files = ['a.txt', 'b.txt', 'sub/c.txt'] +for t, (dst_tree, basis_tree) in results.items(): + for rel in rel_files: + d = dst_tree.entries.get(rel) + b = basis_tree.entries.get(rel) + if d is None or b is None: + test_fail(f'[{t}] missing {rel} in dst or basis') + dst_ino = os.stat(dst_tree.root / rel).st_ino + dst_dev = os.stat(dst_tree.root / rel).st_dev + bas_ino = os.stat(basis_tree.root / rel).st_ino + bas_dev = os.stat(basis_tree.root / rel).st_dev + shared = (dst_ino, dst_dev) == (bas_ino, bas_dev) + # C2: a non-root daemon that cannot satisfy -o would legitimately + # break linking. Here owner is identical on both ends, so the + # documented mapping holds and linking MUST occur. If it does not, + # that is exactly the #915 silent divergence -- fail loudly. + if not shared: + test_fail( + f'[{t}] --link-dest did NOT share inode for {rel} ' + f'(dst ino={dst_ino} dev={dst_dev}, basis ino={bas_ino} ' + f'dev={bas_dev}). This is the #915 link-dest-over-' + f'transport regression: the file was re-copied instead ' + f'of hard-linked. privileged={numeric_ids_only()}' + ) + +for m in tolerated_all: + print(f'tolerated (documented mapping): {m}') +legs = ', '.join(sorted(results)) +print(f'link-dest-equiv: shared inodes verified across [{legs}] ' + f'({len(rel_files)} files/leg)') diff --git a/testsuite/link-dest-variants_test.py b/testsuite/link-dest-variants_test.py new file mode 100644 index 000000000..e7df94ebe --- /dev/null +++ b/testsuite/link-dest-variants_test.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Workstream-1 invariant group C -- alternate-basis (link/copy/compare-dest) +and hardlink-group semantics. + +C2 (link-dest shared inodes across transports) lives in its own file +(link-dest-equiv_test.py) and is NOT duplicated here. This file covers the +OTHER C cases, derived from rsync.1.md + generator.c try_dests_reg/_non and +hlink.c: + + C3: --link-dest + -I/--ignore-times never hard-links. This is EMERGENT + behavior with no explicit guard: quick_check_ok() returns 0 under + ignore_times (generator.c ~601), so try_dests_reg never reaches the + full-attr match level that would set the link, and the file is + re-sent. There is no code line that says "if ignore_times don't + link" -- it falls out of the match-level machinery -- so it is + fragile to refactor and kept as an explicit regression test. We + assert the dst file does NOT share an inode with the link-dest basis. + + copy-dest: --copy-dest copies the unchanged candidate from the basis; + the dst is a real, distinct-inode copy with identical content (NOT a + hard link). Assert content identical AND inode NOT shared. + + compare-dest: --compare-dest skips the transfer entirely when the basis + matches; the dst file may not be created at all. Assert the unchanged + file is absent from the dst (skipped), while a CHANGED file IS + transferred. + + C7: hardlink grouping is SRC ⊆ DST, not equality. When transferring a + subset of a hardlink group with -H, the dst group may include + pre-existing extra members. Assert the transferred members share one + inode (subset coherence) and NEVER assert strict set equality between + the src group and the dst group. +""" + +import os + +from rsyncfns import ( + SCRATCHDIR, assert_same, makepath, rmtree, run_rsync, test_fail, +) + + +def shared_inode(a, b): + sa, sb = os.stat(a), os.stat(b) + return (sa.st_dev, sa.st_ino) == (sb.st_dev, sb.st_ino) + + +# Each subtest uses a fresh subtree under the scratch dir. +ROOT = SCRATCHDIR / 'cgroup' +rmtree(ROOT) +makepath(ROOT) + + +# -------------------------------------------------------------------------- +# C3 -- --link-dest with -I/--ignore-times must NOT hard-link. +# -------------------------------------------------------------------------- +# Build a basis that is a byte-identical, identical-mtime copy of the source +# (so without -I it WOULD link). Then transfer with --link-dest AND -I and +# assert the dst file is a fresh copy (distinct inode from the basis). +c3 = ROOT / 'c3' +src = c3 / 'src' +basis = c3 / 'basis' +dst = c3 / 'dst' +makepath(src, basis, dst) + +(src / 'f.txt').write_text('link-dest ignore-times candidate\n' * 8) +(basis / 'f.txt').write_text('link-dest ignore-times candidate\n' * 8) +st = os.stat(src / 'f.txt') +os.utime(basis / 'f.txt', ns=(st.st_atime_ns, st.st_mtime_ns)) + +# Sanity: without -I, --link-dest DOES link (proves the basis is a valid +# candidate, so the C3 non-link below is attributable to -I and not to a +# bad fixture). +dst_ctl = c3 / 'dst_ctl' +makepath(dst_ctl) +run_rsync('-a', f'--link-dest={basis}', f'{src}/', f'{dst_ctl}/') +if not shared_inode(dst_ctl / 'f.txt', basis / 'f.txt'): + test_fail('C3 fixture invalid: --link-dest without -I did not link; ' + 'cannot attribute the -I non-link result to ignore-times') + +# The invariant: with -I the file is re-sent, so NO inode sharing. +run_rsync('-a', '-I', f'--link-dest={basis}', f'{src}/', f'{dst}/') +assert_same(src / 'f.txt', dst / 'f.txt', label='C3 content still correct') +if shared_inode(dst / 'f.txt', basis / 'f.txt'): + test_fail('C3 VIOLATION: --link-dest with -I/--ignore-times hard-linked ' + 'the dst to the basis. The ignore-times fast-path-off should ' + 'force a re-send with a fresh inode (emergent behavior, no ' + 'explicit guard -- a refactor likely broke it).') + + +# -------------------------------------------------------------------------- +# copy-dest -- copies from the basis: distinct inode, identical content. +# -------------------------------------------------------------------------- +cc = ROOT / 'copydest' +src = cc / 'src' +basis = cc / 'basis' +dst = cc / 'dst' +makepath(src, basis, dst) +(src / 'f.txt').write_text('copy-dest candidate body\n' * 8) +(basis / 'f.txt').write_text('copy-dest candidate body\n' * 8) +st = os.stat(src / 'f.txt') +os.utime(basis / 'f.txt', ns=(st.st_atime_ns, st.st_mtime_ns)) + +run_rsync('-a', f'--copy-dest={basis}', f'{src}/', f'{dst}/') +# The dst file must exist with the source content... +assert_same(src / 'f.txt', dst / 'f.txt', label='copy-dest content') +# ...but be a real copy, NOT a hard link to the basis. +if shared_inode(dst / 'f.txt', basis / 'f.txt'): + test_fail('copy-dest VIOLATION: dst shares an inode with the basis. ' + '--copy-dest must COPY (distinct inode), only --link-dest ' + 'hard-links.') + + +# -------------------------------------------------------------------------- +# compare-dest -- skips transfer when the basis matches; dst not created. +# -------------------------------------------------------------------------- +cmp = ROOT / 'comparedest' +src = cmp / 'src' +basis = cmp / 'basis' +dst = cmp / 'dst' +makepath(src, basis, dst) + +# 'same.txt' matches the basis (will be SKIPPED -> absent in dst). +(src / 'same.txt').write_text('unchanged compare-dest body\n' * 8) +(basis / 'same.txt').write_text('unchanged compare-dest body\n' * 8) +st = os.stat(src / 'same.txt') +os.utime(basis / 'same.txt', ns=(st.st_atime_ns, st.st_mtime_ns)) +# 'diff.txt' has no basis match (will be TRANSFERRED -> present in dst). +(src / 'diff.txt').write_text('this file has no basis match at all\n' * 8) + +run_rsync('-a', f'--compare-dest={basis}', f'{src}/', f'{dst}/') +if (dst / 'same.txt').exists(): + test_fail('compare-dest VIOLATION: the basis-matching file was created ' + 'in the dst. --compare-dest must SKIP the transfer entirely ' + '(file not created) when the basis matches.') +if not (dst / 'diff.txt').exists(): + test_fail('compare-dest VIOLATION: a file with no basis match was NOT ' + 'transferred. --compare-dest must still transfer files the ' + 'basis does not cover.') +assert_same(src / 'diff.txt', dst / 'diff.txt', label='compare-dest changed file') + + +# -------------------------------------------------------------------------- +# C7 -- hardlink grouping is SRC ⊆ DST, not equality. +# -------------------------------------------------------------------------- +# Source has a 3-member hardlink group {h1,h2,h3}. The destination already +# contains an extra pre-existing member h0 hard-linked... but rsync can only +# extend a group with what IT transfers, so the real subset property we can +# assert portably is: transferring a SUBSET of the source group still +# produces a coherent single-inode group on the dst, and we NEVER require the +# dst group to equal the src group. +c7 = ROOT / 'c7' +src = c7 / 'src' +dst = c7 / 'dst' +makepath(src, dst) + +(src / 'h1').write_text('hardlink group payload\n' * 8) +os.link(src / 'h1', src / 'h2') +os.link(src / 'h1', src / 'h3') + +# Transfer only a SUBSET of the group (h1, h2) with -H. h3 is excluded, so the +# dst group is a strict subset of the src group. +run_rsync('-aH', '--exclude=h3', f'{src}/', f'{dst}/') + +d1 = os.stat(dst / 'h1') +d2 = os.stat(dst / 'h2') +# Subset coherence: the members we DID transfer share one inode. +if (d1.st_dev, d1.st_ino) != (d2.st_dev, d2.st_ino): + test_fail('C7 VIOLATION: transferred hardlink-group members h1,h2 do NOT ' + 'share an inode on the dst. -H must preserve linkage among the ' + 'transferred subset.') +# Subset, not equality: h3 was excluded and must be absent. We assert the +# subset bound, NEVER that the dst group equals the src group. +if (dst / 'h3').exists(): + test_fail('C7 fixture error: excluded h3 unexpectedly present in dst') + +# The dst inode need NOT equal any src inode, and the dst group need NOT equal +# the src group {h1,h2,h3}; asserting either would be over-assertion. We only +# assert the transferred subset is internally consistent (done above) and that +# content is correct. +assert_same(src / 'h1', dst / 'h1', label='C7 content') + +print('link-dest-variants: C3 (no link under -I), copy-dest (copy not link), ' + 'compare-dest (skip when matched), C7 (SRC subset-of DST grouping) ' + 'verified') diff --git a/testsuite/metadata-fidelity_test.py b/testsuite/metadata-fidelity_test.py new file mode 100644 index 000000000..43b9836ca --- /dev/null +++ b/testsuite/metadata-fidelity_test.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Workstream-1 invariant group B -- metadata fidelity. + +Driven by generator.c's *_differ predicates (reference.md Part 2): under the +matching -a sub-options, rsync makes the destination's metadata match the +source's, and unchanged_attrs() treats a single attribute difference as a +reason to re-stamp. We assert each attribute is reproduced -- PARTITIONED so +that legitimately-privilege/feature-dependent attributes (owner, group, ACL +ids, xattr namespace, devices) are only asserted when the environment can +actually preserve them, and SKIP cleanly otherwise. + +Covered: perms, exec-bit (-E), times incl. nanoseconds (mtime_differs ~395), +owner/group (ownership_differs ~428, privilege-partitioned), hardlinks (-H), +devices (root-only), ACLs (-A, feature+ability gated), xattrs (-X, feature +gated), omit-dir-times (-O) and omit-link-times (--omit-link-times). +""" + +import os +import platform +import shutil +import stat +import subprocess + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_hardlinked, assert_mode, assert_mtime_close, + makepath, rmtree, run_rsync, test_fail, + xattr_dump, xattr_set, xattrs_supported, +) +from equiv_fns import am_root + + +VV = run_rsync('-VV', check=True, capture_output=True).stdout + + +def _fresh(*dirs): + for d in (FROMDIR, TODIR, *dirs): + rmtree(d) + makepath(FROMDIR) + + +# -------------------------------------------------------------------------- +# B-perms -- -p reproduces the full permission bits. +# -------------------------------------------------------------------------- +_fresh() +for nm, mode in (('a', 0o644), ('b', 0o600), ('c', 0o751), ('d', 0o444)): + (FROMDIR / nm).write_text(nm) + os.chmod(FROMDIR / nm, mode) +run_rsync('-a', f'{FROMDIR}/', f'{TODIR}/') +for nm, mode in (('a', 0o644), ('b', 0o600), ('c', 0o751), ('d', 0o444)): + assert_mode(TODIR / nm, mode, label=f'B-perms {nm}') + + +# -------------------------------------------------------------------------- +# B-exec -- -E (--executability) propagates ONLY the executable bit. +# -------------------------------------------------------------------------- +# Without -p, a non-exec source file's exec bit must be cleared on a dest that +# has it; an exec source file's exec bit must be set. Non-exec permission bits +# are NOT forced to match (that's -p's job), so we assert exactly the exec bit. +_fresh() +(FROMDIR / 'prog').write_text('#!/bin/sh\n') +(FROMDIR / 'data').write_text('plain\n') +os.chmod(FROMDIR / 'prog', 0o700) # has exec +os.chmod(FROMDIR / 'data', 0o600) # no exec +# Pre-seed dest with the OPPOSITE exec state so -E has to flip both. +makepath(TODIR) +(TODIR / 'prog').write_text('old\n') +(TODIR / 'data').write_text('old\n') +os.chmod(TODIR / 'prog', 0o600) # missing exec, must gain it +os.chmod(TODIR / 'data', 0o700) # has exec, must lose it +run_rsync('-rtE', f'{FROMDIR}/', f'{TODIR}/') +if not (os.stat(TODIR / 'prog').st_mode & 0o111): + test_fail('B-exec: -E did not set the exec bit on an executable source') +if os.stat(TODIR / 'data').st_mode & 0o111: + test_fail('B-exec: -E did not clear the exec bit on a non-executable source') + + +# -------------------------------------------------------------------------- +# B-times -- -t reproduces mtime; nanoseconds preserved where representable. +# -------------------------------------------------------------------------- +_fresh() +(FROMDIR / 'f').write_text('timed\n') +# A whole-second base plus a non-zero nanosecond remainder. +WHOLE = 1_500_000_000 +NSEC = 123_456_789 +os.utime(FROMDIR / 'f', ns=(WHOLE * 1_000_000_000 + NSEC, + WHOLE * 1_000_000_000 + NSEC)) +src_ns = os.stat(FROMDIR / 'f').st_mtime_ns + +run_rsync('-a', f'{FROMDIR}/', f'{TODIR}/') +# Whole-second mtime must always match. +assert_mtime_close(TODIR / 'f', WHOLE, tol=1.0, label='B-times whole-second') + +# Nanosecond sub-assertion: only meaningful if BOTH the source filesystem AND +# rsync actually represent sub-second mtimes. Probe the source: if the fs +# truncated our nsec to 0, we cannot assert nsec fidelity -- degrade cleanly. +dst_ns = os.stat(TODIR / 'f').st_mtime_ns +src_sub = src_ns % 1_000_000_000 +if src_sub == 0 or '"symtimes": true' not in VV: + print('B-times: nanosecond sub-assertion skipped (fs/rsync lacks ' + 'sub-second mtime representation)') +else: + if dst_ns != src_ns: + test_fail(f'B-times nsec: dst mtime_ns {dst_ns} != src {src_ns} ' + '(sub-second mtime not preserved)') + + +# -------------------------------------------------------------------------- +# B-owner-group -- -og. PARTITIONED by privilege. +# -------------------------------------------------------------------------- +# ownership_differs(): UID is only reproduced when am_root && uid_ndx; GID can +# be set by an unprivileged caller ONLY for groups it belongs to. We therefore: +# * always assert the dest GID matches the source for a file we chgrp to one +# of OUR OWN supplementary groups (no privilege needed); +# * assert UID equality ONLY when running as root. +_fresh() +(FROMDIR / 'f').write_text('owned\n') + +# Group test: pick a secondary group we already belong to (so chgrp succeeds +# unprivileged). Skip the group sub-check if we have no usable second group. +my_gid = os.getgid() +groups = [g for g in os.getgroups() if g != my_gid] +if groups: + target_gid = groups[0] + os.chown(FROMDIR / 'f', -1, target_gid) + run_rsync('-a', f'{FROMDIR}/', f'{TODIR}/') + dst_gid = os.stat(TODIR / 'f').st_gid + if dst_gid != target_gid: + test_fail(f'B-group: -g did not reproduce gid {target_gid} ' + f'(dst gid {dst_gid})') +else: + print('B-group: skipped (no usable secondary group to chgrp into)') + +# Owner test: only assert under root; chown(2) to an arbitrary uid needs root. +if am_root(): + rmtree(TODIR) + os.chown(FROMDIR / 'f', 5000, -1) + run_rsync('-a', f'{FROMDIR}/', f'{TODIR}/') + dst_uid = os.stat(TODIR / 'f').st_uid + if dst_uid != 5000: + test_fail(f'B-owner: -o did not reproduce uid 5000 (dst {dst_uid})') +else: + print('B-owner: uid-equality sub-check skipped (needs root to set/verify ' + 'arbitrary ownership)') + + +# -------------------------------------------------------------------------- +# B-hardlinks -- -H preserves a hard-link group (shared inode on the dest). +# -------------------------------------------------------------------------- +_fresh() +(FROMDIR / 'h1').write_text('linked\n') +os.link(FROMDIR / 'h1', FROMDIR / 'h2') +os.link(FROMDIR / 'h1', FROMDIR / 'h3') +# Sanity: source really is one inode. +assert_hardlinked(FROMDIR / 'h1', FROMDIR / 'h2', label='B-hardlinks src') +run_rsync('-aH', f'{FROMDIR}/', f'{TODIR}/') +assert_hardlinked(TODIR / 'h1', TODIR / 'h2', label='B-hardlinks h1==h2') +assert_hardlinked(TODIR / 'h1', TODIR / 'h3', label='B-hardlinks h1==h3') +# Without -H the destination files must NOT be linked (proves -H is load- +# bearing, not an accident of the filesystem). +rmtree(TODIR) +run_rsync('-a', f'{FROMDIR}/', f'{TODIR}/') +s2, s3 = os.stat(TODIR / 'h1'), os.stat(TODIR / 'h2') +if (s2.st_dev, s2.st_ino) == (s3.st_dev, s3.st_ino): + test_fail('B-hardlinks: dest files share an inode WITHOUT -H ' + '(hard-link preservation is not actually being controlled by -H)') + + +# -------------------------------------------------------------------------- +# B-devices -- -D reproduces a device node's type+rdev. ROOT-ONLY. +# -------------------------------------------------------------------------- +# mknod(2) of a char/block device needs root; mirror devices-fake's pattern of +# skipping cleanly when not privileged. +if not am_root(): + print('B-devices: skipped (mknod needs root)') +else: + _fresh() + dev = FROMDIR / 'nulldev' + try: + os.mknod(dev, 0o600 | 0o020000, os.makedev(1, 3)) # S_IFCHR + except (PermissionError, OSError) as e: + print(f'B-devices: skipped (mknod failed: {e})') + else: + run_rsync('-aD', f'{FROMDIR}/', f'{TODIR}/') + dst = TODIR / 'nulldev' + st = os.stat(dst, follow_symlinks=False) + if not (platform.system() and os.path.exists(dst)): + test_fail('B-devices: device node not created on dest') + if not stat.S_ISCHR(st.st_mode): + test_fail('B-devices: dest is not a character device') + if st.st_rdev != os.makedev(1, 3): + test_fail(f'B-devices: dest rdev {st.st_rdev} != source ' + f'{os.makedev(1, 3)}') + + +# -------------------------------------------------------------------------- +# B-acls -- -A preserves a POSIX ACL. FEATURE + ABILITY gated. +# -------------------------------------------------------------------------- +if '"ACLs": true' not in VV: + print('B-acls: skipped (rsync built without ACL support)') +elif platform.system() != 'Linux' or not (shutil.which('setfacl') + and shutil.which('getfacl')): + print('B-acls: skipped (no setfacl/getfacl on this platform)') +else: + _fresh() + (FROMDIR / 'f').write_text('acl\n') + # Grant an extra ACL entry to a group we belong to (no privilege needed), + # falling back to a skip if the filesystem rejects ACLs. + gid = os.getgid() + r = subprocess.run(['setfacl', '-m', f'g:{gid}:rwx', str(FROMDIR / 'f')]) + if r.returncode != 0: + print('B-acls: skipped (filesystem rejected setfacl)') + else: + run_rsync('-aA', f'{FROMDIR}/', f'{TODIR}/') + + def acl_of(p): + out = subprocess.run(['getfacl', '-cE', str(p)], + capture_output=True, text=True).stdout + return '\n'.join(sorted(l for l in out.splitlines() if l.strip())) + if acl_of(FROMDIR / 'f') != acl_of(TODIR / 'f'): + test_fail('B-acls: -A did not reproduce the source ACL\n' + f'src:\n{acl_of(FROMDIR / "f")}\n' + f'dst:\n{acl_of(TODIR / "f")}') + + +# -------------------------------------------------------------------------- +# B-xattrs -- -X preserves a user-namespace xattr. FEATURE gated. +# -------------------------------------------------------------------------- +if not xattrs_supported(): + print('B-xattrs: skipped (no usable xattr surface)') +else: + _fresh() + (FROMDIR / 'f').write_text('xattr\n') + try: + xattr_set('test.attr', 'hello-world', FROMDIR / 'f') + except (PermissionError, OSError) as e: + print(f'B-xattrs: skipped (filesystem rejected xattr set: {e})') + else: + run_rsync('-aX', f'{FROMDIR}/', f'{TODIR}/') + + # xattr_dump prefixes each file with a "# file: " header; strip + # the per-file headers (and any blank lines) so we compare only the + # name="value" payload, which is what -X must reproduce. + def _xpayload(p): + return '\n'.join( + ln for ln in xattr_dump(p).splitlines() + if ln.strip() and not ln.startswith('# file:')) + src_x = _xpayload(FROMDIR / 'f') + dst_x = _xpayload(TODIR / 'f') + if not src_x: + test_fail('B-xattrs: source xattr not set as expected') + if src_x != dst_x: + test_fail(f'B-xattrs: -X did not reproduce xattrs\n' + f'src: {src_x!r}\ndst: {dst_x!r}') + + +# -------------------------------------------------------------------------- +# B-omit-dir-times -- -O preserves FILE mtimes but leaves DIR mtimes alone. +# -------------------------------------------------------------------------- +OLD = 1_400_000_000 +_fresh() +makepath(FROMDIR / 'sub') +(FROMDIR / 'sub' / 'f').write_text('x\n') +for p in (FROMDIR / 'sub' / 'f', FROMDIR / 'sub', FROMDIR): + os.utime(p, (OLD, OLD)) +run_rsync('-rlt', '-O', f'{FROMDIR}/', f'{TODIR}/') +assert_mtime_close(TODIR / 'sub' / 'f', OLD, tol=1.0, label='B-O file mtime') +if abs(os.stat(TODIR / 'sub').st_mtime - OLD) <= 1: + test_fail('B-omit-dir-times: -O preserved a directory mtime instead of ' + 'omitting it') + + +# -------------------------------------------------------------------------- +# B-omit-link-times -- --omit-link-times preserves a symlink but omits its +# mtime (where the platform records symlink mtimes). +# -------------------------------------------------------------------------- +_fresh() +(FROMDIR / 'target').write_text('t\n') +os.symlink('target', FROMDIR / 'sl') +try: + os.utime(FROMDIR / 'sl', (OLD, OLD), follow_symlinks=False) +except (NotImplementedError, OSError): + print('B-omit-link-times: skipped (no symlink-mtime support here)') +else: + if '"symtimes": true' not in VV: + print('B-omit-link-times: skipped (rsync built without symtimes)') + else: + run_rsync('-rlt', '--omit-link-times', f'{FROMDIR}/', f'{TODIR}/') + dst = TODIR / 'sl' + if not os.path.islink(dst): + test_fail('B-omit-link-times: symlink not copied') + if abs(os.lstat(dst).st_mtime - OLD) <= 1: + test_fail('B-omit-link-times: --omit-link-times did not omit the ' + 'symlink mtime') + +print('metadata-fidelity: perms/exec/times+nsec/owner-group/hardlinks/' + 'devices/acls/xattrs/omit-times verified (partitioned by privilege+feature)') diff --git a/testsuite/transport-equiv-meta_test.py b/testsuite/transport-equiv-meta_test.py new file mode 100644 index 000000000..97abc4101 --- /dev/null +++ b/testsuite/transport-equiv-meta_test.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Workstream-1 invariant group F -- transport-equivalence meta-invariants. + +The general guard: the SAME scenario produces the SAME final destination tree +across every transport (local / ssh / daemon_pipe / daemon_tcp). This is +transport-blindness: rsync's result must not depend on how the peers are +connected. We drive several representative scenarios through equiv_fns' +run_matrix + assert_equivalent, which structurally diffs the trees and +partitions every difference into fatal (content/mode/mtime/linktarget/inode- +grouping/membership) vs tolerated-when-unprivileged (uid/gid owner mapping, +directory mtime nanoseconds) -- so an unprivileged run never false-fails on +owner mapping. + +Cases (all derived from rsync.1.md + compat.c, NOT from a known bug): + + F-general: a mixed tree (regular files of several sizes, symlinks, nested + dirs, a hardlink group, varied perms) must land byte/structure + identical on every transport, including the shared-inode + partition of the hardlink group. + + F-clamp: the negotiated protocol is the MINIMUM of the two peers + (compat.c setup_protocol ~606 clamps to min, not average). + Forcing --protocol=N (a lower version) must be honored and the + transfer must still succeed with an equivalent final tree on + every transport. + + F-negotiate: checksum/compress negotiation is intersection-or-error. We do + NOT assert a specific digest/compressor (those are version- and + build-dependent). We assert (a) a valid negotiation succeeds and + yields a correct tree, and (b) an EMPTY intersection (an + unsupported forced --compress-choice) ERRORS CLEANLY rather than + silently mis-transferring. +""" + +import os +import subprocess +import tempfile +from pathlib import Path + +from rsyncfns import ( + RSYNC, USE_TCP, rsync_argv, test_fail, +) +from equiv_fns import ( + TRANSPORTS, Scenario, assert_equivalent, diff_trees, partition_diffs, + run_matrix, +) + + +# Skip the TCP leg cleanly without --use-tcp; local + ssh + daemon_pipe still +# run under plain `make check`. +transports = list(TRANSPORTS) +if not USE_TCP: + transports = [t for t in transports if t != 'daemon_tcp'] + + +# -------------------------------------------------------------------------- +# Shared mixed-tree builder. Run fresh per transport leg (each leg starts +# from identical inputs) so any cross-leg divergence is a real transport +# defect, not a fixture artifact. +# -------------------------------------------------------------------------- +# Fixed epoch for every source mtime so each per-leg fixture is byte- AND +# time-identical: that makes any cross-leg mtime/content divergence in the +# RESULT a real transport defect, not an artifact of two wall-clock fixture +# creations. (The link-dest-equiv test shares one fixture for the same +# reason; here setup() must run per leg, so we pin determinism instead.) +FIXED_MTIME = 1_600_000_000 # 2020-09-13, well in the past, sub-second = 0 + + +def _det_bytes(n, seed): + """Deterministic pseudo-random-looking bytes (LCG); identical every run + so every transport leg copies the SAME content.""" + out = bytearray(n) + x = seed & 0xFFFFFFFF + for i in range(n): + x = (1103515245 * x + 12345) & 0xFFFFFFFF + out[i] = (x >> 16) & 0xFF + return bytes(out) + + +def build_mixed_tree(work, *, with_symlinks=False): + """Populate /src with a representative mixed tree. + + Regular files of several sizes (incl. empty + multi-block), nested dirs, a + 3-member hardlink group, and varied perms. Content is deterministic and + every mtime is pinned to FIXED_MTIME so the fixture is identical across + legs. + + Symlinks are gated behind ``with_symlinks`` because a daemon MUNGES + symlink targets by default (``munge symlinks`` -- a documented daemon + security mapping, prefixing ``/rsyncd-munged/``). That munge makes symlink + targets legitimately NON-equivalent daemon-vs-local, so symlinks are + excluded from the all-transport byte-equality matrix and verified + separately across the non-munging transports (local, ssh). + """ + src = work / 'src' + (src / 'nested' / 'deep').mkdir(parents=True) + + (src / 'empty').write_text('') + (src / 'small.txt').write_text('a small file body\n' * 4) + (src / 'nested' / 'mid.txt').write_text('mid file in a subdir\n' * 200) + (src / 'nested' / 'deep' / 'leaf.bin').write_bytes(_det_bytes(40000, 12345)) + + # Hardlink group (3 members across two dirs). + h = src / 'h1' + h.write_text('hardlink group payload across transports\n' * 8) + os.link(h, src / 'h2') + os.link(h, src / 'nested' / 'h3') + + if with_symlinks: + # A relative (in-tree) and a dangling symlink. + os.symlink('small.txt', src / 'rel_link') + os.symlink('does-not-exist', src / 'dangling_link') + + # Varied perms. + os.chmod(src / 'small.txt', 0o600) + os.chmod(src / 'nested' / 'mid.txt', 0o644) + os.chmod(src / 'nested', 0o755) + # An executable bit somewhere to exercise the perm-preservation path. + (src / 'run.sh').write_text('#!/bin/sh\necho hi\n') + os.chmod(src / 'run.sh', 0o755) + + # Pin every mtime (files, dirs, symlinks) to FIXED_MTIME, deepest first so + # writing a parent's children doesn't re-stamp the parent afterwards. + paths = [] + for dirpath, dirnames, filenames in os.walk(src): + for nm in filenames + dirnames: + paths.append(os.path.join(dirpath, nm)) + paths.append(str(src)) + # Sort by depth descending so children are stamped before parents. + for p in sorted(paths, key=lambda q: q.count(os.sep), reverse=True): + os.utime(p, (FIXED_MTIME, FIXED_MTIME), follow_symlinks=False) + + +# -------------------------------------------------------------------------- +# F-general -- mixed tree is transport-blind. +# -------------------------------------------------------------------------- +# -aHl preserves perms/times/owner/group + hardlinks + symlinks: the full +# structure must be reproduced identically on every transport. +general = Scenario( + opts=['-aH'], + rel_src='src/', + rel_dst='dst/', + setup=build_mixed_tree, +) +trees = run_matrix(general, transports=transports) +tolerated = assert_equivalent(trees) +for m in tolerated: + print(f'F-general tolerated (documented mapping): {m}') + +# Symlinks: verified across the NON-munging transports only (local, ssh). +# A daemon munges symlink targets by default, a documented mapping that makes +# them non-equivalent over a daemon; including them in the all-transport +# matrix would false-fail on that documented behavior. +sym_transports = [t for t in transports if t in ('local', 'ssh')] +if len(sym_transports) >= 2: + sym = Scenario( + opts=['-aHl'], + rel_src='src/', + rel_dst='dst/', + setup=lambda w: build_mixed_tree(w, with_symlinks=True), + ) + sym_trees = run_matrix(sym, transports=sym_transports) + for m in assert_equivalent(sym_trees): + print(f'F-symlink tolerated (documented mapping): {m}') + + +# -------------------------------------------------------------------------- +# F-clamp -- forcing a lower --protocol is honored and stays transport-blind. +# -------------------------------------------------------------------------- +# The negotiated version is min(local, remote); forcing a lower --protocol=N +# on the client must be honored (and the daemon/ssh peer clamps to it) with +# the SAME final tree across transports. We pick a version below the build's +# max but at/above the minimum. 30 is the modern floor with full feature set +# (varint flist, etc.) and is universally supported by current rsync. +FORCE_PROTOCOL = 30 + +clamp = Scenario( + opts=['-aH', f'--protocol={FORCE_PROTOCOL}'], + rel_src='src/', + rel_dst='dst/', + setup=build_mixed_tree, +) +clamp_trees = run_matrix(clamp, transports=transports) +clamp_tolerated = assert_equivalent(clamp_trees) +for m in clamp_tolerated: + print(f'F-clamp tolerated (documented mapping): {m}') + +# The clamped result must also match the UN-clamped result's structure (the +# protocol floor must not change the final bytes/structure of this tree). +# Compare each clamp leg against the general local reference. +ref = 'local' if trees.get('local') is not None else next( + t for t, v in trees.items() if v is not None) +for t, ct in clamp_trees.items(): + if ct is None: + continue + d = diff_trees(trees[ref], ct) + fatal, _tol = partition_diffs(d) + if fatal: + test_fail(f'F-clamp: --protocol={FORCE_PROTOCOL} changed the final ' + f'tree vs the unclamped transfer ({ref} vs clamp/{t}):\n ' + + '\n '.join(fatal)) + + +# -------------------------------------------------------------------------- +# F-negotiate -- intersection-or-error, no specific algorithm asserted. +# -------------------------------------------------------------------------- +# (a) A valid negotiation succeeds and produces a correct tree. We let rsync +# negotiate freely (default) -- already covered by F-general -- and ALSO +# pin a checksum the build is known to support to confirm an explicit +# valid choice negotiates. We do NOT assert WHICH algorithm wins. +# (b) An empty intersection (an unsupported forced --compress-choice) must +# ERROR CLEANLY (non-zero exit, no destructive/silent mis-transfer), +# never silently fall back. + +# (b) -- the load-bearing negative case. A bogus compressor name has no +# mutual option, so negotiation must fail with a clean non-zero exit. +negroot = Path(tempfile.mkdtemp(prefix='fneg-', dir=os.environ['scratchdir'])) +(negroot / 'src').mkdir() +(negroot / 'src' / 'f.txt').write_text('negotiation payload\n' * 4) +(negroot / 'dst').mkdir() + +bogus = subprocess.run( + rsync_argv('-a', '--compress', '--compress-choice=no-such-algo-xyz', + f'{negroot}/src/', f'{negroot}/dst/'), + capture_output=True, text=True, +) +if bogus.returncode == 0: + test_fail('F-negotiate: an unsupported --compress-choice was accepted ' + '(exit 0). An empty negotiation intersection must error cleanly, ' + 'not silently transfer.') +# Clean error: nothing should have been silently transferred under the bogus +# choice (the run aborted before/at negotiation). The dst must be empty. +if (negroot / 'dst' / 'f.txt').exists(): + test_fail('F-negotiate: a file was transferred despite the failed ' + 'compress negotiation -- the error was not clean.') + +# (a) -- a valid explicit checksum choice negotiates and transfers correctly. +# Discover an algorithm the build actually supports rather than hard-coding +# one (build-dependent: md5/md4/sha1/xxh*/...). +vv = subprocess.run(rsync_argv('--version'), capture_output=True, text=True) +# rsync --version lists "Checksum list:\n "; parse the algo line. +algos = [] +lines = vv.stdout.splitlines() +for i, ln in enumerate(lines): + if 'Checksum list' in ln: + # algorithms are on the following indented line(s) + for j in range(i + 1, min(i + 3, len(lines))): + for tok in lines[j].split(): + if tok and not tok.endswith(':'): + algos.append(tok) + break +# Filter to real algorithm tokens (drop stray words). +algos = [a for a in algos if a.replace('-', '').isalnum()] +if not algos: + # Fall back to the universally-present md5; if even that fails the test + # will catch it below. + algos = ['md5'] + +valid_choice = algos[0] +(negroot / 'dst2').mkdir() +good = subprocess.run( + rsync_argv('-a', f'--checksum-choice={valid_choice}', + f'{negroot}/src/', f'{negroot}/dst2/'), + capture_output=True, text=True, +) +if good.returncode != 0: + test_fail(f'F-negotiate: a valid --checksum-choice={valid_choice} failed ' + f'to negotiate: {good.stderr}') +if (negroot / 'dst2' / 'f.txt').read_text() != 'negotiation payload\n' * 4: + test_fail('F-negotiate: valid checksum-choice transfer produced wrong ' + 'content.') + + +legs = ', '.join(sorted(t for t, v in trees.items() if v is not None)) +print(f'transport-equiv-meta: F-general + F-clamp (--protocol={FORCE_PROTOCOL}) ' + f'transport-blind across [{legs}]; F-negotiate intersection-or-error ' + f'(bogus compress rejected cleanly, valid checksum={valid_choice} ' + f'negotiated) verified') diff --git a/xattrs.c b/xattrs.c index 99795f244..a225c0b7b 100644 --- a/xattrs.c +++ b/xattrs.c @@ -295,8 +295,10 @@ static int rsync_xal_get(const char *fname, item_list *xalp) rxa = xalp->items; if (count > 1) qsort(rxa, count, sizeof (rsync_xa), rsync_xal_compare_names); - for (rxa += count-1; count; count--, rxa--) - rxa->num = count; + if (count) { + for (rxa += count-1; count; count--, rxa--) + rxa->num = count; + } return 0; } @@ -460,7 +462,8 @@ static int rsync_xal_store(item_list *xalp) * entire initial-count, not just enough space for one new item. */ *new_list = empty_xa_list; (void)EXPAND_ITEM_LIST(&new_list->xa_items, rsync_xa, xalp->count); - memcpy(new_list->xa_items.items, xalp->items, xalp->count * sizeof (rsync_xa)); + if (xalp->count) + memcpy(new_list->xa_items.items, xalp->items, xalp->count * sizeof (rsync_xa)); new_list->xa_items.count = xalp->count; xalp->count = 0; From c967d4b5929bda1721ff073431807af7c9367ff3 Mon Sep 17 00:00:00 2001 From: pterror Date: Mon, 1 Jun 2026 00:48:01 +1000 Subject: [PATCH 2/4] fix: receiver discard-path NULL-deref (R1) + options uninit read (R2) + recv_discard fuzz harness --- fuzz/.gitignore | 1 + fuzz/Makefile | 19 ++ fuzz/README.md | 15 ++ .../fuzz_recv_discard/match_nobasis.bin | Bin 0 -> 20 bytes fuzz/fuzz_recv_discard.c | 171 ++++++++++++++++++ fuzz/recv_discard_stubs.c | 65 +++++++ fuzz/run-regression.sh | 7 + options.c | 7 + receiver.c | 33 +++- 9 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 fuzz/corpus/fuzz_recv_discard/match_nobasis.bin create mode 100644 fuzz/fuzz_recv_discard.c create mode 100644 fuzz/recv_discard_stubs.c diff --git a/fuzz/.gitignore b/fuzz/.gitignore index 5fffa8f17..be209c9a1 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -5,6 +5,7 @@ /fuzz_flist /fuzz_token /fuzz_xattrs +/fuzz_recv_discard # libFuzzer crash / leak reproducers (a real find should be MINIMIZED and # committed under corpus// with a descriptive name, not left here) diff --git a/fuzz/Makefile b/fuzz/Makefile index 66a20d1dc..a7ae5337c 100644 --- a/fuzz/Makefile +++ b/fuzz/Makefile @@ -50,6 +50,13 @@ TOKEN_OBJS := $(RSYNC_DIR)/token.o $(RSYNC_DIR)/io.o $(ZLIB_OBJS) TOKEN_LIBS := -llz4 -lzstd HARNESSES := fuzz_io fuzz_token +# fuzz_recv_discard drives the receiver discard-path NULL-deref (static-findings +# REAL #1). It needs the real read_sum_head/recv_token (io.o + token.o + zlib) +# plus the real full_fname (util1.o), where the NULL deref actually lands. +RECV_DISCARD_OBJS := $(RSYNC_DIR)/util1.o $(TOKEN_OBJS) +RECV_DISCARD_LIBS := -llz4 -lzstd + +HARNESSES := fuzz_io fuzz_token fuzz_recv_discard .PHONY: all clean regression all: $(HARNESSES) @@ -69,6 +76,18 @@ fuzz_token.o: fuzz_token.c fuzz_token: fuzz_token.o stubs.o $(TOKEN_OBJS) $(CC) $(FUZZ_SAN) $^ -o $@ $(TOKEN_LIBS) +fuzz_recv_discard.o: fuzz_recv_discard.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +recv_discard_stubs.o: recv_discard_stubs.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +fuzz_recv_discard: fuzz_recv_discard.o stubs.o $(RECV_DISCARD_OBJS) recv_discard_stubs.o + # util1.o provides real definitions of a few helpers (flist_ndx_push/pop, + # glob_expand*) that stubs.c also stubs; none are on the full_fname path, so + # allow the duplicates (linker keeps the first / stubs.c version). + $(CC) $(FUZZ_SAN) -Wl,--allow-multiple-definition $^ -o $@ $(RECV_DISCARD_LIBS) + regression: all ./run-regression.sh diff --git a/fuzz/README.md b/fuzz/README.md index d2f92cf63..dd0f8b988 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -15,6 +15,21 @@ crash here means a real parser bug, not a harness artifact. | `fuzz_flist` | `flist.c` `recv_file_entry` | staged stub (see header notes) | | `fuzz_xattrs` | `xattrs.c` `receive_xattr` | staged stub (see header notes) | +| `fuzz_recv_discard` | `receiver.c` discard-path NULL deref: `read_sum_head` + match-token → `full_fname(NULL)` (static-findings REAL #1, util1.c:1282) | **working — bug FIXED, now a clean-gate regression** | +| `fuzz_flist` | `flist.c` `recv_file_entry` | staged stub (see header notes) | +| `fuzz_xattrs` | `xattrs.c` `receive_xattr` | staged stub (see header notes) | + +> **`fuzz_recv_discard` is a REGRESSION harness for a now-FIXED bug.** On the +> original (vulnerable) code its committed seed +> `corpus/fuzz_recv_discard/match_nobasis.bin` triggered an ASan/UBSan NULL +> dereference in `full_fname` (util1.c:1282) when a block-match token arrived on +> the discard path (`fname == NULL`). The receiver fix (handle a block-match +> token on the discard path by absorbing it benignly — it is normal protocol, +> not malformed — and restrict the `mapbuf == NULL` protocol error to real-output +> transfers where `fd != -1` and `fname` is non-NULL) makes this path unwind +> cleanly. The harness is therefore now part of the default `run-regression.sh` +> clean-gate target list; it must stay green. + The two stubs are no-op `LLVMFuzzerTestOneInput`s with a detailed header documenting the exact target region, the required global-init contract, and the dependency wall that puts a *hygienic* harness out of WS2 budget. They are not diff --git a/fuzz/corpus/fuzz_recv_discard/match_nobasis.bin b/fuzz/corpus/fuzz_recv_discard/match_nobasis.bin new file mode 100644 index 0000000000000000000000000000000000000000..4f3c6c040ffa527fdc74a64b3e0afefc7c32b148 GIT binary patch literal 20 UcmZQ%00IsM1_2Na1pk2m01H+G761SM literal 0 HcmV?d00001 diff --git a/fuzz/fuzz_recv_discard.c b/fuzz/fuzz_recv_discard.c new file mode 100644 index 000000000..baa272c55 --- /dev/null +++ b/fuzz/fuzz_recv_discard.c @@ -0,0 +1,171 @@ +/* + * fuzz_recv_discard.c - regression harness for the receiver discard-path + * NULL-pointer dereference (static-findings.md REAL #1, receiver.c:413). + * + * THE DEFECT + * ---------- + * receiver.c discard_receive_data() calls + * receive_data(f_in, NULL, -1, 0, NULL, -1, file, 0); + * i.e. fname_r == NULL, fd_r == -1, size_r == 0, fname == NULL. + * Inside receive_data(), because fd_r < 0 && size_r == 0, the basis is never + * mapped: mapbuf = NULL (receiver.c:326-327). When the peer then sends a + * block-MATCH token (recv_token() returns a negative value) with a sum-header + * count > 0 so the index check at receiver.c:393 passes, control reaches the + * fork-added block at receiver.c:411-415: + * + * if (!mapbuf) { + * rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", + * full_fname(fname), who_am_i()); // <-- fname == NULL + * exit_cleanup(RERR_PROTOCOL); + * } + * + * full_fname(NULL) dereferences its argument unconditionally at util1.c:1282 + * if (*fn == '/') + * => NULL read => SIGSEGV / ASan SEGV. A remote (sender) peer controls both + * the sum-header count and the token stream, so this is reachable from + * untrusted protocol input while the receiver is merely *discarding* a file. + * + * WHAT THIS HARNESS DOES + * ---------------------- + * receive_data() is `static` in receiver.c, so we reproduce the exact + * discard-path decision sequence here against the REAL, unmodified rsync + * objects on that path: io.o (read_sum_head, recv_token via token.o, read_int, + * read_buf) and util1.o (full_fname). This is a faithful reproduction: every + * function that participates in the bug - read_sum_head, recv_token, the + * mapbuf==NULL branch, and full_fname - is the production function, and the + * NULL deref occurs inside the production full_fname(). + * + * The fname passed to full_fname is hard-NULL, exactly as discard_receive_data + * supplies it. mapbuf is hard-NULL, exactly as receive_data computes it on the + * discard configuration (fd_r=-1, size_r=0). + * + * EXPECTED RESULT + * --------------- + * On the ORIGINAL (vulnerable) code this aborted under ASan with a NULL read in + * full_fname (util1.c:1282). The receiver fix makes the discard path (fd == -1, + * fname == NULL) absorb a block-match token benignly -- it is normal protocol, + * since the sender does not know the receiver is discarding -- and restricts the + * "no basis file" protocol error to real-output transfers (fd != -1, fname + * non-NULL, full_fname safe). This harness mirrors that fixed logic and now + * unwinds cleanly (no full_fname(NULL)); it is the standing regression proof and + * a clean-gate target in run-regression.sh. + */ + +#include "rsync.h" +#include +#include +#include + +extern void read_sum_head(int f, struct sum_struct *sum); +extern int32 recv_token(int f, char **data); +extern char *full_fname(const char *fn); + +extern jmp_buf fuzz_unwind_env; +extern int fuzz_unwind_armed; +extern int protocol_version; +extern int do_compression; +extern int xfer_sum_len; + +static int fd_from_bytes(const uint8_t *data, size_t size) +{ + int fds[2]; + if (pipe(fds) != 0) + return -1; + fcntl(fds[1], F_SETFL, O_NONBLOCK); + size_t off = 0; + while (off < size) { + ssize_t n = write(fds[1], data + off, size - off); + if (n <= 0) + break; + off += (size_t)n; + } + close(fds[1]); + return fds[0]; +} + +/* + * Mirror of the receiver discard path inside receive_data(): + * read_sum_head -> mapbuf=NULL -> recv_token loop -> match-token branch -> + * index check -> !mapbuf -> full_fname(fname==NULL). + * Everything reachable from received bytes here is the real rsync code. + */ +static void drive_discard_path(int f_in) +{ + struct sum_struct sum; + struct map_struct *mapbuf; + char *fname = NULL; /* discard_receive_data passes NULL */ + int fd_r = -1; /* discard configuration */ + int fd = -1; /* discard configuration: no output fd */ + OFF_T size_r = 0; /* discard configuration */ + OFF_T offset = 0; + int32 len = 0; + char *data; + int32 i; + + read_sum_head(f_in, &sum); /* peer-controlled sum.count */ + + if (fd_r >= 0 && size_r > 0) + mapbuf = (struct map_struct *)-1; /* unreachable on discard */ + else + mapbuf = NULL; /* exactly receiver.c:327 */ + + while (1) { + data = NULL; + i = recv_token(f_in, &data); /* real token decode */ + if (i == 0) + break; + + if (i > 0) { + /* literal token: discard path ignores the payload */ + if (!data) + exit_cleanup(RERR_PROTOCOL); + continue; + } + + /* block-match token */ + i = -(i + 1); + if (i < 0 || i >= sum.count) /* receiver.c:393 guard */ + exit_cleanup(RERR_PROTOCOL); + + len = sum.blength; + if (i == (int)sum.count - 1 && sum.remainder != 0) + len = sum.remainder; + + if (!mapbuf) { /* receiver.c: !mapbuf branch */ + /* FIXED behavior: on the discard path (fd == -1) a match + * token is normal protocol; absorb it benignly instead of + * calling full_fname(fname==NULL). Only a real-output + * transfer (fd != -1) hard-errors -- and there fname is + * non-NULL, so full_fname is safe. */ + if (fd != -1) { + rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", + full_fname(fname), who_am_i()); + exit_cleanup(RERR_PROTOCOL); + } + offset += len; + continue; + } + } +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + if (size < 1) + return 0; + + protocol_version = 30; + do_compression = CPRES_NONE; /* simple_recv_token path */ + xfer_sum_len = 16; + + int f = fd_from_bytes(data, size); + if (f < 0) + return 0; + + fuzz_unwind_armed = 1; + if (setjmp(fuzz_unwind_env) == 0) + drive_discard_path(f); + fuzz_unwind_armed = 0; + + close(f); + return 0; +} diff --git a/fuzz/recv_discard_stubs.c b/fuzz/recv_discard_stubs.c new file mode 100644 index 000000000..40dc76096 --- /dev/null +++ b/fuzz/recv_discard_stubs.c @@ -0,0 +1,65 @@ +/* + * recv_discard_stubs.c - extra link-time symbols for fuzz_recv_discard. + * + * fuzz_recv_discard links the REAL util1.o (for full_fname) on top of the + * shared stubs.c. util1.o is a whole translation unit, so it references many + * symbols from other rsync TUs that are NEVER on the discard-path / full_fname + * code path we exercise. We supply them here so the object links. + * + * IMPORTANT: none of these are reached by the harness. full_fname(NULL) reads + * *fn (util1.c:1282) BEFORE touching module_id/lp_name/curr_dir, so the NULL + * deref fires first. The function stubs abort() if ever called, which would + * surface as an obvious failure rather than a silent wrong answer (oracle + * fidelity, same spirit as stubs.c). + * + * The globals are given the same neutral defaults rsync uses at start-up: + * module_id = -1 (already in stubs.c) -> full_fname skips lp_name() + * module_dirlen = 0 -> full_fname's curr_dir math is inert + * so full_fname is deterministic up to the (crashing) *fn read. + */ + +#include "rsync.h" +#include + +/* ---- globals util1.o references (neutral start-up values) ---- */ +unsigned int module_dirlen = 0; +char *module_dir = NULL; +char *partial_dir = NULL; +filter_rule_list daemon_filter_list = { .debug_type = " [daemon]" }; + +int am_daemon = 0; +int am_chrooted = 0; +int dry_run = 0; +int relative_paths = 0; +int modify_window = 0; +int preserve_xattrs = 0; +int preallocate_files = 0; +int omit_link_times = 0; + +/* ---- functions util1.o references but the full_fname path never calls ---- */ +#define NEVER(name) do { (void)(name); abort(); } while (0) + +char *lp_name(int module_id_) { (void)module_id_; NEVER("lp_name"); return NULL; } +int check_filter(filter_rule_list *lp, enum logcode code, const char *name, int name_is_dir) +{ (void)lp; (void)code; (void)name; (void)name_is_dir; NEVER("check_filter"); return 0; } +int wildmatch(const char *p, const char *t) { (void)p; (void)t; NEVER("wildmatch"); return 0; } +int copy_xattrs(const char *s, const char *d) { (void)s; (void)d; NEVER("copy_xattrs"); return 0; } +int secure_relative_open(const char *b, const char *r, int fl, mode_t m) +{ (void)b; (void)r; (void)fl; (void)m; NEVER("secure_relative_open"); return -1; } + +OFF_T do_fallocate(int fd, OFF_T off, OFF_T len) { (void)fd; (void)off; (void)len; NEVER("do_fallocate"); return -1; } +int do_fstat(int fd, STRUCT_STAT *st) { (void)fd; (void)st; NEVER("do_fstat"); return -1; } +int do_fsync(int fd) { (void)fd; NEVER("do_fsync"); return -1; } +int do_ftruncate(int fd, OFF_T sz) { (void)fd; (void)sz; NEVER("do_ftruncate"); return -1; } +int do_stat(const char *p, STRUCT_STAT *st) { (void)p; (void)st; NEVER("do_stat"); return -1; } +int do_lstat_at(const char *p, STRUCT_STAT *st) { (void)p; (void)st; NEVER("do_lstat_at"); return -1; } +int do_mkdir(char *p, mode_t m) { (void)p; (void)m; NEVER("do_mkdir"); return -1; } +int do_mkdir_at(char *p, mode_t m) { (void)p; (void)m; NEVER("do_mkdir_at"); return -1; } +int do_rmdir_at(const char *p) { (void)p; NEVER("do_rmdir_at"); return -1; } +int do_unlink_at(const char *p) { (void)p; NEVER("do_unlink_at"); return -1; } +int do_rename_at(const char *o, const char *n) { (void)o; (void)n; NEVER("do_rename_at"); return -1; } +int do_open_at(const char *p, int fl, mode_t m) { (void)p; (void)fl; (void)m; NEVER("do_open_at"); return -1; } +int do_open_nofollow(const char *p, int fl) { (void)p; (void)fl; NEVER("do_open_nofollow"); return -1; } +int do_utimensat_at(const char *p, STRUCT_STAT *st) { (void)p; (void)st; NEVER("do_utimensat_at"); return -1; } +int do_lutimes(const char *p, STRUCT_STAT *st) { (void)p; (void)st; NEVER("do_lutimes"); return -1; } +int do_utimes(const char *p, STRUCT_STAT *st) { (void)p; (void)st; NEVER("do_utimes"); return -1; } diff --git a/fuzz/run-regression.sh b/fuzz/run-regression.sh index 8f967b95e..fa99ff8cb 100755 --- a/fuzz/run-regression.sh +++ b/fuzz/run-regression.sh @@ -23,6 +23,13 @@ TARGETS="${FUZZ_TARGETS:-fuzz_io}" # Ensure the rsync wire-parser objects exist & are sanitizer-instrumented. # (CI is expected to have configured with the campaign CFLAGS already.) make -C .. io.o >/dev/null +TARGETS="${FUZZ_TARGETS:-fuzz_io fuzz_token fuzz_recv_discard}" + +# Ensure the rsync wire-parser objects exist & are sanitizer-instrumented. +# (CI is expected to have configured with the campaign CFLAGS already.) +# io.o feeds fuzz_io; token.o feeds fuzz_token; util1.o (real full_fname) feeds +# fuzz_recv_discard's discard-path regression. +make -C .. io.o token.o util1.o >/dev/null make all diff --git a/options.c b/options.c index 3c2d23526..8cd8c3516 100644 --- a/options.c +++ b/options.c @@ -3131,6 +3131,13 @@ char *check_for_hostspec(char *s, char **host_ptr, int *port_ptr) { char *path; + /* Establish the default port value up front. parse_hostspec() only + * writes *port_ptr when it parses an explicit port, and the non-URL + * call below passes a NULL port_ptr, so without this the reads at the + * "!*port_ptr" tests below can observe an uninitialized caller value. */ + if (port_ptr) + *port_ptr = 0; + if (port_ptr && strncasecmp(URL_PREFIX, s, strlen(URL_PREFIX)) == 0) { *host_ptr = parse_hostspec(s + strlen(URL_PREFIX), &path, port_ptr); if (*host_ptr) { diff --git a/receiver.c b/receiver.c index 7d429fe84..44d6e56fd 100644 --- a/receiver.c +++ b/receiver.c @@ -402,16 +402,31 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, stats.matched_data += len; - /* A block match can only be honored if we actually mapped the - * basis. If we didn't (basis open failed), the sender should - * never have been told a basis existed -- treat it as a protocol - * inconsistency rather than silently omitting these bytes from - * the verification checksum (which yields a spurious failure) or - * leaving a hole in the output. */ + /* A block match with no mapped basis is a protocol inconsistency + * ONLY when we are actually producing output (fd != -1): the + * generator told the sender a basis existed but the receiver could + * not open it, so honoring the match would silently omit these + * bytes from the verification checksum (a spurious failure) or + * leave a hole in the output. Fail cleanly in that case. + * + * On the DISCARD path (fd == -1, fname == NULL) there is no output + * and no verification: discard_receive_data() deliberately drains a + * delta the receiver never intends to write (basis fstat failed, + * basis is a directory, output open failed, batch skip, ...). The + * sender does not know the data is being discarded and streams an + * ordinary delta, so a match token here is NORMAL protocol, not + * malformed. Absorb it benignly (advance the offset and continue), + * exactly as the pre-31fbb17d "if (mapbuf)" guards did -- erroring + * would wrongly break legitimate transfers, and full_fname(fname) + * with fname==NULL would dereference NULL (remote DoS). */ if (!mapbuf) { - rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", - full_fname(fname), who_am_i()); - exit_cleanup(RERR_PROTOCOL); + if (fd != -1) { + rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", + full_fname(fname), who_am_i()); + exit_cleanup(RERR_PROTOCOL); + } + offset += len; + continue; } if (DEBUG_GTE(DELTASUM, 3)) { From 7d67a6488bd01c29786efa0880be423a52703aac Mon Sep 17 00:00:00 2001 From: pterror Date: Mon, 1 Jun 2026 00:48:48 +1000 Subject: [PATCH 3/4] fuzz: live flist/xattrs + deflated-token harnesses, narrow LSan suppressions, stub hardening --- flist.c | 47 ++++ fuzz/.gitignore | 6 + fuzz/Makefile | 75 +++++- fuzz/README.md | 16 +- fuzz/corpus/fuzz_deflated_token/01_end | Bin 0 -> 1 bytes .../fuzz_deflated_token/02_literal_small | Bin 0 -> 31 bytes fuzz/corpus/fuzz_deflated_token/03_literal_4k | Bin 0 -> 4105 bytes .../fuzz_deflated_token/04_literal_maxish | Bin 0 -> 40023 bytes fuzz/corpus/fuzz_deflated_token/05_tok_rel | Bin 0 -> 2 bytes fuzz/corpus/fuzz_deflated_token/06_tokrun_rel | Bin 0 -> 4 bytes fuzz/corpus/fuzz_deflated_token/07_tok_long | Bin 0 -> 6 bytes .../corpus/fuzz_deflated_token/08_tokrun_long | Bin 0 -> 8 bytes fuzz/corpus/fuzz_deflated_token/09_mixed | Bin 0 -> 30 bytes .../fuzz_deflated_token/10_tok_max_index | Bin 0 -> 6 bytes .../fuzz_deflated_token/11_tokrun_boundary | Bin 0 -> 9 bytes fuzz/corpus/fuzz_deflated_token/12_rel_chain | Bin 0 -> 9 bytes fuzz/corpus/fuzz_deflated_token/13_trunc_hdr | Bin 0 -> 2 bytes .../fuzz_deflated_token/14_trunc_payload | Bin 0 -> 6 bytes fuzz/corpus/fuzz_deflated_token/15_maxlen_hdr | Bin 0 -> 16386 bytes .../corpus/fuzz_deflated_token/16_bad_deflate | Bin 0 -> 11 bytes fuzz/corpus/fuzz_deflated_token/17_zero_frame | Bin 0 -> 3 bytes fuzz/corpus/fuzz_deflated_token/18_neg_tok | Bin 0 -> 6 bytes fuzz/corpus/fuzz_deflated_token/19_zero_run | Bin 0 -> 4 bytes .../corpus/fuzz_deflated_token/20_multi_noend | Bin 0 -> 20015 bytes fuzz/corpus/fuzz_deflated_token/21_garbage | Bin 0 -> 2 bytes .../fuzz_deflated_token/22_inflated_syncpoint | Bin 0 -> 12 bytes fuzz/corpus/fuzz_flist/seed_basic | Bin 0 -> 10 bytes fuzz/corpus/fuzz_flist/seed_csum | Bin 0 -> 10 bytes fuzz/corpus/fuzz_flist/seed_longname | Bin 0 -> 18 bytes fuzz/corpus/fuzz_xattrs/seed_abbrev | Bin 0 -> 23 bytes fuzz/corpus/fuzz_xattrs/seed_one | Bin 0 -> 16 bytes fuzz/corpus/fuzz_xattrs/seed_zerocount | Bin 0 -> 4 bytes fuzz/fuzz_deflated_token.c | 104 +++++++++ fuzz/fuzz_flist.c | 220 +++++++++++++++--- fuzz/fuzz_xattrs.c | 147 +++++++++--- fuzz/globals.c | 159 +++++++++++++ fuzz/lsan-suppressions.txt | 24 ++ fuzz/run-regression.sh | 33 ++- fuzz/stubs.c | 63 +++-- token.c | 32 +++ xattrs.c | 36 +++ 41 files changed, 845 insertions(+), 117 deletions(-) create mode 100644 fuzz/corpus/fuzz_deflated_token/01_end create mode 100644 fuzz/corpus/fuzz_deflated_token/02_literal_small create mode 100644 fuzz/corpus/fuzz_deflated_token/03_literal_4k create mode 100644 fuzz/corpus/fuzz_deflated_token/04_literal_maxish create mode 100644 fuzz/corpus/fuzz_deflated_token/05_tok_rel create mode 100644 fuzz/corpus/fuzz_deflated_token/06_tokrun_rel create mode 100644 fuzz/corpus/fuzz_deflated_token/07_tok_long create mode 100644 fuzz/corpus/fuzz_deflated_token/08_tokrun_long create mode 100644 fuzz/corpus/fuzz_deflated_token/09_mixed create mode 100644 fuzz/corpus/fuzz_deflated_token/10_tok_max_index create mode 100644 fuzz/corpus/fuzz_deflated_token/11_tokrun_boundary create mode 100644 fuzz/corpus/fuzz_deflated_token/12_rel_chain create mode 100644 fuzz/corpus/fuzz_deflated_token/13_trunc_hdr create mode 100644 fuzz/corpus/fuzz_deflated_token/14_trunc_payload create mode 100644 fuzz/corpus/fuzz_deflated_token/15_maxlen_hdr create mode 100644 fuzz/corpus/fuzz_deflated_token/16_bad_deflate create mode 100644 fuzz/corpus/fuzz_deflated_token/17_zero_frame create mode 100644 fuzz/corpus/fuzz_deflated_token/18_neg_tok create mode 100644 fuzz/corpus/fuzz_deflated_token/19_zero_run create mode 100644 fuzz/corpus/fuzz_deflated_token/20_multi_noend create mode 100644 fuzz/corpus/fuzz_deflated_token/21_garbage create mode 100644 fuzz/corpus/fuzz_deflated_token/22_inflated_syncpoint create mode 100644 fuzz/corpus/fuzz_flist/seed_basic create mode 100644 fuzz/corpus/fuzz_flist/seed_csum create mode 100644 fuzz/corpus/fuzz_flist/seed_longname create mode 100644 fuzz/corpus/fuzz_xattrs/seed_abbrev create mode 100644 fuzz/corpus/fuzz_xattrs/seed_one create mode 100644 fuzz/corpus/fuzz_xattrs/seed_zerocount create mode 100644 fuzz/fuzz_deflated_token.c create mode 100644 fuzz/globals.c create mode 100644 fuzz/lsan-suppressions.txt diff --git a/flist.c b/flist.c index 2ec07f54a..70580c781 100644 --- a/flist.c +++ b/flist.c @@ -3432,3 +3432,50 @@ struct file_list *get_dirlist(char *dirname, int dlen, int flags) return dirlist; } + +#ifdef RSYNC_FUZZ_FLIST +/* Fuzzing hook (compiled ONLY when RSYNC_FUZZ_FLIST is defined; the normal + * rsync build never sees this). It exposes the file-internal static function + * recv_file_entry() to fuzz/fuzz_flist.c, replicating exactly the per-entry + * work the real caller (recv_file_list, lines ~2625-2658) does around it: + * flist_expand(flist, 1); file = recv_file_entry(f, flist, flags); + * flist->files[flist->used++] = file; + * It deliberately omits the heavy, non-target tail of recv_file_list + * (flist_sort_and_clean / recv_id_list / fsort) so the fuzzer stays focused on + * the wire parser and its stateful lastname[] reconstruction. The harness owns + * setjmp; an over-range guard's overflow_exit/exit_cleanup longjmps out. */ +struct file_list *fuzz_flist_new(void) +{ + struct file_list *flist = new0(struct file_list); + flist->file_pool = pool_create(NORMAL_EXTENT, 0, _out_of_memory, POOL_INTERN); + if (!flist->file_pool) + out_of_memory("fuzz_flist_new"); + flist->ndx_start = 0; + flist->pool_boundary = pool_boundary(flist->file_pool, 0); + /* recv_file_entry consults first_flist/cur_flist indirectly via globals + * in some paths; mirror flist_new's first-list bookkeeping. */ + first_flist = cur_flist = flist->prev = flist; + return flist; +} + +struct file_struct *fuzz_recv_file_entry(int f, struct file_list *flist, int xflags) +{ + struct file_struct *file; + flist_expand(flist, 1); + file = recv_file_entry(f, flist, xflags); + if (file) + flist->files[flist->used++] = file; + return file; +} + +void fuzz_flist_free(struct file_list *flist) +{ + if (!flist) + return; + if (flist->file_pool) + pool_destroy(flist->file_pool); + free(flist->files); + free(flist); + first_flist = cur_flist = NULL; +} +#endif /* RSYNC_FUZZ_FLIST */ diff --git a/fuzz/.gitignore b/fuzz/.gitignore index be209c9a1..57d10fdb2 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -4,6 +4,7 @@ /fuzz_io /fuzz_flist /fuzz_token +/fuzz_deflated_token /fuzz_xattrs /fuzz_recv_discard @@ -17,3 +18,8 @@ oom-* # libFuzzer-generated corpus entries (40-hex-char names). The committed seed # corpus uses descriptive names; generated growth is not tracked. corpus/*/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* + +# Transient fuzzer working corpora (deep-run mutation pools); only the +# descriptive seeds under corpus// are tracked. +/corpus_flist/ +/corpus_xattrs/ diff --git a/fuzz/Makefile b/fuzz/Makefile index a7ae5337c..3261f48eb 100644 --- a/fuzz/Makefile +++ b/fuzz/Makefile @@ -49,14 +49,47 @@ ZLIB_OBJS := $(RSYNC_DIR)/zlib/deflate.o $(RSYNC_DIR)/zlib/inffast.o \ TOKEN_OBJS := $(RSYNC_DIR)/token.o $(RSYNC_DIR)/io.o $(ZLIB_OBJS) TOKEN_LIBS := -llz4 -lzstd -HARNESSES := fuzz_io fuzz_token # fuzz_recv_discard drives the receiver discard-path NULL-deref (static-findings # REAL #1). It needs the real read_sum_head/recv_token (io.o + token.o + zlib) # plus the real full_fname (util1.o), where the NULL deref actually lands. RECV_DISCARD_OBJS := $(RSYNC_DIR)/util1.o $(TOKEN_OBJS) RECV_DISCARD_LIBS := -llz4 -lzstd -HARNESSES := fuzz_io fuzz_token fuzz_recv_discard +# fuzz_deflated_token: drives recv_deflated_token (zlib/CPRES_ZLIB path), which +# is a file-static. token_fuzz.o is the REAL token.c recompiled with the in-file +# fuzz hook enabled (-DRSYNC_FUZZ_TOKEN) - same parser/inflate/bounds code, plus +# the thin reset+entry wrappers. Links the REAL io.o + REAL bundled zlib. +DEFL_TOKEN_OBJS := $(RSYNC_DIR)/token_fuzz.o $(RSYNC_DIR)/io.o $(ZLIB_OBJS) +DEFL_TOKEN_LIBS := -llz4 -lzstd + +# fuzz_flist: links the REAL flist.o (compiled -DRSYNC_FUZZ_FLIST as flist_fuzz.o) +# plus its whole reachable call graph. The fuzz wrappers live inside flist.c +# under #ifdef RSYNC_FUZZ_FLIST, so we build a dedicated flist_fuzz.o. +# The whole reachable call graph of recv_file_entry / receive_xattr, linked +# REAL & instrumented. REAL_CORE excludes xattrs.o so each harness can pick the +# plain (fuzz_flist) or -DRSYNC_FUZZ_XATTRS (fuzz_xattrs) build of it. +REAL_CORE := $(RSYNC_DIR)/io.o $(RSYNC_DIR)/util1.o $(RSYNC_DIR)/util2.o \ + $(RSYNC_DIR)/uidlist.o $(RSYNC_DIR)/exclude.o \ + $(RSYNC_DIR)/hashtable.o $(RSYNC_DIR)/checksum.o \ + $(RSYNC_DIR)/syscall.o $(RSYNC_DIR)/acls.o \ + $(RSYNC_DIR)/fileio.o \ + $(RSYNC_DIR)/lib/wildmatch.o $(RSYNC_DIR)/lib/compat.o \ + $(RSYNC_DIR)/lib/snprintf.o $(RSYNC_DIR)/lib/mdfour.o \ + $(RSYNC_DIR)/lib/md5.o $(RSYNC_DIR)/lib/permstring.o \ + $(RSYNC_DIR)/lib/pool_alloc.o $(RSYNC_DIR)/lib/sysacls.o \ + $(RSYNC_DIR)/lib/sysxattrs.o $(RSYNC_DIR)/chmod.o +FLIST_OBJS := $(RSYNC_DIR)/flist_fuzz.o globals.o $(RSYNC_DIR)/xattrs.o $(REAL_CORE) +FLIST_LIBS := -lcrypto -lxxhash -lacl + +# fuzz_xattrs: receive_xattr lives in xattrs.o; storage tail pulls checksum.o + +# hashtable.o + acls/exclude. Built as xattrs_fuzz.o (#ifdef RSYNC_FUZZ_XATTRS). +# receive_xattr's storage tail (rsync_xal_store -> hashtable + checksum) and +# f_name reach into flist.o, so we link flist_fuzz.o (which also supplies the +# globals flist.c owns) alongside the same real core. +XATTRS_OBJS := $(RSYNC_DIR)/xattrs_fuzz.o $(RSYNC_DIR)/flist_fuzz.o globals.o $(REAL_CORE) +XATTRS_LIBS := -lcrypto -lxxhash -lacl + +HARNESSES := fuzz_io fuzz_token fuzz_recv_discard fuzz_deflated_token fuzz_flist fuzz_xattrs .PHONY: all clean regression all: $(HARNESSES) @@ -64,6 +97,9 @@ all: $(HARNESSES) stubs.o: stubs.c $(CC) $(HARNESS_CFLAGS) -c $< -o $@ +globals.o: globals.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + fuzz_io.o: fuzz_io.c $(CC) $(HARNESS_CFLAGS) -c $< -o $@ @@ -88,6 +124,41 @@ fuzz_recv_discard: fuzz_recv_discard.o stubs.o $(RECV_DISCARD_OBJS) recv_discard # allow the duplicates (linker keeps the first / stubs.c version). $(CC) $(FUZZ_SAN) -Wl,--allow-multiple-definition $^ -o $@ $(RECV_DISCARD_LIBS) +$(RSYNC_DIR)/token_fuzz.o: $(RSYNC_DIR)/token.c + $(CC) $(RSYNC_CFLAGS) -DRSYNC_FUZZ_TOKEN -c $< -o $@ + +fuzz_deflated_token.o: fuzz_deflated_token.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +fuzz_deflated_token: fuzz_deflated_token.o stubs.o $(DEFL_TOKEN_OBJS) + $(CC) $(FUZZ_SAN) $^ -o $@ $(DEFL_TOKEN_LIBS) + +# flist_fuzz.o / xattrs_fuzz.o are the REAL flist.c / xattrs.c recompiled with +# the in-file fuzz hook enabled (#ifdef RSYNC_FUZZ_*). Same sanitizer CFLAGS as +# the rest of the instrumented rsync objects; -DRSYNC_FUZZ_* only adds the thin +# wrappers, it does not alter parser code. +RSYNC_CFLAGS := -std=gnu23 -I$(RSYNC_DIR) -I$(RSYNC_DIR)/zlib -g -O1 \ + -fsanitize=fuzzer-no-link,address,undefined -fno-omit-frame-pointer \ + -DHAVE_CONFIG_H -Wall -W + +$(RSYNC_DIR)/flist_fuzz.o: $(RSYNC_DIR)/flist.c + $(CC) $(RSYNC_CFLAGS) -DRSYNC_FUZZ_FLIST -c $< -o $@ + +$(RSYNC_DIR)/xattrs_fuzz.o: $(RSYNC_DIR)/xattrs.c + $(CC) $(RSYNC_CFLAGS) -DRSYNC_FUZZ_XATTRS -c $< -o $@ + +fuzz_flist.o: fuzz_flist.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +fuzz_flist: fuzz_flist.o stubs.o $(FLIST_OBJS) + $(CC) $(FUZZ_SAN) $^ -o $@ $(FLIST_LIBS) + +fuzz_xattrs.o: fuzz_xattrs.c + $(CC) $(HARNESS_CFLAGS) -c $< -o $@ + +fuzz_xattrs: fuzz_xattrs.o stubs.o $(XATTRS_OBJS) + $(CC) $(FUZZ_SAN) $^ -o $@ $(XATTRS_LIBS) + regression: all ./run-regression.sh diff --git a/fuzz/README.md b/fuzz/README.md index dd0f8b988..0f2d7907d 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -12,12 +12,10 @@ crash here means a real parser bug, not a harness artifact. |----------------|-------------------------------------------|--------| | `fuzz_io` | `io.c` primitives: `read_sum_head`, `read_varint`/`read_varlong`/`read_longint`/`read_vstring`, `read_int_bounded`/`read_varint_bounded`/`read_varint_size` | **working** | | `fuzz_token` | `token.c` `recv_token` → `simple_recv_token` (CPRES_NONE literal-token path; the `i > CHUNK_SIZE` guard) | **working** | -| `fuzz_flist` | `flist.c` `recv_file_entry` | staged stub (see header notes) | -| `fuzz_xattrs` | `xattrs.c` `receive_xattr` | staged stub (see header notes) | - | `fuzz_recv_discard` | `receiver.c` discard-path NULL deref: `read_sum_head` + match-token → `full_fname(NULL)` (static-findings REAL #1, util1.c:1282) | **working — bug FIXED, now a clean-gate regression** | -| `fuzz_flist` | `flist.c` `recv_file_entry` | staged stub (see header notes) | -| `fuzz_xattrs` | `xattrs.c` `receive_xattr` | staged stub (see header notes) | +| `fuzz_deflated_token` | `token.c` `recv_deflated_token` (zlib/CPRES_ZLIB inflate path) | **working** | +| `fuzz_flist` | `flist.c` `recv_file_entry` | **working (live harness)** | +| `fuzz_xattrs` | `xattrs.c` `receive_xattr` | **working (live harness)** | > **`fuzz_recv_discard` is a REGRESSION harness for a now-FIXED bug.** On the > original (vulnerable) code its committed seed @@ -30,10 +28,10 @@ crash here means a real parser bug, not a harness artifact. > cleanly. The harness is therefore now part of the default `run-regression.sh` > clean-gate target list; it must stay green. -The two stubs are no-op `LLVMFuzzerTestOneInput`s with a detailed header -documenting the exact target region, the required global-init contract, and the -dependency wall that puts a *hygienic* harness out of WS2 budget. They are not -wired into the Makefile and never run in regression. +The `fuzz_flist` / `fuzz_xattrs` harnesses are LIVE: they link the REAL +`flist.c` / `xattrs.c` recompiled with an in-file `#ifdef RSYNC_FUZZ_*` hook +(`flist_fuzz.o` / `xattrs_fuzz.o`) plus their whole reachable call graph, and +`fuzz_deflated_token` drives the real `recv_deflated_token` via `token_fuzz.o`. ## Toolchain diff --git a/fuzz/corpus/fuzz_deflated_token/01_end b/fuzz/corpus/fuzz_deflated_token/01_end new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/02_literal_small b/fuzz/corpus/fuzz_deflated_token/02_literal_small new file mode 100644 index 0000000000000000000000000000000000000000..a5f9f2545a4809e221e1d2f42263db69cbc1f109 GIT binary patch literal 31 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 HudV<96F2?8 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/04_literal_maxish b/fuzz/corpus/fuzz_deflated_token/04_literal_maxish new file mode 100644 index 0000000000000000000000000000000000000000..69518414c2eb2f53d85c927aa212f14c4bc7bf93 GIT binary patch literal 40023 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 De(=>5 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/05_tok_rel b/fuzz/corpus/fuzz_deflated_token/05_tok_rel new file mode 100644 index 0000000000000000000000000000000000000000..438e74a03d19519f3369fe66bbb3a87592888623 GIT binary patch literal 2 JcmZo*000330D%Ai literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/06_tokrun_rel b/fuzz/corpus/fuzz_deflated_token/06_tokrun_rel new file mode 100644 index 0000000000000000000000000000000000000000..b01049c52bbcf5c6a4b45ab862a5fd91bbc59c86 GIT binary patch literal 4 LcmX@W%D?~s0}}wo literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/07_tok_long b/fuzz/corpus/fuzz_deflated_token/07_tok_long new file mode 100644 index 0000000000000000000000000000000000000000..ad8c6e3931baccd6eae18476c1e0c703e3341fb8 GIT binary patch literal 6 NcmY$eG+t<8 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/12_rel_chain b/fuzz/corpus/fuzz_deflated_token/12_rel_chain new file mode 100644 index 0000000000000000000000000000000000000000..2ac65e644ef2741a24aa08e157256d1820267788 GIT binary patch literal 9 LcmdnL9|9NvAl(J| literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/13_trunc_hdr b/fuzz/corpus/fuzz_deflated_token/13_trunc_hdr new file mode 100644 index 0000000000000000000000000000000000000000..62e8bdd539862339d75fc9fb3e303b99707339cf GIT binary patch literal 2 JcmWGw0001@08sz{ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/14_trunc_payload b/fuzz/corpus/fuzz_deflated_token/14_trunc_payload new file mode 100644 index 0000000000000000000000000000000000000000..b4fd24e4479230032e7c86297242a48cc1182650 GIT binary patch literal 6 LcmZ<|U;qLD0oDLf literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_deflated_token/15_maxlen_hdr b/fuzz/corpus/fuzz_deflated_token/15_maxlen_hdr new file mode 100644 index 0000000000000000000000000000000000000000..2c83d30e55deeb1d12b5fb424e36b7fd60380375 GIT binary patch literal 16386 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 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_xattrs/seed_abbrev b/fuzz/corpus/fuzz_xattrs/seed_abbrev new file mode 100644 index 0000000000000000000000000000000000000000..8764d153507ea56bac41d7ef75be174b1fca6b5d GIT binary patch literal 23 ecmZQzVEX@`v9vg~NG~xdsVKE9GqEH!g#iF#x(H|h literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_xattrs/seed_one b/fuzz/corpus/fuzz_xattrs/seed_one new file mode 100644 index 0000000000000000000000000000000000000000..b2e8324568dff8fdc03f00b5e27566e877a4202a GIT binary patch literal 16 XcmZQzU}P;VPA$@_U|>l}EJ*|a8;Ar7 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/fuzz_xattrs/seed_zerocount b/fuzz/corpus/fuzz_xattrs/seed_zerocount new file mode 100644 index 0000000000000000000000000000000000000000..f16a710f6cc9d1aa852cf27f99515e3a65989d97 GIT binary patch literal 4 LcmZQ$U|;|M02}}Z literal 0 HcmV?d00001 diff --git a/fuzz/fuzz_deflated_token.c b/fuzz/fuzz_deflated_token.c new file mode 100644 index 000000000..f5b1efbd2 --- /dev/null +++ b/fuzz/fuzz_deflated_token.c @@ -0,0 +1,104 @@ +/* + * fuzz_deflated_token.c - libFuzzer harness for rsync's zlib compressed-token + * decoder: recv_deflated_token() in token.c (reference.md Part 3.3, lines + * 552-664), reached via recv_token() when do_compression == CPRES_ZLIB. + * + * This closes the coverage gap left by fuzz_token.c, which only drives the + * uncompressed simple_recv_token() path. The compressed path is the same + * untrusted-peer wire-parsing surface as CVE-2026-43618 (a malicious *sender* + * drives the receiver's decode loop): it reads attacker-controlled flag bytes, + * a 14-bit DEFLATED_DATA length, absolute/relative token numbers and run + * counts, and feeds the byte stream through zlib inflate() into fixed + * cbuf[MAX_DATA_COUNT] / dbuf[AVAIL_OUT_SIZE(CHUNK_SIZE)] buffers. + * + * LIVE objects (NOT stubbed): the harness links the REAL instrumented token.o + * (compiled as token_fuzz.o with -DRSYNC_FUZZ_TOKEN, which only adds the thin + * ifdef hook below the parser - no parser/bound/inflate logic is altered), the + * REAL io.o readers (read_byte/read_buf/read_int -> safe_read), and rsync's + * REAL bundled zlib objects (inflate.o, inffast.o, inftrees.o, ...). The + * decode/inflate/bounds/run-accounting code all runs for real and is + * sanitizer-instrumented; masking any of it would hide the very bugs we hunt. + * Only true process-boundary externals (exit_cleanup, logging, allocator) are + * shimmed in fuzz/stubs.c, and exit_cleanup longjmps back here so a *correctly + * rejected* hostile input is not counted as a crash (a real OOB trips ASan + * BEFORE any guard fires - oracle fidelity preserved). + * + * Decompressor init fidelity: we do NOT hand-roll inflateInit2. The real + * receiver initializes rx_strm lazily inside recv_deflated_token's r_init arm + * (inflateInit2(&rx_strm, -15), then inflateReset on subsequent streams). The + * harness simply forces recv_state=r_init between inputs via the reset hook, so + * the decompressor is set up EXACTLY as the real receiver sets it up - any + * crash is a real parser/inflate bug, not a harness mis-init. + * + * Plumbing: identical fd trick to fuzz_io/fuzz_token - fuzz bytes arrive via a + * pipe and io.c's readers take the non-iobuf safe_read() fast path. + */ + +#include "rsync.h" +#include +#include +#include + +extern int32 fuzz_recv_deflated_token(int f, char **data); +extern void fuzz_recv_deflated_token_reset(void); + +extern int do_compression; +extern jmp_buf fuzz_unwind_env; +extern int fuzz_unwind_armed; +extern int protocol_version; + +static int fd_from_bytes(const uint8_t *data, size_t size) +{ + int fds[2]; + if (pipe(fds) != 0) + return -1; + fcntl(fds[1], F_SETFL, O_NONBLOCK); + size_t off = 0; + while (off < size) { + ssize_t n = write(fds[1], data + off, size - off); + if (n <= 0) + break; + off += (size_t)n; + } + close(fds[1]); + return fds[0]; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + if (size < 1) + return 0; + + protocol_version = 30; + do_compression = CPRES_ZLIB; /* recv_deflated_token path */ + + /* Restore the decompressor to a fresh-receiver state for THIS input. The + * next decode call re-enters the r_init arm (inflateReset), mirroring the + * real receiver's per-transfer init. */ + fuzz_recv_deflated_token_reset(); + + int f = fd_from_bytes(data, size); + if (f < 0) + return 0; + + fuzz_unwind_armed = 1; + if (setjmp(fuzz_unwind_env) == 0) { + char *out; + int32 n; + /* Drive the decoder over the whole stream: each call returns a + * positive literal byte count, a negative token index, or 0 at + * END_FLAG (clean end). Bounded loop guards a pathological spin. + * A malformed stream (bad inflate, over-range token/run) makes a + * guard call exit_cleanup, which longjmps out - not a crash. */ + for (int i = 0; i < 1 << 20; i++) { + n = fuzz_recv_deflated_token(f, &out); + if (n == 0) + break; /* END_FLAG: stream finished cleanly */ + (void)out; + } + } + fuzz_unwind_armed = 0; + + close(f); + return 0; +} diff --git a/fuzz/fuzz_flist.c b/fuzz/fuzz_flist.c index 8ee3cf356..898d1e038 100644 --- a/fuzz/fuzz_flist.c +++ b/fuzz/fuzz_flist.c @@ -1,49 +1,193 @@ /* - * fuzz_flist.c - STAGED STUB (not yet wired into fuzz/Makefile). + * fuzz_flist.c - LIVE libFuzzer harness for rsync's recv_file_entry() (flist.c). * - * Intended target: recv_file_entry() (flist.c ~682-1169) - the sender->receiver - * file-list entry stream, with the stateful lastname/thisname reconstruction - * (reference.md Part 3.2) and the symlink-target path (line ~1131/1164). Guards - * of interest: the l2 >= MAXPATHLEN - l1 overflow check (line 724) and the - * linkname_len bounds (line 941). + * TARGET: recv_file_entry (flist.c ~682-1229) - the sender->receiver file-list + * entry wire decode, with the stateful static lastname[]/lastdir reconstruction + * (reference.md Part 3.2). recv_file_entry is file-static, so flist.c is compiled + * with -DRSYNC_FUZZ_FLIST which appends three thin wrappers (fuzz_flist_new / + * fuzz_recv_file_entry / fuzz_flist_free) that replicate EXACTLY the per-entry + * work recv_file_list does around the parser (flist_expand + append). The heavy, + * non-target tail (sort/clean/recv_id_list) is intentionally skipped. * - * WHY THIS IS A STUB (honest blocker, not laziness): - * recv_file_entry is NOT self-contained the way io.c's primitives are. The - * flist.o object has ~167 undefined references that recv_file_entry's call - * graph actually reaches, spanning subsystems we would have to either link - * (dragging un-instrumented complexity + their own transitive deps) or stub - * so extensively that we risk masking the very bugs we want to find: - * - uid/gid mapping (add_uid, add_gid, uid_to_user, ...) - * - path utilities (clean_fname, sanitize_path, - * count_dir_elements, push/pop_local_filters) - * - filtering (check_filter, name_is_excluded, filter_list) - * - checksums (file_checksum, csum_len_for_type) - * - acls / xattrs (get_acl, get_xattr, ...) - * - the flist pool allocator + flist_expand - * Doing this hygienically is a workstream of its own. Within the WS2 budget, - * io.c (proven) and token.c (CPRES_NONE path, proven) were prioritized. + * LIVE LINK: this harness links the REAL instrumented flist.o + io.o + util1.o + + * util2.o + uidlist.o + exclude.o + hashtable.o + lib/*.o (pool_alloc, wildmatch, + * mdigest, ...) + checksum.o. Only true process-boundary externals (logging, + * exit_cleanup->longjmp, terminal/socket I/O) are stubbed. NOTHING in the + * parse/alloc/copy path is stubbed. * - * REQUIRED GLOBAL INIT when this is completed (reference.md Part 3.5): - * protocol_version (>=30 for varint30 widths), preserve_links/devices/specials, - * sender_symlink_iconv = NULL, munge_symlinks = 0, sanitize_paths = 0, - * uid_ndx/gid_ndx/acls_ndx/xattrs_ndx (drive which optional fields are read), - * file_extra_cnt + the *_extra index globals (control F_* slot layout), and a - * real struct file_list with an alloc pool (flist->files / flist->pool). - * Crucially, recv_file_entry is STATEFUL: its static lastname[] persists, so a - * useful harness must feed a SEQUENCE of entries (with/without XMIT_SAME_NAME) - * per input - reset/re-init the statics between inputs is not possible from - * outside, so accept the documented cross-input coupling as fuzz_token does. + * PLUMBING: identical fast-path trick as fuzz_io - iobuf stays default + * ({.in_fd=-1}), the fd we hand the parser is a pipe, so every read_buf/read_sbuf + * takes the safe_read() path straight off attacker bytes. EOF => safe_read short + * => whine_about_eof -> exit_cleanup -> longjmp back here (clean unwind). * - * Strategy to finish (sketch): link flist.o + io.o + util1.o/util2.o + uidlist.o - * + exclude.o + hashtable.o + the flist pool, stub only the leaf I/O syscalls - * and logging, and assert ASan stays the oracle (guards => longjmp unwind). + * STATEFULNESS: recv_file_entry's lastname[]/lastdir/mode/uid/... are function + * statics persisting across entries; XMIT_SAME_NAME reuses l1 bytes of lastname. + * We drive a SEQUENCE of entries from one input so the cross-entry name/dir + * reconstruction (a prime bug site) is exercised. State leaks across fuzzer + * inputs too (cannot reset function statics from outside) - documented, same as + * fuzz_token. + * + * ORACLE: a correctly-rejected hostile value calls overflow_exit/exit_cleanup + * AFTER its guard => clean longjmp, not a finding. A genuine OOB/UB during a copy + * or read trips ASan/UBSan BEFORE any guard => real finding. */ -#include -#include +#include "rsync.h" +#include +#include +#include + +extern struct file_list *fuzz_flist_new(void); +extern struct file_struct *fuzz_recv_file_entry(int f, struct file_list *flist, int xflags); +extern void fuzz_flist_free(struct file_list *flist); + +extern uchar read_byte(int f); + +/* From fuzz/stubs.c */ +extern jmp_buf fuzz_unwind_env; +extern int fuzz_unwind_armed; + +/* Globals recv_file_entry consults. */ +extern int protocol_version; +extern int preserve_links, preserve_devices, preserve_specials, preserve_hard_links; +extern int preserve_uid, preserve_gid, preserve_acls, preserve_xattrs; +extern int relative_paths, sanitize_paths, munge_symlinks; +extern int atimes_ndx, uid_ndx, gid_ndx, acls_ndx, xattrs_ndx; +extern int crtimes_ndx, pathname_ndx, depth_ndx, unsort_ndx; +extern int preserve_atimes, preserve_crtimes; +extern int file_extra_cnt; +extern int numeric_ids, inc_recurse, am_root, always_checksum; +extern int xfer_dirs, recurse, one_file_system, copy_devices; +extern int trust_sender_filter; + +void init_flist(void); /* sets flist_csum_len from file_sum_nni */ +void parse_checksum_choice(int); /* sets file_sum_nni/xfer_sum_nni (negotiation) */ + +static int fd_from_bytes(const uint8_t *data, size_t size) +{ + int fds[2]; + if (pipe(fds) != 0) + return -1; + fcntl(fds[1], F_SETFL, O_NONBLOCK); + size_t off = 0; + while (off < size) { + ssize_t n = write(fds[1], data + off, size - off); + if (n <= 0) + break; + off += (size_t)n; + } + close(fds[1]); + return fds[0]; +} + +static int inited; int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { - (void)data; (void)size; - return 0; /* no-op until the link surface above is built out */ + if (size < 2) + return 0; + + /* Byte 0: protocol + feature selection (cover proto<28 / <30 / >=30 and + * the optional-field branches that change which reads happen). */ + uint8_t sel = data[0]; + static const int protos[] = { 26, 28, 29, 30, 31 }; + protocol_version = protos[(sel >> 1) % 5]; + + preserve_links = (sel & 0x01) ? 1 : 0; + preserve_devices = (sel & 0x02) ? 1 : 0; + preserve_specials = (sel & 0x04) ? 1 : 0; + preserve_uid = (sel & 0x08) ? 1 : 0; + preserve_gid = (sel & 0x10) ? 1 : 0; + preserve_hard_links = (sel & 0x20) ? 1 : 0; + always_checksum = (sel & 0x40) ? 1 : 0; + /* Keep acls/xattrs OFF here: those tails have dedicated harnesses + * (fuzz_xattrs) and would otherwise dominate this corpus. */ + preserve_acls = 0; + preserve_xattrs = 0; + + /* Replicate setup_protocol()'s extra-slot assignment EXACTLY (compat.c + * 575-595). The *_ndx values and file_extra_cnt are NOT free parameters: + * 64-bit fields (atime) must be assigned first so their slot is 8-byte + * aligned. Arbitrary ndx/cnt combinations produce misaligned F_ATIME + * accesses that the real receiver never generates (a harness artifact, + * not a recv_file_entry bug). We are the receiver: am_sender=0, am_server=0. */ + preserve_atimes = (data[1] & 0x01) ? 1 : 0; + preserve_crtimes = 0; /* SUPPORT_CRTIMES not configured */ + atimes_ndx = crtimes_ndx = pathname_ndx = depth_ndx = 0; + uid_ndx = gid_ndx = acls_ndx = xattrs_ndx = unsort_ndx = 0; + file_extra_cnt = 0; + if (preserve_atimes) + atimes_ndx = (file_extra_cnt += EXTRA64_CNT); + /* am_sender==0 => depth_ndx branch */ + depth_ndx = ++file_extra_cnt; + if (preserve_uid) + uid_ndx = ++file_extra_cnt; + if (preserve_gid) + gid_ndx = ++file_extra_cnt; + if (preserve_acls) /* (!am_sender already true) */ + acls_ndx = ++file_extra_cnt; + if (preserve_xattrs) + xattrs_ndx = ++file_extra_cnt; + + relative_paths = (data[1] & 0x10) ? 1 : 0; + sanitize_paths = (data[1] & 0x20) ? 1 : 0; + munge_symlinks = 0; /* keep SYMLINK_PREFIX math out unless wanted */ + numeric_ids = 1; + inc_recurse = 0; + am_root = 0; + one_file_system = 0; + copy_devices = 0; + trust_sender_filter = 1; /* skip check_server_filter (exclude path) */ + xfer_dirs = 1; + recurse = 0; + + if (!inited) { + /* Mirror the receiver's checksum negotiation: parse_checksum_choice + * populates file_sum_nni/xfer_sum_nni (NULL choice => protocol default) + * which init_flist then consults for flist_csum_len. */ + parse_checksum_choice(0); + init_flist(); /* sets flist_csum_len from file_sum_nni */ + /* Allocate the hard-link dev/inode table ONCE. recv_file_entry's + * proto<30 hard-link path (flist.c:1191) calls idev_find(), which needs + * dev_tbl. init_hard_links only creates it when protocol_version<30, so + * we force that here. We never idev_destroy() between inputs: idev_find + * keeps a static dev_node pointer into dev_tbl that we cannot reset from + * outside, so the table is process-lived (benign cross-input coupling, + * documented like the lastname[] statics). */ + int saved = protocol_version; + protocol_version = 26; + init_hard_links(); + protocol_version = saved; + inited = 1; + } + + const uint8_t *body = data + 2; + size_t bodysz = size - 2; + + int f = fd_from_bytes(body, bodysz); + if (f < 0) + return 0; + + struct file_list *flist = fuzz_flist_new(); + + fuzz_unwind_armed = 1; + if (setjmp(fuzz_unwind_env) == 0) { + /* Drive a sequence of entries. Each leading byte from the stream + * (consumed as the xflags) chains entries; we cap the count so a + * tiny input can't spin forever, and stop when the parser unwinds on + * EOF. */ + for (int i = 0; i < 64; i++) { + int flags = read_byte(f); + if (flags == 0) + break; + if (protocol_version >= 28 && (flags & XMIT_EXTENDED_FLAGS)) + flags |= read_byte(f) << 8; + fuzz_recv_file_entry(f, flist, flags); + } + } + fuzz_unwind_armed = 0; + + fuzz_flist_free(flist); + close(f); + return 0; } diff --git a/fuzz/fuzz_xattrs.c b/fuzz/fuzz_xattrs.c index e33dcf3ea..7fc7f2ff3 100644 --- a/fuzz/fuzz_xattrs.c +++ b/fuzz/fuzz_xattrs.c @@ -1,46 +1,123 @@ /* - * fuzz_xattrs.c - STAGED STUB (not yet wired into fuzz/Makefile). + * fuzz_xattrs.c - LIVE libFuzzer harness for rsync's receive_xattr() (xattrs.c + * ~774-880): the per-file xattr wire decode (reference.md Part 3.4). The + * fuzz-worthy region is the bounded-read loop (xattrs.c 802-872): + * ndx = read_varint(f) (783) + * count = read_varint_bounded(f, 0, MAX_WIRE_XATTR_COUNT) (796) + * name_len = read_varint_size(f, MAX_WIRE_XATTR_NAMELEN) (805) + * datum_len = read_varint_size(f, MAX_WIRE_XATTR_DATALEN) (806) + * overflow guard (SIZE_MAX - dget_len < extra_len || ...) (809) + * read_buf(name) + trailing-'\0' check (813-817) + * read_buf(datum / abbreviated checksum) (818-823) + * then rsync_xal_store() (real: hashtable + checksum subsystem). * - * Intended target: receive_xattr() (xattrs.c ~771-877) - the per-file xattr - * wire decode (reference.md Part 3.4). The actual fuzz-worthy region is the - * bounded-read loop, lines 780-820: - * ndx = read_varint(f) (guard 782) - * count = read_varint_bounded(f, 0, MAX_WIRE_XATTR_COUNT) (793) - * name_len = read_varint_size(f, MAX_WIRE_XATTR_NAMELEN) (802) - * datum_len = read_varint_size(f, MAX_WIRE_XATTR_DATALEN) (803) - * overflow guard: SIZE_MAX - dget_len < extra_len || ... (806) - * read_buf(name), trailing-'\0' check (810-814) - * read_buf(datum / abbrev checksum) (815-819) + * LIVE LINK: xattrs.o (compiled -DRSYNC_FUZZ_XATTRS) + the SAME real core as + * fuzz_flist (io/util1/util2/uidlist/exclude/hashtable/checksum/flist/acls/ + * fileio/syscall + lib/*). Only true process-boundary externals are stubbed. + * NOTHING in the xattr parse/alloc/copy/hash path is stubbed. * - * WHY THIS IS A STUB (honest blocker): - * receive_xattr is one function: after the parse loop it unconditionally calls - * rsync_xal_store(), which reaches xattr_lookup_hash() -> sum_init/sum_update/ - * sum_end (the checksum subsystem: openssl/xxhash/md*, ~all of checksum.o) and - * hashtable_create/hashtable_find (hashtable.o). It also calls f_name() and, - * if saw_xattr_filter, name_is_excluded() (exclude.o + filter state). We cannot - * exercise the bounded-read region without also linking that storage tail. - * Linking checksum.o pulls a large, mostly-irrelevant surface; stubbing - * sum_*/hashtable_* risks masking an OOB that occurs in the abbreviated-datum - * (XSTATE_ABBREV) checksum copy at line 819. Out of WS2 budget after io + token. + * PLUMBING: same pipe-fd fast path as fuzz_flist (read_buf -> safe_read off the + * attacker bytes; EOF -> exit_cleanup -> longjmp). * - * REQUIRED GLOBAL INIT when completed (reference.md Part 3.5): - * protocol_version, xfer_sum_len, xattr_sum_len (used by the abbrev-datum - * branch, line 819), preserve_xattrs = 2, saw_xattr_filter = 0 (to skip the - * exclude.o path), am_root, file_extra_cnt + xattrs_ndx (for F_XATTR slot), - * and rsync_xal_l / rsync_xal_h reset to empty between inputs (statics: - * document the cross-input coupling as in fuzz_token). + * INIT (reference.md Part 3.5): protocol_version, xfer_sum_len + xattr_sum_len + * (the abbreviated-datum branch at 822 copies xattr_sum_len bytes), + * preserve_xattrs (1 or 2 - 2 enables rsync.%FOO names), saw_xattr_filter=0 so + * name_is_excluded()/exclude filter state is never reached, am_root (gates the + * HAS_PREFIX namespace-rewrite branches), and xattrs_ndx for the F_XATTR slot. * - * Strategy to finish (sketch): link xattrs.o + io.o + hashtable.o + checksum.o - * (+ its crypto deps) OR provide faithful sum_*/hashtable_* shims that still - * let ASan see every wire-driven allocation and the line-819 abbrev copy. - * Set saw_xattr_filter = 0 so name_is_excluded()/exclude.o is never reached. + * ORACLE: a correctly-rejected hostile value (out-of-range ndx/count, bad + * trailing NUL, overflow) calls exit_cleanup/overflow_exit AFTER its guard => + * clean longjmp, not a finding. A genuine OOB/UB in a copy or pointer-arith + * step trips ASan/UBSan BEFORE any guard => real finding. */ -#include -#include +#include "rsync.h" +#include +#include +#include + +extern void fuzz_receive_xattr(int f, struct file_struct *file); +extern struct file_struct *fuzz_xattr_file_new(alloc_pool_t pool); + +/* Reuse fuzz_flist's pool helpers for a throwaway flist + pool. */ +extern struct file_list *fuzz_flist_new(void); +extern void fuzz_flist_free(struct file_list *flist); + +extern jmp_buf fuzz_unwind_env; +extern int fuzz_unwind_armed; + +extern int protocol_version, preserve_xattrs, saw_xattr_filter, am_root; +extern int xattr_sum_len, xfer_sum_len, file_extra_cnt, xattrs_ndx; +extern int numeric_ids, inc_recurse, am_sender, am_server; + +void parse_checksum_choice(int); +void init_flist(void); + +static int fd_from_bytes(const uint8_t *data, size_t size) +{ + int fds[2]; + if (pipe(fds) != 0) + return -1; + fcntl(fds[1], F_SETFL, O_NONBLOCK); + size_t off = 0; + while (off < size) { + ssize_t n = write(fds[1], data + off, size - off); + if (n <= 0) + break; + off += (size_t)n; + } + close(fds[1]); + return fds[0]; +} + +static int inited; int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { - (void)data; (void)size; - return 0; /* no-op until the storage-tail link surface is built out */ + if (size < 1) + return 0; + + uint8_t sel = data[0]; + static const int protos[] = { 28, 29, 30, 31 }; + protocol_version = protos[(sel >> 1) & 3]; + preserve_xattrs = (sel & 0x01) ? 2 : 1; /* >=1 required; 2 enables %FOO names */ + am_root = (sel & 0x04) ? 1 : 0; /* gates namespace-prefix rewrite branches */ + saw_xattr_filter = 0; /* keep name_is_excluded()/filter state out */ + numeric_ids = 1; + inc_recurse = 0; + am_sender = 0; + am_server = 0; + + if (!inited) { + parse_checksum_choice(0); /* sets xattr_sum_len via file/xfer sums */ + /* setup_protocol assigns xattrs_ndx; receive_xattr writes F_XATTR via + * it. As receiver with only -X: a single trailing extra slot. */ + file_extra_cnt = 1; + xattrs_ndx = 1; + inited = 1; + } + + const uint8_t *body = data + 1; + size_t bodysz = size - 1; + + int f = fd_from_bytes(body, bodysz); + if (f < 0) + return 0; + + struct file_list *flist = fuzz_flist_new(); + struct file_struct *file = fuzz_xattr_file_new(flist->file_pool); + + fuzz_unwind_armed = 1; + if (setjmp(fuzz_unwind_env) == 0) { + /* Drive a sequence of receive_xattr calls from one input so the + * rsync_xal_l accumulation / find_matching_xattr dedup path (a prime + * bug site) is exercised across entries. */ + for (int i = 0; i < 32; i++) + fuzz_receive_xattr(f, file); + } + fuzz_unwind_armed = 0; + + fuzz_flist_free(flist); + close(f); + return 0; } diff --git a/fuzz/globals.c b/fuzz/globals.c new file mode 100644 index 000000000..a3995bdfd --- /dev/null +++ b/fuzz/globals.c @@ -0,0 +1,159 @@ +/* + * fuzz/globals.c - rsync option/state globals and a handful of leaf functions + * that the REAL flist.o / xattrs.o / acls.o / uidlist.o / exclude.o objects + * reference but that normally live in options.c / main.c / loadparm.c / hlink.c + * / log.c (TUs we deliberately do NOT link, since recv_file_entry / + * receive_xattr never reach their bodies). + * + * These are GENUINE process-state globals, not parse/alloc/copy logic, so + * defining them here does NOT mask any bug: the harness sets the load-bearing + * ones (preserve_*, *_ndx, protocol_version, ...) per-input to match the real + * receiver's configured state. The rest default to 0/NULL exactly as a freshly + * started receiver before option parsing. + * + * The few FUNCTIONS here are leaves outside recv_file_entry's reachable graph + * (hardlink table setup, name-conversion subprocess, loadparm module lookup, + * name_num registries). Each aborts loudly if ever actually called, so the + * oracle still fires instead of silently returning a bogus value. + */ + +#include "rsync.h" + +/* Defined in stubs.c; needed by the idev_find / init_hard_links copies below. */ +extern int am_sender, protocol_version, inc_recurse; + +/* ---- option / mode globals (harness overrides the load-bearing ones) ---- */ +/* preserve_hard_links lives in stubs.c; sanitize_paths in util1.o. */ +int preserve_links, preserve_devices, preserve_specials; +int preserve_uid, preserve_gid, preserve_acls, preserve_xattrs; +int preserve_perms, preserve_executability; +int relative_paths, munge_symlinks; +int uid_ndx, gid_ndx, acls_ndx, xattrs_ndx, atimes_ndx, depth_ndx, pathname_ndx, unsort_ndx; +int crtimes_ndx, preserve_atimes, preserve_crtimes; +int numeric_ids, always_checksum, recurse, xfer_dirs, one_file_system, copy_devices; +int copy_links, copy_dirlinks, copy_unsafe_links; +int omit_link_times; +int delete_during, delete_excluded, delete_mode; +int implied_dirs, prune_empty_dirs, non_perishable_cnt, ignore_perishable; +int need_unsorted_flist, use_safe_inc_flist, xmit_id0_names, proper_seed_order; +int sender_keeps_checksum, sender_symlink_iconv, use_qsort; +int am_chrooted, am_daemon, dry_run, quiet, ignore_errors, missing_args; +int modify_window, whole_file, sparse_files, inplace, preallocate_files; +/* no_acl_syscall_error lives in lib/sysacls.o */ +int cvs_exclude, output_needs_newline; +int human_readable = 0; +int do_fsync = 0; +int open_noatime = 0; +int orig_umask = 022; +int our_uid, our_gid; +int filesfrom_fd = -1; +int read_only = 0; + +char *usermap = NULL, *groupmap = NULL; +char *module_dir = NULL; +char *partial_dir = NULL; +char *filesfrom_host = NULL; +unsigned int module_dirlen = 0; + +/* xattr_sum_nni / xattr_sum_len normally come from compat.c; the abbreviated + * xattr-datum branch (xattrs.c:822) reads xattr_sum_len bytes. Default to the + * MD5 length; fuzz_xattrs overrides. */ +struct name_num_item *xattr_sum_nni = NULL; +int xattr_sum_len = 16; + +/* checksum_choice normally set by validate_choice_vs_env; checksum.c reads it. */ +char *checksum_choice = NULL; + +/* chmod_modes is a struct chmod_mode_struct* the tweak path consults. */ +struct chmod_mode_struct *chmod_modes = NULL; + +/* ---- leaf functions outside recv_file_entry / receive_xattr graph ---- */ + +NORETURN static void fuzz_unreachable(const char *who) +{ + /* If the parser path ever truly reaches one of these, that is itself a + * finding (our reachability assumption was wrong) - abort so ASan/the + * fuzzer records it rather than silently continuing on a bogus return. */ + rprintf(FERROR, "fuzz: unreachable leaf %s reached\n", who); + abort(); +} + +/* Faithful copies of hlink.c's device/inode hashtable helpers. recv_file_entry + * reaches idev_find() on the proto<30 hard-link path (flist.c:1191), so these + * must be REAL (they use the real hashtable.o); a stub would mask any OOB in + * that path. Linking all of hlink.o would drag in the receiver/generator graph, + * so we lift just these three + their statics verbatim. */ +static void *data_when_new = ""; +static struct hashtable *dev_tbl; + +void init_hard_links(void) +{ + if (am_sender || protocol_version < 30) + dev_tbl = hashtable_create(16, HT_KEY64); + /* inc_recurse/prior_hlinks branch is unreached: harness keeps inc_recurse=0 */ +} + +struct ht_int64_node *idev_find(int64 dev, int64 ino) +{ + static struct ht_int64_node *dev_node = NULL; + if (!dev_node || dev_node->key != dev+1) { + dev_node = hashtable_find(dev_tbl, dev+1, data_when_new); + if (dev_node->data == data_when_new) + dev_node->data = hashtable_create(512, HT_KEY64); + } + return hashtable_find(dev_node->data, ino, (void*)-1L); +} + +void idev_destroy(void) +{ + int i; + if (!dev_tbl) + return; + for (i = 0; i < dev_tbl->size; i++) { + struct ht_int32_node *node = HT_NODE(dev_tbl, dev_tbl->nodes, i); + if (node->data) + hashtable_destroy(node->data); + } + hashtable_destroy(dev_tbl); + dev_tbl = NULL; +} + +BOOL namecvt_call(const char *cmd, const char **name_p, id_t *id_p) +{ (void)cmd; (void)name_p; (void)id_p; fuzz_unreachable("namecvt_call"); } +int namecvt_pid = 0; + +/* Faithful copies of compat.c's registry walks (reached by init_checksum_choices + * during the legitimate checksum-negotiation init the receiver performs). Pure + * list lookups, no I/O - replicating them masks nothing. */ +struct name_num_item *get_nni_by_name(struct name_num_obj *nno, const char *name, int len) +{ + struct name_num_item *nni; + if (len < 0) + len = strlen(name); + for (nni = nno->list; nni->name; nni++) { + if (nni->num == CSUM_gone) + continue; + if (strncasecmp(name, nni->name, len) == 0 && nni->name[len] == '\0') + return nni; + } + return NULL; +} +struct name_num_item *get_nni_by_num(struct name_num_obj *nno, int num) +{ + struct name_num_item *nni; + for (nni = nno->list; nni->name; nni++) { + if (num == nni->num) + return nni; + } + return NULL; +} +void validate_choice_vs_env(int ntype, int num1, int num2) +{ (void)ntype; (void)num1; (void)num2; } + +const char *default_cvsignore(void) { return ""; } + +char *lp_name(int m) { (void)m; return "fuzz"; } +BOOL lp_use_chroot(int m) { (void)m; return 0; } +BOOL lp_ignore_nonreadable(int m) { (void)m; return 0; } + +void rflush(enum logcode code) { (void)code; } diff --git a/fuzz/lsan-suppressions.txt b/fuzz/lsan-suppressions.txt new file mode 100644 index 000000000..f48d9dfe0 --- /dev/null +++ b/fuzz/lsan-suppressions.txt @@ -0,0 +1,24 @@ +# LeakSanitizer suppressions for the rsync wire-parser fuzz harnesses. +# +# These name EXACTLY the two intentional process-lifetime static caches the +# code review identified. Both are by-design shared buffers whose lifetime is +# the process and which the receive-only harnesses cannot free from outside +# (they are file-static). They are NOT per-entry leaks and NOT memory-safety +# defects; freeing them would introduce a real use-after-free. +# +# Suppressions are by FUNCTION NAME (the allocation site frame), not a blanket +# detect_leaks=0, so any DIFFERENT leak -- a genuine new regression anywhere +# else in the parser call graph -- is still reported and still fails the run. +# +# 1) flist.c `lastdir`: recv_file_entry assigns a fresh new_array() to the +# file-static `lastdir` whenever a new directory prefix appears, without +# freeing the previous buffer, because consecutive same-directory +# file_structs SHARE it via file->dirname. Freeing on replace would dangle +# every earlier file->dirname. Reclaimed by the OS at process exit. +leak:recv_file_entry + +# 2) xattrs.c `rsync_xal_l` dedup cache: receive_xattr's storage tail moves the +# parsed items into the global process-lifetime dedup cache rsync_xal_l via +# rsync_xal_store. Only partially trimmed by uncache_tmp_xattrs, a +# generator/sender path this receive-only harness never exercises. +leak:receive_xattr diff --git a/fuzz/run-regression.sh b/fuzz/run-regression.sh index fa99ff8cb..8c2f3b1c9 100755 --- a/fuzz/run-regression.sh +++ b/fuzz/run-regression.sh @@ -18,34 +18,47 @@ set -eu cd "$(dirname "$0")" MAX_TIME="${FUZZ_MAX_TIME:-30}" -TARGETS="${FUZZ_TARGETS:-fuzz_io}" - -# Ensure the rsync wire-parser objects exist & are sanitizer-instrumented. -# (CI is expected to have configured with the campaign CFLAGS already.) -make -C .. io.o >/dev/null -TARGETS="${FUZZ_TARGETS:-fuzz_io fuzz_token fuzz_recv_discard}" +TARGETS="${FUZZ_TARGETS:-fuzz_io fuzz_token fuzz_recv_discard fuzz_deflated_token fuzz_flist fuzz_xattrs}" # Ensure the rsync wire-parser objects exist & are sanitizer-instrumented. # (CI is expected to have configured with the campaign CFLAGS already.) # io.o feeds fuzz_io; token.o feeds fuzz_token; util1.o (real full_fname) feeds -# fuzz_recv_discard's discard-path regression. -make -C .. io.o token.o util1.o >/dev/null +# fuzz_recv_discard's discard-path regression; the flist/xattrs harnesses pull +# in a broad real call graph, so build those objects too. +make -C .. io.o token.o util1.o util2.o uidlist.o exclude.o hashtable.o checksum.o \ + syscall.o acls.o xattrs.o fileio.o chmod.o \ + lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \ + lib/permstring.o lib/pool_alloc.o lib/sysacls.o lib/sysxattrs.o >/dev/null make all +# Narrow LSan suppressions for fuzz_flist / fuzz_xattrs: those harnesses drive +# recv_file_entry / receive_xattr, which populate two intentional +# process-lifetime static caches (flist.c `lastdir`, xattrs.c `rsync_xal_l`). +# Those are by-design and cannot be freed from a receive-only harness, so LSan +# would otherwise report them on every run and drown a real new leak. The +# suppressions name ONLY those two allocation-site functions (not a blanket +# detect_leaks=0), so any OTHER leak still fails the run. See lsan-suppressions.txt. +LSAN_SUPP="$(pwd)/lsan-suppressions.txt" + rc=0 for t in $TARGETS; do echo "=== regression: $t ===" corpus="corpus/$t" mkdir -p "$corpus" + # Only the flist/xattrs harnesses touch the documented static caches. + case "$t" in + fuzz_flist|fuzz_xattrs) tlsan="suppressions=$LSAN_SUPP" ;; + *) tlsan="" ;; + esac # 1) Deterministic replay of every committed seed (runs=0 => just the corpus). - if ! ./"$t" -runs=0 "$corpus"; then + if ! LSAN_OPTIONS="$tlsan" ./"$t" -runs=0 "$corpus"; then echo "FAIL: $t crashed replaying seed corpus" >&2 rc=1 continue fi # 2) Bounded top-up fuzz seeded from the corpus; any crash file => fail. - if ! ./"$t" -max_total_time="$MAX_TIME" -print_final_stats=0 "$corpus"; then + if ! LSAN_OPTIONS="$tlsan" ./"$t" -max_total_time="$MAX_TIME" -print_final_stats=0 "$corpus"; then echo "FAIL: $t crashed during bounded fuzz run" >&2 rc=1 fi diff --git a/fuzz/stubs.c b/fuzz/stubs.c index 384beffe8..625d7d8dd 100644 --- a/fuzz/stubs.c +++ b/fuzz/stubs.c @@ -36,7 +36,7 @@ NORETURN void _exit_cleanup(int code, const char *file, int line) _exit(99); } -NORETURN void _out_of_memory(const char *msg, const char *file, int line) +__attribute__((weak)) NORETURN void _out_of_memory(const char *msg, const char *file, int line) { (void)msg; (void)file; (void)line; if (fuzz_unwind_armed) @@ -44,7 +44,7 @@ NORETURN void _out_of_memory(const char *msg, const char *file, int line) _exit(99); } -NORETURN void _overflow_exit(const char *msg, const char *file, int line) +__attribute__((weak)) NORETURN void _overflow_exit(const char *msg, const char *file, int line) { (void)msg; (void)file; (void)line; if (fuzz_unwind_armed) @@ -60,7 +60,7 @@ void rwrite(enum logcode code, const char *buf, int len, int is_utf8) const char *who_am_i(void) { return "fuzz"; } -char *do_big_num(int64 num, int human_flag, const char *fract) +__attribute__((weak)) char *do_big_num(int64 num, int human_flag, const char *fract) { static char buf[32]; (void)human_flag; (void)fract; @@ -68,16 +68,19 @@ char *do_big_num(int64 num, int human_flag, const char *fract) return buf; } -int msleep(int t) { (void)t; return 0; } +__attribute__((weak)) int msleep(int t) { (void)t; return 0; } /* my_alloc: a self-contained allocator so ASan tracks every wire-driven * allocation. Mirrors rsync's semantics closely enough for the parsers: * honours max_alloc, returns NULL when file==NULL on over-limit (callers like * EXPAND_ITEM_LIST rely on that), zero-fills on the calloc sentinel. */ -char *do_calloc = "42"; +/* WEAK: real util2.o defines do_calloc + my_alloc; when fuzz_flist/fuzz_xattrs + * link util2.o those strong defs win. fuzz_io/fuzz_token (no util2.o) fall back + * to these. Same weakening applies to the few globals flist.o itself defines. */ +__attribute__((weak)) char *do_calloc = "42"; extern size_t max_alloc; -void *my_alloc(void *ptr, size_t num, size_t size, const char *file, int line) +__attribute__((weak)) void *my_alloc(void *ptr, size_t num, size_t size, const char *file, int line) { (void)line; if (size && num >= max_alloc / size) { @@ -96,13 +99,13 @@ struct stats stats; size_t max_alloc = 1u << 30; /* 1 GiB cap so over-range counts still get rejected by guards */ int protocol_version = PROTOCOL_VERSION; -int xfer_sum_len = 16; /* MD5-ish default; harness may override */ +__attribute__((weak)) int xfer_sum_len = 16; /* MD5-ish default; flist/checksum may override */ int file_extra_cnt = 0; int am_server = 0, am_sender = 0, am_generator = 0, am_receiver = 0, am_root = 0; int local_server = 0, daemon_connection = 0; int inc_recurse = 0; -int io_error = 0; +__attribute__((weak)) int io_error = 0; /* flist.o defines this strong */ int io_timeout = 0; int batch_fd = -1; int eol_nulls = 0; @@ -110,10 +113,10 @@ int read_batch = 0; int list_only = 0; int protect_args = 0; int checksum_seed = 0; -int flist_eof = 0; +__attribute__((weak)) int flist_eof = 0; /* flist.o strong */ int compat_flags = 0; -int file_total = 0; -int file_old_total = 0; +__attribute__((weak)) int file_total = 0; /* flist.o strong */ +__attribute__((weak)) int file_old_total = 0; /* flist.o strong */ int preserve_hard_links = 0; int remove_source_files = 0; int extra_flist_sending_enabled = 0; @@ -128,33 +131,47 @@ int stop_at_utime = 0; short info_levels[COUNT_INFO]; short debug_levels[COUNT_DEBUG]; -struct file_list *cur_flist = NULL; +__attribute__((weak)) struct file_list *cur_flist = NULL; /* flist.o strong */ /* ------- functions io.o references but the parser paths never reach ------- */ void check_for_finished_files(int itemizing, enum logcode code, int check_redo) { (void)itemizing; (void)code; (void)check_redo; } +/* flist_for_ndx lives in rsync.c, which NO harness links, so this stub is the + * only definition. The reachable receive-side parser paths exercised here never + * call it (recv_file_entry's proto<30 hardlink path uses idev_find, not + * flist_for_ndx). A NULL return would silently diverge from real receiver + * behavior and could mask a bug, so instead of returning fake data we abort + * loudly: if a future parser path ever reaches it, the harness fails the run + * rather than carrying on with wrong state. (Not made weak: there is no real + * flist_for_ndx object to override it; weak would just leave NULL behind.) */ struct file_list *flist_for_ndx(int ndx, const char *fatal_error_msg) -{ (void)ndx; (void)fatal_error_msg; return NULL; } +{ + fprintf(stderr, "fuzz/stubs.c: flist_for_ndx(%d, %s) reached -- the " + "harness does not link the real implementation; aborting rather " + "than returning fake flist data.\n", + ndx, fatal_error_msg ? fatal_error_msg : "(null)"); + abort(); +} -struct file_list *recv_file_list(int f, int dir_ndx) { (void)f; (void)dir_ndx; return NULL; } -void send_extra_file_list(int f, int at_least) { (void)f; (void)at_least; } +__attribute__((weak)) struct file_list *recv_file_list(int f, int dir_ndx) { (void)f; (void)dir_ndx; return NULL; } +__attribute__((weak)) void send_extra_file_list(int f, int at_least) { (void)f; (void)at_least; } -int flist_ndx_pop(flist_ndx_list *lp) { (void)lp; return -1; } -void flist_ndx_push(flist_ndx_list *lp, int ndx) { (void)lp; (void)ndx; } +__attribute__((weak)) int flist_ndx_pop(flist_ndx_list *lp) { (void)lp; return -1; } +__attribute__((weak)) void flist_ndx_push(flist_ndx_list *lp, int ndx) { (void)lp; (void)ndx; } void log_delete(const char *fname, int mode) { (void)fname; (void)mode; } void match_hard_links(struct file_list *flist) { (void)flist; } void successful_send(int ndx) { (void)ndx; } -int glob_expand(const char *arg, char ***argv_p, int *argc_p, int *maxargs_p) +__attribute__((weak)) int glob_expand(const char *arg, char ***argv_p, int *argc_p, int *maxargs_p) { (void)arg; (void)argv_p; (void)argc_p; (void)maxargs_p; return 0; } -void glob_expand_module(char *base1, char *arg, char ***argv_p, int *argc_p, int *maxargs_p) +__attribute__((weak)) void glob_expand_module(char *base1, char *arg, char ***argv_p, int *argc_p, int *maxargs_p) { (void)base1; (void)arg; (void)argv_p; (void)argc_p; (void)maxargs_p; } -void add_implied_include(const char *arg, int skip_daemon_module) { (void)arg; (void)skip_daemon_module; } -void free_implied_include_partial_string(void) {} -void implied_include_partial_string(const char *s_start, const char *s_end) { (void)s_start; (void)s_end; } +__attribute__((weak)) void add_implied_include(const char *arg, int skip_daemon_module) { (void)arg; (void)skip_daemon_module; } +__attribute__((weak)) void free_implied_include_partial_string(void) {} +__attribute__((weak)) void implied_include_partial_string(const char *s_start, const char *s_end) { (void)s_start; (void)s_end; } int iconvbufs(iconv_t ic, xbuf *in, xbuf *out, int flags) { (void)ic; (void)in; (void)out; (void)flags; return 0; } @@ -172,5 +189,5 @@ int module_id = -1; char *skip_compress = NULL; char *lp_dont_compress(int module_id_) { (void)module_id_; return NULL; } -char *map_ptr(struct map_struct *map, OFF_T offset, int32 len) +__attribute__((weak)) char *map_ptr(struct map_struct *map, OFF_T offset, int32 len) { (void)map; (void)offset; (void)len; return NULL; } diff --git a/token.c b/token.c index 62ffae151..91dff6e19 100644 --- a/token.c +++ b/token.c @@ -1126,3 +1126,35 @@ void see_token(char *data, int32 toklen) NOISY_DEATH("Unknown do_compression value"); } } + +#ifdef RSYNC_FUZZ_TOKEN +/* Fuzzing hook (compiled ONLY when RSYNC_FUZZ_TOKEN is defined; the normal + * rsync build never sees this). It exposes the file-internal static + * recv_deflated_token() (the zlib/CPRES_ZLIB compressed-token decoder) to + * fuzz/fuzz_deflated_token.c, plus a reset that restores the per-stream decode + * state to what a fresh receiver has at the start of a transfer. + * + * recv_deflated_token keeps file-static decode state (recv_state, the rx_strm + * inflate stream, rx_token/rx_run run accounting). A real receiver processes + * exactly one well-formed token stream per process lifetime and resets to + * r_init on END_FLAG. To isolate fuzz iterations - including iterations that + * unwound mid-stream via an exit_cleanup longjmp, leaving rx_strm mid-inflate- + * block - fuzz_recv_deflated_token_reset() forces recv_state back to r_init. + * The very next call then takes the r_init arm, which runs inflateReset(&rx_strm) + * (after first init) - the identical zlib re-init the real receiver performs - + * so every input starts from a pristine, faithfully-initialized decompressor, + * and rx_token is zeroed there too. (The function-local static saved_flag can + * carry a value across a mid-DEFLATED_DATA unwind; it is masked & 0xff and fed + * back as a flag byte, so at worst it injects one spurious bounded token/inflate + * step on the next input - documented cross-input coupling, never a memory bug.) + * No parse, bound, inflate, or accounting logic is altered. */ +void fuzz_recv_deflated_token_reset(void) +{ + recv_state = r_init; +} + +int32 fuzz_recv_deflated_token(int f, char **data) +{ + return recv_deflated_token(f, data); +} +#endif /* RSYNC_FUZZ_TOKEN */ diff --git a/xattrs.c b/xattrs.c index a225c0b7b..65079ff26 100644 --- a/xattrs.c +++ b/xattrs.c @@ -1287,4 +1287,40 @@ int x_fstat(int fd, STRUCT_STAT *fst, STRUCT_STAT *xst) return ret; } +#ifdef RSYNC_FUZZ_XATTRS +#include "rounding.h" /* EXTRA_ROUNDING - rsync.h does NOT pull this in; flist.c does */ +/* Fuzzing hook (compiled ONLY when RSYNC_FUZZ_XATTRS is defined). Exposes the + * per-file xattr wire decode receive_xattr() to fuzz/fuzz_xattrs.c together with + * a minimal file_struct that owns the F_XATTR extra slot the function writes. + * receive_xattr's storage tail (rsync_xal_store -> xattr_lookup_hash -> + * hashtable + checksum) runs for real against the linked instrumented objects; + * nothing in the parse/alloc/copy path is stubbed. The rsync_xal_l / temp_xattr + * statics persist across inputs (cannot reset file-statics from outside); + * documented cross-input coupling, same as recv_file_entry's lastname[]. */ +struct file_struct *fuzz_xattr_file_new(alloc_pool_t pool) +{ + /* Replicate recv_file_entry's extra-slot alignment dance (flist.c + * 1024-1034) EXACTLY so the file_struct lands 8-byte aligned - otherwise + * F_XATTR's union access is misaligned (a harness artifact, not a bug). */ + int extra_len = file_extra_cnt * EXTRA_LEN; + char *bp; + struct file_struct *file; +#if EXTRA_ROUNDING > 0 + if (extra_len & (EXTRA_ROUNDING * EXTRA_LEN)) + extra_len = (extra_len | (EXTRA_ROUNDING * EXTRA_LEN)) + EXTRA_LEN; +#endif + bp = pool_alloc(pool, FILE_STRUCT_LEN + extra_len + 1, "fuzz_xattr_file_new"); + memset(bp, 0, FILE_STRUCT_LEN + extra_len); + bp += extra_len; + file = (struct file_struct *)bp; + file->mode = S_IFREG | 0644; /* a plain file; not a symlink */ + return file; +} + +void fuzz_receive_xattr(int f, struct file_struct *file) +{ + receive_xattr(f, file); +} +#endif /* RSYNC_FUZZ_XATTRS */ + #endif /* SUPPORT_XATTRS */ From 658a9926af9e43d49eab2dd349fe95f52e3ca8f8 Mon Sep 17 00:00:00 2001 From: pterror Date: Mon, 1 Jun 2026 00:48:54 +1000 Subject: [PATCH 4/4] harden: hlink lower-bound guards, secure_mkstemp dotdot check, --copy-as daemon refuse --- hlink.c | 10 ++++++++-- options.c | 2 ++ syscall.c | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/hlink.c b/hlink.c index eb36730fd..330880b5e 100644 --- a/hlink.c +++ b/hlink.c @@ -133,9 +133,11 @@ static void match_gnums(int32 *ndx_list, int ndx_count) struct file_list *flist; prev = IVAL(node->data, 1); flist = flist_for_ndx(prev, NULL); - if (flist) + if (flist) { + if (prev < flist->ndx_start) + exit_cleanup(RERR_PROTOCOL); flist->files[prev - flist->ndx_start]->flags &= ~FLAG_HLINK_LAST; - else { + } else { /* We skipped all prior files in this * group, so mark this as a "first". */ file->flags |= FLAG_HLINK_FIRST; @@ -255,6 +257,8 @@ static char *check_prior(struct file_struct *file, int gnum, if (prev_ndx < 0 || (flist = flist_for_ndx(prev_ndx, NULL)) == NULL) break; + if (prev_ndx < flist->ndx_start) + exit_cleanup(RERR_PROTOCOL); fp = flist->files[prev_ndx - flist->ndx_start]; if (!(fp->flags & FLAG_SKIP_HLINK)) { *prev_ndx_p = prev_ndx; @@ -507,6 +511,8 @@ void finish_hard_link(struct file_struct *file, const char *fname, int fin_ndx, while ((ndx = prev_ndx) >= 0) { int val; flist = flist_for_ndx(ndx, "finish_hard_link"); + if (ndx < flist->ndx_start) + exit_cleanup(RERR_PROTOCOL); file = flist->files[ndx - flist->ndx_start]; file->flags = (file->flags & ~FLAG_HLINK_FIRST) | FLAG_HLINK_DONE; prev_ndx = F_HL_PREV(file); diff --git a/options.c b/options.c index 8cd8c3516..2cc306a53 100644 --- a/options.c +++ b/options.c @@ -972,6 +972,7 @@ static void set_refuse_options(void) || strcmp("checksum-seed", longName) == 0 || strcmp("copy-devices", longName) == 0 /* disable wild-match (it gets refused below) */ || strcmp("write-devices", longName) == 0 /* disable wild-match (it gets refused below) */ + || strcmp("copy-as", longName) == 0 /* disable wild-match (it gets refused below) */ || strcmp("log-format", longName) == 0 /* aka out-format (NOT log-file-format) */ || strcmp("sender", longName) == 0 || strcmp("server", longName) == 0) @@ -984,6 +985,7 @@ static void set_refuse_options(void) if (am_daemon) { /* Refused by default, but can be accepted via a negated exact match. */ parse_one_refuse_match(0, "copy-devices", list_end); parse_one_refuse_match(0, "write-devices", list_end); + parse_one_refuse_match(0, "copy-as", list_end); } while (1) { diff --git a/syscall.c b/syscall.c index 0748d9988..27233df54 100644 --- a/syscall.c +++ b/syscall.c @@ -1960,7 +1960,7 @@ int secure_mkstemp(char *template, mode_t perms) errno = EINVAL; return -1; } - if (strncmp(template, "../", 3) == 0 || strstr(template, "/../")) { + if (path_has_dotdot_component(template)) { errno = EINVAL; return -1; }