Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/almalinux-8-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check
run: RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long 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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cygwin-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=acls-default,acls-depth,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check'
run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls-depth,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-access-ip,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis 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.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/macos-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=acls-default,acls-depth,chmod-temp-dir,daemon-chroot-acl,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,simd-checksum,sparse make check
run: sudo RSYNC_EXPECT_SKIPPED=acls-default,acls-depth,chmod-temp-dir,daemon-access-ip,daemon-chroot-acl,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,simd-checksum,sparse 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.
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ubuntu-22.04-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ jobs:
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long 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.
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ubuntu-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ jobs:
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long 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
Expand Down
47 changes: 45 additions & 2 deletions Makefile.in
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ clean: cleantests
git-version.h rounding rounding.h *.old rsync*.1 rsync*.5 @MAKE_RRSYNC_1@ \
*.html daemon-parm.h help-*.h default-*.h proto.h proto.h-tstamp
rm -f *.gcno *.gcda lib/*.gcno lib/*.gcda zlib/*.gcno zlib/*.gcda popt/*.gcno popt/*.gcda
rm -rf coverage coverage-tcp
rm -rf coverage coverage-tcp coverage-all coverage-fallback

.PHONY: cleantests
cleantests:
Expand Down Expand Up @@ -342,6 +342,15 @@ 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.
COVERAGE_EXCLUDE = -e '(^|/)zlib/' -e '(^|/)popt/' \
-e '(^|/)lib/(getaddrinfo|getpass|inet_ntop|inet_pton)\.'

.PHONY: check
check: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
$(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J)
Expand Down Expand Up @@ -369,7 +378,7 @@ coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
find . -name '*.gcda' -delete
@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) --decisions --print-summary \
gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \
--html-details -o $(COVERAGE_DIR)/index.html . || exit $$?; \
echo "Coverage report written to $(COVERAGE_DIR)/index.html"; \
if test $$rc != 0; then \
Expand All @@ -383,6 +392,40 @@ coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
coverage-tcp:
$(MAKE) coverage COVERAGE_RUNFLAGS=--use-tcp COVERAGE_DIR=coverage-tcp

# Comprehensive single report: run the suite under several configurations,
# accumulating into the shared .gcda counters (NOT cleared between runs), then
# emit one merged, rsync-scoped report. Covers the default (pipe) transport, the
# protocol-29/30 compat branches, and the real-TCP daemon path (which also runs
# the require_tcp-only tests). Run under sudo to additionally cover root-only
# paths (devices, chown, use-chroot, protected-regular). Local target -- CI uses
# the plain `coverage`/`coverage-tcp` targets.
.PHONY: coverage-all
coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
@case '$(CFLAGS)' in *--coverage*) ;; \
*) 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
@rc=0; \
for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp'; do \
echo "===== coverage-all: runtests.py $$cfg ====="; \
$(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(COVERAGE_J) $$cfg || rc=$$?; \
done; \
rm -rf coverage-all && mkdir -p coverage-all; \
gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \
--html-details -o coverage-all/index.html . || exit $$?; \
echo "Merged coverage report written to coverage-all/index.html"; \
if test $$rc != 0; then \
echo "*** some suite runs FAILED (status $$rc) -- report still written above"; \
fi; \
exit $$rc

# Coverage for the portable (non-openat2) resolver tier. Requires a SEPARATE
# build configured with --enable-coverage --disable-openat2: its .gcno differ
# from the openat2 build, so this report cannot be merged with the others.
.PHONY: coverage-fallback
coverage-fallback:
$(MAKE) coverage COVERAGE_DIR=coverage-fallback

wildtest.o: wildtest.c t_stub.o lib/wildmatch.c rsync.h config.h
wildtest$(EXEEXT): wildtest.o lib/compat.o lib/snprintf.o @BUILD_POPT@
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ wildtest.o lib/compat.o lib/snprintf.o @BUILD_POPT@ $(LIBS)
Expand Down
11 changes: 9 additions & 2 deletions runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,15 @@ def process_result(tr):
print('-' * 60)

exit_code = failed + vg_errors
if exit_code == 0 and skipped_str != args.expect_skipped:
exit_code = 1
if exit_code == 0:
# Compare the skipped set order-insensitively: which tests skipped is
# what matters, not the order runtests happened to collect them in
# (that order is just sorted filenames -- an easy thing to get subtly
# wrong when maintaining the per-platform expected lists).
got = set(s for s in skipped_str.split(',') if s)
want = set(s for s in args.expect_skipped.split(',') if s)
if got != want:
exit_code = 1

print(f'overall result is {exit_code}')
sys.exit(exit_code)
Expand Down
65 changes: 65 additions & 0 deletions testsuite/daemon-access-ip_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Daemon coverage: hosts allow / hosts deny IP and CIDR matching (access.c).

access.c's make_mask / match_address / match_binary only run for a real TCP
peer matched against a numeric hosts allow/deny pattern -- so this needs
--use-tcp (the loopback peer is 127.0.0.1). Verifies that exact-IP and CIDR
allow patterns permit the connection while a CIDR deny / a non-matching allow
refuse it.

The config sets NO global hosts allow: an inherited global allow-list would
match (e.g. via "localhost") and short-circuit before a module's deny is
consulted, so the per-module patterns must be the sole decider here.
"""

import subprocess

from rsyncfns import (
FROMDIR, SCRATCHDIR,
make_tree, require_tcp, rmtree, rsync_argv, start_test_daemon, test_fail,
)

DAEMON_PORT = 12892
require_tcp("hosts allow/deny address matching needs a real TCP peer")

src = FROMDIR
rmtree(src)
make_tree(src, depth=2)

conf = SCRATCHDIR / 'access-ip.conf'
conf.write_text(
f"pid file = {SCRATCHDIR}/rsyncd.pid\n"
"use chroot = no\n"
f"log file = {SCRATCHDIR}/rsyncd.log\n"
f"\n[allow-exact]\n\tpath = {src}\n\tread only = yes\n\thosts allow = 127.0.0.1\n"
f"\n[allow-cidr]\n\tpath = {src}\n\tread only = yes\n\thosts allow = 127.0.0.0/8\n"
f"\n[deny-cidr]\n\tpath = {src}\n\tread only = yes\n\thosts deny = 127.0.0.0/8\n"
f"\n[allow-other]\n\tpath = {src}\n\tread only = yes\n\thosts allow = 10.0.0.0/8\n"
)
url = start_test_daemon(conf, DAEMON_PORT)


def connect(mod):
"""Return rsync's exit code for listing the module over the daemon."""
return subprocess.run(rsync_argv('-r', f'{url}{mod}/'),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True).returncode


for mod in ('allow-exact', 'allow-cidr'):
if connect(mod) != 0:
test_fail(f"connection to {mod} should be ALLOWED but was refused")
for mod in ('deny-cidr', 'allow-other'):
if connect(mod) == 0:
test_fail(f"connection to {mod} should be DENIED but succeeded")

# Client --address binds the outgoing socket to a local address (socket.c
# try_bind_local) before connecting to the daemon.
proc = subprocess.run(
rsync_argv('-r', '--address=127.0.0.1', f'{url}allow-cidr/'),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if proc.returncode != 0:
test_fail(f"--address=127.0.0.1 client connection failed: {proc.stderr}")

print("daemon-access-ip: hosts allow/deny matching + client --address verified")

52 changes: 52 additions & 0 deletions testsuite/daemon-config_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Daemon coverage: the &include config directive (params.c include_config /
parse_directives) and a module whose path doesn't exist (clientserver.c
path_failure).

Uses a hand-written rsyncd.conf because &include is a directive line, not a
`name = value` parameter.
"""

import subprocess

from rsyncfns import (
FROMDIR, SCRATCHDIR,
make_tree, rmtree, rsync_argv, start_test_daemon, test_fail,
)

DAEMON_PORT = 12893

src = FROMDIR
rmtree(src)
make_tree(src, depth=2)

inc = SCRATCHDIR / 'included.conf'
inc.write_text(f"[inc-mod]\n\tpath = {src}\n\tread only = yes\n\tcomment = via-include\n")

conf = SCRATCHDIR / 'daemon-config.conf'
conf.write_text(
f"pid file = {SCRATCHDIR}/rsyncd.pid\n"
"use chroot = no\n"
"hosts allow = localhost 127.0.0.0/8\n"
f"log file = {SCRATCHDIR}/rsyncd.log\n"
f"&include {inc}\n"
f"\n[badpath]\n\tpath = {SCRATCHDIR}/no-such-dir\n\tread only = yes\n"
)
url = start_test_daemon(conf, DAEMON_PORT)

# &include pulled in inc-mod: it must be listable and present in the module list.
proc = subprocess.run(rsync_argv('-r', f'{url}inc-mod/'),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if proc.returncode != 0:
test_fail(f"&include-defined module not reachable: {proc.stderr}")
proc = subprocess.run(rsync_argv(url), capture_output=True, text=True)
if 'inc-mod' not in proc.stdout:
test_fail(f"&include-defined module absent from the listing:\n{proc.stdout}")

# A module whose path does not exist must fail the connection (path_failure).
proc = subprocess.run(rsync_argv('-r', f'{url}badpath/'),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if proc.returncode == 0:
test_fail("a module with a non-existent path unexpectedly served a connection")

print("daemon-config: &include directive + bad-path failure verified")
17 changes: 16 additions & 1 deletion testsuite/delete-deep_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,19 @@ def fresh_dest():
test_fail("--ignore-existing overwrote an existing deep file")
assert_same(TODIR / 'f0', src / 'f0', label='--ignore-existing created new file')

print("delete-deep: delete family, max-delete, existing/ignore-existing at depth")
# --- --backup --delete (no backup-dir) consults is_backup_file ---------------
# When deleting extraneous files under --backup, a name already ending in the
# backup suffix is unlinked directly rather than backed up again (delete.c
# is_backup_file); the source tree is unaffected.
rels = seed_src()
fresh_dest()
(TODIR / 'd1' / 'd2' / 'gone').write_text("extraneous\n")
(TODIR / 'd1' / 'd2' / 'gone~').write_text("an existing backup file\n")
run_rsync('-a', '-b', '--delete', f'{src}/', f'{TODIR}/')
assert_not_exists(TODIR / 'd1' / 'd2' / 'gone',
label='--backup --delete removed extraneous')
for rel in rels:
assert_same(TODIR / rel, src / rel, label=f'--backup --delete kept {rel}')

print("delete-deep: delete family, max-delete, existing/ignore-existing, "
"backup-delete at depth")
37 changes: 37 additions & 0 deletions testsuite/fuzzy-basis_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Coverage of --fuzzy basis selection scoring (util1.c fuzzy_distance).

When the destination has no exact match for a source file, --fuzzy makes the
generator score the same-directory candidates by name similarity (fuzzy_distance)
and use the closest as the delta basis. Set this up at depth with several
similar-named candidates so the scorer actually runs.
"""

import os

from rsyncfns import (
FROMDIR, TODIR,
assert_same, make_data_file, makepath, rmtree, run_rsync,
)

src = FROMDIR
deepdir = os.path.join('d1', 'd2')
newfile = os.path.join(deepdir, 'archive-v2.tar')

rmtree(src)
rmtree(TODIR)
makepath(src / deepdir, TODIR / deepdir)

make_data_file(src / newfile, 300_000)
base = (src / newfile).read_bytes()

# Destination has NO 'archive-v2.tar', but several similar-named candidates that
# are mostly identical to it -- so fuzzy must score them by name distance.
(TODIR / deepdir / 'archive-v1.tar').write_bytes(base[:280_000] + b'older tail data')
(TODIR / deepdir / 'archive-old.tar').write_bytes(base[:200_000])
(TODIR / deepdir / 'unrelated.dat').write_bytes(b'nothing alike' * 1000)

run_rsync('-a', '--fuzzy', '--no-whole-file', f'{src}/', f'{TODIR}/')
assert_same(TODIR / newfile, src / newfile, label='fuzzy result')

print("fuzzy-basis: --fuzzy candidate scoring (fuzzy_distance) verified at depth")
62 changes: 62 additions & 0 deletions testsuite/preallocate_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Coverage of the file-allocation syscalls in syscall.c at depth:
do_fallocate (--preallocate) and do_punch_hole (sparse writes).

These are receiver-side file operations the resolver restructure also touches.
Where the filesystem lacks fallocate/punch-hole the calls warn and the transfer
still completes, so the content assertions hold regardless; the coverage is
gained wherever the kernel supports them.
"""

import os

from rsyncfns import (
FROMDIR, TODIR,
assert_same, make_data_file, makepath, rmtree, run_rsync,
)

src = FROMDIR
deep = os.path.join('d1', 'd2', 'd3', 'f')


def seed_plain(size=1_000_000):
rmtree(src)
rmtree(TODIR)
makepath(src / 'd1' / 'd2' / 'd3')
make_data_file(src / deep, size)


def seed_holey(head=4096, hole=2 * 1024 * 1024, tail=4096):
rmtree(src)
rmtree(TODIR)
makepath(src / 'd1' / 'd2' / 'd3')
with open(src / deep, 'wb') as f:
f.write(os.urandom(head))
f.write(b'\0' * hole) # a real zero run for the sparse writer
f.write(os.urandom(tail))


# --- --preallocate: do_fallocate on the receiver ----------------------------
seed_plain()
run_rsync('-a', '--preallocate', f'{src}/', f'{TODIR}/')
assert_same(TODIR / deep, src / deep, label='--preallocate content')

# --- --preallocate --sparse on a holey file: do_fallocate + do_punch_hole ---
seed_holey()
run_rsync('-a', '--preallocate', '--sparse', f'{src}/', f'{TODIR}/')
assert_same(TODIR / deep, src / deep, label='--preallocate --sparse content')

# --- --inplace --sparse update that introduces a zero run: do_punch_hole ----
# (sparse_end's updating_basis_or_equiv branch punches the hole in place.)
seed_plain()
run_rsync('-a', f'{src}/', f'{TODIR}/') # dest starts fully populated
data = bytearray((src / deep).read_bytes())
data[200_000:800_000] = b'\0' * 600_000 # same size, new zero run
(src / deep).write_bytes(bytes(data))
st = os.stat(src / deep)
os.utime(src / deep, (st.st_atime, st.st_mtime + 100)) # force a delta update
run_rsync('-a', '--inplace', '--sparse', '--no-whole-file', f'{src}/', f'{TODIR}/')
assert_same(TODIR / deep, src / deep, label='--inplace --sparse content')

print("preallocate: --preallocate (do_fallocate) + sparse hole-punching "
"(do_punch_hole) verified at depth")
Loading
Loading