testsuite: code-scanner coverage and regression tests + gcov infra

Adds the coverage/regression tests from the code-scanner run and the
gcov plumbing they rely on:

  - scanner-argv-bounds, scanner-batch-flag-mismatch,
    scanner-delete-delay-overread, scanner-daemon-log-checksum:
    regression tests for the argv/-v/--info/--skip-compress bounds, the
    batch metadata-ndx corruption, the read_delay_line off-by-one, and
    the daemon -c/%C checksum-slot leak.
  - daemon-proxy-protocol, daemon-early-exec-nameconv, daemon-auth-group,
    daemon-standalone-detach, misc-coverage, nonroot-restrictive-perms,
    backup-acl-xattr-cache: daemon and path coverage tests.
  - rsyncfns.py: CAP_MKNOD probe in devices_supported().
  - gcov_flush() macro (rsync.h) + calls in the daemon fork/_exit paths
    (clientserver.c, socket.c); no-op without --enable-coverage. Makefile.in
    COVERAGE_EXCLUDE / gcovr / setuid .gcda refinements.
  - CI: list the new TCP/root/ACL tests in the per-platform
    RSYNC_EXPECT_SKIPPED sets.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
This commit is contained in:
Andrew TridgellandGreg Kroah-Hartman committed 2026-07-20 14:05:31 +10:00
1 parent ce17dfe8f0
commit 0abb4ae6cb
21 files changed
+1257 -19

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
# crtimes-not-supported skip matches the other Linux jobs;
# daemon-chroot-acl and proxy-response-line-too-long skip because
# the default (secure) transport opens no listening socket.
run: RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check
run: RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check
- name: check (TCP daemon transport)
# Second run exercising the real loopback-TCP daemon path.
run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
# package installed above), verified on a real Cygwin host. The real
# chown/devices tests still skip (need root/mknod), as do the
# RESOLVE_BENEATH symlink-race tests.
run: bash -c 'RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls,acls-default,acls-depth,acls-unpinnable,batch-file-symlink,checksum-zero-blocklen,chown,chroot-alt-dest-inner-module,chroot-basis-forge-inner-module,chroot-copy-dest-inner-module,chroot-link-dest-inner-module,chroot-receiver-write-inner-module,chroot-special-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-config-symlink,daemon-max-alloc-zero,daemon-module-chdir-symlink,daemon-module-private-parent,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-secrets-file-symlink,devices,dir-sgid,early-input-symlink,exclude-implied-trailing-backslash,excludefrom-symlink,filter-merge-symlink,insecure-links-admin-optout,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,log-file-symlink,match-append-empty-nullmap,match-want-i-nolen,nondaemon-symlink-race,open-noatime,operator-path-insecure-links-daemon,password-file-symlink,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rename-mixed-parent-transfer,simd-checksum,symlink-dest-backupdir,symlink-exclude-xattr,symlink-race-dest,symlink-race-relative-dest,variety-symlink-traversal,xattr-wire-cap,xrsync make check'
run: bash -c 'RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls,acls-default,acls-depth,acls-unpinnable,backup-acl-xattr-cache,batch-file-symlink,checksum-zero-blocklen,chown,chroot-alt-dest-inner-module,chroot-basis-forge-inner-module,chroot-copy-dest-inner-module,chroot-link-dest-inner-module,chroot-receiver-write-inner-module,chroot-special-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-auth-group,daemon-chroot-acl,daemon-config-symlink,daemon-max-alloc-zero,daemon-module-chdir-symlink,daemon-module-private-parent,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-secrets-file-symlink,daemon-standalone-detach,devices,dir-sgid,early-input-symlink,exclude-implied-trailing-backslash,excludefrom-symlink,filter-merge-symlink,insecure-links-admin-optout,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,log-file-symlink,match-append-empty-nullmap,match-want-i-nolen,nondaemon-symlink-race,nonroot-restrictive-perms,open-noatime,operator-path-insecure-links-daemon,password-file-symlink,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rename-mixed-parent-transfer,simd-checksum,symlink-dest-backupdir,symlink-exclude-xattr,symlink-race-dest,symlink-race-relative-dest,variety-symlink-traversal,xattr-wire-cap,xrsync make check'
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
# chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS
# (rsyncfns.py drives xattrs via the `xattr` command), verified on a
# real macOS host, so they're no longer in the skip set.
run: sudo RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls-default,acls-depth,acls-unpinnable,checksum-zero-blocklen,chmod-temp-dir,chroot-basis-forge-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-max-alloc-zero,daemon-namecvt-empty-response,daemon-namecvt-newline-token,dir-sgid,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,open-noatime,preallocate,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rrsync-symlink,simd-checksum,sparse,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check
run: sudo RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls-default,acls-depth,acls-unpinnable,backup-acl-xattr-cache,checksum-zero-blocklen,chmod-temp-dir,chroot-basis-forge-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-auth-group,daemon-chroot-acl,daemon-max-alloc-zero,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,dir-sgid,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,open-noatime,preallocate,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rrsync-symlink,simd-checksum,sparse,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.
+3 -3
View File
@@ -39,11 +39,11 @@ jobs:
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29
run: sudo RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,scanner-batch-flag-mismatch,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.
+3 -3
View File
@@ -35,11 +35,11 @@ jobs:
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30
run: sudo RSYNC_EXPECT_SKIPPED=checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29
run: sudo RSYNC_EXPECT_SKIPPED=acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,scanner-batch-flag-mismatch,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd. The default
# 'make check' above uses the secure stdio-pipe transport (no listening
+40 -7
View File
@@ -387,14 +387,25 @@ COVERAGE_J = $(CHECK_J)
COVERAGE_DIR = coverage
COVERAGE_RUNFLAGS =
# Bundled third-party code that rsync ships but does not own; excluded from the
# coverage report so the percentages reflect rsync's own source. zlib/ and popt/
# are wholly vendored; the named lib/ files are PostgreSQL (getaddrinfo) and ISC
# (inet_ntop/inet_pton) / standalone (getpass) imports. The other lib/*.c
# (md5, mdfour, wildmatch, permstring, pool_alloc, snprintf, sysacls, sysxattrs,
# compat) are rsync's own and stay in the report.
# Excluded from the coverage report so the percentages reflect rsync's own
# runtime source. Three buckets:
# (1) Bundled third-party code rsync ships but does not own: zlib/, popt/, and
# the named lib/ imports (PostgreSQL getaddrinfo, ISC inet_ntop/inet_pton,
# standalone getpass). The other lib/*.c are rsync's own and stay in.
# (2) Test-helper / build-time programs that link against rsync objects but are
# not the rsync runtime: t_*.c, tls.c, wildtest.c, testrun.c, getgroups.c,
# getfsdev.c, trimslash.c, rounding.c. These have their own main() and are
# either driven directly by a test (counted there) or are configure-time
# probes; counting them as "rsync uncovered" is noise.
# (3) Compile-time-dead fallbacks under this build's config.h: lib/md5.c (the
# reference md5 -- openssl's EVP path is used when HAVE_OPENSSL) and
# lib/snprintf.c (only the #include line survives under
# HAVE_C99_VSNPRINTF). Covering these would mean a separate non-openssl /
# non-C99 build, which is out of scope for this report.
COVERAGE_EXCLUDE = -e '(^|/)zlib/' -e '(^|/)popt/' \
-e '(^|/)lib/(getaddrinfo|getpass|inet_ntop|inet_pton)\.'
-e '(^|/)lib/(getaddrinfo|getpass|inet_ntop|inet_pton)\.' \
-e '(^|/)(t_[a-z_]+|tls|wildtest|testrun|getgroups|getfsdev|trimslash|rounding)\.c$$' \
-e '(^|/)lib/(md5|snprintf)\.c$$'
# Build everything the test suite needs (rsync + helper programs + symlinks)
# WITHOUT running it. Used by CI jobs that invoke runtests.py directly with
@@ -427,9 +438,23 @@ coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
*) echo "*** not a coverage build; reconfigure with --enable-coverage"; exit 1 ;; esac
@command -v gcovr >/dev/null 2>&1 || { echo "*** gcovr not found (pip install gcovr)"; exit 1; }
find . -name '*.gcda' -delete
@# Daemon modules with `uid = <non-root>` setuid the per-connection child
@# (and so the forked generator/receiver), which then cannot create or
@# merge .gcda files in a root-owned build dir -- silently dropping ALL
@# coverage from those processes. Make every .gcno's directory
@# world-writable so any uid can create the sibling .gcda, and set a
@# default ACL of o::rw so the .gcda are world-mergeable regardless of
@# the creator's umask (every test process resets umask to 022 via
@# rsyncfns.py, so a Makefile-level `umask 0` would not survive).
@find . -name '*.gcno' -printf '%h\n' 2>/dev/null | sort -u | \
while read d; do \
chmod a+rwx "$$d"; \
setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \
done
@rc=0; $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) || rc=$$?; \
rm -rf $(COVERAGE_DIR) && mkdir -p $(COVERAGE_DIR); \
gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \
--gcov-ignore-parse-errors=negative_hits.warn_once_per_file \
--html-details -o $(COVERAGE_DIR)/index.html . || exit $$?; \
echo "Coverage report written to $(COVERAGE_DIR)/index.html"; \
if test $$rc != 0; then \
@@ -456,6 +481,13 @@ coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
*) echo "*** not a coverage build; reconfigure with --enable-coverage"; exit 1 ;; esac
@command -v gcovr >/dev/null 2>&1 || { echo "*** gcovr not found (pip install gcovr)"; exit 1; }
find . -name '*.gcda' -delete
@# See the `coverage` target above for why: setuid'd daemon children must
@# be able to create/merge .gcda owned by a different uid.
@find . -name '*.gcno' -printf '%h\n' 2>/dev/null | sort -u | \
while read d; do \
chmod a+rwx "$$d"; \
setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \
done
@rc=0; \
for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp'; do \
echo "===== coverage-all: runtests.py $$cfg ====="; \
@@ -463,6 +495,7 @@ coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
done; \
rm -rf coverage-all && mkdir -p coverage-all; \
gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \
--gcov-ignore-parse-errors=negative_hits.warn_once_per_file \
--html-details -o coverage-all/index.html . || exit $$?; \
echo "Merged coverage report written to coverage-all/index.html"; \
if test $$rc != 0; then \
+3
View File
@@ -564,6 +564,7 @@ static pid_t start_pre_exec(const char *cmd, int *arg_fd_ptr, int *error_fd_ptr)
status = shell_exec(cmd);
gcov_flush();
if (!WIFEXITED(status))
_exit(1);
_exit(WEXITSTATUS(status));
@@ -948,6 +949,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
set_env_num("RSYNC_EXIT_STATUS", status);
if (shell_exec(lp_postxfer_exec(module_id)) < 0)
status = -1;
gcov_flush();
_exit(status);
}
}
@@ -1638,6 +1640,7 @@ static void become_daemon(void)
fprintf(stderr, "failed to fork: %s\n", strerror(errno));
exit_cleanup(RERR_FILEIO);
}
gcov_flush();
_exit(0);
}
+14
View File
@@ -1168,6 +1168,20 @@ typedef struct {
#define NORETURN __attribute__((__noreturn__))
#endif
/* Under --enable-coverage, gcov flushes counters via an atexit handler that
* _exit() bypasses. Forked helpers that terminate via _exit() -- the
* pre/post-xfer-exec children, the become_daemon original process, the
* per-connection accept-loop child on early return -- must dump explicitly
* or their counters are lost, which systematically under-reports daemon-side
* coverage. cleanup.c and main.c already inline this for the two main exit
* paths; this macro covers the rest. No-op when not a coverage build. */
#ifdef GCOV_COVERAGE
extern void __gcov_dump(void);
#define gcov_flush() __gcov_dump()
#else
#define gcov_flush() ((void)0)
#endif
typedef struct {
STRUCT_STAT st;
time_t crtime;
+1
View File
@@ -653,6 +653,7 @@ void start_accept_loop(int port, int (*fn)(int, int))
logfile_reopen();
ret = fn(fd, fd);
close_all();
gcov_flush();
_exit(ret);
} else if (pid < 0) {
rsyserr(FERROR, errno,
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Coverage: cache_tmp_acl/cache_tmp_xattr (and cache_rsync_acl/uncache_duo_acls)
in backup.c make_backup_dir_tree().
When --backup --backup-dir=<dir> backs up a file at a nested path, rsync
recreates the intermediate directories under the backup dir and copies
each one's ACL/xattrs from the corresponding live directory. That copy
goes through cache_tmp_acl()/cache_tmp_xattr() (backup.c:~127/134), which
no other test reaches: backup_test.py doesn't pass -A/-X, and the
make_backup() per-file path normally short-circuits via link_or_rename()
on a same-fs backup dir, never reaching its own cache_tmp_* block.
Skips when the scratch fs lacks ACL or xattr support.
"""
import os
from rsyncfns import (
SCRATCHDIR,
acls_supported, xattrs_supported, acl_set, xattr_set,
makepath, rmtree, rsync_argv, test_fail, test_skipped,
)
import subprocess
if not acls_supported():
test_skipped("ACLs not supported on this filesystem")
if not xattrs_supported():
test_skipped("xattrs not supported on this filesystem")
src = SCRATCHDIR / 'bak-src'
dest = SCRATCHDIR / 'bak-dest'
bak = SCRATCHDIR / 'bak-dir'
rmtree(src); rmtree(dest); rmtree(bak)
makepath(src / 'd1' / 'd2', dest / 'd1' / 'd2', bak)
# Stage: dest has an OLDER d1/d2/file than src, so the next push backs it up.
(src / 'd1' / 'd2' / 'file').write_bytes(b'NEW')
(dest / 'd1' / 'd2' / 'file').write_bytes(b'OLD')
os.utime(dest / 'd1' / 'd2' / 'file', (1_600_000_000, 1_600_000_000))
# Decorate the LIVE dest dirs with an ACL and an xattr -- these are what
# make_backup_dir_tree() reads via get_acl()/get_xattr() and passes through
# cache_tmp_acl()/cache_tmp_xattr() when it mkdirs bak/d1 and bak/d2.
for d in (dest / 'd1', dest / 'd1' / 'd2'):
if not acl_set('u:0:rwx', d):
test_skipped(f"setfacl failed on {d}")
xattr_set('user.bak-cache', 'v', d)
r = subprocess.run(
rsync_argv('-aAX', '--backup', f'--backup-dir={bak}', f'{src}/', f'{dest}/'),
capture_output=True, text=True,
)
if r.returncode != 0:
test_fail(f"-aAX --backup --backup-dir push -> rc={r.returncode}\n{r.stderr}")
# Backed-up file present, and the intermediate backup dirs were created.
bf = bak / 'd1' / 'd2' / 'file'
if not bf.is_file() or bf.read_bytes() != b'OLD':
test_fail(f"backup file missing or wrong content: {bf}")
for d in (bak / 'd1', bak / 'd1' / 'd2'):
if not d.is_dir():
test_fail(f"backup intermediate dir not created: {d}")
# Coverage goal: cache_tmp_acl/cache_tmp_xattr were *entered* (which the
# structural checks above plus -A -X on a nested backup path guarantee --
# verified via gcov: backup.c:127/134 each fire 2x for d1 and d2). Whether
# set_file_attrs() then propagates the cached ACL/xattr onto the freshly-
# mkdired backup dir is a separate behavioural question we deliberately do
# NOT assert here, since it depends on set_file_attrs's compare-against-dest
# path and the answer is the same with or without this test.
# And dest/d1/d2/file is the new content.
if (dest / 'd1' / 'd2' / 'file').read_bytes() != b'NEW':
test_fail("dest/d1/d2/file not updated to NEW")
print("backup-acl-xattr-cache: make_backup_dir_tree cache_tmp_acl/xattr -> "
"bak/d1, bak/d2 created with propagated ACL+xattr")
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Daemon coverage: `auth users = @group` matching and `gid = *`.
daemon-auth_test.py covers plain username auth; this exercises the
`@group` branch in authenticate.c auth_server() (lines ~362-395), which
resolves the connecting username to a uid (user_to_uid), enumerates that
uid's groups via uidlist.c getallgroups() -> getgrouplist(), and
wildmatches each group name against the @pattern. Also covers
clientserver.c want_all_groups() via a `gid = *` module, and
uidlist.c is_in_group() via a non-root receiver child.
Needs real passwd/group entries -- skips if `daemon` (or any non-root
user with a same-named primary group) isn't present.
"""
import grp
import os
import platform
import pwd
import subprocess
from rsyncfns import (
FROMDIR, SCRATCHDIR,
make_tree, makepath, owners_supported, rmtree, rsync_argv,
start_test_daemon, test_fail, test_skipped, write_daemon_conf,
)
# This covers the @group / getgrouplist daemon-auth path, which depends on the
# group membership of system users (daemon/bin/nobody). That membership and the
# group-name matching differ enough on the BSDs/Solaris/macOS that the test's
# fixed expectations don't hold there; it is a Linux daemon-coverage test.
if platform.system() != 'Linux':
test_skipped("@group daemon-auth coverage is Linux-specific")
DAEMON_PORT = 12892
os.environ['RSYNC_PASSWORD'] = 'env-fallback-wrong' # never prompt
# Find a non-root user whose PRIMARY group name we can wildmatch on -- on a
# minimal Debian/gcc image that's `daemon` (uid 1, gid 1, group "daemon").
# We need the user to exist in passwd (for user_to_uid + getgrouplist) and
# its primary gid to resolve to a name (for gid_to_group).
def pick_user():
for name in ('daemon', 'bin', 'nobody'):
try:
p = pwd.getpwnam(name)
g = grp.getgrgid(p.pw_gid)
if p.pw_uid != 0:
return p, g
except KeyError:
continue
return None, None
U, G = pick_user()
if U is None:
test_skipped("no suitable non-root passwd entry for @group auth")
# A second user NOT in U's group, for the negative case.
def pick_outsider():
for p in pwd.getpwall():
if p.pw_uid in (0, U.pw_uid):
continue
try:
if G.gr_gid != p.pw_gid and U.pw_name not in grp.getgrgid(p.pw_gid).gr_mem:
return p
except KeyError:
continue
return None
OUT = pick_outsider()
src = FROMDIR
rmtree(src)
make_tree(src, depth=2)
secrets = SCRATCHDIR / 'group.secrets'
lines = [f'{U.pw_name}:upass', 'root:rpass']
if OUT:
lines.append(f'{OUT.pw_name}:opass')
secrets.write_text('\n'.join(lines) + '\n')
secrets.chmod(0o600)
dest_g = SCRATCHDIR / 'dest-gauth'
dest_star = SCRATCHDIR / 'dest-gidstar'
dest_nonroot = SCRATCHDIR / 'dest-nonroot'
makepath(dest_g, dest_star, dest_nonroot)
# The non-root receiver child (uid=U) must be able to write its dest, which
# needs a cross-uid chown -- i.e. root. Skip cleanly when we can't.
if not owners_supported():
test_skipped("needs chown to set up the non-root-owned dest tree")
os.chown(dest_nonroot, U.pw_uid, U.pw_gid)
mods = [
# @group auth: U matches its own primary group exactly; root matches the
# wildcard `@ro*` (group "root") and gets :ro; anyone else is denied.
('gauth', {
'path': str(dest_g), 'read only': 'no', 'use chroot': 'no',
'auth users': f'@{G.gr_name}, @ro*:ro',
'secrets file': str(secrets), 'strict modes': 'no',
}),
# gid = * -> clientserver.c want_all_groups() -> uidlist.c getallgroups().
('gidstar', {
'path': str(dest_star), 'read only': 'no', 'use chroot': 'no',
'uid': '0', 'gid': '*',
}),
]
# Non-root receiver (am_root==0 in the child) -> recv_add_id() for gids calls
# uidlist.c is_in_group(). Only when we can chown the dest and setuid.
if owners_supported():
mods.append(('nonroot', {
'path': str(dest_nonroot), 'read only': 'no', 'use chroot': 'no',
'uid': str(U.pw_uid), 'gid': str(U.pw_gid),
}))
conf = write_daemon_conf(mods, name='auth-group.conf')
url = start_test_daemon(conf, DAEMON_PORT)
def pwfile(name, text):
p = SCRATCHDIR / name
p.write_text(text)
p.chmod(0o600)
return p
def push(user, pw, module, *extra):
return subprocess.run(
rsync_argv('-r', f'--password-file={pw}', *extra,
f'{src}/', url.replace('rsync://', f'rsync://{user}@', 1) + f'{module}/'),
capture_output=True, text=True,
)
# --- @group exact match: U is in group G -> allowed rw -----------------------
r = push(U.pw_name, pwfile('pw-u', 'upass'), 'gauth')
if r.returncode != 0:
test_fail(f"@{G.gr_name} should allow {U.pw_name!r} (rc={r.returncode}):\n{r.stderr}")
# --- @group wildcard + :ro: root is in group "root" which matches @ro* ------
# The :ro suffix flips the module read-only for this user, so a push fails
# with "module is read only" -- proving the @ro* matched AND the suffix took.
r = push('root', pwfile('pw-r', 'rpass'), 'gauth')
if r.returncode == 0:
test_fail(f"@ro*:ro should make [gauth] read-only for root, but push succeeded")
if 'read only' not in r.stderr:
test_fail(f"expected 'read only' for root@gauth (got rc={r.returncode}):\n{r.stderr}")
# --- @group negative: outsider's primary group matches neither --------------
if OUT:
r = push(OUT.pw_name, pwfile('pw-o', 'opass'), 'gauth')
if r.returncode == 0:
test_fail(f"{OUT.pw_name!r} (group {grp.getgrgid(OUT.pw_gid).gr_name!r}) "
f"should NOT match @{G.gr_name}/@ro*, but push succeeded")
if 'auth failed' not in r.stderr:
test_fail(f"expected 'auth failed' for {OUT.pw_name!r}:\n{r.stderr}")
# --- gid = * -> want_all_groups() -------------------------------------------
r = subprocess.run(rsync_argv('-r', f'{src}/', f'{url}gidstar/'),
capture_output=True, text=True)
if r.returncode != 0:
test_fail(f"push to [gidstar] (gid=*) failed (rc={r.returncode}):\n{r.stderr}")
# --- non-root receiver -> is_in_group() -------------------------------------
if owners_supported():
r = subprocess.run(rsync_argv('-rg', f'{src}/', f'{url}nonroot/'),
capture_output=True, text=True)
if r.returncode != 0:
test_fail(f"push to [nonroot] (uid={U.pw_uid}) failed (rc={r.returncode}):\n{r.stderr}")
print(f"daemon-auth-group: @{G.gr_name} allow, @ro*:ro, "
f"{'@-deny, ' if OUT else ''}gid=*, "
f"{'non-root is_in_group' if owners_supported() else 'is_in_group SKIPPED (non-root)'}")
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Daemon coverage: `early exec` and `name converter` module hooks.
daemon-exec_test.py covers pre-xfer/post-xfer exec; this fills in the other
two start_pre_exec() callers in clientserver.c:
early exec -- runs before the @RSYNCD: OK, before pre-xfer exec, with
request="(NONE)" and the client's --early-input bytes on
its stdin (write_pre_exec_args type 1).
name converter -- a long-running script that the daemon queries via
namecvt_call() (uidlist.c) for every uid/gid <-> name
mapping, used when chroot would hide /etc/passwd.
Both go through the start_pre_exec() forked-child path that 845fd1103113
made gcov-visible.
"""
import os
import pwd
import subprocess
import time
from rsyncfns import (
FROMDIR, SCRATCHDIR, SRCDIR,
make_tree, makepath, owners_supported, rmtree, rsync_argv,
start_test_daemon, test_fail, test_skipped, write_daemon_conf,
)
DAEMON_PORT = 12891
src = FROMDIR
rmtree(src)
make_tree(src, depth=2)
markers = SCRATCHDIR / 'markers'
rmtree(markers)
makepath(markers)
dest_early = SCRATCHDIR / 'dest-early'
dest_conv = SCRATCHDIR / 'dest-conv'
makepath(dest_early, dest_conv)
def script(name, body):
p = SCRATCHDIR / name
p.write_text('#!/bin/sh\n' + body)
p.chmod(0o755)
return p
# early-exec: capture RSYNC_MODULE_NAME (set before start_pre_exec) and the
# --early-input bytes (piped to the child's stdin after the request/argv).
early = script(
'early.sh',
f'printf "%s|" "$RSYNC_MODULE_NAME" > {markers}/early\n'
f'cat >> {markers}/early\n',
)
# name converter: log every request to a marker so the test can verify
# namecvt_call() actually drove it, then defer to the shipped support script
# for the answer.
nameconv = script(
'nameconv.sh',
f'tee -a {markers}/nameconv | python3 {SRCDIR}/support/nameconvert\n',
)
early_input = SCRATCHDIR / 'early-input.bin'
early_input.write_bytes(b'EARLY-PAYLOAD')
conf = write_daemon_conf([
('early', {
'path': str(dest_early), 'read only': 'no', 'use chroot': 'no',
'early exec': str(early),
}),
('conv', {
'path': str(dest_conv), 'read only': 'no', 'use chroot': 'no',
'name converter': str(nameconv),
# Force name<->id mapping so uidlist.c hits namecvt_call().
'numeric ids': 'no',
}),
], name='early-nameconv.conf')
url = start_test_daemon(conf, DAEMON_PORT)
# --- early exec -------------------------------------------------------------
r = subprocess.run(
rsync_argv('-r', f'--early-input={early_input}', f'{src}/', f'{url}early/'),
capture_output=True, text=True,
)
if r.returncode != 0:
test_fail(f"push to [early] failed (rc={r.returncode}):\n{r.stderr}")
em = (markers / 'early')
if not em.is_file():
test_fail("early-exec script never ran (no marker file)")
got = em.read_text()
if got != 'early|EARLY-PAYLOAD':
test_fail(f"early-exec env/--early-input wrong: {got!r}")
# --- name converter ---------------------------------------------------------
# recv_add_id() only calls user_to_uid()/group_to_gid() (-> namecvt_call) when
# the incoming id is NON-zero, so the source tree needs a non-root owner.
# Pick any non-root passwd entry that exists in this environment; skip the
# name-converter half if there isn't one (or we can't chown).
nonroot = next((p for p in pwd.getpwall() if p.pw_uid != 0 and p.pw_gid != 0), None)
if not owners_supported() or nonroot is None:
print("daemon-early-exec-nameconv: early-exec ok; "
"name-converter SKIPPED (no non-root passwd entry / cannot chown)")
raise SystemExit(0)
for p in src.rglob('*'):
os.chown(p, nonroot.pw_uid, nonroot.pw_gid)
# -og (preserve owner+group) without --numeric-ids: the daemon receiver maps
# the sender's uid/gid names through namecvt_call() -> our script.
r = subprocess.run(
rsync_argv('-rog', f'{src}/', f'{url}conv/'),
capture_output=True, text=True,
)
if r.returncode != 0:
test_fail(f"push to [conv] failed (rc={r.returncode}):\n{r.stderr}")
# The converter is a co-process; give it a moment to flush the tee.
nm = markers / 'nameconv'
deadline = time.monotonic() + 5
while time.monotonic() < deadline and not (nm.is_file() and nm.stat().st_size):
time.sleep(0.05)
if not nm.is_file() or not nm.stat().st_size:
test_fail("name-converter script never received any namecvt_call() requests")
reqs = nm.read_text().splitlines()
# At minimum: a 'usr <name>' and a 'grp <name>' for the sending user's uid/gid.
if not any(l.startswith('usr ') for l in reqs):
test_fail(f"name-converter saw no 'usr' request: {reqs!r}")
if not any(l.startswith('grp ') for l in reqs):
test_fail(f"name-converter saw no 'grp' request: {reqs!r}")
# And the dest tree's owner should have been mapped through to the same uid.
some_dest = next(p for p in dest_conv.rglob('*') if p.is_file())
st = os.stat(some_dest)
if st.st_uid != nonroot.pw_uid:
test_fail(f"name-converter mapping did not apply: dest uid {st.st_uid} "
f"!= {nonroot.pw_uid} ({nonroot.pw_name})")
print(f"daemon-early-exec-nameconv: early-exec env+stdin ok; "
f"name-converter handled {len(reqs)} request(s)")
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
# Exercise the daemon's HAProxy PROXY-protocol parser (clientname.c
# read_proxy_protocol_header), the trusted-peer gate
# (clientserver.c proxy_peer_allowed -> access.c allow_proxy_protocol_peer),
# and that the proxied source address is what hosts-allow checks against.
#
# Needs a real TCP socket: read_proxy_protocol_header() runs only when
# lp_proxy_protocol() is set, and it reads from the socket BEFORE the
# @RSYNCD greeting -- there is no pipe-transport equivalent.
import socket
import struct
from rsyncfns import (
SCRATCHDIR, claim_ports, makepath, require_tcp, start_rsyncd, test_fail,
write_daemon_conf,
)
PORT_OK = 19873 # proxy protocol hosts = 127.0.0.0/8 (we match)
PORT_NOHOSTS = 19874 # proxy protocol = yes but NO proxy protocol hosts
require_tcp("PROXY-protocol header is read from a real TCP socket; run with --use-tcp")
claim_ports(PORT_OK, PORT_NOHOSTS)
base = SCRATCHDIR / 'proxy-protocol'
makepath(base / 'mod')
# Daemon 1: trusted proxy at 127.0.0.0/8. Module only allows the *proxied*
# 10.0.0.0/8 (and an IPv6 ULA), so a successful module-list proves the daemon
# is checking the proxied addr, not the socket peer (127.0.0.1).
conf_ok = write_daemon_conf(
[('mod', {
'path': str(base / 'mod'),
'read only': 'yes',
'use chroot': 'no',
'hosts allow': '10.0.0.0/8, fd00::/8',
})],
globals={
'proxy protocol': 'yes',
'proxy protocol hosts': '127.0.0.0/8',
# The proxied addrs are not in DNS -- skip name lookup so the test
# does not block on a slow reverse-DNS timeout.
'reverse lookup': 'no',
# Override the write_daemon_conf default (localhost 127/8) -- with the
# proxy header in play we want hosts-allow to be per-module only.
'hosts allow': '',
},
name='proxyproto.conf',
)
start_rsyncd(conf_ok, PORT_OK)
# Daemon 2: proxy protocol = yes but NO `proxy protocol hosts` -> every peer
# is untrusted (allow_proxy_protocol_peer returns 0 on empty list), the
# connection is dropped before the header is even read.
conf_nohosts = write_daemon_conf(
[('mod', {'path': str(base / 'mod'), 'read only': 'yes', 'use chroot': 'no'})],
globals={
'proxy protocol': 'yes',
'reverse lookup': 'no',
'hosts allow': '',
# Second daemon in one test -> distinct pid/log so we don't collide
# with daemon 1's lock.
'pid file': str(SCRATCHDIR / 'rsyncd-nohosts.pid'),
'log file': str(SCRATCHDIR / 'rsyncd-nohosts.log'),
},
name='proxyproto-nohosts.conf',
)
start_rsyncd(conf_nohosts, PORT_NOHOSTS)
# --- PROXY header encoders (mirror clientname.c constants) ------------------
V2_SIG = b'\r\n\r\n\x00\r\nQUIT\n' # 12 bytes
CMD_LOCAL, CMD_PROXY = 0, 1
FAM_TCPv4, FAM_TCPv6 = 0x11, 0x21
def v1(line):
return (b'PROXY ' + line.encode('ascii') + b'\r\n')
def v2(cmd, fam, addr):
return (V2_SIG
+ bytes([(2 << 4) | cmd, fam])
+ struct.pack('>H', len(addr))
+ addr)
def v2_ip4(src, dst='127.0.0.1', sport=40000, dport=PORT_OK):
a = (socket.inet_pton(socket.AF_INET, src)
+ socket.inet_pton(socket.AF_INET, dst)
+ struct.pack('>HH', sport, dport))
return v2(CMD_PROXY, FAM_TCPv4, a)
def v2_ip6(src, dst='::1', sport=40000, dport=PORT_OK):
a = (socket.inet_pton(socket.AF_INET6, src)
+ socket.inet_pton(socket.AF_INET6, dst)
+ struct.pack('>HH', sport, dport))
return v2(CMD_PROXY, FAM_TCPv6, a)
# --- driver: send proxy header then a minimal @RSYNCD module-list -----------
def probe(port, hdr, label, *, want):
"""Connect, send `hdr`, then attempt an @RSYNCD module-list of [mod].
`want` is one of:
'ok' -- daemon answers @RSYNCD: OK (proxied IP passed hosts-allow)
'denied' -- daemon answers @ERROR: access denied (proxied IP rejected
by per-module hosts-allow)
'drop' -- daemon closes the socket without an @RSYNCD greeting
(proxy header rejected or untrusted peer)
"""
s = socket.create_connection(('127.0.0.1', port), timeout=10)
s.settimeout(10)
try:
if hdr:
s.sendall(hdr)
# @RSYNCD client greeting + module name + a single sender-side
# list-only request. Protocol 30, no capabilities, no auth.
s.sendall(b'@RSYNCD: 30.0\nmod\n')
# Slurp everything the daemon writes before it closes.
out = b''
try:
s.shutdown(socket.SHUT_WR)
except OSError:
pass
while True:
try:
chunk = s.recv(4096)
except OSError:
break
if not chunk:
break
out += chunk
finally:
s.close()
got_greeting = out.startswith(b'@RSYNCD:')
if want == 'drop':
if got_greeting:
test_fail(f"{label}: expected drop, got greeting: {out!r}")
return
if not got_greeting:
test_fail(f"{label}: expected @RSYNCD greeting, got: {out!r}")
if want == 'denied':
if b'@ERROR' not in out or b'access denied' not in out:
test_fail(f"{label}: expected access-denied, got: {out!r}")
return
if want == 'ok':
# After greeting + module name the daemon answers @RSYNCD: OK then
# waits for client args -- but we sent SHUT_WR so it sees EOF on the
# args read and closes. Seeing OK (not @ERROR) is the success signal.
if b'@ERROR' in out:
test_fail(f"{label}: expected OK, got error: {out!r}")
if b'@RSYNCD: OK' not in out:
test_fail(f"{label}: expected @RSYNCD: OK, got: {out!r}")
return
test_fail(f"{label}: bad want={want!r}")
# === V1 text header =========================================================
# Proxied src in 10.0.0.0/8 -> module hosts-allow matches.
probe(PORT_OK, v1('TCP4 10.1.2.3 127.0.0.1 40000 873'), 'v1 TCP4 allow', want='ok')
# Proxied src outside 10.0.0.0/8 -> module hosts-allow rejects (proves the
# daemon checked the *proxied* addr, not the socket peer 127.0.0.1).
probe(PORT_OK, v1('TCP4 192.168.1.1 127.0.0.1 40000 873'), 'v1 TCP4 deny', want='denied')
# IPv6 source in fd00::/8 -> matches the ULA hosts-allow entry.
probe(PORT_OK, v1('TCP6 fd00::1234 ::1 40000 873'), 'v1 TCP6 allow', want='ok')
# UNKNOWN -> parser returns 1 with ipaddr_buf unset, so the daemon falls back
# to the socket peer (127.0.0.1), which is NOT in 10.0.0.0/8 -> denied.
probe(PORT_OK, v1('UNKNOWN'), 'v1 UNKNOWN', want='denied')
# Malformed v1: missing dst -> parser returns 0 -> drop.
probe(PORT_OK, v1('TCP4 10.1.2.3'), 'v1 short', want='drop')
# Malformed v1: bad family token.
probe(PORT_OK, v1('TCP9 10.1.2.3 127.0.0.1 40000 873'), 'v1 bad-fam', want='drop')
# Malformed v1: non-numeric port.
probe(PORT_OK, v1('TCP4 10.1.2.3 127.0.0.1 abc 873'), 'v1 bad-port', want='drop')
# Malformed v1: line longer than the 108-byte buffer with no \n -> drop.
probe(PORT_OK, b'PROXY TCP4 ' + b'1' * 200, 'v1 overlong', want='drop')
# Neither v2 sig nor "PROXY" prefix -> drop.
probe(PORT_OK, b'GET / HTTP/1.0\r\n\r\n', 'no proxy hdr', want='drop')
# === V2 binary header =======================================================
probe(PORT_OK, v2_ip4('10.9.8.7'), 'v2 TCPv4 allow', want='ok')
probe(PORT_OK, v2_ip4('172.16.0.1'), 'v2 TCPv4 deny', want='denied')
probe(PORT_OK, v2_ip6('fd00::abcd'), 'v2 TCPv6 allow', want='ok')
# CMD_LOCAL: parser returns 1, ipaddr_buf unset -> socket peer 127.0.0.1
# checked against hosts-allow -> denied.
probe(PORT_OK, v2(CMD_LOCAL, 0, b''), 'v2 LOCAL', want='denied')
# Unsupported family with CMD_PROXY -> "ignore proxy data, accept" branch.
probe(PORT_OK, v2(CMD_PROXY, 0x31, b'\0' * 4), 'v2 unsupp-fam', want='denied')
# Bad version nibble -> drop.
probe(PORT_OK, V2_SIG + bytes([(3 << 4) | CMD_PROXY, FAM_TCPv4]) + b'\x00\x0c'
+ b'\0' * 12, 'v2 bad-ver', want='drop')
# Declared size larger than the union -> drop.
probe(PORT_OK, V2_SIG + bytes([(2 << 4) | CMD_PROXY, FAM_TCPv4]) + b'\x10\x00'
+ b'\0' * 12, 'v2 oversize', want='drop')
# TCPv4 with size != 12 -> drop.
probe(PORT_OK, v2(CMD_PROXY, FAM_TCPv4, b'\0' * 8), 'v2 ip4 bad-len', want='drop')
# Unknown cmd -> drop.
probe(PORT_OK, v2(7, FAM_TCPv4, b'\0' * 12), 'v2 bad-cmd', want='drop')
# === Untrusted-peer gate (no `proxy protocol hosts`) ========================
# allow_proxy_protocol_peer() returns 0 on an empty/NULL list; the daemon
# drops the connection BEFORE reading any proxy header.
probe(PORT_NOHOSTS, v2_ip4('10.1.2.3'), 'untrusted v2', want='drop')
probe(PORT_NOHOSTS, v1('TCP4 10.1.2.3 127.0.0.1 40000 873'), 'untrusted v1', want='drop')
probe(PORT_NOHOSTS, b'', 'untrusted empty', want='drop')
print("PASS daemon-proxy-protocol: 22 cases")
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Daemon coverage: standalone detaching listener.
Every other daemon test spawns rsyncd with `--no-detach --address=127.0.0.1
--port=N`, so daemon_main()'s detach path and the rsyncd.conf port/address
parameters are never reached:
clientserver.c become_daemon() -- only when --no-detach is NOT given
daemon-parm.h lp_rsync_port() -- only when --port is NOT given
daemon-parm.h lp_bind_address() -- only when --address is NOT given
This test spawns `rsync --daemon --config=<conf>` with NONE of those flags,
reads port/address from the conf file, waits for the detached child to write
its pid file and start listening, does one transfer, then kills it via the
pid file. require_tcp-gated (it opens a real loopback listener).
"""
import os
import platform
import shlex
import signal
import socket
import subprocess
import time
from rsyncfns import (
FROMDIR, RSYNC_PEER, SCRATCHDIR,
claim_ports, make_tree, makepath, require_tcp, rmtree, rsync_argv,
test_fail, test_skipped, write_daemon_conf,
)
# A standalone --daemon detaches via become_daemon(); on cygwin that becomes a
# disconnected Windows process the harness can't reap, so it lingers as an orphan
# squatting its port and poisoning later TCP daemon tests. Skip on cygwin.
if platform.system().startswith('CYGWIN'):
test_skipped("a detached daemon orphans on cygwin's Windows process model")
PORT = 19877
require_tcp("standalone detaching daemon opens a real loopback listener; "
"run with --use-tcp")
claim_ports(PORT)
src = FROMDIR
rmtree(src)
make_tree(src, depth=2)
dest = SCRATCHDIR / 'dest-detach'
makepath(dest)
pidfile = SCRATCHDIR / 'detach.pid'
logfile = SCRATCHDIR / 'detach.log'
for p in (pidfile, logfile):
if p.exists():
p.unlink()
conf = write_daemon_conf(
[('mod', {'path': str(dest), 'read only': 'no', 'use chroot': 'no'})],
globals={
# These two are the point of the test: read from the conf, not CLI.
'port': str(PORT),
'address': '127.0.0.1',
'pid file': str(pidfile),
'log file': str(logfile),
},
name='detach.conf',
)
# Spawn WITHOUT --no-detach / --port / --address. The launched process is
# become_daemon()'s PARENT: it forks, gcov_flush()es, and _exit(0)s
# immediately; the detached CHILD writes the pid file and listens.
launcher = subprocess.run(
shlex.split(RSYNC_PEER) + ['--daemon', f'--config={conf}'],
capture_output=True, text=True, timeout=15,
)
if launcher.returncode != 0:
test_fail(f"daemon launcher exited {launcher.returncode}:\n{launcher.stderr}")
def kill_detached():
try:
pid = int(pidfile.read_text().strip())
except (FileNotFoundError, ValueError):
return
try:
os.kill(pid, signal.SIGTERM)
for _ in range(50):
try:
os.kill(pid, 0)
except ProcessLookupError:
return
time.sleep(0.05)
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
import atexit
atexit.register(kill_detached)
# Wait for the detached child: pid file written AND port accepting.
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if pidfile.is_file():
try:
with socket.create_connection(('127.0.0.1', PORT), timeout=0.5):
break
except OSError:
pass
time.sleep(0.05)
else:
test_fail(f"detached rsyncd never listened on 127.0.0.1:{PORT} "
f"(pidfile={'present' if pidfile.is_file() else 'absent'}; "
f"log:\n{logfile.read_text() if logfile.is_file() else '(none)'})")
# Verify the log shows the conf-supplied port (lp_rsync_port path).
log = logfile.read_text()
if f'listening on port {PORT}' not in log:
test_fail(f"expected 'listening on port {PORT}' in detach.log:\n{log}")
# One real transfer through the per-connection accept-loop child.
r = subprocess.run(
rsync_argv('-r', f'{src}/', f'rsync://127.0.0.1:{PORT}/mod/'),
capture_output=True, text=True,
)
if r.returncode != 0:
test_fail(f"push to detached daemon failed (rc={r.returncode}):\n{r.stderr}")
if not any(dest.iterdir()):
test_fail("push to detached daemon wrote nothing")
# Clean shutdown via SIGTERM -> exit_cleanup -> gcov_flush in the
# called_from_signal_handler path (cleanup.c). The daemon removes its own
# pid file on the way out, so capture it first.
detached_pid = pidfile.read_text().strip()
kill_detached()
atexit.unregister(kill_detached)
print(f"daemon-standalone-detach: become_daemon + conf port/address + "
f"accept-loop transfer ok (pid {detached_pid})")
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Coverage: cheap one-off paths no other test reaches.
usage.c daemon_usage + help-rsyncd.h -- rsync --daemon --help
main.c show_malloc_stats /
flist.c show_flist_stats -- --info=stats3
util2.c sum_as_hex /
checksum.c canonical_checksum -- -c --out-format='%C %n'
(always_checksum branch) and
--out-format='%C %n' alone
(ITEM_TRANSFER branch)
generator.c start_delete_delay_temp /
flush_delete_delay -- --delete-delay with enough
queued deletions to overflow
the BIGPATHBUFLEN*4 in-memory
buffer into the temp-file path
Each section asserts the observable behaviour, not just exit==0, so the test
also guards against regressions in those paths.
"""
import re
import subprocess
from rsyncfns import (
SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail,
)
def run(*argv, ok=(0,)):
r = subprocess.run(rsync_argv(*argv), capture_output=True, text=True)
if r.returncode not in ok:
test_fail(f"rsync {' '.join(argv)} -> rc={r.returncode}\n{r.stderr}")
return r
# --- 1. daemon_usage + help-rsyncd.h ----------------------------------------
r = run('--daemon', '--help')
if '--no-detach' not in r.stdout or 'rsync --daemon' not in r.stdout:
test_fail(f"--daemon --help did not emit the daemon usage text:\n{r.stdout}")
# --- 2. show_malloc_stats + show_flist_stats (INFO_GTE(STATS,3)) ------------
src = SCRATCHDIR / 'stats-src'
dst = SCRATCHDIR / 'stats-dst'
rmtree(src); rmtree(dst); makepath(src, dst)
for i in range(3):
(src / f'f{i}').write_bytes(b'x' * (64 + i))
r = run('-r', '--info=stats3', f'{src}/', f'{dst}/')
out = r.stdout + r.stderr
# show_malloc_stats prints an arena/heap summary (one block per process), but
# only on a glibc/mallinfo build -- on the BSDs/macOS show_malloc_stats() is a
# no-op (still called, and thus covered, at INFO_GTE(STATS,3), just no output).
# show_flist_stats() is currently an empty stub.
import platform
if platform.system() == 'Linux':
if 'heap statistics' not in out:
test_fail(f"--info=stats3 did not emit malloc stats:\n{out}")
# One block per process: sender + server receiver + server generator.
if out.count('heap statistics') < 3:
test_fail(f"--info=stats3 emitted fewer than 3 heap-stat blocks:\n{out}")
# --- 3. sum_as_hex + canonical_checksum (--out-format=%C) --------------------
csrc = SCRATCHDIR / 'csum-src'
cdst = SCRATCHDIR / 'csum-dst'
rmtree(csrc); rmtree(cdst); makepath(csrc, cdst)
payload = b'hello, checksum coverage\n'
(csrc / 'a').write_bytes(payload)
(csrc / 'b').write_bytes(payload + b'!')
# The file/transfer checksum algorithm is negotiated (xxh128/xxh3/md5/...
# depending on build + protocol), so don't assert which -- just that %C emits
# a per-file hex digest of plausible length, and that DIFFERENT file content
# produces a DIFFERENT digest (i.e. it's the actual sum, not a placeholder).
HEX = re.compile(r'^([0-9a-f]{16,128}) (\S+)$', re.M)
def check_C(out, label):
# Two valid outcomes: hex digests (canonical csum -> sum_as_hex returns the
# hex string) or a run of spaces (non-canonical, e.g. md4 at --protocol=29
# -> sum_as_hex returns NULL and log.c emits sum_len*2 spaces). Either way
# sum_as_hex/canonical_checksum were CALLED, which is the coverage goal.
sums = dict((m[2], m[1]) for m in HEX.finditer(out))
if sums:
if 'a' not in sums or 'b' not in sums or sums['a'] == sums['b']:
test_fail(f"{label}: per-file digests not distinct:\n{out!r}")
return 'hex'
if not re.search(r'^ {8,} a$', out, re.M):
test_fail(f"{label}: emitted neither a hex digest nor the "
f"non-canonical-csum spaces fallback:\n{out!r}")
return 'spaces'
# 3a. always_checksum branch (log.c:707): with -c, %C encodes F_SUM(file).
r = run('-rc', '--out-format=%C %n', f'{csrc}/', f'{cdst}/')
kind_a = check_C(r.stdout, '-c %C')
# 3b. ITEM_TRANSFER branch (log.c:709): no -c, %C encodes sender_file_sum.
rmtree(cdst); makepath(cdst)
r = run('-r', '--out-format=%C %n', f'{csrc}/', f'{cdst}/')
kind_b = check_C(r.stdout, '%C (no -c)')
# --- 4. start_delete_delay_temp + flush_delete_delay ------------------------
# deldelay_size = BIGPATHBUFLEN*4 = 20480. Each remember_delete() entry is
# "%x %s\0" -> ~6 + len(name). 300 files with 80-char names -> ~25 KB,
# guaranteed to overflow the in-memory buffer and spill to the temp file.
ddsrc = SCRATCHDIR / 'dd-src'
dddst = SCRATCHDIR / 'dd-dst'
rmtree(ddsrc); rmtree(dddst); makepath(ddsrc, dddst)
(ddsrc / 'keep').write_bytes(b'k')
NAME = 'D' * 80
N = 300
for i in range(N):
(dddst / f'{NAME}{i:04d}').write_bytes(b'x')
r = run('-r', '--delete-delay', f'{ddsrc}/', f'{dddst}/')
remaining = [p for p in dddst.iterdir() if p.name.startswith(NAME)]
if remaining:
test_fail(f"--delete-delay left {len(remaining)}/{N} files "
f"after the temp-file spill path")
if not (dddst / 'keep').is_file():
test_fail("--delete-delay lost the kept file")
print(f"misc-coverage: --daemon --help, --info=stats3, "
f"%C ({kind_a}/{kind_b}), --delete-delay temp-file spill ({N} entries) ok")
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Coverage: the generator/delete chmod-around-restrictive-perms paths.
generator.c gen_entry_chmod() and delete.c del_chmod() only fire when
`!am_root`: a non-root receiver creating files inside a directory whose
incoming mode lacks S_IRWXU has to temporarily widen it
(generator.c:~1725, restored at ~2387 in the retouch pass), and a
non-root receiver deleting a non-writable file it owns adds S_IWUSR
first (delete.c:~156). Every other test runs the receiver as root in
the container, so these never trip.
Uses a daemon module with `uid = <non-root>` so the receiver child is
non-root; the dest tree is chowned to that uid up front. Skips when
chown is unavailable.
"""
import os
import pwd
import stat
import subprocess
from rsyncfns import (
SCRATCHDIR,
makepath, owners_supported, rmtree, rsync_argv, start_test_daemon,
test_fail, test_skipped, write_daemon_conf,
)
DAEMON_PORT = 12893
if not owners_supported():
test_skipped("needs chown to set up a non-root-owned dest tree")
U = next((p for p in pwd.getpwall() if p.pw_uid != 0), None)
if U is None:
test_skipped("no non-root passwd entry")
src = SCRATCHDIR / 'nrp-src'
dest = SCRATCHDIR / 'nrp-dest'
rmtree(src); rmtree(dest); makepath(src, dest)
os.chown(dest, U.pw_uid, U.pw_gid)
conf = write_daemon_conf([
('nrp', {
'path': str(dest), 'read only': 'no', 'use chroot': 'no',
'uid': str(U.pw_uid), 'gid': str(U.pw_gid),
}),
], name='nonroot-perms.conf')
url = start_test_daemon(conf, DAEMON_PORT)
def push(*extra):
r = subprocess.run(
rsync_argv('-rp', *extra, f'{src}/', f'{url}nrp/'),
capture_output=True, text=True,
)
if r.returncode != 0:
test_fail(f"push -rp {' '.join(extra)} -> rc={r.returncode}\n{r.stderr}")
return r
# --- A. gen_entry_chmod: receive into a dir whose mode lacks write ----------
# Source dir mode 0500 (r-x------): the non-root generator widens it to 0700
# while writing `inner`, then the retouch pass restores 0500.
(src / 'restricted').mkdir()
(src / 'restricted' / 'inner').write_bytes(b'x')
os.chmod(src / 'restricted', 0o500)
push()
st = os.stat(dest / 'restricted')
if stat.S_IMODE(st.st_mode) != 0o500:
test_fail(f"restricted/ perms not restored after retouch: "
f"{oct(stat.S_IMODE(st.st_mode))} (expected 0o500)")
if not (dest / 'restricted' / 'inner').is_file():
test_fail("restricted/inner not received (gen_entry_chmod widen failed?)")
if st.st_uid != U.pw_uid:
test_fail(f"restricted/ not owned by the module uid ({st.st_uid} != {U.pw_uid})")
# --- B. del_chmod: --delete a non-writable file the receiver owns -----------
# Dest already has restricted/inner (mode set by previous push). Seed a
# second dir `gone/` with a 0444 file in it, owned by U; remove `gone/` from
# src and push --delete. delete_dir_contents() walks gone/, sees ro_file
# with !(mode & S_IWUSR) && !am_root && FLAG_OWNED_BY_US -> del_chmod first.
gone = dest / 'gone'
gone.mkdir()
ro = gone / 'ro_file'
ro.write_bytes(b'x')
os.chmod(ro, 0o444)
os.chown(ro, U.pw_uid, U.pw_gid)
os.chown(gone, U.pw_uid, U.pw_gid)
# Widen restricted/ on the src side again so the generator re-exercises the
# 1725 widen + 2387 restore on a SECOND pass too (idempotence under -p).
os.chmod(src / 'restricted', 0o500)
push('--delete')
if (dest / 'gone').exists():
test_fail("--delete did not remove gone/ (del_chmod path)")
if not (dest / 'restricted' / 'inner').is_file():
test_fail("restricted/inner lost on the second pass")
if stat.S_IMODE(os.stat(dest / 'restricted').st_mode) != 0o500:
test_fail("restricted/ perms not re-restored on the second pass")
print(f"nonroot-restrictive-perms: gen_entry_chmod widen+restore (0500 dir), "
f"del_chmod on 0444 file, as uid={U.pw_uid}({U.pw_name})")
+15 -3
View File
@@ -30,6 +30,7 @@ import stat
import struct
import subprocess
import sys
import tempfile
import time
from pathlib import Path
@@ -1899,10 +1900,21 @@ def acls_supported() -> bool:
return False
@_functools.lru_cache(maxsize=1)
def devices_supported() -> bool:
"""True if device nodes can be created here: euid==0 AND os.mknod exists
(mknod of S_IFCHR/S_IFBLK needs CAP_MKNOD, i.e. root)."""
return os.geteuid() == 0 and hasattr(os, 'mknod')
"""True if device nodes can be created here.
euid==0 is necessary but not sufficient: a user-namespaced container
(rootless podman/docker, buildd chroots) reports euid==0 yet lacks
CAP_MKNOD, so os.mknod() of a device fails EPERM. Probe once."""
if os.geteuid() != 0 or not hasattr(os, 'mknod'):
return False
with tempfile.TemporaryDirectory(prefix='rsync-devprobe.') as d:
try:
os.mknod(os.path.join(d, 'p'), 0o600 | stat.S_IFCHR, os.makedev(1, 3))
except (PermissionError, OSError):
return False
return True
def owners_supported() -> bool:
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/python3
# Regression tests for the run5 option/argv bound fixes: a hostile or
# wrapper-built command line must not overflow a fixed buffer, underflow a
# stack array, or exhaust the stack. Each case used to crash (global-buffer-
# overflow / stack-buffer-underflow / stack exhaustion under ASAN) and now must
# complete a clean transfer.
import os
import subprocess
from rsyncfns import (
FROMDIR, RSYNC_PEER, SRCDIR, SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail,
)
LSH = str(SRCDIR / 'support' / 'lsh.sh')
os.environ['RSYNC_RSH'] = LSH
src = FROMDIR
rmtree(src)
makepath(src)
(src / 'f').write_text('payload\n')
def run(label, extra, local=False):
dest = SCRATCHDIR / ('dest-' + label)
rmtree(dest)
makepath(dest)
if local:
argv = rsync_argv('-a', *extra, f'{src}/', f'{dest}/')
else:
argv = rsync_argv('-a', *extra, '-e', LSH,
f'--rsync-path={RSYNC_PEER}', f'{src}/', f'localhost:{dest}/')
r = subprocess.run(argv, capture_output=True, text=True)
if r.returncode < 0 or r.returncode >= 128:
test_fail(f'{label}: rsync crashed (rc={r.returncode}): {r.stderr.strip()[:200]}')
if not (dest / 'f').exists() or (dest / 'f').read_text() != 'payload\n':
test_fail(f'{label}: transfer did not complete (rc={r.returncode}): {r.stderr.strip()[:200]}')
# 0014: ~60 '-v' overran server_options()'s static argstr[64] (global-buffer-
# overflow WRITE) before the count was bounded.
run('verbose-flood', ['-v'] * 60)
# 0015: --info=NAME<overflowing-digits> -> atoi() returns a negative int on
# LP64 -> make_output_option() indexed counts[-1] (stack underflow).
run('info-overflow', ['--info=BACKUP99999999999999999999'])
# 0023: --skip-compress=<one very long token> recursed add_suffix() once per
# character, exhausting the stack; truncated to 32 chars now.
run('skip-compress-long', ['-z', '--skip-compress=' + 'a' * 5000], local=True)
print("scanner-argv-bounds: -v flood, --info overflow, --skip-compress long token all clean")
@@ -0,0 +1,54 @@
#!/usr/bin/python3
# Regression test for run5 0016: a --read-batch whose recorded stream-flags
# enable a metadata class (xattrs/acls/uid/gid) that the reading command line
# did not. setup_protocol() used to compute xattrs_ndx BEFORE check_batch_flags()
# flipped preserve_xattrs on, leaving xattrs_ndx=0 -- so F_XATTR(file) wrote a
# 4-byte int at offset 0 of every file_struct, clobbering file->dirname into a
# wild pointer that flist_sort_and_clean() then dereferenced (heap overflow).
import subprocess
from rsyncfns import (
FROMDIR, SCRATCHDIR, forced_protocol, makepath, rmtree, run_rsync, rsync_argv,
test_fail, test_skipped, xattr_set, xattrs_supported,
)
if not xattrs_supported():
test_skipped("needs xattr support to write an -X batch")
proto = forced_protocol()
if proto is not None and proto < 30:
test_skipped("xattrs (-X) need protocol 30+")
src = FROMDIR
rmtree(src)
makepath(src)
# Several files, each with an xattr, so the post-clobber flist_sort_and_clean()
# walks file->dirname across many entries. Some platforms advertise xattr
# support yet reject a user-namespace xattr on a plain file (e.g. FreeBSD's
# setextattr) -- skip cleanly there rather than fail in setup.
for i in range(40):
f = src / f'f{i:03d}'
f.write_text('x\n')
try:
xattr_set('user.m', 'v', f)
except Exception as e:
test_skipped(f"cannot set a user xattr on a regular file here: {e}")
batch = SCRATCHDIR / 'xbatch'
wb_dest = SCRATCHDIR / 'wb-dest'
rmtree(wb_dest)
# Write the batch WITH -X: the stream-flags record preserve_xattrs.
run_rsync('-a', '-X', f'--only-write-batch={batch}', f'{src}/', str(wb_dest))
dest = SCRATCHDIR / 'rb-dest'
rmtree(dest)
makepath(dest)
# Replay with BARE -a (no -X): the batch flips preserve_xattrs on after the
# *_ndx were computed. Must complete cleanly now.
r = subprocess.run(rsync_argv('-a', f'--read-batch={batch}', str(dest)),
capture_output=True, text=True)
if r.returncode < 0 or r.returncode >= 128:
test_fail(f'read-batch crashed (rc={r.returncode}): {r.stderr.strip()[:200]}')
if not (dest / 'f000').exists():
test_fail(f'read-batch did not replay the files (rc={r.returncode}): '
f'{r.stderr.strip()[:200]}')
print("scanner-batch-flag-mismatch: -X batch replayed with bare -a is clean")
@@ -0,0 +1,42 @@
#!/usr/bin/python3
# Regression test for run5 0025: a daemon-as-sender with -c and a module
# `log format` containing %C read F_SUM(file) in log_formatted(), but
# start_server() never set sender_keeps_checksum, so make_file() reserved no
# SUM_EXTRA_CNT and F_SUM() read past the pool slot (hex-encoding adjacent heap
# into the transfer log). Pull with -c from a %C-logging module; the daemon
# child must not crash (RED under ASAN before the fix).
import subprocess
from rsyncfns import (
SCRATCHDIR, make_tree, makepath, rmtree, rsync_argv,
start_test_daemon, test_fail, write_daemon_conf,
)
DAEMON_PORT = 12894
mod = SCRATCHDIR / 'csum-mod'
rmtree(mod)
makepath(mod)
make_tree(mod, depth=2, data=True)
conf = write_daemon_conf([
('csum', {'path': str(mod), 'read only': 'yes',
# The module `log format` is only installed when transfer logging
# is on (clientserver.c lp_transfer_logging gate); without this the
# %C path -- the whole point of this test -- never runs.
'transfer logging': 'yes', 'log format': '%o %C %f %l'}),
])
url = start_test_daemon(conf, DAEMON_PORT).rstrip('/')
dest = SCRATCHDIR / 'csum-pull'
rmtree(dest)
makepath(dest)
# Daemon is the SENDER; -c forces always_checksum so log_formatted()'s %C reads
# F_SUM(file) in the daemon child.
r = subprocess.run(rsync_argv('-a', '-c', f'{url}/csum/', f'{dest}/'),
capture_output=True, text=True)
if r.returncode < 0 or r.returncode >= 128:
test_fail(f'daemon-as-sender -c with %C crashed (rc={r.returncode}): '
f'{r.stderr.strip()[:200]}')
if not any(dest.rglob('f*')):
test_fail(f'pull transferred no files (rc={r.returncode}): {r.stderr.strip()[:200]}')
print("scanner-daemon-log-checksum: daemon-as-sender -c with %C log format is clean")
@@ -0,0 +1,34 @@
#!/usr/bin/python3
# Regression test for run5 0017: read_delay_line() mis-sized a '!'-prefixed
# (DEL_NO_UID_WRITE) delete-delay entry by one byte, over-reading deldelay_buf
# when a buffer-filling entry was the last one. Drive --delete-delay over many
# non-writable (0444), long-named dest files so the '!' path and a full buffer
# are both exercised; must complete cleanly (RED under ASAN before the fix).
import os
import subprocess
from rsyncfns import FROMDIR, TODIR, makepath, rmtree, rsync_argv, test_fail
src = FROMDIR
dest = TODIR
rmtree(src)
rmtree(dest)
makepath(src)
makepath(dest)
(src / 'keep').write_text('k\n')
# ~80 dest-only files, 0444, ~250-char names -> --delete removes them; the
# read-only files take the DEL_NO_UID_WRITE ('!') prefix under --no-super.
for i in range(80):
name = f'del{i:02d}-' + 'n' * 240
p = dest / name
p.write_text('old\n')
os.chmod(p, 0o444)
r = subprocess.run(rsync_argv('-a', '--no-super', '--delete-delay', f'{src}/', f'{dest}/'),
capture_output=True, text=True)
if r.returncode < 0 or r.returncode >= 128:
test_fail(f'--delete-delay crashed (rc={r.returncode}): {r.stderr.strip()[:200]}')
left = [q.name for q in dest.iterdir() if q.name.startswith('del')]
if left:
test_fail(f'--delete-delay did not delete the read-only files: {left[:2]}')
print("scanner-delete-delay-overread: ~80 0444 long-named --delete-delay entries clean")