From 7a80076c64c0ee6af90e6ea3faeaa44136bca394 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 24 May 2026 12:39:19 +1000 Subject: [PATCH 1/4] build: scope gcov report to rsync's own source; add coverage-all The coverage report counted bundled third-party code (zlib/, popt/, and the PostgreSQL/ISC lib/ imports getaddrinfo/getpass/inet_ntop/inet_pton) that rsync ships but does not own, muddying the percentages. Add a COVERAGE_EXCLUDE gcovr filter (shared by all coverage targets) so the report reflects rsync's own code: on the same data, lines 63.9%->65.5%, functions 81.4%->85.0%, branches 55.0%->56.5% (rsync's own md5/mdfour/wildmatch/etc. stay in the report). Add 'make coverage-all': run the suite under pipe + --protocol=30 + --protocol=29 + --use-tcp, accumulating into the shared .gcda (not cleared between runs), then one merged scoped report -- covers the daemon/TCP and protocol-compat paths a single pipe run misses (lines 67.6%, functions 87.6%, branches 58.6%). Also add 'make coverage-fallback' for a separate --disable-openat2 build (different .gcno, so it can't merge with the openat2 report). CI is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile.in | 47 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/Makefile.in b/Makefile.in index 9e9b9a82e..bde2c5897 100644 --- a/Makefile.in +++ b/Makefile.in @@ -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: @@ -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) @@ -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 \ @@ -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) From 6697cb20fc753be31c50441b3f4abb4078bba821 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 24 May 2026 13:24:03 +1000 Subject: [PATCH 2/4] testsuite: cover daemon access-control, config includes, --stop-at Target the lowest-coverage rsync files identified from a merged (pipe + proto29/30 + tcp) gcov report: daemon-access-ip hosts allow / hosts deny with exact-IP and CIDR patterns over --use-tcp, exercising access.c make_mask/match_address/ match_binary (19% -> 62% lines), plus client --address (socket.c try_bind_local). require_tcp. daemon-config the &include rsyncd.conf directive (params.c include_config/ parse_directives, 48% -> 60%) and a module with a missing path (clientserver.c path_failure). stop-time --stop-at future/past (options.c parse_time) and --stop-after (options.c 59% -> 64%). Merged scoped coverage: lines 67.3%->68.3%, functions 87.5%->88.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- testsuite/daemon-access-ip_test.py | 65 ++++++++++++++++++++++++++++++ testsuite/daemon-config_test.py | 52 ++++++++++++++++++++++++ testsuite/stop-time_test.py | 39 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 testsuite/daemon-access-ip_test.py create mode 100644 testsuite/daemon-config_test.py create mode 100644 testsuite/stop-time_test.py diff --git a/testsuite/daemon-access-ip_test.py b/testsuite/daemon-access-ip_test.py new file mode 100644 index 000000000..53fd10024 --- /dev/null +++ b/testsuite/daemon-access-ip_test.py @@ -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") + diff --git a/testsuite/daemon-config_test.py b/testsuite/daemon-config_test.py new file mode 100644 index 000000000..b680c73ce --- /dev/null +++ b/testsuite/daemon-config_test.py @@ -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") diff --git a/testsuite/stop-time_test.py b/testsuite/stop-time_test.py new file mode 100644 index 000000000..d2c84da54 --- /dev/null +++ b/testsuite/stop-time_test.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Coverage of --stop-at (options.c parse_time) and --stop-after. + +--stop-at parses an absolute y-m-dTh:m time (parse_time): a future time is +accepted and the transfer proceeds; a past time is rejected at parse. --stop-after +takes a minute count. These exercise the OPT_STOP_AT/OPT_STOP_AFTER option +handling that no other test reaches. +""" + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, test_fail, walk_files, +) + +src = FROMDIR +rmtree(src) +make_tree(src, depth=2) +rels = [p.relative_to(src) for p in walk_files(src)] + +# --- --stop-at far in the future: parses, transfer completes normally ------- +rmtree(TODIR) +run_rsync('-a', '--stop-at=2099-12-31T23:59', f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'--stop-at future {rel}') + +# --- --stop-at in the past: rejected at parse ------------------------------- +rmtree(TODIR) +proc = run_rsync('-a', '--stop-at=2000-01-01T00:00', f'{src}/', f'{TODIR}/', + check=False) +if proc.returncode == 0: + test_fail("--stop-at with a past time was not rejected") + +# --- --stop-after (minutes): parses, transfer completes --------------------- +rmtree(TODIR) +run_rsync('-a', '--stop-after=60', f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'--stop-after {rel}') + +print("stop-time: --stop-at future/past and --stop-after verified") From ccf67013b9d1f3c39b7547f09c9f9d586bb19224 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 24 May 2026 13:24:03 +1000 Subject: [PATCH 3/4] runtests: compare expected-skipped order-insensitively; register daemon-access-ip The --expect-skipped check compared the skip list as an ordered string, so the per-platform RSYNC_EXPECT_SKIPPED lists had to match runtests' collection order (sorted filenames) exactly -- a subtle, easy-to-break ordering dependency. Compare the skipped SET instead; which tests skipped is what matters. Register the new require_tcp test daemon-access-ip in the per-platform expected-skipped lists (it skips in the pipe-transport make check, like daemon-chroot-acl and proxy-response-line-too-long). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/almalinux-8-build.yml | 2 +- .github/workflows/cygwin-build.yml | 2 +- .github/workflows/macos-build.yml | 2 +- .github/workflows/ubuntu-22.04-build.yml | 6 +++--- .github/workflows/ubuntu-build.yml | 6 +++--- runtests.py | 11 +++++++++-- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index e269a3e7a..3eb96f9ac 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -62,7 +62,7 @@ jobs: # crtimes-not-supported skip matches the other Linux jobs; # daemon-chroot-acl and proxy-response-line-too-long skip because # the default (secure) transport opens no listening socket. - run: RSYNC_EXPECT_SKIPPED=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 diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 7433b8396..577a20985 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -44,7 +44,7 @@ jobs: # package installed above), verified on a real Cygwin host. The real # chown/devices tests still skip (need root/mknod), as do the # RESOLVE_BENEATH symlink-race tests. - run: bash -c 'RSYNC_EXPECT_SKIPPED=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. diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index e73de3ed7..a87c276e6 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -44,7 +44,7 @@ jobs: # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS # (rsyncfns.py drives xattrs via the `xattr` command), verified on a # real macOS host, so they're no longer in the skip set. - run: sudo RSYNC_EXPECT_SKIPPED=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. diff --git a/.github/workflows/ubuntu-22.04-build.yml b/.github/workflows/ubuntu-22.04-build.yml index 154b1b90a..3cf271e55 100644 --- a/.github/workflows/ubuntu-22.04-build.yml +++ b/.github/workflows/ubuntu-22.04-build.yml @@ -39,11 +39,11 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=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. diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 5fe6cca44..47312f0a8 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -35,11 +35,11 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=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 diff --git a/runtests.py b/runtests.py index 2259aee15..49d85571c 100755 --- a/runtests.py +++ b/runtests.py @@ -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) From 4f1fc8f482216506188194741c9e3341d5168b90 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 24 May 2026 13:36:32 +1000 Subject: [PATCH 4/4] testsuite: cover more path/file-operation code (syscall.c, util1.c, delete.c) Target previously-uncovered functions in the path/file-operation files the resolver restructure touches, confirmed hit under coverage: preallocate --preallocate (syscall.c do_fallocate) and sparse hole-punching via --preallocate --sparse and --inplace --sparse (do_punch_hole), on a file several levels deep. fuzzy-basis --fuzzy basis selection with similar-named candidates and no exact match, so the generator scores them (util1.c fuzzy_distance). delete-deep add a --backup --delete case so removing an extraneous backup-suffixed file consults delete.c is_backup_file. All three are plain local transfers (no skips), green on master and under --protocol=29/30. Co-Authored-By: Claude Opus 4.7 (1M context) --- testsuite/delete-deep_test.py | 17 +++++++++- testsuite/fuzzy-basis_test.py | 37 +++++++++++++++++++++ testsuite/preallocate_test.py | 62 +++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 testsuite/fuzzy-basis_test.py create mode 100644 testsuite/preallocate_test.py diff --git a/testsuite/delete-deep_test.py b/testsuite/delete-deep_test.py index 3525c2e3b..f42846721 100644 --- a/testsuite/delete-deep_test.py +++ b/testsuite/delete-deep_test.py @@ -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") diff --git a/testsuite/fuzzy-basis_test.py b/testsuite/fuzzy-basis_test.py new file mode 100644 index 000000000..77889439b --- /dev/null +++ b/testsuite/fuzzy-basis_test.py @@ -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") diff --git a/testsuite/preallocate_test.py b/testsuite/preallocate_test.py new file mode 100644 index 000000000..3abdae575 --- /dev/null +++ b/testsuite/preallocate_test.py @@ -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")