diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index adef6d17..9ee9bf53 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -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 diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 957509a6..8e421aeb 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -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. diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index c3ff58c8..1f270a2b 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -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. diff --git a/.github/workflows/ubuntu-22.04-build.yml b/.github/workflows/ubuntu-22.04-build.yml index 6ecaf800..b5ab6410 100644 --- a/.github/workflows/ubuntu-22.04-build.yml +++ b/.github/workflows/ubuntu-22.04-build.yml @@ -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. diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 2b8dd4ea..9fd37c68 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -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 diff --git a/Makefile.in b/Makefile.in index a44cd271..a046b885 100644 --- a/Makefile.in +++ b/Makefile.in @@ -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 = ` 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 \ diff --git a/clientserver.c b/clientserver.c index b66f7246..a2a53a86 100644 --- a/clientserver.c +++ b/clientserver.c @@ -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); } diff --git a/rsync.h b/rsync.h index a6bbf64d..20be4805 100644 --- a/rsync.h +++ b/rsync.h @@ -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; diff --git a/socket.c b/socket.c index 92b9e236..ce673fe3 100644 --- a/socket.c +++ b/socket.c @@ -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, diff --git a/testsuite/backup-acl-xattr-cache_test.py b/testsuite/backup-acl-xattr-cache_test.py new file mode 100644 index 00000000..eacd4e6e --- /dev/null +++ b/testsuite/backup-acl-xattr-cache_test.py @@ -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= 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") diff --git a/testsuite/daemon-auth-group_test.py b/testsuite/daemon-auth-group_test.py new file mode 100644 index 00000000..1d3820cb --- /dev/null +++ b/testsuite/daemon-auth-group_test.py @@ -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)'}") diff --git a/testsuite/daemon-early-exec-nameconv_test.py b/testsuite/daemon-early-exec-nameconv_test.py new file mode 100644 index 00000000..deba2a9e --- /dev/null +++ b/testsuite/daemon-early-exec-nameconv_test.py @@ -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 ' and a 'grp ' 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)") diff --git a/testsuite/daemon-proxy-protocol_test.py b/testsuite/daemon-proxy-protocol_test.py new file mode 100644 index 00000000..05201f23 --- /dev/null +++ b/testsuite/daemon-proxy-protocol_test.py @@ -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") diff --git a/testsuite/daemon-standalone-detach_test.py b/testsuite/daemon-standalone-detach_test.py new file mode 100644 index 00000000..a5399c44 --- /dev/null +++ b/testsuite/daemon-standalone-detach_test.py @@ -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=` 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})") diff --git a/testsuite/misc-coverage_test.py b/testsuite/misc-coverage_test.py new file mode 100644 index 00000000..5bf27333 --- /dev/null +++ b/testsuite/misc-coverage_test.py @@ -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") diff --git a/testsuite/nonroot-restrictive-perms_test.py b/testsuite/nonroot-restrictive-perms_test.py new file mode 100644 index 00000000..4fce2410 --- /dev/null +++ b/testsuite/nonroot-restrictive-perms_test.py @@ -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 = ` 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})") diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index 5dcd571a..aa463cee 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -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: diff --git a/testsuite/scanner-argv-bounds_test.py b/testsuite/scanner-argv-bounds_test.py new file mode 100644 index 00000000..02682877 --- /dev/null +++ b/testsuite/scanner-argv-bounds_test.py @@ -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 -> atoi() returns a negative int on +# LP64 -> make_output_option() indexed counts[-1] (stack underflow). +run('info-overflow', ['--info=BACKUP99999999999999999999']) + +# 0023: --skip-compress= 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") diff --git a/testsuite/scanner-batch-flag-mismatch_test.py b/testsuite/scanner-batch-flag-mismatch_test.py new file mode 100644 index 00000000..58be9514 --- /dev/null +++ b/testsuite/scanner-batch-flag-mismatch_test.py @@ -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") diff --git a/testsuite/scanner-daemon-log-checksum_test.py b/testsuite/scanner-daemon-log-checksum_test.py new file mode 100644 index 00000000..5e98ac31 --- /dev/null +++ b/testsuite/scanner-daemon-log-checksum_test.py @@ -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") diff --git a/testsuite/scanner-delete-delay-overread_test.py b/testsuite/scanner-delete-delay-overread_test.py new file mode 100644 index 00000000..e2effe79 --- /dev/null +++ b/testsuite/scanner-delete-delay-overread_test.py @@ -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")