mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-15 06:41:28 -04:00
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:
1 parent
ce17dfe8f0
commit
0abb4ae6cb
21 files changed
+1257
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
@@ -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})")
|
||||
@@ -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")
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user