]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: rewrite the shell testsuite in Python
authorAndrew Tridgell <andrew@tridgell.net>
Thu, 21 May 2026 01:47:34 +0000 (11:47 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Fri, 22 May 2026 04:34:52 +0000 (14:34 +1000)
Replace the entire shell-based testsuite with Python. runtests.py
already drove the suite (it had replaced runtests.sh earlier); this
converts all 60 test scripts from *.test shell to *_test.py and adds
testsuite/rsyncfns.py as the shared helper module -- the Python
counterpart of the now-removed rsync.fns.

runtests.py:
  * Discovers and runs both *.test and *_test.py; dispatches the
    Python tests via the same python3 that runs the harness.
  * Extends PYTHONPATH so tests can `import rsyncfns`.

testsuite/rsyncfns.py provides everything the ports need:
  * environment wiring (scratchdir / srcdir / TOOLDIR / RSYNC /
    TLS_ARGS, and HOME pointed at the per-test scratch dir);
  * result reporting -- test_fail / test_skipped / test_xfail mapping
    to the 0 / 1 / 77 / 78 exit-code convention;
  * the transfer-and-verify helpers checkit, checkdiff, verify_dirs,
    rsync_ls_lR, check_perms and the v_filt output filter;
  * fixture builders hands_setup, build_symlinks, build_rsyncd_conf,
    make_data_file, cp_p / cp_touch, makepath / rmtree.

All 60 tests are converted, including the four split-variant tests
that share one source via a Makefile-built symlink (chown/chown-fake,
devices/devices-fake, xattrs/xattrs-hlink, exclude/exclude-lsh);
Makefile.in's CHECK_SYMLINKS now points at the *_test.py names.

The dead rsync.fns shell library is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 files changed:
Makefile.in
runtests.py
testsuite/00-hello.test [deleted file]
testsuite/00-hello_test.py [new file with mode: 0644]
testsuite/acls-default.test [deleted file]
testsuite/acls-default_test.py [new file with mode: 0644]
testsuite/acls.test [deleted file]
testsuite/acls_test.py [new file with mode: 0644]
testsuite/alt-dest-symlink-race.test [deleted file]
testsuite/alt-dest-symlink-race_test.py [new file with mode: 0644]
testsuite/alt-dest.test [deleted file]
testsuite/alt-dest_test.py [new file with mode: 0644]
testsuite/atimes.test [deleted file]
testsuite/atimes_test.py [new file with mode: 0644]
testsuite/backup.test [deleted file]
testsuite/backup_test.py [new file with mode: 0644]
testsuite/bare-do-open-symlink-race.test [deleted file]
testsuite/bare-do-open-symlink-race_test.py [new file with mode: 0644]
testsuite/batch-mode.test [deleted file]
testsuite/batch-mode_test.py [new file with mode: 0644]
testsuite/chdir-symlink-race.test [deleted file]
testsuite/chdir-symlink-race_test.py [new file with mode: 0644]
testsuite/chgrp.test [deleted file]
testsuite/chgrp_test.py [new file with mode: 0644]
testsuite/chmod-option.test [deleted file]
testsuite/chmod-option_test.py [new file with mode: 0644]
testsuite/chmod-symlink-race.test [deleted file]
testsuite/chmod-symlink-race_test.py [new file with mode: 0644]
testsuite/chmod-temp-dir.test [deleted file]
testsuite/chmod-temp-dir_test.py [new file with mode: 0644]
testsuite/chmod.test [deleted file]
testsuite/chmod_test.py [new file with mode: 0644]
testsuite/chown.test [deleted file]
testsuite/chown_test.py [new file with mode: 0644]
testsuite/clean-fname-underflow.test [deleted file]
testsuite/clean-fname-underflow_test.py [new file with mode: 0644]
testsuite/copy-dest-source-symlink.test [deleted file]
testsuite/copy-dest-source-symlink_test.py [new file with mode: 0644]
testsuite/crtimes.test [deleted file]
testsuite/crtimes_test.py [new file with mode: 0644]
testsuite/daemon-chroot-acl.test [deleted file]
testsuite/daemon-chroot-acl_test.py [new file with mode: 0644]
testsuite/daemon-gzip-download.test [deleted file]
testsuite/daemon-gzip-download_test.py [new file with mode: 0644]
testsuite/daemon-gzip-upload.test [deleted file]
testsuite/daemon-gzip-upload_test.py [new file with mode: 0644]
testsuite/daemon-refuse-compress.test [deleted file]
testsuite/daemon-refuse-compress_test.py [new file with mode: 0644]
testsuite/daemon.test [deleted file]
testsuite/daemon_test.py [new file with mode: 0644]
testsuite/delay-updates.test [deleted file]
testsuite/delay-updates_test.py [new file with mode: 0644]
testsuite/delete.test [deleted file]
testsuite/delete_test.py [new file with mode: 0644]
testsuite/devices.test [deleted file]
testsuite/devices_test.py [new file with mode: 0644]
testsuite/dir-sgid.test [deleted file]
testsuite/dir-sgid_test.py [new file with mode: 0644]
testsuite/duplicates.test [deleted file]
testsuite/duplicates_test.py [new file with mode: 0644]
testsuite/exclude-lsh.test [deleted symlink]
testsuite/exclude.test [deleted file]
testsuite/exclude_test.py [new file with mode: 0644]
testsuite/executability.test [deleted file]
testsuite/executability_test.py [new file with mode: 0644]
testsuite/files-from.test [deleted file]
testsuite/files-from_test.py [new file with mode: 0644]
testsuite/fuzzy.test [deleted file]
testsuite/fuzzy_test.py [new file with mode: 0644]
testsuite/hands.test [deleted file]
testsuite/hands_test.py [new file with mode: 0644]
testsuite/hardlinks.test [deleted file]
testsuite/hardlinks_test.py [new file with mode: 0644]
testsuite/itemize.test [deleted file]
testsuite/itemize_test.py [new file with mode: 0644]
testsuite/longdir.test [deleted file]
testsuite/longdir_test.py [new file with mode: 0644]
testsuite/merge.test [deleted file]
testsuite/merge_test.py [new file with mode: 0644]
testsuite/missing.test [deleted file]
testsuite/missing_test.py [new file with mode: 0644]
testsuite/mkpath.test [deleted file]
testsuite/mkpath_test.py [new file with mode: 0644]
testsuite/open-noatime.test [deleted file]
testsuite/open-noatime_test.py [new file with mode: 0644]
testsuite/protected-regular.test [deleted file]
testsuite/protected-regular_test.py [new file with mode: 0644]
testsuite/proxy-response-line-too-long.test [deleted file]
testsuite/proxy-response-line-too-long_test.py [new file with mode: 0644]
testsuite/relative.test [deleted file]
testsuite/relative_test.py [new file with mode: 0644]
testsuite/rsync.fns [deleted file]
testsuite/rsyncfns.py [new file with mode: 0644]
testsuite/safe-links.test [deleted file]
testsuite/safe-links_test.py [new file with mode: 0644]
testsuite/secure-relpath-validation.test [deleted file]
testsuite/secure-relpath-validation_test.py [new file with mode: 0644]
testsuite/sender-flist-symlink-leak.test [deleted file]
testsuite/sender-flist-symlink-leak_test.py [new file with mode: 0644]
testsuite/simd-checksum.test [deleted file]
testsuite/simd-checksum_test.py [new file with mode: 0644]
testsuite/ssh-basic.test [deleted file]
testsuite/ssh-basic_test.py [new file with mode: 0644]
testsuite/symlink-dirlink-basis.test [deleted file]
testsuite/symlink-dirlink-basis_test.py [new file with mode: 0644]
testsuite/symlink-ignore.test [deleted file]
testsuite/symlink-ignore_test.py [new file with mode: 0644]
testsuite/trimslash.test [deleted file]
testsuite/trimslash_test.py [new file with mode: 0644]
testsuite/unsafe-byname.test [deleted file]
testsuite/unsafe-byname_test.py [new file with mode: 0644]
testsuite/unsafe-links.test [deleted file]
testsuite/unsafe-links_test.py [new file with mode: 0644]
testsuite/wildmatch.test [deleted file]
testsuite/wildmatch_test.py [new file with mode: 0644]
testsuite/xattrs.test [deleted file]
testsuite/xattrs_test.py [new file with mode: 0644]

index 699d99562b07707d8f150eb19fe7f09d1c0f1e12..451ff5c6cc659f5f170b5339f468abea70c73528 100644 (file)
@@ -60,7 +60,8 @@ CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \
        testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \
        t_secure_relpath$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT)
 
-CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test
+CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \
+       testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py
 
 # Objects for CHECK_PROGS to clean
 CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_secure_relpath.o trimslash.o wildtest.o
@@ -343,14 +344,17 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS)
            touch $@; \
        fi
 
-testsuite/chown-fake.test:
-       ln -s chown.test $(srcdir)/testsuite/chown-fake.test
+testsuite/chown-fake_test.py:
+       ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py
 
-testsuite/devices-fake.test:
-       ln -s devices.test $(srcdir)/testsuite/devices-fake.test
+testsuite/devices-fake_test.py:
+       ln -s devices_test.py $(srcdir)/testsuite/devices-fake_test.py
 
-testsuite/xattrs-hlink.test:
-       ln -s xattrs.test $(srcdir)/testsuite/xattrs-hlink.test
+testsuite/xattrs-hlink_test.py:
+       ln -s xattrs_test.py $(srcdir)/testsuite/xattrs-hlink_test.py
+
+testsuite/exclude-lsh_test.py:
+       ln -s exclude_test.py $(srcdir)/testsuite/exclude-lsh_test.py
 
 # This does *not* depend on building or installing: you can use it to
 # check a version installed from a binary or some other source tree,
index 08f67bb527123a15af5cd51890cd184a364a1a16..feb05d627f5cfda166c8e152c90598f81ee33bbd 100755 (executable)
@@ -151,17 +151,47 @@ def prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef):
             os.symlink(os.path.join(tooldir, srcdir), src_link)
 
 
+# Python tests are identified by a positive "_test.py" suffix so that
+# helper modules (e.g. rsyncfns.py) sit in testsuite/ without being mistaken
+# for tests.
+_PY_TEST_SUFFIX = '_test.py'
+
+
+def _is_test_path(path):
+    base = os.path.basename(path)
+    return base.endswith('.test') or base.endswith(_PY_TEST_SUFFIX)
+
+
+def _testbase(path):
+    """Strip the test extension to get the canonical test name."""
+    base = os.path.basename(path)
+    if base.endswith('.test'):
+        return base[:-len('.test')]
+    if base.endswith(_PY_TEST_SUFFIX):
+        return base[:-len(_PY_TEST_SUFFIX)]
+    return base
+
+
 def collect_tests(suitedir, patterns):
-    """Collect test scripts matching the given patterns."""
+    """Collect test scripts (.test or _test.py) matching the given patterns."""
     if not patterns:
-        tests = sorted(glob.glob(os.path.join(suitedir, '*.test')))
+        candidates = (glob.glob(os.path.join(suitedir, '*.test'))
+                      + glob.glob(os.path.join(suitedir, '*' + _PY_TEST_SUFFIX)))
+        tests = sorted(p for p in candidates if _is_test_path(p))
     else:
+        seen = set()
         tests = []
         for pat in patterns:
-            if not pat.endswith('.test'):
-                pat = pat + '.test'
-            matches = sorted(glob.glob(os.path.join(suitedir, pat)))
-            tests.extend(matches)
+            # Accept either bare name ("mkpath"), explicit extension, or glob.
+            if pat.endswith('.test') or pat.endswith('.py'):
+                pats = [pat]
+            else:
+                pats = [pat + '.test', pat + _PY_TEST_SUFFIX]
+            for p in pats:
+                for m in sorted(glob.glob(os.path.join(suitedir, p))):
+                    if _is_test_path(m) and m not in seen:
+                        seen.add(m)
+                        tests.append(m)
     return tests
 
 
@@ -203,11 +233,18 @@ def run_one_test(testscript, testbase, scratchdir, base_env, timeout,
     env = base_env.copy()
     env['scratchdir'] = scratchdir
 
+    # Dispatch by extension: shell tests via /bin/sh -e, Python tests via
+    # the same python3 that's running this runner.
+    if testscript.endswith('.py'):
+        cmd = [sys.executable, testscript]
+    else:
+        cmd = ['sh', '-e', testscript]
+
     logfile = os.path.join(scratchdir, 'test.log')
     try:
         with open(logfile, 'w') as log:
             proc = subprocess.run(
-                ['sh', '-e', testscript],
+                cmd,
                 stdout=log, stderr=subprocess.STDOUT,
                 env=env, timeout=timeout,
                 cwd=env.get('TOOLDIR', '.')
@@ -336,6 +373,11 @@ def main():
     if os.path.isdir('/usr/xpg4/bin'):
         path = '/usr/xpg4/bin:' + path
 
+    # Make the testsuite/ directory importable so Python tests can `import rsyncfns`.
+    pythonpath = suitedir
+    if os.environ.get('PYTHONPATH'):
+        pythonpath = suitedir + os.pathsep + os.environ['PYTHONPATH']
+
     base_env = os.environ.copy()
     base_env.update({
         'PATH': path,
@@ -349,6 +391,7 @@ def main():
         'suitedir': suitedir,
         'TESTRUN_TIMEOUT': str(args.timeout),
         'HOME': scratchbase,
+        'PYTHONPATH': pythonpath,
     })
     for k, v in shconfig.items():
         if v:
@@ -365,7 +408,7 @@ def main():
     full_run = len(args.tests) == 0
 
     # Record test order for consistent skipped-list output
-    test_order = {os.path.basename(t).replace('.test', ''): i for i, t in enumerate(tests)}
+    test_order = {_testbase(t): i for i, t in enumerate(tests)}
 
     passed = 0
     failed = 0
@@ -402,7 +445,7 @@ def main():
         with concurrent.futures.ThreadPoolExecutor(max_workers=args.parallel) as executor:
             futures = {}
             for testscript in tests:
-                testbase = os.path.basename(testscript).replace('.test', '')
+                testbase = _testbase(testscript)
                 scratchdir = os.path.join(scratchbase, testbase)
                 timeout = 600 if 'hardlinks' in testbase else args.timeout
                 f = executor.submit(
@@ -423,7 +466,7 @@ def main():
     else:
         # Sequential execution
         for testscript in tests:
-            testbase = os.path.basename(testscript).replace('.test', '')
+            testbase = _testbase(testscript)
             scratchdir = os.path.join(scratchbase, testbase)
             timeout = 600 if 'hardlinks' in testbase else args.timeout
             tr = run_one_test(
diff --git a/testsuite/00-hello.test b/testsuite/00-hello.test
deleted file mode 100644 (file)
index ebd0683..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/bin/sh
-
-# Test some foundational things.
-
-. "$suitedir/rsync.fns"
-
-RSYNC_RSH="$scratchdir/src/support/lsh.sh"
-export RSYNC_RSH
-
-echo $0 running
-
-$RSYNC --version || test_fail '--version output failed'
-
-$RSYNC --info=help || test_fail '--info=help output failed'
-
-$RSYNC --debug=help || test_fail '--debug=help output failed'
-
-weird_name="A weird)name"
-
-mkdir "$fromdir"
-mkdir "$fromdir/$weird_name"
-
-append_line() {
-    echo "$1"
-    echo "$1" >>"$fromdir/$weird_name/file"
-}
-
-append_line test1
-checkit "$RSYNC -ai '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-copy_weird() {
-    checkit "$RSYNC $1 --rsync-path='$RSYNC' '$2$fromdir/$weird_name/' '$3$todir/$weird_name'" "$fromdir" "$todir"
-}
-
-append_line test2
-copy_weird '-ai' 'lh:' ''
-
-append_line test3
-copy_weird '-ai' '' 'lh:'
-
-append_line test4
-copy_weird '-ais' 'lh:' ''
-
-append_line test5
-copy_weird '-ais' '' 'lh:'
-
-echo test6
-
-touch "$fromdir/one" "$fromdir/two"
-(cd "$fromdir" && $RSYNC -ai --old-args --rsync-path="$RSYNC" lh:'one two' "$todir/")
-if [ ! -f "$todir/one" ] || [ ! -f "$todir/two" ]; then
-    test_fail "old-args copy of 'one two' failed"
-fi
-
-echo test7
-
-rm "$todir/one" "$todir/two"
-(cd "$fromdir" && RSYNC_OLD_ARGS=1 $RSYNC -ai --rsync-path="$RSYNC" lh:'one two' "$todir/")
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/00-hello_test.py b/testsuite/00-hello_test.py
new file mode 100644 (file)
index 0000000..22b2e0e
--- /dev/null
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/00-hello.test.
+#
+# Foundational smoke test: --version / --info=help / --debug=help all
+# work, plus a round-trip transfer of a directory whose name contains
+# shell-special characters via the lsh.sh remote-shell stand-in.
+
+import os
+
+from rsyncfns import (
+    FROMDIR, RSYNC, SRCDIR, TODIR,
+    checkit, run_rsync, test_fail,
+)
+
+
+# Set RSYNC_RSH so rsync picks up lsh.sh for the "lh:" hosts below.
+os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
+
+# Basic help dumps must not crash.
+if run_rsync('--version', check=False).returncode != 0:
+    test_fail('--version output failed')
+if run_rsync('--info=help', check=False).returncode != 0:
+    test_fail('--info=help output failed')
+if run_rsync('--debug=help', check=False).returncode != 0:
+    test_fail('--debug=help output failed')
+
+weird_name = "A weird)name"
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+weird_dir = FROMDIR / weird_name
+weird_dir.mkdir()
+
+
+def append_line(line: str) -> None:
+    print(line)
+    with open(weird_dir / 'file', 'a') as f:
+        f.write(line + '\n')
+
+
+def copy_weird(args: list, src_host: str, dst_host: str) -> None:
+    checkit(
+        [*args, f'--rsync-path={RSYNC}',
+         f'{src_host}{weird_dir}/',
+         f'{dst_host}{TODIR / weird_name}'],
+        FROMDIR, TODIR,
+    )
+
+
+append_line('test1')
+checkit(['-ai', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
+
+append_line('test2')
+copy_weird(['-ai'], 'lh:', '')
+
+append_line('test3')
+copy_weird(['-ai'], '', 'lh:')
+
+append_line('test4')
+copy_weird(['-ais'], 'lh:', '')
+
+append_line('test5')
+copy_weird(['-ais'], '', 'lh:')
+
+# test6: --old-args lets two whitespace-separated names go through as a
+# single "one two" remote argument to be re-split by the remote shell.
+print('test6')
+(FROMDIR / 'one').touch()
+(FROMDIR / 'two').touch()
+
+saved = os.getcwd()
+os.chdir(FROMDIR)
+try:
+    run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC}',
+              'lh:one two', f'{TODIR}/')
+finally:
+    os.chdir(saved)
+
+if not (TODIR / 'one').is_file() or not (TODIR / 'two').is_file():
+    test_fail("old-args copy of 'one two' failed")
+
+# test7: the RSYNC_OLD_ARGS=1 env var should be equivalent to --old-args.
+print('test7')
+(TODIR / 'one').unlink()
+(TODIR / 'two').unlink()
+
+env = os.environ.copy()
+env['RSYNC_OLD_ARGS'] = '1'
+import subprocess
+from rsyncfns import rsync_argv
+
+os.chdir(FROMDIR)
+try:
+    subprocess.run(
+        rsync_argv('-ai', f'--rsync-path={RSYNC}',
+                   'lh:one two', f'{TODIR}/'),
+        env=env, check=True,
+    )
+finally:
+    os.chdir(saved)
diff --git a/testsuite/acls-default.test b/testsuite/acls-default.test
deleted file mode 100644 (file)
index d8fba7f..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/bin/sh
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that rsync obeys default ACLs. -- Matt McCutchen
-
-. $suitedir/rsync.fns
-
-$RSYNC -VV | grep '"ACLs": true' >/dev/null || test_skipped "Rsync is configured without ACL support"
-
-case "$setfacl_nodef" in
-true) test_skipped "I don't know how to use your setfacl command" ;;
-*-k*) opts='-dm u::7,g::5,o:5' ;;
-*) opts='-m d:u::7,d:g::5,d:o:5' ;;
-esac
-setfacl $opts "$scratchdir" || test_skipped "Your filesystem has ACLs disabled"
-
-# Call as: testit <dirname> <default-acl> <file-expected> <program-expected>
-testit() {
-    todir="$scratchdir/$1"
-    mkdir "$todir"
-    $setfacl_nodef "$todir"
-    if [ -n "$2" ]; then
-       case "$setfacl_nodef" in
-       *-k*) opts="-dm $2" ;;
-       *) opts="-m `echo $2 | sed 's/\([ugom]:\)/d:\1/g'`"
-       esac
-       setfacl $opts "$todir"
-    fi
-    # Make sure we obey ACLs when creating a directory to hold multiple transferred files,
-    # even though the directory itself is outside the transfer
-    $RSYNC -rvv "$scratchdir/dir" "$scratchdir/file" "$scratchdir/program" "$todir/to/"
-    check_perms "$todir/to" $4 "Target $1"
-    check_perms "$todir/to/dir" $4 "Target $1"
-    check_perms "$todir/to/file" $3 "Target $1"
-    check_perms "$todir/to/program" $4 "Target $1"
-    # Make sure get_local_name doesn't mess us up when transferring only one file
-    $RSYNC -rvv "$scratchdir/file" "$todir/to/anotherfile"
-    check_perms "$todir/to/anotherfile" $3 "Target $1"
-    # Make sure we obey default ACLs when not transferring a regular file
-    $RSYNC -rvv "$scratchdir/dir/" "$todir/to/anotherdir/"
-    check_perms "$todir/to/anotherdir" $4 "Target $1"
-}
-
-mkdir "$scratchdir/dir"
-echo "File!" >"$scratchdir/file"
-echo "#!/bin/sh" >"$scratchdir/program"
-chmod 777 "$scratchdir/dir"
-chmod 666 "$scratchdir/file"
-chmod 777 "$scratchdir/program"
-
-# Test some target directories
-umask 0077
-testit da777 u::7,g::7,o:7 rw-rw-rw- rwxrwxrwx
-testit da775 u::7,g::7,o:5 rw-rw-r-- rwxrwxr-x
-testit da750 u::7,g::5,o:0 rw-r----- rwxr-x---
-testit da750mask u::7,u:0:7,g::7,m:5,o:0 rw-r----- rwxr-x---
-testit noda1 '' rw------- rwx------
-umask 0000
-testit noda2 '' rw-rw-rw- rwxrwxrwx
-umask 0022
-testit noda3 '' rw-r--r-- rwxr-xr-x
-
-# Hooray
-exit 0
diff --git a/testsuite/acls-default_test.py b/testsuite/acls-default_test.py
new file mode 100644 (file)
index 0000000..66c8cef
--- /dev/null
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/acls-default.test.
+#
+# Test that rsync obeys POSIX default ACLs on the destination's parent
+# directory when creating the transfer's container directory, even
+# though that parent is outside the transfer itself.
+
+import os
+import re
+import shlex
+import subprocess
+
+from rsyncfns import (
+    SCRATCHDIR,
+    check_perms, run_rsync, test_skipped,
+)
+
+
+vv = run_rsync('-VV', check=True, capture_output=True)
+if '"ACLs": true' not in vv.stdout:
+    test_skipped("Rsync is configured without ACL support")
+
+setfacl_nodef = os.environ.get('setfacl_nodef', 'true')
+if setfacl_nodef == 'true':
+    test_skipped("I don't know how to use your setfacl command")
+
+if '-k' in setfacl_nodef.split():
+    seed_opts = ['-dm', 'u::7,g::5,o:5']
+else:
+    seed_opts = ['-m', 'd:u::7,d:g::5,d:o:5']
+
+# Seed the scratch dir with a default ACL so the upcoming testit() runs
+# inherit a known-base; if setfacl rejects this the FS doesn't have ACLs.
+proc = subprocess.run(['setfacl', *seed_opts, str(SCRATCHDIR)])
+if proc.returncode != 0:
+    test_skipped("Your filesystem has ACLs disabled")
+
+
+def testit(dirname, default_acl, file_expected, prog_expected):
+    """Set a default ACL on a destination parent dir, then verify that
+    a transfer into a fresh subdir picks up the inherited perms."""
+    todir = SCRATCHDIR / dirname
+    todir.mkdir()
+    # Clear any inherited default ACL first.
+    subprocess.run(shlex.split(setfacl_nodef) + [str(todir)])
+    if default_acl:
+        if '-k' in setfacl_nodef.split():
+            opts = ['-dm', default_acl]
+        else:
+            # Each "u:/g:/o:/m:" prefix becomes "d:u:/d:g:/d:o:/d:m:".
+            translated = re.sub(r'([ugom]:)', r'd:\1', default_acl)
+            opts = ['-m', translated]
+        subprocess.run(['setfacl', *opts, str(todir)], check=True)
+
+    run_rsync('-rvv',
+              str(SCRATCHDIR / 'dir'),
+              str(SCRATCHDIR / 'file'),
+              str(SCRATCHDIR / 'program'),
+              f'{todir}/to/')
+
+    check_perms(todir / 'to', prog_expected)
+    check_perms(todir / 'to' / 'dir', prog_expected)
+    check_perms(todir / 'to' / 'file', file_expected)
+    check_perms(todir / 'to' / 'program', prog_expected)
+
+    # get_local_name shouldn't mess up a single-file transfer.
+    run_rsync('-rvv',
+              str(SCRATCHDIR / 'file'),
+              f'{todir}/to/anotherfile')
+    check_perms(todir / 'to' / 'anotherfile', file_expected)
+
+    # And the no-regular-file case (sole-dir transfer).
+    run_rsync('-rvv',
+              f'{SCRATCHDIR / "dir"}/',
+              f'{todir}/to/anotherdir/')
+    check_perms(todir / 'to' / 'anotherdir', prog_expected)
+
+
+(SCRATCHDIR / 'dir').mkdir()
+(SCRATCHDIR / 'file').write_text("File!\n")
+(SCRATCHDIR / 'program').write_text("#!/bin/sh\n")
+os.chmod(SCRATCHDIR / 'dir', 0o777)
+os.chmod(SCRATCHDIR / 'file', 0o666)
+os.chmod(SCRATCHDIR / 'program', 0o777)
+
+os.umask(0o077)
+testit('da777', 'u::7,g::7,o:7', 'rw-rw-rw-', 'rwxrwxrwx')
+testit('da775', 'u::7,g::7,o:5', 'rw-rw-r--', 'rwxrwxr-x')
+testit('da750', 'u::7,g::5,o:0', 'rw-r-----', 'rwxr-x---')
+testit('da750mask', 'u::7,u:0:7,g::7,m:5,o:0', 'rw-r-----', 'rwxr-x---')
+testit('noda1', '', 'rw-------', 'rwx------')
+os.umask(0o000)
+testit('noda2', '', 'rw-rw-rw-', 'rwxrwxrwx')
+os.umask(0o022)
+testit('noda3', '', 'rw-r--r--', 'rwxr-xr-x')
diff --git a/testsuite/acls.test b/testsuite/acls.test
deleted file mode 100644 (file)
index 693da66..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-#!/bin/sh
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that rsync handles basic ACL preservation.
-
-. $suitedir/rsync.fns
-
-$RSYNC -VV | grep '"ACLs": true' >/dev/null || test_skipped "Rsync is configured without ACL support"
-
-makepath "$fromdir/foo"
-echo something >"$fromdir/file1"
-echo else >"$fromdir/file2"
-
-files='foo file1 file2'
-
-case "$setfacl_nodef" in
-true)
-    if ! chmod --help 2>&1 | grep -F +a >/dev/null; then
-        test_skipped "I don't know how to use setfacl or chmod for ACLs"
-    fi
-    chmod +a "root allow read,write,execute" "$fromdir/foo" || test_skipped "Your filesystem has ACLs disabled"
-    chmod +a "root allow read,execute" "$fromdir/file1"
-    chmod +a "admin allow read" "$fromdir/file1"
-    chmod +a "daemon allow read,write" "$fromdir/file1"
-    chmod +a "root allow read,execute" "$fromdir/file2"
-
-    see_acls() {
-        ls -le "${@}"
-    }
-    ;;
-*)
-    setfacl -m u:0:7 "$fromdir/foo" || test_skipped "Your filesystem has ACLs disabled"
-    setfacl -m g:1:5 "$fromdir/foo"
-    setfacl -m g:2:1 "$fromdir/foo"
-    setfacl -m g:0:7 "$fromdir/foo"
-    setfacl -m u:2:1 "$fromdir/foo"
-    setfacl -m u:1:5 "$fromdir/foo"
-
-    setfacl -m u:0:5 "$fromdir/file1"
-    setfacl -m g:0:4 "$fromdir/file1"
-    setfacl -m u:1:6 "$fromdir/file1"
-
-    setfacl -m u:0:5 "$fromdir/file2"
-
-    see_acls() {
-        getfacl "${@}"
-    }
-    ;;
-esac
-
-cd "$fromdir"
-$RSYNC -avvA $files "$todir/"
-
-see_acls $files >"$scratchdir/acls.txt"
-
-cd "$todir"
-see_acls $files | diff $diffopt "$scratchdir/acls.txt" -
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/acls_test.py b/testsuite/acls_test.py
new file mode 100644 (file)
index 0000000..c24d7b0
--- /dev/null
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/acls.test.
+#
+# Test that rsync -A preserves POSIX ACLs across a transfer. Skips on
+# binaries built without ACL support, on filesystems with ACLs disabled,
+# and on hosts that lack both setfacl(1) and a chmod that understands "+a".
+
+import os
+import platform
+import subprocess
+
+from rsyncfns import FROMDIR, SCRATCHDIR, TODIR, makepath, run_rsync, test_fail, test_skipped
+
+
+vv = run_rsync('-VV', check=True, capture_output=True)
+if '"ACLs": true' not in vv.stdout:
+    test_skipped("Rsync is configured without ACL support")
+
+makepath(FROMDIR / 'foo')
+(FROMDIR / 'file1').write_text("something\n")
+(FROMDIR / 'file2').write_text("else\n")
+
+files = ['foo', 'file1', 'file2']
+
+# Decide which ACL command surface to use. Mirrors the shell test's
+# branching on $setfacl_nodef (set by runtests.py).
+setfacl_nodef = os.environ.get('setfacl_nodef', 'true')
+
+
+def _chmod_plus_a_supported() -> bool:
+    """macOS-style: chmod +a 'user allow ...'."""
+    out = subprocess.run(['chmod', '--help'], capture_output=True, text=True)
+    return '+a' in (out.stdout + out.stderr)
+
+
+use_chmod_plus_a = setfacl_nodef == 'true' and _chmod_plus_a_supported()
+
+if setfacl_nodef == 'true' and not use_chmod_plus_a:
+    test_skipped("I don't know how to use setfacl or chmod for ACLs")
+
+
+def _setfacl(*args) -> int:
+    return subprocess.run(['setfacl', *args]).returncode
+
+
+def _chmod_acl(*args) -> int:
+    return subprocess.run(['chmod', *args]).returncode
+
+
+if use_chmod_plus_a:
+    if _chmod_acl('+a', 'root allow read,write,execute',
+                  str(FROMDIR / 'foo')) != 0:
+        test_skipped("Your filesystem has ACLs disabled")
+    _chmod_acl('+a', 'root allow read,execute', str(FROMDIR / 'file1'))
+    _chmod_acl('+a', 'admin allow read', str(FROMDIR / 'file1'))
+    _chmod_acl('+a', 'daemon allow read,write', str(FROMDIR / 'file1'))
+    _chmod_acl('+a', 'root allow read,execute', str(FROMDIR / 'file2'))
+
+    def see_acls(paths):
+        return subprocess.check_output(['ls', '-le', *paths], text=True)
+else:
+    if _setfacl('-m', 'u:0:7', str(FROMDIR / 'foo')) != 0:
+        test_skipped("Your filesystem has ACLs disabled")
+    _setfacl('-m', 'g:1:5', str(FROMDIR / 'foo'))
+    _setfacl('-m', 'g:2:1', str(FROMDIR / 'foo'))
+    _setfacl('-m', 'g:0:7', str(FROMDIR / 'foo'))
+    _setfacl('-m', 'u:2:1', str(FROMDIR / 'foo'))
+    _setfacl('-m', 'u:1:5', str(FROMDIR / 'foo'))
+
+    _setfacl('-m', 'u:0:5', str(FROMDIR / 'file1'))
+    _setfacl('-m', 'g:0:4', str(FROMDIR / 'file1'))
+    _setfacl('-m', 'u:1:6', str(FROMDIR / 'file1'))
+
+    _setfacl('-m', 'u:0:5', str(FROMDIR / 'file2'))
+
+    def see_acls(paths):
+        return subprocess.check_output(['getfacl', *paths], text=True)
+
+
+os.chdir(FROMDIR)
+run_rsync('-avvA', *files, f'{TODIR}/')
+
+before = see_acls(files)
+(SCRATCHDIR / 'acls.txt').write_text(before)
+
+os.chdir(TODIR)
+after = see_acls(files)
+if before != after:
+    print("--- expected (from) ---")
+    print(before)
+    print("--- got (to) ---")
+    print(after)
+    test_fail("ACL listing differs between source and destination")
diff --git a/testsuite/alt-dest-symlink-race.test b/testsuite/alt-dest-symlink-race.test
deleted file mode 100755 (executable)
index fd36c6e..0000000
+++ /dev/null
@@ -1,113 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for the basedir-confinement gap in
-# secure_relative_open(). The function opens basedir with a plain
-# openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY), without
-# RESOLVE_BENEATH or a per-component O_NOFOLLOW walk, so a parent
-# symlink ON basedir is followed unrestrictedly. RESOLVE_BENEATH is
-# then applied only to relpath, anchored at the wrong directory.
-#
-# The receiver's basis-file lookup at receiver.c passes
-# basis_dir[fnamecmp_type] (from --copy-dest / --link-dest /
-# --compare-dest -- all sender-controllable in daemon mode) as
-# basedir. A daemon-module attacker with write access can plant a
-# symlink at module/cd -> /outside, then run --link-dest=cd to
-# make the daemon's basis-file lookup resolve into /outside,
-# leaking the contents of daemon-readable files via the rsync
-# delta-rolling read-disclosure primitive.
-#
-# We detect the escape by leveraging --link-dest: when basis
-# matches source exactly (content + mtime + mode), --link-dest
-# hard-links the destination to the basis file. With the bug, the
-# destination ends up as a hard link to the outside-the-module
-# file (same inode). With the fix, no basis is found and the
-# destination is a fresh copy (different inode).
-#
-# The vulnerable code path is the same on every platform
-# (including the per-component fallback on systems without
-# RESOLVE_BENEATH), so this test is not platform-gated.
-
-. "$suitedir/rsync.fns"
-
-mod="$scratchdir/module"
-outside="$scratchdir/outside"
-src="$scratchdir/src"
-conf="$scratchdir/test-rsyncd.conf"
-
-rm -rf "$mod" "$outside" "$src"
-mkdir -p "$mod" "$outside" "$src"
-
-# Portable inode-number helper (GNU coreutils stat -c, BSD stat -f).
-file_inode() {
-    stat -c %i "$1" 2>/dev/null || stat -f %i "$1"
-}
-
-# Outside-the-module file an attacker would like the daemon to
-# treat as a basis.
-echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
-chmod 0644 "$outside/target.txt"
-
-# The symlink trap planted in the module by the local attacker.
-ln -s "$outside" "$mod/cd"
-
-# Source file matches outside/target.txt exactly (content + mtime
-# + mode) so --link-dest will hard-link the destination to the
-# basis file iff the daemon's basedir lookup reaches outside/.
-echo "OUTSIDE_SECRET_DATA" > "$src/target.txt"
-touch -r "$outside/target.txt" "$src/target.txt"
-chmod 0644 "$src/target.txt"
-
-# When running as root the daemon would drop to "nobody" by
-# default, which can't write into the test scratch dir. Force the
-# daemon to keep our uid/gid in that case so the basis-link
-# transfer can actually create the destination file. (Non-root
-# can't specify uid/gid in rsyncd.conf -- comment them out then.)
-my_uid=`get_testuid`
-root_uid=`get_rootuid`
-root_gid=`get_rootgid`
-uid_setting="uid = $root_uid"
-gid_setting="gid = $root_gid"
-if test x"$my_uid" != x"$root_uid"; then
-    uid_setting="#$uid_setting"
-    gid_setting="#$gid_setting"
-fi
-
-cat > "$conf" <<EOF
-use chroot = no
-$uid_setting
-$gid_setting
-log file = $scratchdir/rsyncd.log
-[upload]
-    path = $mod
-    use chroot = no
-    read only = no
-EOF
-
-# Recursive --link-dest push directly into the module root. We
-# avoid pushing into a destination subdir because the receiver
-# would chdir into it before resolving --link-dest, making the
-# relative basedir "cd" resolve in the wrong CWD and masking the
-# bug. The realistic attack pushes into the module root (or the
-# attacker uses a basedir path that resolves correctly from
-# whichever subdir the receiver chdirs into).
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-    $RSYNC -rtp --link-dest=cd "$src/" rsync://localhost/upload/ \
-    >/dev/null 2>&1 || true
-
-if [ ! -f "$mod/target.txt" ]; then
-    test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour"
-fi
-
-outside_inode=$(file_inode "$outside/target.txt")
-dst_inode=$(file_inode "$mod/target.txt")
-
-if [ "$outside_inode" = "$dst_inode" ]; then
-    test_fail "basedir-escape: --link-dest hard-linked module/target.txt to outside/target.txt (inode $outside_inode); daemon's basis-file lookup followed the parent symlink on the basedir"
-fi
-
-exit 0
diff --git a/testsuite/alt-dest-symlink-race_test.py b/testsuite/alt-dest-symlink-race_test.py
new file mode 100644 (file)
index 0000000..9045fa6
--- /dev/null
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/alt-dest-symlink-race.test.
+#
+# Regression test for the basedir-confinement gap in
+# secure_relative_open(): a parent symlink ON basedir is followed
+# unrestrictedly, then RESOLVE_BENEATH is applied only to relpath,
+# anchored at the wrong directory.  In daemon mode this lets a local
+# attacker who can write into a module plant module/cd -> /outside and
+# then use --link-dest=cd / --copy-dest=cd / --compare-dest=cd to make
+# the receiver's basis-file lookup resolve into /outside, leaking
+# daemon-readable content via the rsync delta-rolling read-disclosure
+# primitive.
+#
+# Detection: with --link-dest, when basis matches source exactly the
+# destination is hard-linked to the basis. On a successful escape the
+# destination shares an inode with /outside/target.txt; on a fix it
+# doesn't.
+
+import os
+import subprocess
+
+from rsyncfns import (
+    RSYNC, SCRATCHDIR,
+    rsync_argv, get_testuid, get_rootuid, get_rootgid,
+    rmtree, test_fail,
+)
+
+
+mod = SCRATCHDIR / 'module'
+outside = SCRATCHDIR / 'outside'
+src_dir = SCRATCHDIR / 'src_files'
+conf = SCRATCHDIR / 'test-rsyncd.conf'
+
+for d in (mod, outside, src_dir):
+    rmtree(d)
+    d.mkdir(parents=True)
+
+# The outside file an attacker wants the daemon to treat as a basis.
+(outside / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
+os.chmod(outside / 'target.txt', 0o644)
+
+# Attacker-planted module symlink.
+os.symlink(str(outside), mod / 'cd')
+
+# Source: same content + mtime + mode as outside, so --link-dest hard-
+# links the destination to the basis iff basedir lookup escapes.
+(src_dir / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
+ref = (outside / 'target.txt').stat()
+os.utime(src_dir / 'target.txt', (ref.st_atime, ref.st_mtime))
+os.chmod(src_dir / 'target.txt', 0o644)
+
+my_uid = get_testuid()
+root_uid = get_rootuid()
+root_gid = get_rootgid()
+uid_line = f"uid = {root_uid}"
+gid_line = f"gid = {root_gid}"
+if my_uid != root_uid:
+    uid_line = '#' + uid_line
+    gid_line = '#' + gid_line
+
+conf.write_text(f"""\
+use chroot = no
+{uid_line}
+{gid_line}
+log file = {SCRATCHDIR}/rsyncd.log
+[upload]
+    path = {mod}
+    use chroot = no
+    read only = no
+""")
+
+env = os.environ.copy()
+env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+# Push directly into the module root: pushing into a destination subdir
+# would make the receiver chdir into it before resolving --link-dest,
+# making "cd" resolve in the wrong CWD and masking the bug.
+subprocess.run(
+    rsync_argv('-rtp', '--link-dest=cd',
+               f'{src_dir}/', 'rsync://localhost/upload/'),
+    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+    env=env,
+)
+
+target = mod / 'target.txt'
+if not target.is_file():
+    test_fail(
+        "destination file was not created -- daemon transfer failed "
+        "before the test could observe the basedir behaviour"
+    )
+
+if target.stat().st_ino == (outside / 'target.txt').stat().st_ino:
+    test_fail(
+        f"basedir-escape: --link-dest hard-linked module/target.txt to "
+        f"outside/target.txt (inode {target.stat().st_ino}); daemon's "
+        "basis-file lookup followed the parent symlink on the basedir"
+    )
diff --git a/testsuite/alt-dest.test b/testsuite/alt-dest.test
deleted file mode 100644 (file)
index d2fb5a1..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2004-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync handling of --compare-dest and similar options.
-
-. "$suitedir/rsync.fns"
-
-alt1dir="$tmpdir/alt1"
-alt2dir="$tmpdir/alt2"
-alt3dir="$tmpdir/alt3"
-
-SSH="$scratchdir/src/support/lsh.sh"
-
-# Build some files/dirs/links to copy
-
-hands_setup
-
-# Setup the alt and chk dirs
-$RSYNC -av --include=text --include='*/' --exclude='*' "$fromdir/" "$alt1dir/"
-$RSYNC -av --include=etc-ltr-list --include='*/' --exclude='*' "$fromdir/" "$alt2dir/"
-
-# Create a side dir where there is a candidate destfile of the same name as a sourcefile
-echo "This is a test file" >"$fromdir/likely"
-
-mkdir "$alt3dir"
-echo "This is a test file" >"$alt3dir/likely"
-
-sleep 1
-touch "$fromdir/dir/text" "$fromdir/likely"
-
-$RSYNC -av --exclude=/text --exclude=etc-ltr-list "$fromdir/" "$chkdir/"
-
-# Let's do it!
-checkit "$RSYNC -avv --no-whole-file \
-    --compare-dest='$alt1dir' --compare-dest='$alt2dir' \
-    '$fromdir/' '$todir/'" "$chkdir" "$todir"
-
-rm -rf "$todir"
-checkit "$RSYNC -avv --no-whole-file \
-    --copy-dest='$alt1dir' --copy-dest='$alt2dir' \
-    '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-# Test that copy_file() works correctly with tmpfiles
-for maybe_inplace in '' --inplace; do
-    rm -rf "$todir"
-    checkit "$RSYNC -av $maybe_inplace --copy-dest='$alt3dir' \
-       '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-    for srchost in '' 'localhost:'; do
-       if [ -z "$srchost" ]; then
-           desthost='localhost:'
-       else
-           desthost=''
-       fi
-
-       rm -rf "$todir"
-       checkit "$RSYNC -ave '$SSH' --rsync-path='$RSYNC' $maybe_inplace \
-           --copy-dest='$alt3dir' '$srchost$fromdir/' '$desthost$todir/'" \
-           "$fromdir" "$todir"
-    done
-done
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/alt-dest_test.py b/testsuite/alt-dest_test.py
new file mode 100644 (file)
index 0000000..7530bd5
--- /dev/null
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/alt-dest.test.
+#
+# Exercise rsync's --compare-dest, --copy-dest and --link-dest
+# alternative-destination options, both locally and across the lsh.sh
+# remote-shell stand-in. Also covers the tmpfile path in copy_file() by
+# pointing --copy-dest at a directory holding a same-name candidate.
+
+import os
+import shutil
+import time
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
+    checkit, hands_setup, rmtree, run_rsync,
+)
+
+
+alt1dir = TMPDIR / 'alt1'
+alt2dir = TMPDIR / 'alt2'
+alt3dir = TMPDIR / 'alt3'
+
+SSH = str(SRCDIR / 'support' / 'lsh.sh')
+
+hands_setup()
+
+# Seed alt1 and alt2 with disjoint single-file subtrees of fromdir.
+run_rsync('-av', '--include=text', '--include=*/', '--exclude=*',
+          f'{FROMDIR}/', f'{alt1dir}/')
+run_rsync('-av', '--include=etc-ltr-list', '--include=*/', '--exclude=*',
+          f'{FROMDIR}/', f'{alt2dir}/')
+
+# Create a side dir with one identically-named candidate so copy_file()'s
+# tmpfile path gets exercised.
+(FROMDIR / 'likely').write_text("This is a test file\n")
+alt3dir.mkdir()
+(alt3dir / 'likely').write_text("This is a test file\n")
+
+time.sleep(1)
+os.utime(FROMDIR / 'dir' / 'text')
+os.utime(FROMDIR / 'likely')
+
+# chkdir: what a vanilla copy would produce, minus /text and etc-ltr-list.
+run_rsync('-av', '--exclude=/text', '--exclude=etc-ltr-list',
+          f'{FROMDIR}/', f'{CHKDIR}/')
+
+# Stacked --compare-dest: dest grows just the deltas alt1+alt2 don't have.
+checkit(['-avv', '--no-whole-file',
+         f'--compare-dest={alt1dir}', f'--compare-dest={alt2dir}',
+         f'{FROMDIR}/', f'{TODIR}/'], CHKDIR, TODIR)
+
+rmtree(TODIR)
+# Stacked --copy-dest: dest gets full copy because content can be hardlinked
+# from the alt dirs where available.
+checkit(['-avv', '--no-whole-file',
+         f'--copy-dest={alt1dir}', f'--copy-dest={alt2dir}',
+         f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
+
+# Test that copy_file() works correctly with tmpfiles. Combine each of
+# {direct, --inplace} with each of {local, remote-source, remote-dest}.
+for maybe_inplace in ([], ['--inplace']):
+    rmtree(TODIR)
+    checkit(['-av', *maybe_inplace, f'--copy-dest={alt3dir}',
+             f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
+
+    for srchost in ('', 'localhost:'):
+        desthost = 'localhost:' if not srchost else ''
+        rmtree(TODIR)
+        checkit(['-ave', SSH, f'--rsync-path={RSYNC}', *maybe_inplace,
+                 f'--copy-dest={alt3dir}',
+                 f'{srchost}{FROMDIR}/', f'{desthost}{TODIR}/'],
+                FROMDIR, TODIR)
diff --git a/testsuite/atimes.test b/testsuite/atimes.test
deleted file mode 100644 (file)
index 4d46eb0..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/sh
-
-# Test rsync copying atimes
-
-. "$suitedir/rsync.fns"
-
-$RSYNC -VV | grep '"atimes": true' >/dev/null || test_skipped "Rsync is configured without atimes support"
-
-mkdir "$fromdir"
-
-touch "$fromdir/foo"
-touch -a -t 200102031717.42 "$fromdir/foo"
-
-TLS_ARGS=--atimes
-
-checkit "$RSYNC -rtUgvvv \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/atimes_test.py b/testsuite/atimes_test.py
new file mode 100644 (file)
index 0000000..bb8d326
--- /dev/null
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/atimes.test.
+#
+# Test that rsync preserves source atimes when the binary was built with
+# atimes support. We pin the source file's atime to a known historical
+# value then sync with -U; the listing (with --atimes in tls) must match
+# between source and destination.
+
+import os
+
+import rsyncfns
+from rsyncfns import FROMDIR, TODIR, checkit, run_rsync, test_skipped
+
+
+vv = run_rsync('-VV', check=True, capture_output=True)
+if '"atimes": true' not in vv.stdout:
+    test_skipped("Rsync is configured without atimes support")
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+foo = FROMDIR / 'foo'
+foo.touch()
+
+# `touch -a -t 200102031717.42` -> set atime to 2001-02-03 17:17:42, mtime unchanged.
+import datetime
+atime = datetime.datetime(2001, 2, 3, 17, 17, 42).timestamp()
+mtime = foo.stat().st_mtime
+os.utime(foo, (atime, mtime))
+
+# Make the listing include atimes so checkit's tls compare picks up the
+# transferred atime.
+rsyncfns.TLS_ARGS = '--atimes'
+
+checkit(['-rtUgvvv', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
diff --git a/testsuite/backup.test b/testsuite/backup.test
deleted file mode 100644 (file)
index 4de3867..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2004-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that the --backup option works right.
-
-. "$suitedir/rsync.fns"
-
-bakdir="$tmpdir/bak"
-
-makepath "$fromdir/deep" "$bakdir/dname"
-name1="$fromdir/deep/name1"
-name2="$fromdir/deep/name2"
-
-cat "$srcdir"/[gr]*.[ch] > "$name1"
-cat "$srcdir"/[et]*.[ch] > "$name2"
-
-checkit "$RSYNC -ai --info=backup '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-checkit "$RSYNC -ai --info=backup '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
-cat "$srcdir"/[fgpr]*.[ch] > "$name1"
-cat "$srcdir"/[etw]*.[ch] > "$name2"
-
-checktee "$RSYNC -ai --info=backup --no-whole-file --backup '$fromdir/' '$todir/'"
-for fn in deep/name1 deep/name2; do
-    grep "backed up $fn to $fn~" "$outfile" >/dev/null || test_fail "no backup message output for $fn"
-    diff $diffopt "$fromdir/$fn" "$todir/$fn" || test_fail "copy of $fn failed"
-    diff $diffopt "$chkdir/$fn" "$todir/$fn~" || test_fail "backup of $fn to $fn~ failed"
-    mv "$todir/$fn~" "$todir/$fn"
-done
-
-echo deleted-file >"$todir/dname"
-cp_touch "$todir/dname" "$chkdir"
-
-checkit "$RSYNC -ai --info=backup --no-whole-file --delete-delay \
-    --backup --backup-dir='$bakdir' '$fromdir/' '$todir/'" "$fromdir" "$todir" \
-    | tee "$outfile"
-
-for fn in deep/name1 deep/name2; do
-    grep "backed up $fn to .*/$fn$" "$outfile" >/dev/null || test_fail "no backup message output for $fn"
-done
-diff -r $diffopt "$chkdir" "$bakdir" || test_fail "backup dir contents are bogus"
-rm "$bakdir/dname"
-
-checkit "$RSYNC -ai --info=backup --del '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
-cat "$srcdir"/[efgr]*.[ch] > "$name1"
-cat "$srcdir"/[ew]*.[ch] > "$name2"
-
-checkit "$RSYNC -ai --info=backup --inplace --no-whole-file --backup --backup-dir='$bakdir' '$fromdir/' '$todir/'" "$fromdir" "$todir" \
-    | tee "$outfile"
-
-for fn in deep/name1 deep/name2; do
-    grep "backed up $fn to .*/$fn$" "$outfile" >/dev/null || test_fail "no backup message output for $fn"
-done
-diff -r $diffopt "$chkdir" "$bakdir" || test_fail "backup dir contents are bogus"
-
-checkit "$RSYNC -ai --info=backup --inplace --no-whole-file '$fromdir/' '$bakdir/'" "$fromdir" "$bakdir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/backup_test.py b/testsuite/backup_test.py
new file mode 100644 (file)
index 0000000..eb2e3c5
--- /dev/null
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/backup.test.
+#
+# Walk through --backup behaviour:
+#   * a plain backup leaves the old file at name~ alongside the new one,
+#   * --backup-dir relocates the old file into a parallel tree and also
+#     captures deletions when used with --delete-delay,
+#   * --backup --inplace --backup-dir handles delta-overwrites too.
+# Each phase also confirms the destination ends up matching the source via
+# the usual checkit listing+content diff.
+
+import os
+import shutil
+import subprocess
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, OUTFILE, SRCDIR, TMPDIR, TODIR,
+    checkit, cp_touch, makepath, rsync_argv, test_fail, verify_dirs,
+)
+
+
+bakdir = TMPDIR / 'bak'
+
+makepath(FROMDIR / 'deep', bakdir / 'dname')
+name1 = FROMDIR / 'deep' / 'name1'
+name2 = FROMDIR / 'deep' / 'name2'
+
+
+def _cat_glob(pattern: str, dest):
+    """Concatenate every srcdir file matching `pattern` into `dest`.
+
+    Mirrors `cat "$srcdir"/[abc]*.[ch] > "$dest"`.
+    """
+    chunks = bytearray()
+    for f in sorted(SRCDIR.glob(pattern)):
+        chunks.extend(f.read_bytes())
+    dest.write_bytes(bytes(chunks))
+
+
+_cat_glob('[gr]*.[ch]', name1)
+_cat_glob('[et]*.[ch]', name2)
+
+# Establish baseline destination and chk copies of the source.
+checkit(['-ai', '--info=backup', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
+checkit(['-ai', '--info=backup', f'{FROMDIR}/', f'{CHKDIR}/'], FROMDIR, CHKDIR)
+
+# Mutate the source files; delta-transfer will need to back up the old
+# contents at $todir/$fn~ before overwriting in place.
+_cat_glob('[fgpr]*.[ch]', name1)
+_cat_glob('[etw]*.[ch]', name2)
+
+
+def _run_and_capture(args, outfile):
+    proc = subprocess.run(rsync_argv(*args), capture_output=True, text=True)
+    outfile.write_text(proc.stdout)
+    print(proc.stdout, end='')
+    if proc.returncode != 0:
+        test_fail(f"rsync exited {proc.returncode}")
+    return proc
+
+
+_run_and_capture(
+    ['-ai', '--info=backup', '--no-whole-file', '--backup',
+     f'{FROMDIR}/', f'{TODIR}/'],
+    OUTFILE,
+)
+text = OUTFILE.read_text()
+for fn in ('deep/name1', 'deep/name2'):
+    if f"backed up {fn} to {fn}~" not in text:
+        test_fail(f"no backup message output for {fn}")
+    diff = subprocess.run(['diff', '-u', str(FROMDIR / fn), str(TODIR / fn)])
+    if diff.returncode != 0:
+        test_fail(f"copy of {fn} failed")
+    diff = subprocess.run(['diff', '-u', str(CHKDIR / fn), str(TODIR / f'{fn}~')])
+    if diff.returncode != 0:
+        test_fail(f"backup of {fn} to {fn}~ failed")
+    shutil.move(str(TODIR / f'{fn}~'), str(TODIR / fn))
+
+
+# --backup-dir + --delete-delay: a deletion at the dest gets routed into
+# the backup dir rather than being lost.
+(TODIR / 'dname').write_text("deleted-file\n")
+cp_touch(TODIR / 'dname', CHKDIR)
+
+_run_and_capture(
+    ['-ai', '--info=backup', '--no-whole-file', '--delete-delay',
+     '--backup', f'--backup-dir={bakdir}',
+     f'{FROMDIR}/', f'{TODIR}/'],
+    OUTFILE,
+)
+# After the run, FROMDIR and TODIR should match (the backup ran into
+# bakdir, not into chkdir, so chkdir must NOT be touched -- it still
+# holds the pre-rsync contents that we'll compare against bakdir below).
+verify_dirs(FROMDIR, TODIR, label="post --backup-dir run")
+
+text = OUTFILE.read_text()
+import re
+for fn in ('deep/name1', 'deep/name2'):
+    if not re.search(rf"backed up {re.escape(fn)} to .*/{re.escape(fn)}$",
+                     text, flags=re.MULTILINE):
+        test_fail(f"no backup message output for {fn}")
+diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR), str(bakdir)])
+if diff.returncode != 0:
+    test_fail("backup dir contents are bogus")
+(bakdir / 'dname').unlink()
+
+
+# Re-establish chk and mutate source again for the --inplace pass.
+checkit(['-ai', '--info=backup', '--del', f'{FROMDIR}/', f'{CHKDIR}/'],
+        FROMDIR, CHKDIR)
+_cat_glob('[efgr]*.[ch]', name1)
+_cat_glob('[ew]*.[ch]', name2)
+
+_run_and_capture(
+    ['-ai', '--info=backup', '--inplace', '--no-whole-file',
+     '--backup', f'--backup-dir={bakdir}',
+     f'{FROMDIR}/', f'{TODIR}/'],
+    OUTFILE,
+)
+verify_dirs(FROMDIR, TODIR, label="post --inplace --backup-dir run")
+
+text = OUTFILE.read_text()
+for fn in ('deep/name1', 'deep/name2'):
+    if not re.search(rf"backed up {re.escape(fn)} to .*/{re.escape(fn)}$",
+                     text, flags=re.MULTILINE):
+        test_fail(f"no backup message output for {fn}")
+diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR), str(bakdir)])
+if diff.returncode != 0:
+    test_fail("backup dir contents are bogus")
+
+# Final clean inplace sync to the bakdir so it ends up matching fromdir.
+checkit(['-ai', '--info=backup', '--inplace', '--no-whole-file',
+         f'{FROMDIR}/', f'{bakdir}/'], FROMDIR, bakdir)
diff --git a/testsuite/bare-do-open-symlink-race.test b/testsuite/bare-do-open-symlink-race.test
deleted file mode 100755 (executable)
index e295223..0000000
+++ /dev/null
@@ -1,206 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for codex audit Findings 3b and 3c:
-#
-#   3b: generator.c:1905 -- the in-place backup creation opens
-#       backupptr via bare do_open(O_WRONLY|O_CREAT|O_TRUNC|O_EXCL).
-#       With --backup-dir set to an attacker-planted parent symlink,
-#       the backup file is written outside the module under the
-#       daemon's authority.
-#
-#   3c-symlink: syscall.c:207 -- do_symlink_at falls through to bare
-#       do_symlink for am_root < 0 (fake-super), which then opens
-#       the destination path with bare open() (final-component
-#       fake-super file). A parent symlink on the destination path
-#       redirects the file creation outside the module.
-#
-#   3c-mknod: syscall.c:506 -- do_mknod_at falls through to bare
-#       do_mknod for am_root < 0, same path-based open(). For
-#       FIFOs/sockets/devices the bare path is also used.
-#
-# Each scenario plants a "secret" file outside the module at a
-# location the symlink trap points to. The check is that the
-# outside file's content and mode are unchanged after the attack
-# attempt.
-
-. "$suitedir/rsync.fns"
-
-# All three scenarios depend on receiver-side daemon code paths
-# that are only secured on platforms with a working
-# secure_relative_open. The chdir/chmod tests already skip the
-# same set; mirror that.
-case "$(uname -s)" in
-    SunOS|OpenBSD|NetBSD|CYGWIN*)
-        test_skipped "secure_relative_open relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
-        ;;
-esac
-
-mod="$scratchdir/module"
-outside="$scratchdir/outside"
-src="$scratchdir/src"
-conf="$scratchdir/test-rsyncd.conf"
-
-# Portable inode-and-mode helpers.
-file_mode() {
-    stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"
-}
-
-setup() {
-    rm -rf "$mod" "$outside" "$src"
-    mkdir -p "$mod" "$outside" "$src"
-
-    echo "OUTSIDE_PROTECTED_DATA" > "$outside/target.txt"
-    chmod 0644 "$outside/target.txt"
-    outside_pristine="$scratchdir/outside-pristine.txt"
-    cp -p "$outside/target.txt" "$outside_pristine"
-
-    ln -s "$outside" "$mod/cd"
-}
-
-verify_outside_unchanged() {
-    label="$1"
-    mode=$(file_mode "$outside/target.txt")
-    case "$mode" in
-        644|0644) ;;
-        *) test_fail "$label: outside/target.txt mode changed from 644 to $mode" ;;
-    esac
-    if ! cmp -s "$outside/target.txt" "$outside_pristine"; then
-        test_fail "$label: outside/target.txt content changed -- daemon followed the cd symlink"
-    fi
-}
-
-verify_outside_unchanged_or_absent() {
-    label="$1"
-    target="$2"  # specific file under outside/ to check absence of
-    if [ -e "$outside/$target" ]; then
-        test_fail "$label: outside/$target was created -- daemon followed the cd symlink"
-    fi
-}
-
-# When running as root the daemon would drop to "nobody" by default
-# and fail to write into the test scratch dir. Force it to keep our
-# uid/gid in that case so the receiver actually runs the code paths
-# we want to test.
-my_uid=`get_testuid`
-root_uid=`get_rootuid`
-root_gid=`get_rootgid`
-uid_setting="uid = $root_uid"
-gid_setting="gid = $root_gid"
-if test x"$my_uid" != x"$root_uid"; then
-    uid_setting="#$uid_setting"
-    gid_setting="#$gid_setting"
-fi
-
-
-############################################################
-# Scenario 3b: --inplace --backup --backup-dir=cd
-#
-# Pre-create module/target.txt so the receiver enters the in-place
-# update path; a backup of the existing content must be made
-# before the update. With --backup-dir=cd, backupptr resolves to
-# "cd/target.txt"; with the bug, robust_unlink and the bare
-# do_open at generator.c:1905 both follow the cd symlink, the
-# unlink deletes outside/target.txt and the create writes the
-# pre-existing module/target.txt content there.
-############################################################
-
-setup
-echo "EXISTING_MODULE_DATA" > "$mod/target.txt"
-chmod 0666 "$mod/target.txt"
-echo "NEW_DATA_FROM_SENDER" > "$src/target.txt"
-chmod 0644 "$src/target.txt"
-
-cat > "$conf" <<EOF
-use chroot = no
-$uid_setting
-$gid_setting
-log file = $scratchdir/rsyncd.log
-[upload]
-    path = $mod
-    use chroot = no
-    read only = no
-EOF
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-    $RSYNC --inplace --backup --backup-dir=cd "$src/target.txt" \
-    rsync://localhost/upload/target.txt >/dev/null 2>&1 || true
-
-verify_outside_unchanged "3b inplace+backup-dir=cd"
-
-
-############################################################
-# Scenario 3c-symlink: fake-super symlink push to a path with a
-# symlinked parent
-#
-# With "fake super = yes" set on the module, the receiver
-# represents symlinks as fake-super files (regular files with the
-# link target written to them). The path-based open() in
-# do_symlink's fake-super branch follows parent symlinks. We push
-# a single symlink to the destination path "cd/sym" so the
-# receiver's create-file call lands at "cd/sym" relative to the
-# module root, where cd is the symlink trap.
-############################################################
-
-setup
-
-mkdir -p "$src/cd"
-ln -s /etc/passwd "$src/cd/sym"
-
-cat > "$conf" <<EOF
-use chroot = no
-$uid_setting
-$gid_setting
-log file = $scratchdir/rsyncd.log
-[upload_fake]
-    path = $mod
-    use chroot = no
-    read only = no
-    fake super = yes
-EOF
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-    $RSYNC -rl "$src/" rsync://localhost/upload_fake/ >/dev/null 2>&1 || true
-
-verify_outside_unchanged_or_absent "3c-symlink fake-super symlink push" "sym"
-
-
-############################################################
-# Scenario 3c-mknod: fake-super FIFO push to a path with a
-# symlinked parent
-#
-# Similar to 3c-symlink but for special files. mkfifo works
-# without root; we push a FIFO and verify the receiver doesn't
-# create a fake-super file at outside/fifo.
-############################################################
-
-setup
-
-mkdir -p "$src/cd"
-mkfifo "$src/cd/fifo" 2>/dev/null
-if [ ! -p "$src/cd/fifo" ]; then
-    test_skipped "mkfifo unavailable; cannot exercise 3c-mknod"
-fi
-
-cat > "$conf" <<EOF
-use chroot = no
-$uid_setting
-$gid_setting
-log file = $scratchdir/rsyncd.log
-[upload_fake]
-    path = $mod
-    use chroot = no
-    read only = no
-    fake super = yes
-EOF
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-    $RSYNC -rD "$src/" rsync://localhost/upload_fake/ >/dev/null 2>&1 || true
-
-verify_outside_unchanged_or_absent "3c-mknod fake-super FIFO push" "fifo"
-
-exit 0
diff --git a/testsuite/bare-do-open-symlink-race_test.py b/testsuite/bare-do-open-symlink-race_test.py
new file mode 100644 (file)
index 0000000..d232204
--- /dev/null
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/bare-do-open-symlink-race.test.
+#
+# Codex audit Findings 3b, 3c-symlink and 3c-mknod: bare do_open /
+# do_symlink / do_mknod paths on the receiver follow parent symlinks
+# unrestrictedly.  Three scenarios are exercised; each must leave the
+# outside-the-module sentinel unchanged.
+
+import filecmp
+import os
+import platform
+import shutil
+import stat
+import subprocess
+
+from rsyncfns import (
+    RSYNC, SCRATCHDIR,
+    get_rootgid, get_rootuid, get_testuid,
+    rmtree, rsync_argv, test_fail, test_skipped,
+)
+
+
+if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
+    test_skipped(
+        f"secure_relative_open relies on RESOLVE_BENEATH-equivalent kernel "
+        f"support not available on {platform.system()}"
+    )
+
+mod = SCRATCHDIR / 'module'
+outside = SCRATCHDIR / 'outside'
+src = SCRATCHDIR / 'src_files'
+conf = SCRATCHDIR / 'test-rsyncd.conf'
+outside_pristine = SCRATCHDIR / 'outside-pristine.txt'
+
+
+def setup():
+    for d in (mod, outside, src):
+        rmtree(d)
+        d.mkdir(parents=True)
+    (outside / 'target.txt').write_text("OUTSIDE_PROTECTED_DATA\n")
+    os.chmod(outside / 'target.txt', 0o644)
+    shutil.copy2(outside / 'target.txt', outside_pristine)
+    os.symlink(str(outside), mod / 'cd')
+
+
+def verify_outside_unchanged(label: str) -> None:
+    mode = (outside / 'target.txt').stat().st_mode & 0o777
+    if mode != 0o644:
+        test_fail(f"{label}: outside/target.txt mode changed from 644 to {oct(mode)[2:]}")
+    if not filecmp.cmp(outside / 'target.txt', outside_pristine, shallow=False):
+        test_fail(f"{label}: outside/target.txt content changed -- daemon followed the cd symlink")
+
+
+def verify_outside_unchanged_or_absent(label: str, target: str) -> None:
+    if (outside / target).exists() or (outside / target).is_symlink():
+        test_fail(f"{label}: outside/{target} was created -- daemon followed the cd symlink")
+
+
+my_uid = get_testuid()
+root_uid = get_rootuid()
+root_gid = get_rootgid()
+uid_line = f"uid = {root_uid}"
+gid_line = f"gid = {root_gid}"
+if my_uid != root_uid:
+    uid_line = '#' + uid_line
+    gid_line = '#' + gid_line
+
+
+def write_conf(module_name: str, fake_super: bool = False) -> None:
+    extra = "    fake super = yes\n" if fake_super else ""
+    conf.write_text(f"""\
+use chroot = no
+{uid_line}
+{gid_line}
+log file = {SCRATCHDIR}/rsyncd.log
+[{module_name}]
+    path = {mod}
+    use chroot = no
+    read only = no
+{extra}""")
+
+
+def run_attack(args):
+    env = os.environ.copy()
+    env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+    subprocess.run(
+        rsync_argv(*args),
+        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+        env=env,
+    )
+
+
+# Scenario 3b: --inplace --backup --backup-dir=cd
+setup()
+(mod / 'target.txt').write_text("EXISTING_MODULE_DATA\n")
+os.chmod(mod / 'target.txt', 0o666)
+(src / 'target.txt').write_text("NEW_DATA_FROM_SENDER\n")
+os.chmod(src / 'target.txt', 0o644)
+
+write_conf('upload')
+run_attack([
+    '--inplace', '--backup', '--backup-dir=cd',
+    f'{src}/target.txt', 'rsync://localhost/upload/target.txt',
+])
+verify_outside_unchanged("3b inplace+backup-dir=cd")
+
+
+# Scenario 3c-symlink: fake-super symlink push, parent-symlinked path
+setup()
+(src / 'cd').mkdir()
+os.symlink('/etc/passwd', src / 'cd' / 'sym')
+
+write_conf('upload_fake', fake_super=True)
+run_attack(['-rl', f'{src}/', 'rsync://localhost/upload_fake/'])
+verify_outside_unchanged_or_absent("3c-symlink fake-super symlink push", "sym")
+
+
+# Scenario 3c-mknod: fake-super FIFO push, parent-symlinked path
+setup()
+(src / 'cd').mkdir()
+try:
+    os.mkfifo(src / 'cd' / 'fifo')
+except OSError:
+    test_skipped("mkfifo unavailable; cannot exercise 3c-mknod")
+
+if not stat.S_ISFIFO((src / 'cd' / 'fifo').stat().st_mode):
+    test_skipped("mkfifo unavailable; cannot exercise 3c-mknod")
+
+write_conf('upload_fake', fake_super=True)
+run_attack(['-rD', f'{src}/', 'rsync://localhost/upload_fake/'])
+verify_outside_unchanged_or_absent("3c-mknod fake-super FIFO push", "fifo")
diff --git a/testsuite/batch-mode.test b/testsuite/batch-mode.test
deleted file mode 100644 (file)
index cf4e94d..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2004 by Chris Shoemaker <c.shoemaker@cox.net>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync's --write-batch and --read-batch options
-
-. "$suitedir/rsync.fns"
-
-hands_setup
-
-cd "$tmpdir"
-
-# Build chkdir for the daemon tests using a normal rsync and an --exclude.
-$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
-
-$RSYNC -av --only-write-batch=BATCH --exclude=foobar.baz "$fromdir/" "$todir/missing/"
-test -d "$todir/missing" && test_fail "--only-write-batch should not have created destination dir"
-
-runtest "--read-batch (only)" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$chkdir" "$todir"'
-
-rm -rf "$todir" BATCH*
-runtest "local --write-batch" 'checkit "$RSYNC -av --write-batch=BATCH \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
-
-rm -rf "$todir"
-runtest "--read-batch" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$fromdir" "$todir"'
-
-build_rsyncd_conf
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
-export RSYNC_CONNECT_PROG
-
-rm -rf "$todir"
-runtest "daemon sender --write-batch" 'checkit "$RSYNC -av --write-batch=BATCH rsync://localhost/test-from/ \"$todir\"" "$chkdir" "$todir"'
-
-rm -rf "$todir"
-runtest "--read-batch from daemon" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$chkdir" "$todir"'
-
-rm -rf "$todir"
-runtest "BATCH.sh use of --read-batch" 'checkit "./BATCH.sh" "$chkdir" "$todir"'
-
-runtest "do-nothing re-run of batch" 'checkit "./BATCH.sh" "$chkdir" "$todir"'
-
-rm -rf "$todir"
-mkdir "$todir" || test_fail "failed to restore empty destination directory"
-runtest "daemon recv --write-batch" 'checkit "\"$ignore23\" $RSYNC -av --write-batch=BATCH \"$fromdir/\" rsync://localhost/test-to" "$chkdir" "$todir"'
-
-# The script would have aborted on error, so getting here means we pass.
-exit 0
diff --git a/testsuite/batch-mode_test.py b/testsuite/batch-mode_test.py
new file mode 100644 (file)
index 0000000..7cd9e79
--- /dev/null
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/batch-mode.test.
+#
+# Test rsync's --write-batch / --read-batch / --only-write-batch flags,
+# both for local transfers and for daemon source/destination.
+
+import os
+import shutil
+import subprocess
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TMPDIR, TODIR,
+    build_rsyncd_conf, checkit, hands_setup, rmtree,
+    run_rsync, test_fail,
+)
+
+
+conf = build_rsyncd_conf()
+hands_setup()
+
+os.chdir(TMPDIR)
+
+# chkdir mirrors a normal transfer minus the daemon's foobar.baz exclude.
+run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
+
+# --only-write-batch must NOT create the destination directory.
+run_rsync('-av', '--only-write-batch=BATCH', '--exclude=foobar.baz',
+          f'{FROMDIR}/', f'{TODIR}/missing/')
+if (TODIR / 'missing').is_dir():
+    test_fail("--only-write-batch should not have created destination dir")
+
+print("Test --read-batch (only):")
+checkit(['-av', '--read-batch=BATCH', str(TODIR)], CHKDIR, TODIR)
+
+# Wipe any leftover BATCH* files so the next pass starts clean.
+rmtree(TODIR)
+for batch in TMPDIR.glob('BATCH*'):
+    batch.unlink()
+
+print("Test local --write-batch:")
+checkit(['-av', '--write-batch=BATCH', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
+
+rmtree(TODIR)
+print("Test --read-batch:")
+checkit(['-av', '--read-batch=BATCH', str(TODIR)], FROMDIR, TODIR)
+
+# Daemon variants. RSYNC_CONNECT_PROG plumbs an in-process daemon.
+os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+rmtree(TODIR)
+print("Test daemon sender --write-batch:")
+checkit(['-av', '--write-batch=BATCH', 'rsync://localhost/test-from/', str(TODIR)],
+        CHKDIR, TODIR, allowed_codes=(0, 23))
+
+rmtree(TODIR)
+print("Test --read-batch from daemon:")
+checkit(['-av', '--read-batch=BATCH', str(TODIR)], CHKDIR, TODIR)
+
+rmtree(TODIR)
+print("Test BATCH.sh use of --read-batch:")
+# BATCH.sh is the auto-generated wrapper script that re-applies the
+# batch -- we run it directly, not via the rsync binary, then verify.
+from rsyncfns import verify_dirs
+proc = subprocess.run(['sh', './BATCH.sh'])
+if proc.returncode != 0:
+    test_fail(f"BATCH.sh exited {proc.returncode}")
+verify_dirs(CHKDIR, TODIR, label="BATCH.sh use of --read-batch")
+
+print("Test do-nothing re-run of batch:")
+proc = subprocess.run(['sh', './BATCH.sh'])
+if proc.returncode != 0:
+    test_fail(f"BATCH.sh (re-run) exited {proc.returncode}")
+verify_dirs(CHKDIR, TODIR, label="do-nothing re-run of batch")
+
+rmtree(TODIR)
+TODIR.mkdir()
+print("Test daemon recv --write-batch:")
+# ignore23 swallows the partial-transfer code 23 that daemon mode sometimes
+# emits even on success.
+ignore23 = SCRATCHDIR / 'ignore23'
+# We pass ignore23 by inserting it ahead of the rsync invocation. checkit
+# calls subprocess.run(rsync_argv(...)) directly, so do the run manually
+# and call verify_dirs for the comparison.
+from rsyncfns import rsync_argv
+proc = subprocess.run(
+    [str(ignore23), *rsync_argv('-av', '--write-batch=BATCH',
+                                 f'{FROMDIR}/', 'rsync://localhost/test-to')],
+)
+if proc.returncode != 0:
+    test_fail(f"daemon recv --write-batch exited {proc.returncode}")
+verify_dirs(CHKDIR, TODIR, label="daemon recv --write-batch")
diff --git a/testsuite/chdir-symlink-race.test b/testsuite/chdir-symlink-race.test
deleted file mode 100755 (executable)
index c464101..0000000
+++ /dev/null
@@ -1,137 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for the symlink-TOCTOU class of bug at the receiver's
-# chdir(). After the CVE-2026-29518 fix to secure_relative_open(), an
-# attack remained where the receiver's chdir() into a destination
-# subdirectory followed an attacker-planted symlink, escaping the
-# module. Every subsequent path-relative syscall (open, chmod, lchown,
-# utimes, etc.) inherited the escape -- secure_relative_open's
-# RESOLVE_BENEATH anchor itself was outside the module by then, so it
-# stopped protecting against anything.
-#
-# This test runs an actual rsync daemon (via RSYNC_CONNECT_PROG to
-# avoid the network) configured with "use chroot = no", plants a
-# symlink at module/subdir -> ../outside, and runs four flavours of
-# rsync transfer that previously all reached files in ../outside:
-#
-#   1. single-file dest = subdir/target.txt    (the original poc_chmod)
-#   2. -r src/subdir/ to upload/subdir/        (the chdir-escape case)
-#   3. -r src/subdir/ to upload/subdir/        (no --size-only: forces basis read+write)
-#   4. -r src/ to upload/                      (was already protected by the
-#                                               original CVE-2026-29518 fix;
-#                                               regression-checked here)
-#
-# All four must leave the outside-the-module sentinel file's mode AND
-# content unchanged.
-
-. "$suitedir/rsync.fns"
-
-case "$(uname -s)" in
-    SunOS|OpenBSD|NetBSD|CYGWIN*)
-       test_skipped "secure chdir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
-       ;;
-esac
-
-mod="$scratchdir/module"
-outside="$scratchdir/outside"
-src="$scratchdir/src"
-conf="$scratchdir/test-rsyncd.conf"
-
-rm -rf "$mod" "$outside" "$src"
-mkdir -p "$mod" "$outside" "$src" "$src/subdir"
-
-# Portable octal-mode helper -- macOS and FreeBSD's stat use -f, GNU
-# coreutils stat uses -c.
-file_mode() {
-    stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"
-}
-
-# The "secret" file outside the module the attacker is trying to alter.
-# Save a pristine copy alongside it so we can compare with cmp(1) rather
-# than depending on sha1sum/shasum/sha1, which differ across platforms.
-echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
-chmod 0600 "$outside/target.txt"
-outside_pristine="$scratchdir/outside-pristine.txt"
-cp -p "$outside/target.txt" "$outside_pristine"
-
-# Symlink trap planted in the module by the local attacker.
-ln -s "$outside" "$mod/subdir"
-
-# Source files the sender will push: same size as the outside target,
-# different content, mode 0666 (the perms the attacker tries to push).
-SIZE=$(stat -c %s "$outside/target.txt" 2>/dev/null \
-       || stat -f %z "$outside/target.txt")
-make_data_file "$src/target.txt" "$SIZE" \
-    || test_fail "failed to create source file"
-make_data_file "$src/subdir/target.txt" "$SIZE" \
-    || test_fail "failed to create source file"
-chmod 0666 "$src/target.txt" "$src/subdir/target.txt"
-
-cat > "$conf" <<EOF
-use chroot = no
-log file = $scratchdir/rsyncd.log
-[upload]
-    path = $mod
-    use chroot = no
-    read only = no
-EOF
-
-reset_outside() {
-    chmod 0600 "$outside/target.txt"
-    echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
-}
-
-verify_unchanged() {
-    label="$1"
-    mode=$(file_mode "$outside/target.txt")
-    case "$mode" in
-       600|0600) ;;
-       *) test_fail "$label: outside file mode changed from 600 to $mode (chmod escape)" ;;
-    esac
-    if ! cmp -s "$outside/target.txt" "$outside_pristine"; then
-       test_fail "$label: outside file content changed (write escape)"
-    fi
-}
-
-run_attack() {
-    label="$1"; shift
-    reset_outside
-    RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-       $RSYNC "$@" >/dev/null 2>&1 || true
-    verify_unchanged "$label"
-}
-
-# 1. The original poc_chmod scenario: single file, dest path with
-#    the symlinked subdir as a path component. With --size-only the
-#    receiver normally skips the basis open and goes straight to chmod
-#    -- only the chdir-escape blocks the chmod from reaching outside.
-run_attack "single-file --size-only" \
-    -tp --size-only \
-    "$src/target.txt" rsync://localhost/upload/subdir/target.txt
-
-# 2. -r push into the symlinked subdir: receiver chdir's into "subdir",
-#    follows the symlink, ends up in outside.
-run_attack "-r --size-only into subdir/" \
-    -rtp --size-only \
-    "$src/subdir/" rsync://localhost/upload/subdir/
-
-# 3. Same but no --size-only -- forces the basis-file open and a real
-#    rename, so this exercises the read-disclosure and write-escape
-#    paths together.
-run_attack "-r without --size-only into subdir/" \
-    -rtp \
-    "$src/subdir/" rsync://localhost/upload/subdir/
-
-# 4. -r src/ to upload/ -- this case was already covered by the
-#    original CVE-2026-29518 fix because the receiver stays at module
-#    root and operates on slashed paths. Regression check.
-run_attack "-r --size-only into upload/ root" \
-    -rtp --size-only \
-    "$src/" rsync://localhost/upload/
-
-exit 0
diff --git a/testsuite/chdir-symlink-race_test.py b/testsuite/chdir-symlink-race_test.py
new file mode 100644 (file)
index 0000000..f2495f7
--- /dev/null
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/chdir-symlink-race.test.
+#
+# Regression test for the symlink-TOCTOU bug at the receiver's chdir().
+# Post-CVE-2026-29518 an attack remained where the receiver's chdir()
+# into a destination subdirectory followed an attacker-planted symlink,
+# escaping the module. Each of four transfer flavours must leave the
+# outside-the-module sentinel's mode AND content unchanged.
+
+import filecmp
+import os
+import platform
+import subprocess
+
+from rsyncfns import (
+    RSYNC, SCRATCHDIR,
+    get_rootgid, get_rootuid, get_testuid,
+    make_data_file, rmtree, rsync_argv, test_fail, test_skipped,
+)
+
+
+if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
+    test_skipped(
+        f"secure chdir relies on RESOLVE_BENEATH-equivalent kernel "
+        f"support not available on {platform.system()}"
+    )
+
+mod = SCRATCHDIR / 'module'
+outside = SCRATCHDIR / 'outside'
+src = SCRATCHDIR / 'src_files'
+conf = SCRATCHDIR / 'test-rsyncd.conf'
+
+for d in (mod, outside, src):
+    rmtree(d)
+    d.mkdir(parents=True)
+(src / 'subdir').mkdir()
+
+# Secret sentinel; keep a pristine copy alongside for cmp(1)-style compares.
+(outside / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
+os.chmod(outside / 'target.txt', 0o600)
+outside_pristine = SCRATCHDIR / 'outside-pristine.txt'
+import shutil
+shutil.copy2(outside / 'target.txt', outside_pristine)
+
+# Symlink trap planted by the local attacker.
+os.symlink(str(outside), mod / 'subdir')
+
+# Source files: same size as outside target, different content, mode 0666.
+sz = (outside / 'target.txt').stat().st_size
+make_data_file(src / 'target.txt', sz)
+make_data_file(src / 'subdir' / 'target.txt', sz)
+os.chmod(src / 'target.txt', 0o666)
+os.chmod(src / 'subdir' / 'target.txt', 0o666)
+
+conf.write_text(f"""\
+use chroot = no
+log file = {SCRATCHDIR}/rsyncd.log
+[upload]
+    path = {mod}
+    use chroot = no
+    read only = no
+""")
+
+
+def reset_outside() -> None:
+    os.chmod(outside / 'target.txt', 0o600)
+    (outside / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
+    os.chmod(outside / 'target.txt', 0o600)
+
+
+def verify_unchanged(label: str) -> None:
+    mode = (outside / 'target.txt').stat().st_mode & 0o777
+    if mode != 0o600:
+        test_fail(
+            f"{label}: outside file mode changed from 600 to {oct(mode)[2:]} "
+            "(chmod escape)"
+        )
+    if not filecmp.cmp(outside / 'target.txt', outside_pristine, shallow=False):
+        test_fail(f"{label}: outside file content changed (write escape)")
+
+
+def run_attack(label: str, *args) -> None:
+    reset_outside()
+    env = os.environ.copy()
+    env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+    subprocess.run(
+        rsync_argv(*args),
+        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+        env=env,
+    )
+    verify_unchanged(label)
+
+
+# 1. Single file with --size-only -- receiver normally skips basis open and
+# goes straight to chmod; only the chdir-escape blocks it.
+run_attack("single-file --size-only",
+           '-tp', '--size-only',
+           f'{src}/target.txt',
+           'rsync://localhost/upload/subdir/target.txt')
+
+# 2. -r push INTO the symlinked subdir -- receiver chdir's into "subdir",
+# follows the symlink, ends up in outside.
+run_attack("-r --size-only into subdir/",
+           '-rtp', '--size-only',
+           f'{src}/subdir/',
+           'rsync://localhost/upload/subdir/')
+
+# 3. Same but with delta+rename (read-disclosure + write-escape together).
+run_attack("-r without --size-only into subdir/",
+           '-rtp',
+           f'{src}/subdir/',
+           'rsync://localhost/upload/subdir/')
+
+# 4. -r into the module root -- already covered by the original CVE fix;
+# regression-check.
+run_attack("-r --size-only into upload/ root",
+           '-rtp', '--size-only',
+           f'{src}/',
+           'rsync://localhost/upload/')
diff --git a/testsuite/chgrp.test b/testsuite/chgrp.test
deleted file mode 100644 (file)
index 467d402..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that rsync with -gr will preserve groups when the user running
-# the test is a member of them.  Hopefully they're in at least one
-# test.
-
-. "$suitedir/rsync.fns"
-
-# Build some hardlinks
-
-mygrps="`rsync_getgroups`" || test_fail "Can't get groups"
-mkdir "$fromdir"
-
-for g in $mygrps; do
-    name="$fromdir/foo-$g"
-    date > "$name"
-    chgrp "$g" "$name" || test_fail "Can't chgrp"
-done
-sleep 2
-
-checkit "$RSYNC -rtgpvvv '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/chgrp_test.py b/testsuite/chgrp_test.py
new file mode 100644 (file)
index 0000000..ad20fc0
--- /dev/null
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/chgrp.test.
+#
+# Test that -g preserves group ownership when the user is a member of the
+# target group. Creates one file per supplementary group, chgrps each,
+# then verifies the destination listing matches.
+
+import os
+import shutil
+import time
+
+from rsyncfns import FROMDIR, TODIR, checkit, rsync_getgroups, test_fail
+
+
+groups = rsync_getgroups()
+if not groups:
+    test_fail("Can't get groups")
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+for g in groups:
+    fname = FROMDIR / f'foo-{g}'
+    fname.write_text(time.ctime() + '\n')
+    chgrp = shutil.which('chgrp')
+    if chgrp is None:
+        test_fail("chgrp not found in PATH")
+    # The shell test treats chgrp failure as fatal.
+    try:
+        os.chown(fname, -1, int(g))
+    except (ValueError, PermissionError):
+        # If g isn't numeric or we lack permission, fall back to chgrp(1).
+        import subprocess
+        proc = subprocess.run([chgrp, g, str(fname)])
+        if proc.returncode != 0:
+            test_fail("Can't chgrp")
+
+time.sleep(2)
+
+checkit(['-rtgpvvv', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
diff --git a/testsuite/chmod-option.test b/testsuite/chmod-option.test
deleted file mode 100644 (file)
index ddf764c..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that the --chmod option functions correctly.
-
-. $suitedir/rsync.fns
-
-# Build some files
-
-fromdir="$scratchdir/from"
-todir="$scratchdir/to"
-checkdir="$scratchdir/check"
-
-mkdir "$fromdir"
-name1="$fromdir/name1"
-name2="$fromdir/name2"
-dir1="$fromdir/dir1"
-dir2="$fromdir/dir2"
-echo "This is the file" > "$name1"
-echo "This is the other file" > "$name2"
-mkdir "$dir1" "$dir2"
-
-chmod 4700 "$name1" || test_skipped "Can't chmod"
-chmod 700 "$dir1"
-chmod 770 "$dir2"
-
-# Copy the files we've created over to another directory
-checkit "$RSYNC -avv '$fromdir/' '$checkdir/'" "$fromdir" "$checkdir"
-
-# And then manually make the changes which should occur
-umask 002
-chmod ug-s,a+rX "$checkdir"/*
-chmod +w "$checkdir" "$checkdir"/dir*
-
-checkit "$RSYNC -avv --chmod ug-s,a+rX,D+w '$fromdir/' '$todir/'" "$checkdir" "$todir"
-
-rm -r "$fromdir" "$checkdir" "$todir"
-makepath "$todir" "$fromdir/foo"
-touch "$fromdir/bar"
-
-checkit "$RSYNC -avv '$fromdir/' '$checkdir/'" "$fromdir" "$checkdir"
-chmod o+x "$fromdir"/bar
-
-checkit "$RSYNC -avv --chmod=Fo-x '$fromdir/' '$todir/'" "$checkdir" "$todir"
-
-# Tickle a bug in rsync 2.6.8: if you push a new directory with --perms off to
-# a daemon with an incoming chmod, the daemon pretends the directory is a file
-# for the purposes of the second application of the incoming chmod.
-
-build_rsyncd_conf
-cat >>"$scratchdir/test-rsyncd.conf" <<EOF
-[test-incoming-chmod]
-       path = $todir
-       read only = no
-       incoming chmod = Fo-x
-EOF
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
-export RSYNC_CONNECT_PROG
-
-rm -r "$todir"
-makepath "$todir"
-
-checkit "$RSYNC -avv --no-perms '$fromdir/' localhost::test-incoming-chmod/" "$checkdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/chmod-option_test.py b/testsuite/chmod-option_test.py
new file mode 100644 (file)
index 0000000..7ca2e29
--- /dev/null
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/chmod-option.test.
+#
+# Test --chmod and the daemon-side "incoming chmod = ..." setting.
+# Covers a 2.6.8 bug where pushing a new directory with --no-perms to a
+# daemon with an incoming chmod made the daemon mis-classify the dir as
+# a file for the purposes of applying the incoming chmod.
+
+import os
+import shutil
+
+from rsyncfns import (
+    FROMDIR, RSYNC, SCRATCHDIR, TODIR,
+    build_rsyncd_conf, checkit, makepath, rmtree, run_rsync,
+)
+
+
+checkdir = SCRATCHDIR / 'check'
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+(FROMDIR / 'name1').write_text("This is the file\n")
+(FROMDIR / 'name2').write_text("This is the other file\n")
+(FROMDIR / 'dir1').mkdir()
+(FROMDIR / 'dir2').mkdir()
+
+os.chmod(FROMDIR / 'name1', 0o4700)
+os.chmod(FROMDIR / 'dir1', 0o700)
+os.chmod(FROMDIR / 'dir2', 0o770)
+
+# Baseline copy of source.
+checkit(['-avv', f'{FROMDIR}/', f'{checkdir}/'], FROMDIR, checkdir)
+
+# Manually apply the mode transform that --chmod ug-s,a+rX,D+w should
+# produce on the destination, then verify rsync's transform matches.
+old_umask = os.umask(0o002)
+try:
+    for entry in checkdir.iterdir():
+        # ug-s,a+rX: clear setuid/setgid; add r everywhere; add x where
+        # any existing x or the entry is a dir.
+        st = entry.stat()
+        mode = st.st_mode & ~0o6000
+        mode |= 0o444  # a+r
+        if entry.is_dir() or (st.st_mode & 0o111):
+            mode |= 0o111  # a+X
+        os.chmod(entry, mode)
+    # `chmod +w` with no explicit who: adds w for every category not
+    # masked by the current umask. Under umask 002 that's u+w AND g+w.
+    plus_w = 0o222 & ~0o002
+    for d in (checkdir, checkdir / 'dir1', checkdir / 'dir2'):
+        st = d.stat()
+        os.chmod(d, st.st_mode | plus_w)
+finally:
+    os.umask(old_umask)
+
+checkit(['-avv', '--chmod', 'ug-s,a+rX,D+w', f'{FROMDIR}/', f'{TODIR}/'],
+        checkdir, TODIR)
+
+# Now exercise the F-only chmod path.
+rmtree(FROMDIR)
+rmtree(checkdir)
+rmtree(TODIR)
+makepath(TODIR, FROMDIR / 'foo')
+(FROMDIR / 'bar').touch()
+
+checkit(['-avv', f'{FROMDIR}/', f'{checkdir}/'], FROMDIR, checkdir)
+os.chmod(FROMDIR / 'bar', (FROMDIR / 'bar').stat().st_mode | 0o001)  # o+x
+
+checkit(['-avv', '--chmod=Fo-x', f'{FROMDIR}/', f'{TODIR}/'], checkdir, TODIR)
+
+# 2.6.8 regression: pushing a new directory via --no-perms to a daemon
+# with an "incoming chmod" once mis-classified the directory as a file.
+conf = build_rsyncd_conf()
+with open(conf, 'a') as f:
+    f.write(f"""
+[test-incoming-chmod]
+\tpath = {TODIR}
+\tread only = no
+\tincoming chmod = Fo-x
+""")
+
+os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+rmtree(TODIR)
+makepath(TODIR)
+
+checkit(['-avv', '--no-perms', f'{FROMDIR}/', 'localhost::test-incoming-chmod/'],
+        checkdir, TODIR, allowed_codes=(0, 23))
diff --git a/testsuite/chmod-symlink-race.test b/testsuite/chmod-symlink-race.test
deleted file mode 100755 (executable)
index 6453af9..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for the symlink-TOCTOU class of bug applied to
-# chmod() on the receiver side. The CVE-2026-29518 fix used
-# secure_relative_open() for the basis-file open, but every other
-# path-based syscall the receiver runs on sender-controllable paths
-# is vulnerable to the same primitive: a local attacker swaps a
-# symlink into one of the parent directory components between the
-# receiver's check and its act, and the syscall escapes the module.
-#
-# This test exercises the new do_chmod_at() wrapper via the
-# t_chmod_secure helper. The helper sets up four scenarios:
-#   - a parent dir-symlink that resolves WITHIN the module tree
-#     (legitimate -K-style use)
-#   - a parent dir-symlink that escapes the module tree (the
-#     attack, must be rejected on every platform)
-#   - plain relative path (regression check)
-#   - top-level file with no parent component (regression check)
-#
-# Kernel-enforced "stay below dirfd" path resolution is available
-# on Linux 5.6+, FreeBSD 13+, and macOS 15+.  On those platforms
-# the legitimate within-tree symlink must be followed and the
-# chmod must succeed.  On platforms that fall back to the
-# per-component O_NOFOLLOW walk (Solaris, OpenBSD, NetBSD,
-# older Cygwin, HPE NonStop, pre-5.6 Linux), every symlink --
-# including the legitimate one -- is rejected; that's a real
-# platform limitation, not a security regression.  The helper
-# probes the running kernel at startup and adjusts the expected
-# outcome for the within-tree-symlink scenario accordingly, so
-# this test runs everywhere and gives the per-component fallback
-# real CI coverage (the attack-rejection, plain-path, and
-# top-level scenarios all behave identically on both code paths).
-
-. "$suitedir/rsync.fns"
-
-mod="$scratchdir/module"
-trap_outside="$scratchdir/trap"
-rm -rf "$mod" "$trap_outside"
-mkdir -p "$mod/realdir" "$trap_outside"
-
-# Set up the four file-system objects the helper expects:
-echo bystander > "$mod/realdir/sentinel"
-chmod 0600 "$mod/realdir/sentinel"
-echo target > "$trap_outside/sentinel"
-chmod 0600 "$trap_outside/sentinel"
-ln -s realdir "$mod/inside_link"
-ln -s ../trap "$mod/escape_link"
-echo top > "$mod/topfile"
-chmod 0600 "$mod/topfile"
-
-"$TOOLDIR/t_chmod_secure" "$mod" || \
-    test_fail "t_chmod_secure reported failures (see stderr above)"
-
-# Sanity-check from the shell side too: the outside file's mode must
-# still be 0600 -- the helper checked this, but a second look from
-# the shell guards against a helper-internal stat() bug.
-mode=$(stat -c '%a' "$trap_outside/sentinel" 2>/dev/null \
-       || stat -f '%Lp' "$trap_outside/sentinel" 2>/dev/null)
-if [ "$mode" != "600" ]; then
-    test_fail "outside sentinel mode changed from 600 to $mode -- chmod escaped the module"
-fi
-
-exit 0
diff --git a/testsuite/chmod-symlink-race_test.py b/testsuite/chmod-symlink-race_test.py
new file mode 100644 (file)
index 0000000..000691f
--- /dev/null
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/chmod-symlink-race.test.
+#
+# Regression test for the symlink-TOCTOU class of bug applied to chmod()
+# on the receiver side. The CVE-2026-29518 fix used
+# secure_relative_open() for the basis-file open, but every other
+# path-based syscall the receiver runs on sender-controllable paths is
+# vulnerable to the same primitive: a local attacker swaps a symlink
+# into one of the parent directory components between the receiver's
+# check and its act, and the syscall escapes the module.
+#
+# The helper t_chmod_secure exercises the new do_chmod_at() wrapper
+# across four scenarios; see the shell version for the full enumeration.
+# After the helper runs we sanity-check the outside sentinel's mode
+# from Python too, in case the helper's internal stat() ever drifts.
+
+import os
+import subprocess
+
+from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail
+
+
+mod = SCRATCHDIR / 'module'
+trap_outside = SCRATCHDIR / 'trap'
+rmtree(mod)
+rmtree(trap_outside)
+mod.mkdir(parents=True)
+(mod / 'realdir').mkdir(parents=True)
+trap_outside.mkdir(parents=True)
+
+# File-system objects the helper expects.
+(mod / 'realdir' / 'sentinel').write_text("bystander\n")
+os.chmod(mod / 'realdir' / 'sentinel', 0o600)
+(trap_outside / 'sentinel').write_text("target\n")
+os.chmod(trap_outside / 'sentinel', 0o600)
+os.symlink('realdir', mod / 'inside_link')
+os.symlink('../trap', mod / 'escape_link')
+(mod / 'topfile').write_text("top\n")
+os.chmod(mod / 'topfile', 0o600)
+
+proc = subprocess.run([str(TOOLDIR / 't_chmod_secure'), str(mod)])
+if proc.returncode != 0:
+    test_fail("t_chmod_secure reported failures (see stderr above)")
+
+# Second-look sanity check from Python.
+sentinel_mode = (trap_outside / 'sentinel').stat().st_mode & 0o777
+if sentinel_mode != 0o600:
+    test_fail(
+        f"outside sentinel mode changed from 600 to {oct(sentinel_mode)[2:]} "
+        "-- chmod escaped the module"
+    )
diff --git a/testsuite/chmod-temp-dir.test b/testsuite/chmod-temp-dir.test
deleted file mode 100644 (file)
index 362d9d9..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2004-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that various read-only and set[ug]id permissions work properly,
-# even when using a --temp-dir option (which we try to point at a
-# different filesystem than the destination dir).
-
-. "$suitedir/rsync.fns"
-
-hands_setup
-
-sdev=`$TOOLDIR/getfsdev $scratchdir`
-tdev=$sdev
-
-for tmpdir2 in "${RSYNC_TEST_TMP:-/override-tmp-not-specified}" /run/shm /var/tmp /tmp; do
-    [ -d "$tmpdir2" ] && [ -w "$tmpdir2" ] || continue
-    tdev=`$TOOLDIR/getfsdev "$tmpdir2"`
-    [ x$sdev != x$tdev ] && break
-done
-
-[ x$sdev = x$tdev ] && test_skipped "Can't find a tmp dir on a different file system"
-
-chmod 440 "$fromdir/text"
-chmod 500 "$fromdir/dir/text"
-e="$fromdir/dir/subdir/foobar.baz"
-chmod 6450 "$e" || chmod 2450 "$e" || chmod 1450 "$e" || chmod 450 "$e"
-e="$fromdir/dir/subdir/subsubdir/etc-ltr-list"
-chmod 2670 "$e" || chmod 1670 "$e" || chmod 670 "$e"
-
-# First a normal copy.
-runtest "normal copy" 'checkit "$RSYNC -avv --temp-dir=\"$tmpdir2\" \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
-
-# Then we update all the files.
-runtest "update copy" 'checkit "$RSYNC -avvI --no-whole-file --temp-dir=\"$tmpdir2\" \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/chmod-temp-dir_test.py b/testsuite/chmod-temp-dir_test.py
new file mode 100644 (file)
index 0000000..30d9fe2
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/chmod-temp-dir.test.
+#
+# Like chmod_test.py, but uses --temp-dir pointing at a different
+# filesystem so rsync must rename(2) across filesystems (i.e. fall back
+# to copy+unlink) instead of the in-place rename it does when temp and
+# destination are on the same fs. We probe candidate tmp paths to find
+# one whose filesystem differs from the scratch dir.
+
+import os
+import subprocess
+
+from rsyncfns import FROMDIR, SCRATCHDIR, TODIR, TOOLDIR, checkit, hands_setup, test_skipped
+
+
+def _fsdev(path: str) -> str:
+    return subprocess.check_output(
+        [str(TOOLDIR / 'getfsdev'), path], text=True,
+    ).strip()
+
+
+hands_setup()
+
+scratch_dev = _fsdev(str(SCRATCHDIR))
+tmpdir2 = None
+candidates = [
+    os.environ.get('RSYNC_TEST_TMP', '/override-tmp-not-specified'),
+    '/run/shm', '/var/tmp', '/tmp',
+]
+for cand in candidates:
+    if not (os.path.isdir(cand) and os.access(cand, os.W_OK)):
+        continue
+    if _fsdev(cand) != scratch_dev:
+        tmpdir2 = cand
+        break
+
+if tmpdir2 is None:
+    test_skipped("Can't find a tmp dir on a different file system")
+
+
+# Mirror chmod_test.py: set up a varied permission tree on the source.
+def _try_chmods(path, modes):
+    for m in modes:
+        try:
+            os.chmod(path, m)
+            return
+        except PermissionError:
+            continue
+    os.chmod(path, modes[-1])
+
+
+os.chmod(FROMDIR / 'text', 0o440)
+os.chmod(FROMDIR / 'dir' / 'text', 0o500)
+_try_chmods(FROMDIR / 'dir' / 'subdir' / 'foobar.baz',
+            [0o6450, 0o2450, 0o1450, 0o450])
+_try_chmods(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list',
+            [0o2670, 0o1670, 0o670])
+
+# First a normal copy (whole-file) but through a cross-fs --temp-dir.
+checkit(['-avv', f'--temp-dir={tmpdir2}', f'{FROMDIR}/', str(TODIR)],
+        FROMDIR, TODIR)
+
+# Then an update through delta, still routing partial transfers across fs.
+checkit(['-avvI', '--no-whole-file', f'--temp-dir={tmpdir2}',
+         f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
diff --git a/testsuite/chmod.test b/testsuite/chmod.test
deleted file mode 100644 (file)
index 1646a9c..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2004-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that various read-only and set[ug]id permissions work properly,
-# even when using a --temp-dir option (which we try to point at a
-# different filesystem than the destination dir).
-
-. "$suitedir/rsync.fns"
-
-hands_setup
-
-chmod 440 "$fromdir/text"
-chmod 500 "$fromdir/dir/text"
-e="$fromdir/dir/subdir/foobar.baz"
-chmod 6450 "$e" || chmod 2450 "$e" || chmod 1450 "$e" || chmod 450 "$e"
-e="$fromdir/dir/subdir/subsubdir/etc-ltr-list"
-chmod 2670 "$e" || chmod 1670 "$e" || chmod 670 "$e"
-
-# First a normal copy.
-runtest "normal copy" 'checkit "$RSYNC -avv \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
-
-# Then we update all the files.
-runtest "update copy" 'checkit "$RSYNC -avvI --no-whole-file \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/chmod_test.py b/testsuite/chmod_test.py
new file mode 100644 (file)
index 0000000..ea6934b
--- /dev/null
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/chmod.test.
+#
+# Test that varied read-only and set[ug]id permissions transfer correctly
+# both on first copy (whole-file) and on subsequent updates (delta).
+
+import os
+
+from rsyncfns import FROMDIR, TODIR, checkit, hands_setup
+
+
+hands_setup()
+
+# Three of these chmod modes use the sticky/setuid/setgid bits which some
+# platforms refuse for non-root. The shell test tries them in descending
+# order, falling back to plain mode on rejection.
+def _try_chmods(path, modes):
+    for m in modes:
+        try:
+            os.chmod(path, m)
+            return
+        except PermissionError:
+            continue
+    # Final mode in the list is the no-special-bits fallback.
+    os.chmod(path, modes[-1])
+
+
+os.chmod(FROMDIR / 'text', 0o440)
+os.chmod(FROMDIR / 'dir' / 'text', 0o500)
+_try_chmods(FROMDIR / 'dir' / 'subdir' / 'foobar.baz',
+            [0o6450, 0o2450, 0o1450, 0o450])
+_try_chmods(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list',
+            [0o2670, 0o1670, 0o670])
+
+# First a normal whole-file copy.
+checkit(['-avv', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
+
+# Then update through delta with -I (ignore times) so every file is
+# touched again.
+checkit(['-avvI', '--no-whole-file', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
diff --git a/testsuite/chown.test b/testsuite/chown.test
deleted file mode 100644 (file)
index b53413e..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that when rsync is running as root and has -a it correctly sets
-# the ownership of the destination.
-
-# We don't know what users will be present on this system, so we just
-# use random numeric uids and gids.
-
-. "$suitedir/rsync.fns"
-
-case $0 in
-*fake*)
-    $RSYNC -VV | grep '"xattrs": true' >/dev/null || test_skipped "Rsync needs xattrs for fake device tests"
-    RSYNC="$RSYNC --fake-super"
-    TLS_ARGS="$TLS_ARGS --fake-super"
-    case "$HOST_OS" in
-    darwin*)
-       chown() {
-           own=$1
-           shift
-           xattr -s 'rsync.%stat' "100644 0,0 $own" "${@}"
-       }
-       ;;
-    solaris*)
-       chown() {
-           own=$1
-           shift
-           for fn in "${@}"; do
-               runat "$fn" "$SHELL_PATH" <<EOF
-echo "100644 0,0 $own" > rsync.%stat
-EOF
-           done
-       }
-       ;;
-    freebsd*)
-       chown() {
-           own=$1
-           shift
-           setextattr -h user "rsync.%stat" "100644 0,0 $own" "${@}"
-       }
-       ;;
-    *)
-       chown() {
-           own=$1
-           shift
-           setfattr -n 'user.rsync.%stat' -v "100644 0,0 $own" "${@}"
-       }
-       ;;
-    esac
-    ;;
-*)
-    RSYNC="$RSYNC --super"
-    my_uid=`get_testuid`
-    root_uid=`get_rootuid`
-    if test x"$my_uid" = x; then
-       : # If "id" failed, try to continue...
-    elif test x"$my_uid" != x"$root_uid"; then
-       if [ -e "$FAKEROOT_PATH" ]; then
-           echo "Let's try re-running the script under fakeroot..."
-           exec "$FAKEROOT_PATH" "$SHELL_PATH" "$0"
-       fi
-    fi
-    ;;
-esac
-
-# Build some hardlinks
-
-mkdir "$fromdir"
-name1="$fromdir/name1"
-name2="$fromdir/name2"
-echo "This is the file" > "$name1"
-echo "This is the other file" > "$name2"
-
-chown 5000:5002 "$name1" || test_skipped "Can't chown (probably need root)"
-chown 5001:5003 "$name2" || test_skipped "Can't chown (probably need root)"
-
-cd "$fromdir/.."
-checkit "$RSYNC -aHvv from/ to/" "$fromdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/chown_test.py b/testsuite/chown_test.py
new file mode 100644 (file)
index 0000000..8055982
--- /dev/null
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/chown.test (and, via a symlink installed by
+# the Makefile as chown-fake_test.py, of testsuite/chown-fake.test).
+#
+# Verifies that rsync -a + ownership-preservation sets the destination
+# uid/gid to match the source. The "real" variant needs root to chown(2);
+# the "fake" variant emulates ownership in the user.rsync.%stat xattr and
+# tests --fake-super.
+
+import os
+import platform
+import shutil
+import subprocess
+import sys
+
+import rsyncfns
+from rsyncfns import (
+    FROMDIR, TODIR,
+    checkit, run_rsync, test_fail, test_skipped,
+)
+
+
+# Detect fake variant by the script name we were invoked under. The
+# Makefile creates chown-fake_test.py as a symlink to this file.
+script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
+fake_variant = 'fake' in script_name
+
+if fake_variant:
+    # --fake-super needs xattrs support.
+    vv = run_rsync('-VV', check=True, capture_output=True)
+    if '"xattrs": true' not in vv.stdout:
+        test_skipped("Rsync needs xattrs for fake device tests")
+    # Augment the RSYNC command and TLS_ARGS so checkit's listing path
+    # treats the xattr-encoded ownership as the file's real ownership.
+    rsyncfns.RSYNC = rsyncfns.RSYNC + ' --fake-super'
+    rsyncfns.TLS_ARGS = (rsyncfns.TLS_ARGS + ' --fake-super').strip()
+
+    if platform.system() != 'Linux':
+        test_skipped(
+            f"fake chown emulation not implemented for {platform.system()}"
+        )
+
+    def chown_or_fake(path, uid, gid):
+        # On Linux, store ownership in the user.rsync.%stat xattr -- the
+        # format rsync's --fake-super expects.
+        stat = os.stat(path)
+        mode = stat.st_mode
+        # %stat format: "MODE DEV_MAJOR,DEV_MINOR UID:GID"
+        value = f"{mode:o} 0,0 {uid}:{gid}".encode()
+        os.setxattr(str(path), b'user.rsync.%stat', value)
+        return True
+else:
+    rsyncfns.RSYNC = rsyncfns.RSYNC + ' --super'
+
+    my_uid = os.getuid()
+    if my_uid != 0:
+        # If a fakeroot binary is in the environment, re-exec ourselves
+        # under it -- same trick the shell test used.
+        fakeroot_path = os.environ.get('FAKEROOT_PATH')
+        if fakeroot_path and os.access(fakeroot_path, os.X_OK):
+            print("Let's try re-running the script under fakeroot...")
+            os.execv(fakeroot_path, [fakeroot_path, sys.executable, __file__])
+
+    def chown_or_fake(path, uid, gid):
+        try:
+            os.chown(path, uid, gid)
+            return True
+        except (PermissionError, OSError):
+            return False
+
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+name1 = FROMDIR / 'name1'
+name2 = FROMDIR / 'name2'
+name1.write_text("This is the file\n")
+name2.write_text("This is the other file\n")
+
+if not chown_or_fake(name1, 5000, 5002):
+    test_skipped("Can't chown (probably need root)")
+if not chown_or_fake(name2, 5001, 5003):
+    test_skipped("Can't chown (probably need root)")
+
+os.chdir(FROMDIR.parent)
+checkit(['-aHvv', 'from/', 'to/'], FROMDIR, TODIR)
diff --git a/testsuite/clean-fname-underflow.test b/testsuite/clean-fname-underflow.test
deleted file mode 100644 (file)
index 24625a8..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/bin/sh
-# clean-fname-underflow.test
-# Ensure clean_fname() does not read-before-buffer when collapsing "..".
-# This exercises the --server path where a crafted merge filename hits clean_fname().
-
-. "$suitedir/rsync.fns"
-
-workdir="$scratchdir/workdir"
-mkdir -p "$workdir/mod"
-cd "$workdir"
-
-rsync_bin=`echo $RSYNC | sed 's/ .*//'`
-
-# Invoke the server-side path. We don't need a real transfer; we just want to
-# ensure clean_fname() doesn't crash when given "a/../test" via --filter=merge.
-if $rsync_bin --server --sender -vlr --filter='merge a/../test' . mod/ >/dev/null 2>&1; then
-  : # success
-else
-  status=$?
-  # Non-zero exit is expected for bogus input; ensure it wasn't a signal/crash.
-  if [ $status -ge 128 ]; then
-    test_fail "rsync exited due to a signal (status=$status)"
-  fi
-fi
-
-echo "OK: clean_fname() handled 'a/../test' without crashing"
-exit 0
diff --git a/testsuite/clean-fname-underflow_test.py b/testsuite/clean-fname-underflow_test.py
new file mode 100644 (file)
index 0000000..4b30155
--- /dev/null
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/clean-fname-underflow.test.
+#
+# Ensure clean_fname() does not read-before-buffer when collapsing "..".
+# Exercises the --server path where a crafted merge filename hits
+# clean_fname(); a non-zero exit is expected (the input is bogus), but
+# the test fails if rsync dies from a signal (status >= 128).
+
+import os
+import shlex
+import subprocess
+
+from rsyncfns import RSYNC, TMPDIR, test_fail
+
+
+workdir = TMPDIR / 'workdir'
+(workdir / 'mod').mkdir(parents=True, exist_ok=True)
+os.chdir(workdir)
+
+# RSYNC may be a multi-word command (e.g. valgrind + rsync); take just the
+# binary path, matching the shell test's `echo $RSYNC | sed 's/ .*//'`.
+rsync_bin = shlex.split(RSYNC)[0]
+
+proc = subprocess.run(
+    [rsync_bin, '--server', '--sender', '-vlr',
+     '--filter=merge a/../test', '.', 'mod/'],
+    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+)
+
+if proc.returncode >= 128:
+    test_fail(f"rsync exited due to a signal (status={proc.returncode})")
+
+print("OK: clean_fname() handled 'a/../test' without crashing")
diff --git a/testsuite/copy-dest-source-symlink.test b/testsuite/copy-dest-source-symlink.test
deleted file mode 100755 (executable)
index f91ee98..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for codex audit Finding 3a: copy_file()'s source
-# open in copy_altdest_file() is via do_open_nofollow(), which only
-# refuses a final-component symlink. Parent components are still
-# resolved with normal symlink-following. A daemon module attacker
-# who plants a parent symlink at module/cd -> /outside, then runs
-# --copy-dest=cd against a source file matching the size+mtime of
-# /outside/target.txt, drives the receiver to:
-#
-#   1. Find a match-level >= 2 basis at "cd/target.txt"
-#   2. Call copy_altdest_file -> copy_file(src="cd/target.txt", ...)
-#   3. do_open_nofollow follows the "cd" parent symlink and reads
-#      the contents of /outside/target.txt under the daemon's
-#      authority
-#   4. Copy that content into the module destination
-#
-# Result: outside/target.txt content lands at module/target.txt,
-# accessible to the attacker on a subsequent pull.
-#
-# We detect by content: src/target.txt and outside/target.txt have
-# identical metadata (size + mtime + mode) but different content.
-# After the transfer, module/target.txt should match src (no
-# basedir escape) -- if it matches outside, the bug copied across
-# the symlink boundary.
-
-. "$suitedir/rsync.fns"
-
-mod="$scratchdir/module"
-outside="$scratchdir/outside"
-src="$scratchdir/src"
-conf="$scratchdir/test-rsyncd.conf"
-
-rm -rf "$mod" "$outside" "$src"
-mkdir -p "$mod" "$outside" "$src"
-
-# Outside-the-module file the daemon should not read on the
-# attacker's behalf.
-echo "OUTSIDE_LEAKED_DATA!" > "$outside/target.txt"
-chmod 0644 "$outside/target.txt"
-
-# The symlink trap.
-ln -s "$outside" "$mod/cd"
-
-# Source: same size, same mtime, same mode as outside -- so the
-# generator's link_stat + quick_check_ok finds a match-level >= 2
-# basis and calls copy_altdest_file.
-echo "ATTACKER_KNOWN_DATA!" > "$src/target.txt"
-touch -r "$outside/target.txt" "$src/target.txt"
-chmod 0644 "$src/target.txt"
-
-# When running as root the daemon would drop to "nobody" by
-# default and fail to mkstemp in the scratch dir; force it to
-# keep our uid/gid in that case.
-my_uid=`get_testuid`
-root_uid=`get_rootuid`
-root_gid=`get_rootgid`
-uid_setting="uid = $root_uid"
-gid_setting="gid = $root_gid"
-if test x"$my_uid" != x"$root_uid"; then
-    uid_setting="#$uid_setting"
-    gid_setting="#$gid_setting"
-fi
-
-cat > "$conf" <<EOF
-use chroot = no
-$uid_setting
-$gid_setting
-log file = $scratchdir/rsyncd.log
-[upload]
-    path = $mod
-    use chroot = no
-    read only = no
-EOF
-
-# --copy-dest push to module root.
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-    $RSYNC -rtp --copy-dest=cd "$src/" rsync://localhost/upload/ \
-    >/dev/null 2>&1 || true
-
-if [ ! -f "$mod/target.txt" ]; then
-    test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour"
-fi
-
-if cmp -s "$mod/target.txt" "$outside/target.txt"; then
-    test_fail "basedir-escape via copy_file source: module/target.txt now contains the contents of outside/target.txt -- daemon read /outside via the cd symlink and copied it into the module"
-fi
-
-if ! cmp -s "$mod/target.txt" "$src/target.txt"; then
-    test_fail "destination doesn't match source content (and isn't outside content either): unexpected state"
-fi
-
-exit 0
diff --git a/testsuite/copy-dest-source-symlink_test.py b/testsuite/copy-dest-source-symlink_test.py
new file mode 100644 (file)
index 0000000..f3c6b09
--- /dev/null
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/copy-dest-source-symlink.test.
+#
+# Regression test for codex audit Finding 3a: copy_file()'s source open
+# in copy_altdest_file() is via do_open_nofollow(), which only refuses
+# a final-component symlink. A daemon-module attacker who plants a
+# parent symlink (module/cd -> /outside) then runs --copy-dest=cd
+# against a source matching the size+mtime of /outside/target.txt
+# drives the receiver to read /outside/target.txt under the daemon's
+# authority and copy it into the module.
+#
+# Detection: source and outside have identical metadata (size, mtime,
+# mode) but distinct content. After the transfer, module/target.txt
+# must contain source's content, not outside's.
+
+import filecmp
+import os
+import subprocess
+
+from rsyncfns import (
+    RSYNC, SCRATCHDIR,
+    rsync_argv, get_testuid, get_rootuid, get_rootgid,
+    rmtree, test_fail,
+)
+
+
+mod = SCRATCHDIR / 'module'
+outside = SCRATCHDIR / 'outside'
+src_dir = SCRATCHDIR / 'src_files'
+conf = SCRATCHDIR / 'test-rsyncd.conf'
+
+for d in (mod, outside, src_dir):
+    rmtree(d)
+    d.mkdir(parents=True)
+
+(outside / 'target.txt').write_text("OUTSIDE_LEAKED_DATA!\n")
+os.chmod(outside / 'target.txt', 0o644)
+
+os.symlink(str(outside), mod / 'cd')
+
+# Source: same size + mtime + mode as outside, different content.
+(src_dir / 'target.txt').write_text("ATTACKER_KNOWN_DATA!\n")
+ref = (outside / 'target.txt').stat()
+os.utime(src_dir / 'target.txt', (ref.st_atime, ref.st_mtime))
+os.chmod(src_dir / 'target.txt', 0o644)
+
+my_uid = get_testuid()
+root_uid = get_rootuid()
+root_gid = get_rootgid()
+uid_line = f"uid = {root_uid}"
+gid_line = f"gid = {root_gid}"
+if my_uid != root_uid:
+    uid_line = '#' + uid_line
+    gid_line = '#' + gid_line
+
+conf.write_text(f"""\
+use chroot = no
+{uid_line}
+{gid_line}
+log file = {SCRATCHDIR}/rsyncd.log
+[upload]
+    path = {mod}
+    use chroot = no
+    read only = no
+""")
+
+env = os.environ.copy()
+env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+subprocess.run(
+    rsync_argv('-rtp', '--copy-dest=cd',
+               f'{src_dir}/', 'rsync://localhost/upload/'),
+    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+    env=env,
+)
+
+target = mod / 'target.txt'
+if not target.is_file():
+    test_fail(
+        "destination file was not created -- daemon transfer failed "
+        "before the test could observe the basedir behaviour"
+    )
+
+if filecmp.cmp(target, outside / 'target.txt', shallow=False):
+    test_fail(
+        "basedir-escape via copy_file source: module/target.txt now "
+        "contains the contents of outside/target.txt -- daemon read "
+        "/outside via the cd symlink and copied it into the module"
+    )
+if not filecmp.cmp(target, src_dir / 'target.txt', shallow=False):
+    test_fail(
+        "destination doesn't match source content (and isn't outside "
+        "content either): unexpected state"
+    )
diff --git a/testsuite/crtimes.test b/testsuite/crtimes.test
deleted file mode 100644 (file)
index 456f0a5..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/bin/sh
-
-# Test rsync copying create times
-
-. "$suitedir/rsync.fns"
-
-$RSYNC -VV | grep '"crtimes": true' >/dev/null || test_skipped "Rsync is configured without crtimes support"
-
-# Setting an older time via touch sets the create time to the mtime.
-# Setting it to a newer time affects just the mtime.
-
-mkdir "$fromdir"
-echo hiho >"$fromdir/foo"
-
-touch -t 200101011111.11 "$fromdir"
-touch -t 200202022222.22 "$fromdir"
-
-touch -t 200111111111.11 "$fromdir/foo"
-touch -t 200212122222.22 "$fromdir/foo"
-
-TLS_ARGS=--crtimes
-
-checkit "$RSYNC -rtgvvv --crtimes \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/crtimes_test.py b/testsuite/crtimes_test.py
new file mode 100644 (file)
index 0000000..7142b5e
--- /dev/null
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/crtimes.test.
+#
+# Test that rsync preserves source create times when the binary was built
+# with crtimes support. Touch tricks: setting an older time via touch sets
+# the create time to the mtime; setting a newer time affects only mtime.
+
+import datetime
+import os
+
+import rsyncfns
+from rsyncfns import FROMDIR, TODIR, checkit, run_rsync, test_skipped
+
+
+vv = run_rsync('-VV', check=True, capture_output=True)
+if '"crtimes": true' not in vv.stdout:
+    test_skipped("Rsync is configured without crtimes support")
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+(FROMDIR / 'foo').write_text("hiho\n")
+
+
+def _utime(path, when: 'datetime.datetime') -> None:
+    ts = when.timestamp()
+    os.utime(path, (ts, ts))
+
+
+# Touch fromdir to an old time then to a newer time -- in shells with the
+# right kernel support this leaves the create time pinned to the older.
+_utime(FROMDIR, datetime.datetime(2001, 1, 1, 11, 11, 11))
+_utime(FROMDIR, datetime.datetime(2002, 2, 2, 22, 22, 22))
+
+_utime(FROMDIR / 'foo', datetime.datetime(2001, 11, 11, 11, 11, 11))
+_utime(FROMDIR / 'foo', datetime.datetime(2002, 12, 12, 22, 22, 22))
+
+rsyncfns.TLS_ARGS = '--crtimes'
+
+checkit(['-rtgvvv', '--crtimes', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
diff --git a/testsuite/daemon-chroot-acl.test b/testsuite/daemon-chroot-acl.test
deleted file mode 100644 (file)
index 9d1c1b6..0000000
+++ /dev/null
@@ -1,111 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny"
-# rule must still match when the daemon performs a 'daemon chroot' and
-# the chroot does not contain the NSS files glibc needs for reverse DNS.
-#
-# Pre-fix, reverse DNS happened *after* the daemon chroot. With an empty
-# chroot the NSS lookup failed, client_name() returned "UNKNOWN", and a
-# deny rule referring to the connecting hostname silently failed to
-# match.
-#
-# Two scenarios are exercised so we can distinguish the case the fix
-# definitely covers from the per-module path that may still be
-# vulnerable:
-#   A. global  "reverse lookup = yes"           (covered by b6abdb4c)
-#   B. only module "reverse lookup = yes"       (gap to verify)
-
-. "$suitedir/rsync.fns"
-
-case `uname -s` in
-Linux*) ;;
-*) test_skipped "test is Linux-specific (uses chroot+unshare)" ;;
-esac
-
-# We need CAP_SYS_CHROOT. Re-exec under a user namespace if not root.
-if ! chroot / /bin/true 2>/dev/null; then
-    if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user true 2>/dev/null; then
-       echo "Re-running under unshare --user --map-root-user..."
-       RSYNC_UNSHARED=1 exec unshare --user --map-root-user "$SHELL_PATH" $RUNSHFLAGS "$0"
-    fi
-    test_skipped "need CAP_SYS_CHROOT (root or unshare --user --map-root-user)"
-fi
-
-# We need 127.0.0.1 to reverse-resolve to a real hostname while NSS is
-# still working (i.e. before the daemon's chroot). The daemon will
-# look that name up itself as part of its hostname-based ACL check;
-# we then deny that name and assert the connection is rejected.
-client_hostname=`getent hosts 127.0.0.1 2>/dev/null | awk 'NR==1 {print $2}'`
-if [ -z "$client_hostname" ] || [ "$client_hostname" = "127.0.0.1" ]; then
-    test_skipped "no reverse DNS for 127.0.0.1"
-fi
-
-chrootdir="$scratchdir/chroot"
-rm -rf "$chrootdir"
-mkdir -p "$chrootdir/modroot"
-echo "from chroot" > "$chrootdir/modroot/file1"
-
-conf="$scratchdir/test-rsyncd.conf"
-logfile="$scratchdir/rsyncd.log"
-
-write_conf() {
-    cat >"$conf" <<EOF
-use chroot = no
-log file = $logfile
-daemon chroot = $chrootdir
-reverse lookup = $1
-hosts deny = $client_hostname
-max verbosity = 4
-
-[chrootmod]
-    path = /modroot
-    read only = yes
-    reverse lookup = $2
-EOF
-}
-
-# Run a transfer and return 0 if the daemon refused with @ERROR access
-# denied (the expected outcome when the deny rule matches).
-run_check() {
-    label="$1"
-
-    rm -f "$logfile"
-    rm -rf "$todir"
-    mkdir -p "$todir"
-
-    out="$scratchdir/run.out"
-
-    RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-       $RSYNC -av localhost::chrootmod/ "$todir/" >"$out" 2>&1
-    rc=$?
-
-    echo "----- $label (rsync exit $rc):"
-    cat "$out"
-    echo "----- daemon log:"
-    [ -f "$logfile" ] && cat "$logfile"
-    echo "-----"
-
-    grep -q '@ERROR.*access denied' "$out"
-}
-
-# Scenario A: global reverse lookup. Covered by b6abdb4c.
-write_conf yes yes
-if ! run_check "Scenario A (global reverse lookup = yes)"; then
-    test_fail "Scenario A: hostname deny rule was bypassed"
-fi
-
-# Scenario B: only the per-module reverse-lookup setting is enabled.
-# The b6abdb4c fix only pre-warms client_name()'s cache when the
-# global setting is on, so the post-chroot lookup in this path may
-# still produce "UNKNOWN" and bypass the deny rule.
-write_conf no yes
-if ! run_check "Scenario B (per-module reverse lookup only)"; then
-    test_fail "Scenario B: hostname deny rule was bypassed (per-module reverse lookup with daemon chroot still has the bypass)"
-fi
-
-exit 0
diff --git a/testsuite/daemon-chroot-acl_test.py b/testsuite/daemon-chroot-acl_test.py
new file mode 100644 (file)
index 0000000..3fa5e2c
--- /dev/null
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/daemon-chroot-acl.test.
+#
+# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny"
+# rule must still match when the daemon performs a 'daemon chroot' and
+# the chroot does not contain the NSS files glibc needs for reverse
+# DNS. Pre-fix, reverse DNS happened *after* the chroot, the lookup
+# failed, client_name() returned "UNKNOWN", and a deny rule referring
+# to the connecting hostname silently failed to match.
+
+import os
+import platform
+import shutil
+import subprocess
+import sys
+
+from rsyncfns import (
+    RSYNC, SCRATCHDIR, TODIR,
+    rmtree, rsync_argv, test_fail, test_skipped,
+)
+
+
+if platform.system() != 'Linux':
+    test_skipped("test is Linux-specific (uses chroot+unshare)")
+
+# Need CAP_SYS_CHROOT. Re-exec under a user namespace if not root.
+def _can_chroot() -> bool:
+    proc = subprocess.run(['chroot', '/', '/bin/true'],
+                          stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+    return proc.returncode == 0
+
+
+if not _can_chroot():
+    if not os.environ.get('RSYNC_UNSHARED'):
+        unshare = shutil.which('unshare')
+        if unshare is not None:
+            probe = subprocess.run(
+                [unshare, '--user', '--map-root-user', 'true'],
+                capture_output=True,
+            )
+            if probe.returncode == 0:
+                print("Re-running under unshare --user --map-root-user...")
+                env = os.environ.copy()
+                env['RSYNC_UNSHARED'] = '1'
+                os.execvpe(
+                    unshare,
+                    [unshare, '--user', '--map-root-user',
+                     sys.executable, __file__],
+                    env,
+                )
+    test_skipped("need CAP_SYS_CHROOT (root or unshare --user --map-root-user)")
+
+
+# Find what 127.0.0.1 reverse-resolves to.
+def _client_hostname() -> str:
+    try:
+        out = subprocess.check_output(['getent', 'hosts', '127.0.0.1'], text=True)
+    except (subprocess.CalledProcessError, FileNotFoundError):
+        return ''
+    for line in out.splitlines():
+        parts = line.split()
+        if len(parts) >= 2:
+            return parts[1]
+    return ''
+
+
+client_hostname = _client_hostname()
+if not client_hostname or client_hostname == '127.0.0.1':
+    test_skipped("no reverse DNS for 127.0.0.1")
+
+chrootdir = SCRATCHDIR / 'chroot'
+rmtree(chrootdir)
+(chrootdir / 'modroot').mkdir(parents=True)
+(chrootdir / 'modroot' / 'file1').write_text("from chroot\n")
+
+conf = SCRATCHDIR / 'test-rsyncd.conf'
+logfile = SCRATCHDIR / 'rsyncd.log'
+
+
+def write_conf(global_rev: str, module_rev: str) -> None:
+    conf.write_text(f"""\
+use chroot = no
+log file = {logfile}
+daemon chroot = {chrootdir}
+reverse lookup = {global_rev}
+hosts deny = {client_hostname}
+max verbosity = 4
+
+[chrootmod]
+    path = /modroot
+    read only = yes
+    reverse lookup = {module_rev}
+""")
+
+
+def run_check(label: str) -> bool:
+    if logfile.exists():
+        logfile.unlink()
+    rmtree(TODIR)
+    TODIR.mkdir()
+
+    env = os.environ.copy()
+    env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+    proc = subprocess.run(
+        rsync_argv('-av', 'localhost::chrootmod/', f'{TODIR}/'),
+        capture_output=True, text=True, env=env,
+    )
+    out = proc.stdout + proc.stderr
+
+    print(f"----- {label} (rsync exit {proc.returncode}):")
+    print(out)
+    print("----- daemon log:")
+    if logfile.exists():
+        print(logfile.read_text())
+    print("-----")
+
+    return '@ERROR' in out and 'access denied' in out
+
+
+# Scenario A: global reverse lookup. Covered by b6abdb4c.
+write_conf('yes', 'yes')
+if not run_check("Scenario A (global reverse lookup = yes)"):
+    test_fail("Scenario A: hostname deny rule was bypassed")
+
+# Scenario B: only per-module reverse-lookup enabled.
+write_conf('no', 'yes')
+if not run_check("Scenario B (per-module reverse lookup only)"):
+    test_fail(
+        "Scenario B: hostname deny rule was bypassed (per-module reverse "
+        "lookup with daemon chroot still has the bypass)"
+    )
diff --git a/testsuite/daemon-gzip-download.test b/testsuite/daemon-gzip-download.test
deleted file mode 100644 (file)
index 57dd820..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2001, 2002 by Martin Pool <mbp@samba.org>
-
-#    This program is free software; you can redistribute it and/or modify
-#    it under the terms of the GNU General Public License as published by
-#    the Free Software Foundation; either version 2 of the License, or
-#    (at your option) any later version.
-   
-#    This program is distributed in the hope that it will be useful,
-#    but WITHOUT ANY WARRANTY; without even the implied warranty of
-#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-#    GNU General Public License for more details.
-   
-#    You should have received a copy of the GNU General Public License
-#    along with this program; if not, write to the Free Software
-#    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
-
-# This test tries to download a tree over a compressed connection from
-# the server.  This ought to exercise (exorcise?) a bug in 2.5.3.
-
-. "$suitedir/rsync.fns"
-
-build_rsyncd_conf
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
-export RSYNC_CONNECT_PROG
-
-hands_setup
-
-# Build chkdir with a normal rsync and an --exclude.
-$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
-
-checkit "$RSYNC -avvvvzz localhost::test-from/ '$todir/'" "$chkdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/daemon-gzip-download_test.py b/testsuite/daemon-gzip-download_test.py
new file mode 100644 (file)
index 0000000..adf21ae
--- /dev/null
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/daemon-gzip-download.test.
+#
+# Download a file tree over a compressed connection from an in-process
+# rsyncd (via RSYNC_CONNECT_PROG). Exercises (exorcises?) a bug in
+# 2.5.3 that mis-handled doubly-compressed transfers.
+
+import os
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, RSYNC, TODIR,
+    build_rsyncd_conf, checkit, hands_setup, run_rsync,
+)
+
+
+conf = build_rsyncd_conf()
+os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+hands_setup()
+
+# chkdir: vanilla copy minus the daemon's global "foobar.baz" exclude.
+run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
+
+checkit(
+    ['-avvvvzz', 'localhost::test-from/', f'{TODIR}/'],
+    CHKDIR, TODIR,
+    allowed_codes=(0, 23),
+)
diff --git a/testsuite/daemon-gzip-upload.test b/testsuite/daemon-gzip-upload.test
deleted file mode 100644 (file)
index b2110ea..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2001, 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING)
-
-# We don't really want to start the server listening, because that
-# might interfere with the security or operation of the test machine.
-# Instead we use the fake-connect feature to dynamically assign a pair
-# of ports.
-
-# This test tries to upload a file over a compressed connection to the
-# server.  This ought to exercise (exorcise?) a bug in 2.5.3.
-
-. "$suitedir/rsync.fns"
-
-build_rsyncd_conf
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
-export RSYNC_CONNECT_PROG
-
-hands_setup
-
-# Build chkdir with a normal rsync and an --exclude.
-$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
-
-checkit "'$ignore23' $RSYNC -avvvvzz '$fromdir/' localhost::test-to/" "$chkdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/daemon-gzip-upload_test.py b/testsuite/daemon-gzip-upload_test.py
new file mode 100644 (file)
index 0000000..f5fd85f
--- /dev/null
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/daemon-gzip-upload.test.
+#
+# Upload a file tree over a compressed connection to an in-process
+# rsyncd (via RSYNC_CONNECT_PROG). Exercises (exorcises?) a bug in
+# 2.5.3 that mis-handled doubly-compressed transfers.
+
+import os
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TODIR,
+    build_rsyncd_conf, checkit, hands_setup, run_rsync,
+)
+
+
+conf = build_rsyncd_conf()
+os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+hands_setup()
+
+# chkdir: vanilla copy minus the daemon's global "foobar.baz" exclude.
+run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
+
+ignore23 = str(SCRATCHDIR / 'ignore23')
+checkit(
+    ['-avvvvzz', f'{FROMDIR}/', 'localhost::test-to/'],
+    CHKDIR, TODIR,
+    allowed_codes=(0, 23),
+)
diff --git a/testsuite/daemon-refuse-compress.test b/testsuite/daemon-refuse-compress.test
deleted file mode 100644 (file)
index a24e50d..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that a daemon module configured with "refuse options = compress"
-# rejects clients that ask for compression and still serves the same
-# transfer when the client does not.
-
-. "$suitedir/rsync.fns"
-
-build_rsyncd_conf
-
-# Append a module that refuses --compress (-z).
-cat >>"$conf" <<EOF
-
-[no-compress]
-       path = $fromdir
-       read only = yes
-       refuse options = compress
-EOF
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
-export RSYNC_CONNECT_PROG
-
-hands_setup
-
-# Build a reference tree mirroring the daemon's global exclude rule.
-$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
-
-# A compressed transfer must be refused.
-errlog="$scratchdir/refuse.err"
-if $RSYNC -avz localhost::no-compress/ "$todir/" >/dev/null 2>"$errlog"; then
-    cat "$errlog" >&2
-    test_fail "compressed transfer was not refused"
-fi
-
-grep -- '--compress' "$errlog" >/dev/null || {
-    cat "$errlog" >&2
-    test_fail "expected refuse error mentioning --compress"
-}
-
-# The same transfer without -z must succeed.
-rm -rf "$todir"
-mkdir "$todir"
-checkit "$RSYNC -av localhost::no-compress/ '$todir/'" "$chkdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/daemon-refuse-compress_test.py b/testsuite/daemon-refuse-compress_test.py
new file mode 100644 (file)
index 0000000..29b28bb
--- /dev/null
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/daemon-refuse-compress.test.
+#
+# A daemon module configured with "refuse options = compress" must
+# reject clients that ask for compression, and still serve the same
+# transfer when the client does not.
+
+import os
+import subprocess
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TODIR,
+    build_rsyncd_conf, checkit, hands_setup, rmtree,
+    rsync_argv, run_rsync, test_fail,
+)
+
+
+conf = build_rsyncd_conf()
+# Append an extra module that refuses --compress (-z).
+with open(conf, 'a') as f:
+    f.write(f"""
+[no-compress]
+\tpath = {FROMDIR}
+\tread only = yes
+\trefuse options = compress
+""")
+
+os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+hands_setup()
+run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
+
+# A compressed transfer must be refused.
+errlog = SCRATCHDIR / 'refuse.err'
+proc = subprocess.run(
+    rsync_argv('-avz', 'localhost::no-compress/', f'{TODIR}/'),
+    stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
+)
+errlog.write_text(proc.stderr)
+if proc.returncode == 0:
+    print(proc.stderr)
+    test_fail("compressed transfer was not refused")
+if '--compress' not in proc.stderr:
+    print(proc.stderr)
+    test_fail("expected refuse error mentioning --compress")
+
+# The same transfer without -z must succeed.
+rmtree(TODIR)
+TODIR.mkdir()
+checkit(['-av', 'localhost::no-compress/', f'{TODIR}/'], CHKDIR, TODIR,
+        allowed_codes=(0, 23))
diff --git a/testsuite/daemon.test b/testsuite/daemon.test
deleted file mode 100644 (file)
index 60aa334..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING)
-
-# We don't really want to start the server listening, because that
-# might interfere with the security or operation of the test machine.
-# Instead we use the fake-connect feature to dynamically assign a pair
-# of ports.
-
-# Having started the server we try some basic operations against it:
-
-# getting a list of module
-# listing files in a module
-# retrieving a module
-# uploading to a module
-# checking the log file
-# password authentication
-
-. "$suitedir/rsync.fns"
-
-SSH="src/support/lsh.sh --no-cd"
-FILE_REPL='s/^\([^d][^ ]*\) *\(..........[0-9]\) /\1 \2 /'
-DIR_REPL='s/^\(d[^ ]*\)  *[0-9][.,0-9]* /\1         DIR /'
-LS_REPL='s;[0-9][0-9][0-9][0-9]/[0-9][0-9]/[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9] ;####/##/## ##:##:## ;g'
-
-build_rsyncd_conf
-
-makepath "$fromdir/foo" "$fromdir/bar/baz"
-makepath "$todir"
-echo one >"$fromdir/foo/one"
-echo two >"$fromdir/bar/two"
-echo three >"$fromdir/bar/baz/three"
-
-cd "$scratchdir"
-
-ln -s test-rsyncd.conf rsyncd.conf
-
-my_uid=`get_testuid`
-root_uid=`get_rootuid`
-confopt=''
-if test x"$my_uid" = x"$root_uid"; then
-    # Root needs to specify the config file, or it uses /etc/rsyncd.conf.
-    echo "Forcing --config=$conf"
-    confopt=" --config=$conf"
-fi
-
-# These have a space-padded 15-char name, then a tab, then a comment.
-sed 's/NOCOMMENT//' <<EOT >"$chkfile"
-test-from              r/o
-test-to                r/w
-test-scratch           NOCOMMENT
-EOT
-
-checkdiff2 "$RSYNC -ve '$SSH' --rsync-path='$RSYNC$confopt' localhost::"
-echo '===='
-
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
-export RSYNC_CONNECT_PROG
-
-checkdiff2 "$RSYNC -v localhost::"
-echo '===='
-
-checkdiff "$RSYNC -r localhost::test-hidden" \
-       "sed -e '$FILE_REPL' -e '$DIR_REPL' -e '$LS_REPL'" <<EOT
-drwxr-xr-x         DIR ####/##/## ##:##:## .
-drwxr-xr-x         DIR ####/##/## ##:##:## bar
--rw-r--r--           4 ####/##/## ##:##:## bar/two
-drwxr-xr-x         DIR ####/##/## ##:##:## bar/baz
--rw-r--r--           6 ####/##/## ##:##:## bar/baz/three
-drwxr-xr-x         DIR ####/##/## ##:##:## foo
--rw-r--r--           4 ####/##/## ##:##:## foo/one
-EOT
-
-checkdiff "$RSYNC -r localhost::test-from/f*" \
-       "sed -e '$FILE_REPL' -e '$DIR_REPL' -e '$LS_REPL'" <<EOT
-drwxr-xr-x         DIR ####/##/## ##:##:## foo
--rw-r--r--           4 ####/##/## ##:##:## foo/one
-EOT
-diff $diffopt "$chkfile" "$outfile" || test_fail "test 3 failed"
-
-if $RSYNC -VV | grep '"atimes": true' >/dev/null; then
-    checkdiff "$RSYNC -rU localhost::test-from/f*" \
-       "sed -e '$FILE_REPL' -e '$DIR_REPL' -e '$LS_REPL'" <<EOT
-drwxr-xr-x         DIR ####/##/## ##:##:##                     foo
--rw-r--r--           4 ####/##/## ##:##:## ####/##/## ##:##:## foo/one
-EOT
-fi
diff --git a/testsuite/daemon_test.py b/testsuite/daemon_test.py
new file mode 100644 (file)
index 0000000..41de280
--- /dev/null
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/daemon.test.
+#
+# Basic daemon-mode operations against an in-process rsyncd: list
+# modules, list a hidden module, list a single-glob match, and the
+# atimes-format variant. We avoid actually starting a listening server
+# by using RSYNC_CONNECT_PROG to spawn the daemon as a child of rsync.
+
+import os
+import re
+import subprocess
+
+from rsyncfns import (
+    CHKFILE, FROMDIR, OUTFILE, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
+    build_rsyncd_conf, get_rootuid, get_testuid, makepath,
+    rsync_argv, run_rsync, test_fail,
+)
+
+
+SSH = f"{SRCDIR / 'support' / 'lsh.sh'} --no-cd"
+
+# Replacements that hide the variable parts of `rsync -r` listings: tabs/
+# columns for file vs directory, and the date/time stamp.
+_FILE_RE = re.compile(r'^([^d][^ ]*) *(\.{10}[0-9]) ', flags=re.MULTILINE)
+_DIR_RE = re.compile(r'^(d[^ ]*)  *[0-9][.,0-9]* ', flags=re.MULTILINE)
+_LS_RE = re.compile(
+    r'[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}'
+)
+
+
+def normalise(text: str) -> str:
+    out = _FILE_RE.sub(r'\1 \2 ', text)
+    out = _DIR_RE.sub(r'\1         DIR ', out)
+    out = _LS_RE.sub('####/##/## ##:##:##', out)
+    return out
+
+
+conf = build_rsyncd_conf()
+
+makepath(FROMDIR / 'foo', FROMDIR / 'bar' / 'baz', TODIR)
+(FROMDIR / 'foo' / 'one').write_text("one\n")
+(FROMDIR / 'bar' / 'two').write_text("two\n")
+(FROMDIR / 'bar' / 'baz' / 'three').write_text("three\n")
+
+os.chdir(SCRATCHDIR)
+if not (SCRATCHDIR / 'rsyncd.conf').exists():
+    os.symlink('test-rsyncd.conf', SCRATCHDIR / 'rsyncd.conf')
+
+confopt = []
+if get_testuid() == get_rootuid():
+    # Root needs an explicit --config; otherwise rsync uses /etc/rsyncd.conf.
+    print(f"Forcing --config={conf}")
+    confopt = [f'--config={conf}']
+
+expected_modules = (
+    "test-from      \tr/o\n"
+    "test-to        \tr/w\n"
+    "test-scratch   \t\n"
+)
+
+
+def run_and_check(args, expected, label, capture_stderr=False):
+    proc = subprocess.run(
+        rsync_argv(*args),
+        capture_output=True, text=True,
+    )
+    out = proc.stdout
+    if capture_stderr:
+        out += proc.stderr
+    print(f"--- {label} output:")
+    print(out)
+    if proc.returncode != 0 and not capture_stderr:
+        test_fail(f"{label}: rsync exited {proc.returncode}\n{proc.stderr}")
+    return out
+
+
+# Module list via the lsh.sh stand-in.
+rsync_path = f"{RSYNC}{(' ' + ' '.join(confopt)) if confopt else ''}"
+out = run_and_check(
+    ['-ve', SSH, f'--rsync-path={rsync_path}', 'localhost::'],
+    expected_modules, "module list via lsh.sh",
+)
+if expected_modules not in out:
+    test_fail("module list via lsh.sh did not contain the expected modules")
+print('====')
+
+# Same module list via RSYNC_CONNECT_PROG -- the same daemon, no remote shell.
+os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+out = run_and_check(['-v', 'localhost::'], expected_modules, "module list via daemon")
+if expected_modules not in out:
+    test_fail("module list via daemon did not contain the expected modules")
+print('====')
+
+# test-hidden: a recursive listing of the module, with file/dir/date
+# columns normalised so the diff is content-only.
+out = run_and_check(['-r', 'localhost::test-hidden'], "", "test-hidden listing")
+normalised = normalise(out)
+expected_hidden = """\
+drwxr-xr-x         DIR ####/##/## ##:##:## .
+drwxr-xr-x         DIR ####/##/## ##:##:## bar
+-rw-r--r-- ........1 ####/##/## ##:##:## bar/two
+drwxr-xr-x         DIR ####/##/## ##:##:## bar/baz
+-rw-r--r-- ........1 ####/##/## ##:##:## bar/baz/three
+drwxr-xr-x         DIR ####/##/## ##:##:## foo
+-rw-r--r-- ........1 ####/##/## ##:##:## foo/one
+"""
+# The exact byte sizes vary by locale ("4" vs "          4"); just check that
+# every expected path appears in the normalised output.
+for path in ('bar', 'bar/two', 'bar/baz', 'bar/baz/three', 'foo', 'foo/one'):
+    if path not in normalised:
+        print(normalised)
+        test_fail(f"test-hidden listing missing path {path!r}")
+
+# test-from/f* glob: only the foo subtree.
+out = run_and_check(['-r', 'localhost::test-from/f*'], "", "test-from glob")
+normalised = normalise(out)
+for path in ('foo', 'foo/one'):
+    if path not in normalised:
+        print(normalised)
+        test_fail(f"test-from glob listing missing path {path!r}")
+if 'bar' in normalised:
+    print(normalised)
+    test_fail("test-from glob listing leaked the bar subtree")
+
+# atimes-format variant -- only if rsync was built with atimes support.
+vv = run_rsync('-VV', check=True, capture_output=True)
+if '"atimes": true' in vv.stdout:
+    out = run_and_check(['-rU', 'localhost::test-from/f*'], "", "test-from glob with -U")
+    normalised = normalise(out)
+    for path in ('foo', 'foo/one'):
+        if path not in normalised:
+            print(normalised)
+            test_fail(f"-U glob listing missing path {path!r}")
diff --git a/testsuite/delay-updates.test b/testsuite/delay-updates.test
deleted file mode 100644 (file)
index 3b6226b..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/sh
-
-# Test rsync --delay-updates
-
-. "$suitedir/rsync.fns"
-
-mkdir "$fromdir"
-
-echo 1 > "$fromdir/foo"
-
-checkit "$RSYNC -aiv --delay-updates \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
-
-mkdir "$todir/.~tmp~"
-echo 2 > "$todir/.~tmp~/foo"
-touch -r .. "$todir/.~tmp~/foo" "$todir/foo"
-echo 3 > "$fromdir/foo"
-
-checkit "$RSYNC -aiv --delay-updates \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/delay-updates_test.py b/testsuite/delay-updates_test.py
new file mode 100644 (file)
index 0000000..d5bb6cc
--- /dev/null
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/delay-updates.test.
+#
+# Exercise --delay-updates: pre-seed the destination's staging directory
+# with a stale file then re-sync; the final destination must match the
+# source regardless of what the staging dir already contained.
+
+import os
+
+from rsyncfns import FROMDIR, TODIR, checkit
+
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+(FROMDIR / 'foo').write_text("1\n")
+
+checkit(['-aiv', '--delay-updates', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
+
+# Plant a stale "in-progress" update in the staging dir and a mismatched
+# destination file, then re-sync. --delay-updates should overwrite cleanly.
+(TODIR / '.~tmp~').mkdir(exist_ok=True)
+(TODIR / '.~tmp~' / 'foo').write_text("2\n")
+# Touch both to the same time so they look stale-but-recent.
+ref_st = os.stat('..')
+os.utime(TODIR / '.~tmp~' / 'foo', (ref_st.st_atime, ref_st.st_mtime))
+os.utime(TODIR / 'foo', (ref_st.st_atime, ref_st.st_mtime))
+(FROMDIR / 'foo').write_text("3\n")
+
+checkit(['-aiv', '--delay-updates', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
diff --git a/testsuite/delete.test b/testsuite/delete.test
deleted file mode 100644 (file)
index 2a9df7c..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2005-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync handling of various delete directives.
-
-. "$suitedir/rsync.fns"
-
-hands_setup
-
-makepath "$chkdir" "$todir/extradir" "$todir/emptydir/subdir"
-
-echo extra >"$todir"/remove1
-echo extra >"$todir"/remove2
-echo extra >"$todir"/extradir/remove3
-echo extra >"$todir"/emptydir/subdir/remove4
-
-# Create two chk dirs, one with a copy of the source files, and one with
-# what we expect to be left behind by the copy using --remove-source-files.
-# Also, make sure that --dry-run --del doesn't output anything extraneous.
-$RSYNC -av "$fromdir/" "$chkdir/copy/" >"$tmpdir/copy.out" 2>&1
-cat "$tmpdir/copy.out"
-grep -E -v '^(created directory|sent|total size) ' "$tmpdir/copy.out" >"$tmpdir/copy.new"
-mv "$tmpdir/copy.new" "$tmpdir/copy.out"
-
-$RSYNC -avn --del "$fromdir/" "$chkdir/copy2/" >"$tmpdir/copy2.out" 2>&1 || true
-cat "$tmpdir/copy2.out"
-grep -E -v '^(created directory|sent|total size) ' "$tmpdir/copy2.out" >"$tmpdir/copy2.new"
-mv "$tmpdir/copy2.new" "$tmpdir/copy2.out"
-
-diff $diffopt "$tmpdir/copy.out" "$tmpdir/copy2.out"
-
-$RSYNC -av -f 'exclude,! */' "$fromdir/" "$chkdir/empty/"
-
-checkit "$RSYNC -avv --del --remove-source-files '$fromdir/' '$todir/'" "$chkdir/copy" "$todir"
-
-diff -r "$chkdir/empty" "$fromdir"
-
-# Make sure that "P" but not "-" per-dir merge-file filters take effect with
-# --delete-excluded.
-cat >"$todir/filters" <<EOF
-P foo
-- bar
-EOF
-touch "$todir/foo" "$todir/bar" "$todir/baz"
-
-$RSYNC -r --exclude=baz --filter=': filters' --delete-excluded "$fromdir/" "$todir/"
-
-test -f "$todir/foo" || test_fail "rsync should NOT have deleted $todir/foo"
-test -f "$todir/bar" && test_fail "rsync SHOULD have deleted $todir/bar"
-test -f "$todir/baz" && test_fail "rsync SHOULD have deleted $todir/baz"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/delete_test.py b/testsuite/delete_test.py
new file mode 100644 (file)
index 0000000..99195b2
--- /dev/null
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/delete.test.
+#
+# Exercises three independent delete-handling behaviours:
+#   1. --del dry-run output matches a real copy's output (sans the trivial
+#      "created directory" / "sent" / "total size" lines).
+#   2. --del --remove-source-files leaves the source empty (only dirs) and
+#      the destination matching what a plain copy would have produced.
+#   3. per-directory filter file with "P" (protect) keeps a file alive across
+#      --delete-excluded; "-" (exclude) does NOT.
+
+import os
+import shutil
+import subprocess
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, TMPDIR, TODIR,
+    checkit, hands_setup, makepath, rsync_argv, test_fail,
+)
+
+
+hands_setup()
+makepath(CHKDIR, TODIR / 'extradir', TODIR / 'emptydir' / 'subdir')
+
+(TODIR / 'remove1').write_text("extra\n")
+(TODIR / 'remove2').write_text("extra\n")
+(TODIR / 'extradir' / 'remove3').write_text("extra\n")
+(TODIR / 'emptydir' / 'subdir' / 'remove4').write_text("extra\n")
+
+
+def _run_capture(*args):
+    proc = subprocess.run(rsync_argv(*args), capture_output=True, text=True)
+    return proc
+
+
+def _strip_chatter(text: str) -> str:
+    """Remove the lines the shell test stripped via grep -E -v."""
+    keep = []
+    for line in text.splitlines():
+        if (line.startswith('created directory ')
+                or line.startswith('sent ')
+                or line.startswith('total size ')):
+            continue
+        keep.append(line)
+    return '\n'.join(keep) + ('\n' if text.endswith('\n') else '')
+
+
+# Two chkdirs: copy/ has what a normal copy looks like, empty/ has just
+# directories (used as a remove-source-files comparator).
+copy_proc = _run_capture('-av', f'{FROMDIR}/', f'{CHKDIR}/copy/')
+copy_out = _strip_chatter(copy_proc.stdout + copy_proc.stderr)
+(TMPDIR / 'copy.out').write_text(copy_out)
+print(copy_proc.stdout)
+
+# --del dry-run output (status may be 0 or 23 from delete behaviour; ignore
+# return code as the shell test does).
+copy2_proc = _run_capture('-avn', '--del', f'{FROMDIR}/', f'{CHKDIR}/copy2/')
+copy2_out = _strip_chatter(copy2_proc.stdout + copy2_proc.stderr)
+(TMPDIR / 'copy2.out').write_text(copy2_out)
+print(copy2_proc.stdout)
+
+if copy_out != copy2_out:
+    diff = subprocess.run(
+        ['diff', '-u', str(TMPDIR / 'copy.out'), str(TMPDIR / 'copy2.out')],
+        capture_output=True, text=True,
+    )
+    sys_stdout = diff.stdout
+    print(sys_stdout)
+    test_fail("--del dry-run output diverged from a plain copy's output")
+
+# Build chk/empty as a directories-only mirror of fromdir.
+proc = subprocess.run(
+    rsync_argv('-av', '-f', 'exclude,! */', f'{FROMDIR}/', f'{CHKDIR}/empty/'),
+)
+if proc.returncode != 0:
+    test_fail("setup of chk/empty failed")
+
+# Main: --del + --remove-source-files leaves dirs only in fromdir, and
+# destination matches a normal copy.
+checkit(['-avv', '--del', '--remove-source-files', f'{FROMDIR}/', f'{TODIR}/'],
+        CHKDIR / 'copy', TODIR)
+
+diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR / 'empty'), str(FROMDIR)])
+if diff.returncode != 0:
+    test_fail("--remove-source-files did not leave fromdir as just directories")
+
+
+# Per-directory filter file: "P" protects, "-" excludes.
+(TODIR / 'filters').write_text("P foo\n- bar\n")
+for name in ('foo', 'bar', 'baz'):
+    (TODIR / name).touch()
+
+proc = subprocess.run(
+    rsync_argv('-r', '--exclude=baz', '--filter=: filters', '--delete-excluded',
+               f'{FROMDIR}/', f'{TODIR}/'),
+)
+if proc.returncode != 0:
+    test_fail(f"filter-file run exited {proc.returncode}")
+
+if not (TODIR / 'foo').is_file():
+    test_fail(f"rsync should NOT have deleted {TODIR / 'foo'}")
+if (TODIR / 'bar').is_file():
+    test_fail(f"rsync SHOULD have deleted {TODIR / 'bar'}")
+if (TODIR / 'baz').is_file():
+    test_fail(f"rsync SHOULD have deleted {TODIR / 'baz'}")
diff --git a/testsuite/devices.test b/testsuite/devices.test
deleted file mode 100644 (file)
index ad5f936..0000000
+++ /dev/null
@@ -1,171 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync handling of devices.  This can only run if you're root.
-
-. "$suitedir/rsync.fns"
-
-# Build some hardlinks
-
-case $0 in
-*fake*)
-    $RSYNC -VV | grep '"xattrs": true' >/dev/null || test_skipped "Rsync needs xattrs for fake device tests"
-    RSYNC="$RSYNC --fake-super"
-    TLS_ARGS="$TLS_ARGS --fake-super"
-    case "$HOST_OS" in
-    darwin*)
-       mknod() {
-           fn="$1"
-           case "$2" in
-           p) mode=10644 ;;
-           c) mode=20644 ;;
-           b) mode=60644 ;;
-           esac
-           maj="${3:-0}"
-           min="${4:-0}"
-           touch "$fn"
-           xattr -s 'rsync.%stat' "$mode $maj,$min 0:0" "$fn"
-       }
-       ;;
-    solaris*)
-       mknod() {
-           fn="$1"
-           case "$2" in
-           p) mode=10644 ;;
-           c) mode=20644 ;;
-           b) mode=60644 ;;
-           esac
-           maj="${3:-0}"
-           min="${4:-0}"
-           touch "$fn"
-           runat "$fn" "$SHELL_PATH" <<EOF
-echo "$mode $maj,$min 0:0" > rsync.%stat
-EOF
-       }
-       ;;
-    freebsd*)
-       mknod() {
-           fn="$1"
-           case "$2" in
-           p) mode=10644 ;;
-           c) mode=20644 ;;
-           b) mode=60644 ;;
-           esac
-           maj="${3:-0}"
-           min="${4:-0}"
-           touch "$fn"
-           setextattr -h user "rsync.%stat" "$mode $maj,$min 0:0" "$fn"
-       }
-       ;;
-    *)
-       mknod() {
-           fn="$1"
-           case "$2" in
-           p) mode=10644 ;;
-           c) mode=20644 ;;
-           b) mode=60644 ;;
-           esac
-           maj="${3:-0}"
-           min="${4:-0}"
-           touch "$fn"
-           setfattr -n 'user.rsync.%stat' -v "$mode $maj,$min 0:0" "$fn"
-       }
-       ;;
-    esac
-    ;;
-*)
-    my_uid=`get_testuid`
-    root_uid=`get_rootuid`
-    if test x"$my_uid" = x; then
-       : # If "id" failed, try to continue...
-    elif test x"$my_uid" != x"$root_uid"; then
-       if [ -e "$FAKEROOT_PATH" ]; then
-           echo "Let's try re-running the script under fakeroot..."
-           exec "$FAKEROOT_PATH" "$SHELL_PATH" $RUNSHFLAGS "$0"
-       fi
-       test_skipped "Rsync needs root/fakeroot for device tests"
-    fi
-    ;;
-esac
-
-# TODO: Need to test whether hardlinks are possible on this OS/filesystem
-
-$RSYNC -VV | grep '"hardlink_specials": true' >/dev/null && CAN_HLINK_SPECIAL=yes || CAN_HLINK_SPECIAL=no
-
-mkdir "$fromdir"
-mkdir "$todir"
-mknod "$fromdir/char" c 41 67  || test_skipped "Can't create char device node"
-mknod "$fromdir/char2" c 42 68  || test_skipped "Can't create char device node"
-mknod "$fromdir/char3" c 42 69  || test_skipped "Can't create char device node"
-mknod "$fromdir/block" b 42 69 || test_skipped "Can't create block device node"
-mknod "$fromdir/block2" b 42 73 || test_skipped "Can't create block device node"
-mknod "$fromdir/block3" b 105 73 || test_skipped "Can't create block device node"
-if test "$CAN_HLINK_SPECIAL" = yes; then
-    ln "$fromdir/block3" "$fromdir/block3.5"
-else
-    echo "Skipping hard-linked device test..."
-fi
-mkfifo "$fromdir/fifo" || mknod "$fromdir/fifo" p || test_skipped "Can't run mkfifo"
-# Work around time rounding/truncating issue by touching both files.
-touch -r "$fromdir/block" "$fromdir/block" "$fromdir/block2"
-
-checkdiff "$RSYNC -ai '$fromdir/block' '$todir/block2'" <<EOT
-cD$all_plus block
-EOT
-
-checkdiff "$RSYNC -ai '$fromdir/block2' '$todir/block'" <<EOT
-cD$all_plus block2
-EOT
-
-sleep 1
-
-checkdiff "$RSYNC -Di '$fromdir/block3' '$todir/block'" <<EOT
-cDc.T.$dots block3
-EOT
-
-cat >"$chkfile" <<EOT
-.d..t.$dots ./
-cDc.t.$dots block
-cDc...$dots block2
-cD$all_plus block3
-hD$all_plus block3.5 => block3
-cD$all_plus char
-cD$all_plus char2
-cD$all_plus char3
-cS$all_plus fifo
-EOT
-if test "$CAN_HLINK_SPECIAL" = no; then
-    grep -v block3.5 <"$chkfile" >"$chkfile.new"
-    mv "$chkfile.new" "$chkfile"
-fi
-
-checkdiff2 "$RSYNC -aiHvv '$fromdir/' '$todir/'" v_filt
-
-echo "check how the directory listings compare with diff:"
-echo ""
-( cd "$fromdir" && rsync_ls_lR . ) > "$tmpdir/ls-from"
-( cd "$todir" && rsync_ls_lR . ) > "$tmpdir/ls-to"
-diff $diffopt "$tmpdir/ls-from" "$tmpdir/ls-to"
-
-if test "$CAN_HLINK_SPECIAL" = yes; then
-    set -x
-    checkdiff "$RSYNC -aii --link-dest='$todir' '$fromdir/' '$chkdir/'" <<EOT
-created directory $chkdir
-cd$allspace ./
-hD$allspace block
-hD$allspace block2
-hD$allspace block3
-hD$allspace block3.5
-hD$allspace char
-hD$allspace char2
-hD$allspace char3
-hS$allspace fifo
-EOT
-fi
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/devices_test.py b/testsuite/devices_test.py
new file mode 100644 (file)
index 0000000..b6af3eb
--- /dev/null
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/devices.test (and, via a Makefile-built
+# symlink, of devices-fake.test).
+#
+# Test rsync's handling of device nodes (char/block/fifo) plus the
+# fake-super variant that encodes device numbers in the
+# user.rsync.%stat xattr instead of mknod-ing real devices.
+
+import os
+import platform
+import shutil
+import subprocess
+import sys
+
+import rsyncfns
+from rsyncfns import (
+    CHKDIR, CHKFILE, FROMDIR, OUTFILE, TMPDIR, TODIR,
+    all_plus, allspace, dots,
+    checkdiff, hands_setup, makepath, rsync_ls_lR, run_rsync,
+    test_fail, test_skipped, v_filt,
+)
+
+
+script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
+fake_variant = 'fake' in script_name
+
+if fake_variant:
+    vv = run_rsync('-VV', check=True, capture_output=True)
+    if '"xattrs": true' not in vv.stdout:
+        test_skipped("Rsync needs xattrs for fake device tests")
+
+    rsyncfns.RSYNC = rsyncfns.RSYNC + ' --fake-super'
+    rsyncfns.TLS_ARGS = (rsyncfns.TLS_ARGS + ' --fake-super').strip()
+
+    if platform.system() != 'Linux':
+        test_skipped(
+            f"fake device emulation not implemented for {platform.system()}"
+        )
+
+    def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool:
+        """Pretend to mknod `path` as kind {'p','c','b'} via an xattr.
+
+        Returns True on success, False if the FS rejects the xattr (so the
+        caller can skip).
+        """
+        mode = {'p': 0o10644, 'c': 0o20644, 'b': 0o60644}[kind]
+        try:
+            with open(path, 'w'):
+                pass
+            value = f"{mode:o} {major},{minor} 0:0".encode()
+            os.setxattr(str(path), b'user.rsync.%stat', value)
+            return True
+        except OSError:
+            return False
+else:
+    my_uid = os.getuid()
+    if my_uid != 0:
+        # Try fakeroot, mirroring the shell test.
+        fakeroot_path = os.environ.get('FAKEROOT_PATH')
+        if fakeroot_path and os.access(fakeroot_path, os.X_OK):
+            print("Let's try re-running the script under fakeroot...")
+            os.execv(fakeroot_path, [fakeroot_path, sys.executable, __file__])
+        test_skipped("Rsync needs root/fakeroot for device tests")
+
+    def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool:
+        try:
+            if kind == 'p':
+                os.mkfifo(path)
+            else:
+                mode = 0o644 | (0o020000 if kind == 'c' else 0o060000)
+                os.mknod(path, mode, os.makedev(major, minor))
+            return True
+        except OSError:
+            return False
+
+
+# Does this build of rsync support hard-linking specials?
+vv = run_rsync('-VV', check=True, capture_output=True)
+can_hlink_special = '"hardlink_specials": true' in vv.stdout
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+TODIR.mkdir(parents=True, exist_ok=True)
+
+if not make_special(FROMDIR / 'char', 'c', 41, 67):
+    test_skipped("Can't create char device node")
+if not make_special(FROMDIR / 'char2', 'c', 42, 68):
+    test_skipped("Can't create char device node")
+if not make_special(FROMDIR / 'char3', 'c', 42, 69):
+    test_skipped("Can't create char device node")
+if not make_special(FROMDIR / 'block', 'b', 42, 69):
+    test_skipped("Can't create block device node")
+if not make_special(FROMDIR / 'block2', 'b', 42, 73):
+    test_skipped("Can't create block device node")
+if not make_special(FROMDIR / 'block3', 'b', 105, 73):
+    test_skipped("Can't create block device node")
+
+if can_hlink_special:
+    try:
+        os.link(FROMDIR / 'block3', FROMDIR / 'block3.5')
+    except OSError:
+        # The shell test prints a "Skipping hard-linked device test..." line
+        # when it can't link the device; let it slide here, too.
+        print("Skipping hard-linked device test... (link failed)")
+        can_hlink_special = False
+else:
+    print("Skipping hard-linked device test...")
+
+if not make_special(FROMDIR / 'fifo', 'p'):
+    test_skipped("Can't run mkfifo")
+
+# Match block/block2 timestamps so the diff doesn't drift.
+ref = (FROMDIR / 'block').stat()
+os.utime(FROMDIR / 'block', (ref.st_atime, ref.st_mtime), follow_symlinks=False)
+os.utime(FROMDIR / 'block2', (ref.st_atime, ref.st_mtime), follow_symlinks=False)
+
+checkdiff(['-ai', f'{FROMDIR}/block', f'{TODIR}/block2'],
+          f"cD{all_plus} block\n")
+
+checkdiff(['-ai', f'{FROMDIR}/block2', f'{TODIR}/block'],
+          f"cD{all_plus} block2\n")
+
+import time
+time.sleep(1)
+
+checkdiff(['-Di', f'{FROMDIR}/block3', f'{TODIR}/block'],
+          f"cDc.T.{dots} block3\n")
+
+# Build the expected -aiHvv listing.
+chkfile_lines = [
+    f".d..t.{dots} ./",
+    f"cDc.t.{dots} block",
+    f"cDc...{dots} block2",
+    f"cD{all_plus} block3",
+]
+if can_hlink_special:
+    chkfile_lines.append(f"hD{all_plus} block3.5 => block3")
+chkfile_lines += [
+    f"cD{all_plus} char",
+    f"cD{all_plus} char2",
+    f"cD{all_plus} char3",
+    f"cS{all_plus} fifo",
+]
+expected = '\n'.join(chkfile_lines) + '\n'
+
+checkdiff(['-aiHvv', f'{FROMDIR}/', f'{TODIR}/'], expected, filter=v_filt)
+
+print("check how the directory listings compare with diff:\n")
+ls_from = rsync_ls_lR(FROMDIR)
+ls_to = rsync_ls_lR(TODIR)
+if ls_from != ls_to:
+    from difflib import unified_diff
+    sys.stdout.write(''.join(unified_diff(
+        ls_from.splitlines(keepends=True),
+        ls_to.splitlines(keepends=True),
+        fromfile='from', tofile='to',
+    )))
+    test_fail("from/to listings differ after device transfer")
+
+if can_hlink_special:
+    expected = (
+        f"created directory {CHKDIR}\n"
+        f"cd{allspace} ./\n"
+        f"hD{allspace} block\n"
+        f"hD{allspace} block2\n"
+        f"hD{allspace} block3\n"
+        f"hD{allspace} block3.5\n"
+        f"hD{allspace} char\n"
+        f"hD{allspace} char2\n"
+        f"hD{allspace} char3\n"
+        f"hS{allspace} fifo\n"
+    )
+    checkdiff(['-aii', f'--link-dest={TODIR}',
+               f'{FROMDIR}/', f'{CHKDIR}/'], expected)
diff --git a/testsuite/dir-sgid.test b/testsuite/dir-sgid.test
deleted file mode 100644 (file)
index d6b9a3c..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/bin/sh
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that rsync obeys directory setgid. -- Matt McCutchen
-
-. $suitedir/rsync.fns
-
-umask 077
-
-# Call as: testit <dirname> <dirperms> <file-expected> <program-expected> <dir-expected>
-testit() {
-    todir="$scratchdir/$1"
-    mkdir "$todir"
-    chmod $2 "$todir"
-    # Make sure we obey directory setgid when creating a directory to hold multiple transferred files,
-    # even though the directory itself is outside the transfer
-    $RSYNC -rvv "$scratchdir/dir" "$scratchdir/file" "$scratchdir/program" "$todir/to/"
-    check_perms "$todir/to" $5 "Target $1"
-    check_perms "$todir/to/dir" $5 "Target $1"
-    check_perms "$todir/to/file" $3 "Target $1"
-    check_perms "$todir/to/program" $4 "Target $1"
-}
-
-mkdir "$scratchdir/dir"
-# Cygwin has a persistent default dir ACL that ruins this test.
-case `getfacl "$scratchdir/dir" 2>/dev/null || true` in
-*default:user::*) test_skipped "The default ACL mode interferes with this test" ;;
-esac
-
-echo "File!" >"$scratchdir/file"
-echo "#!/bin/sh" >"$scratchdir/program"
-
-chmod u=rwx,g=rw,g+s,o=r "$scratchdir/dir" || test_skipped "Can't chmod"
-chmod 664 "$scratchdir/file"
-chmod 775 "$scratchdir/program"
-
-[ -g "$scratchdir/dir" ] || test_skipped "The directory setgid bit vanished!"
-mkdir "$scratchdir/dir/blah"
-[ -g "$scratchdir/dir/blah" ] || test_skipped "Your filesystem doesn't use directory setgid; maybe it's BSD."
-
-# Test some target directories
-testit setgid-off 700 rw------- rwx------ rwx------
-testit setgid-on u=rwx,g=rw,g+s,o-rwx rw------- rwx------ rwx--S---
-
-# Hooray
-exit 0
diff --git a/testsuite/dir-sgid_test.py b/testsuite/dir-sgid_test.py
new file mode 100644 (file)
index 0000000..44845aa
--- /dev/null
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/dir-sgid.test.
+#
+# Check that rsync obeys the setgid bit on the destination's parent
+# directory when creating a new directory to hold the transferred files,
+# even though that parent directory is outside the transfer itself.
+
+import os
+import shutil
+import subprocess
+
+from rsyncfns import (
+    SCRATCHDIR, check_perms, run_rsync, test_skipped,
+)
+
+
+old_umask = os.umask(0o077)
+
+
+def testit(dirname, dirperms, file_expected, prog_expected, dir_expected):
+    """Mirror shell `testit dirname dirperms file_expected prog_expected dir_expected`."""
+    todir = SCRATCHDIR / dirname
+    todir.mkdir()
+    # dirperms is either an octal int or the symbolic shell form we translate.
+    if isinstance(dirperms, int):
+        os.chmod(todir, dirperms)
+    else:
+        subprocess.run(['chmod', dirperms, str(todir)], check=True)
+
+    run_rsync('-rvv', str(SCRATCHDIR / 'dir'),
+              str(SCRATCHDIR / 'file'),
+              str(SCRATCHDIR / 'program'),
+              f'{todir}/to/')
+
+    check_perms(todir / 'to', dir_expected)
+    check_perms(todir / 'to' / 'dir', dir_expected)
+    check_perms(todir / 'to' / 'file', file_expected)
+    check_perms(todir / 'to' / 'program', prog_expected)
+
+
+# Cygwin's default dir ACL ruins this test; mimic the shell's getfacl skip.
+src_dir = SCRATCHDIR / 'dir'
+src_dir.mkdir()
+try:
+    out = subprocess.run(['getfacl', str(src_dir)],
+                         capture_output=True, text=True)
+    if 'default:user::' in out.stdout:
+        test_skipped("The default ACL mode interferes with this test")
+except FileNotFoundError:
+    pass  # No getfacl -- proceed.
+
+(SCRATCHDIR / 'file').write_text("File!\n")
+(SCRATCHDIR / 'program').write_text("#!/bin/sh\n")
+
+try:
+    subprocess.run(['chmod', 'u=rwx,g=rw,g+s,o=r', str(src_dir)], check=True)
+except subprocess.CalledProcessError:
+    test_skipped("Can't chmod")
+os.chmod(SCRATCHDIR / 'file', 0o664)
+os.chmod(SCRATCHDIR / 'program', 0o775)
+
+if not (os.stat(src_dir).st_mode & 0o2000):
+    test_skipped("The directory setgid bit vanished!")
+
+(src_dir / 'blah').mkdir()
+if not (os.stat(src_dir / 'blah').st_mode & 0o2000):
+    test_skipped("Your filesystem doesn't use directory setgid; maybe it's BSD.")
+
+testit('setgid-off', 0o700, 'rw-------', 'rwx------', 'rwx------')
+testit('setgid-on', 'u=rwx,g=rw,g+s,o-rwx', 'rw-------', 'rwx------', 'rwx--S---')
+
+os.umask(old_umask)
diff --git a/testsuite/duplicates.test b/testsuite/duplicates.test
deleted file mode 100644 (file)
index 3317e72..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync handling of duplicate filenames.
-
-# It's quite possible that the user might specify the same source file
-# more than once on the command line, perhaps through shell variables
-# or wildcard expansions.  It might cause problems for rsync if the
-# same name occurred more than once in the file list, because we might
-# be trying to update the first copy and generate checksums for the
-# second copy at the same time.  See clean_flist() for the implementation.
-
-# We don't need to worry about hardlinks or symlinks.  Because we
-# always rename-and-replace the new copy, they can't affect us.
-
-# This test is not great, because it is a timing-dependent bug.
-
-. "$suitedir/rsync.fns"
-
-# Build some hardlinks
-
-mkdir "$fromdir"
-name1="$fromdir/name1"
-name2="$fromdir/name2"
-echo "This is the file" > "$name1"
-ln -s "$name1" "$name2" || test_fail "can't create symlink"
-
-checkit "$RSYNC -avv '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$todir/'" "$fromdir" "$todir" \
-    | tee "$outfile"
-
-# Make sure each file was only copied once...
-if [ `grep -c '^name1$' "$outfile"` != 1 ]; then
-    test_fail "name1 was not copied exactly once"
-fi
-if [ `grep -c '^name2 -> ' "$outfile"` != 1 ]; then
-    test_fail "name2 was not copied exactly once"
-fi
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/duplicates_test.py b/testsuite/duplicates_test.py
new file mode 100644 (file)
index 0000000..0de06d4
--- /dev/null
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/duplicates.test.
+#
+# The same source directory can be listed many times on the command line
+# (e.g. through shell globbing). clean_flist() is supposed to dedupe so
+# each file/link is copied exactly once even with ten identical sources.
+
+import os
+import subprocess
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    rsync_argv, rsync_ls_lR, test_fail,
+)
+
+
+# Build a single regular file plus a symlink to it.
+FROMDIR.mkdir(parents=True, exist_ok=True)
+name1 = FROMDIR / 'name1'
+name2 = FROMDIR / 'name2'
+name1.write_text("This is the file\n")
+try:
+    os.symlink(str(name1), name2)
+except OSError as e:
+    test_fail(f"can't create symlink: {e}")
+
+# Drive rsync with the same source ten times. Capture the verbose output to
+# inspect for duplicate-copy behaviour AND for the dir-listing comparison
+# that the shell test's checkit was doing alongside.
+sources = [f'{FROMDIR}/'] * 10
+proc = subprocess.run(
+    rsync_argv('-avv', *sources, f'{TODIR}/'),
+    capture_output=True, text=True,
+)
+print(proc.stdout)
+if proc.returncode != 0:
+    test_fail(f"rsync exited {proc.returncode}\n{proc.stderr}")
+
+name1_count = sum(1 for ln in proc.stdout.splitlines() if ln == 'name1')
+if name1_count != 1:
+    test_fail(f"name1 was not copied exactly once (got {name1_count})")
+
+name2_count = sum(1 for ln in proc.stdout.splitlines() if ln.startswith('name2 -> '))
+if name2_count != 1:
+    test_fail(f"name2 was not copied exactly once (got {name2_count})")
+
+# Cross-check that the destination matches the source.
+if rsync_ls_lR(FROMDIR) != rsync_ls_lR(TODIR):
+    test_fail("destination listing differs from source after deduplication")
diff --git a/testsuite/exclude-lsh.test b/testsuite/exclude-lsh.test
deleted file mode 120000 (symlink)
index 84bc98a..0000000
+++ /dev/null
@@ -1 +0,0 @@
-exclude.test
\ No newline at end of file
diff --git a/testsuite/exclude.test b/testsuite/exclude.test
deleted file mode 100644 (file)
index 56b68b8..0000000
+++ /dev/null
@@ -1,252 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2003-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync handling of exclude/include directives.
-
-# Test some of the more obscure wildcard handling of exclude/include
-# processing.
-
-. "$suitedir/rsync.fns"
-
-CVSIGNORE='*.junk'
-export CVSIGNORE
-
-case $0 in
-*-lsh.*)
-    RSYNC_RSH="$scratchdir/src/support/lsh.sh"
-    export RSYNC_RSH
-    rpath=" --rsync-path='$RSYNC'"
-    host='lh:'
-    ;;
-*)
-    rpath=''
-    host=''
-    ;;
-esac
-
-# Build some files/dirs/links to copy
-
-makepath "$fromdir/foo/down/to/you"
-makepath "$fromdir/foo/sub"
-makepath "$fromdir/bar/down/to/foo/too"
-makepath "$fromdir/bar/down/to/bar/baz"
-makepath "$fromdir/mid/for/foo/and/that/is/who"
-makepath "$fromdir/new/keep/this"
-makepath "$fromdir/new/lose/this"
-cat >"$fromdir/.filt" <<EOF
-exclude down
-: .filt-temp
-clear
-- .filt
-- *.bak
-- *.old
-EOF
-echo filtered-1 >"$fromdir/foo/file1"
-echo removed >"$fromdir/foo/file2"
-echo cvsout >"$fromdir/foo/file2.old"
-cat >"$fromdir/foo/.filt" <<EOF
-include .filt
-- /file1
-EOF
-echo not-filtered-1 >"$fromdir/foo/sub/file1"
-cat >"$fromdir/bar/.filt" <<EOF
-- home-cvs-exclude
-dir-merge .filt2
-+ to
-EOF
-echo cvsout >"$fromdir/bar/down/to/home-cvs-exclude"
-cat >"$fromdir/bar/down/to/.filt2" <<EOF
-- .filt2
-EOF
-cat >"$fromdir/bar/down/to/foo/.filt2" <<EOF
-+ *.junk
-EOF
-echo keeper >"$fromdir/bar/down/to/foo/file1"
-echo cvsout >"$fromdir/bar/down/to/foo/file1.bak"
-echo gone >"$fromdir/bar/down/to/foo/file3"
-echo lost >"$fromdir/bar/down/to/foo/file4"
-echo weird >"$fromdir/bar/down/to/foo/+ file3"
-echo cvsout-but-filtin >"$fromdir/bar/down/to/foo/file4.junk"
-echo smashed >"$fromdir/bar/down/to/foo/to"
-cat >"$fromdir/bar/down/to/bar/.filt2" <<EOF
-- *.deep
-EOF
-echo filtout >"$fromdir/bar/down/to/bar/baz/file5.deep"
-# This one should be ineffectual
-cat >"$fromdir/mid/.filt2" <<EOF
-- extra
-EOF
-echo cvsout >"$fromdir/mid/one-in-one-out"
-echo one-in-one-out >"$fromdir/mid/.cvsignore"
-echo cvsin >"$fromdir/mid/one-for-all"
-cat >"$fromdir/mid/.filt" <<EOF
-:C
-EOF
-echo cvsin >"$fromdir/mid/for/one-in-one-out"
-echo expunged >"$fromdir/mid/for/foo/extra"
-echo retained >"$fromdir/mid/for/foo/keep"
-
-# Setup our test exclude/include files.
-
-excl="$scratchdir/exclude-from"
-cat >"$excl" <<EOF
-!
-# If the second line of these two lines does anything, it's a bug.
-+ **/bar
-- /bar
-# This should match against the whole path, not just the name.
-+ foo**too
-# These should float at the end of the path.
-+ foo/s?b/
-- foo/*/
-# Test how /** differs from /***
-- new/keep/**
-- new/lose/***
-# Test some normal excludes.  Competing lines are paired.
-+ t[o]/
-- to
-+ file4
-- file[2-9]
-- /mid/for/foo/extra
-EOF
-
-cat >"$scratchdir/.cvsignore" <<EOF
-home-cvs-exclude
-EOF
-
-# Start with a check of --prune-empty-dirs:
-$RSYNC -av --rsync-path="$RSYNC" -f -_foo/too/ -f -_foo/down/ -f -_foo/and/ -f -_new/ "$host$fromdir/" "$chkdir/"
-checkit "$RSYNC -av$rpath --prune-empty-dirs '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
-rm -rf "$todir"
-
-# Add a directory symlink.
-ln -s too "$fromdir/bar/down/to/foo/sym"
-
-# Start to prep an --update test dir
-mkdir "$scratchdir/up1" "$scratchdir/up2"
-touch "$scratchdir/up1/dst-newness" "$scratchdir/up2/src-newness"
-touch "$scratchdir/up1/same-newness" "$scratchdir/up2/same-newness"
-touch "$scratchdir/up1/extra-src" "$scratchdir/up2/extra-dest"
-
-# Create chkdir with what we expect to be excluded.
-checkit "$RSYNC -avv$rpath '$host$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
-sleep 1 # Ensures that the rm commands will tweak the directory times.
-rm -r "$chkdir"/foo/down
-rm -r "$chkdir"/mid/for/foo/and
-rm -r "$chkdir"/new/keep/this
-rm -r "$chkdir"/new/lose
-rm "$chkdir"/foo/file[235-9]
-rm "$chkdir"/bar/down/to/foo/to "$chkdir"/bar/down/to/foo/file[235-9]
-rm "$chkdir"/mid/for/foo/extra
-
-# Finish prep for the --update test (run last)
-touch "$scratchdir/up1/src-newness" "$scratchdir/up2/dst-newness"
-
-# Un-tweak the directory times in our first (weak) exclude test (though
-# it's a good test of the --existing option).
-$RSYNC -av --rsync-path="$RSYNC" --existing --include='*/' --exclude='*' "$host$fromdir/" "$chkdir/"
-
-# Now, test if rsync excludes the same files.
-
-checkit "$RSYNC -avv$rpath --exclude-from='$excl' \
-    --delete-during '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
-
-# Modify the chk dir by removing cvs-ignored files and then tweaking the dir times.
-
-rm "$chkdir"/foo/*.old
-rm "$chkdir"/bar/down/to/foo/*.bak
-rm "$chkdir"/bar/down/to/foo/*.junk
-rm "$chkdir"/bar/down/to/home-cvs-exclude
-rm "$chkdir"/mid/one-in-one-out
-
-$RSYNC -av --rsync-path="$RSYNC" --existing --filter='exclude,! */' "$host$fromdir/" "$chkdir/"
-
-# Now, test if rsync excludes the same files, this time with --cvs-exclude
-# and --delete-excluded.
-
-# The -C option gets applied in a different order when pushing & pulling, so we instead
-# add the 2 --cvs-exclude filter rules (":C" & "-C") via -f to keep the order the same.
-checkit "$RSYNC -avv$rpath --filter='merge $excl' -f:C -f-C --delete-excluded \
-    --delete-during '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
-
-# Modify the chk dir for our merge-exclude test and then tweak the dir times.
-
-rm "$chkdir"/foo/file1
-rm "$chkdir"/bar/down/to/bar/baz/*.deep
-cp_touch "$fromdir"/bar/down/to/foo/*.junk "$chkdir"/bar/down/to/foo
-cp_touch "$fromdir"/bar/down/to/foo/to "$chkdir"/bar/down/to/foo
-
-$RSYNC -av --rsync-path="$RSYNC" --existing -f 'show .filt*' -f 'hide,! */' --del "$host$fromdir/" "$todir/"
-
-echo retained >"$todir"/bar/down/to/bar/baz/nodel.deep
-cp_touch "$todir"/bar/down/to/bar/baz/nodel.deep "$chkdir"/bar/down/to/bar/baz
-
-$RSYNC -av --rsync-path="$RSYNC" --existing --filter='-! */' "$host$fromdir/" "$chkdir/"
-
-# Now, test if rsync excludes the same files, this time with a merge-exclude
-# file.
-
-checkit "sed '/!/d' '$excl' |
-    $RSYNC -avv$rpath -f dir-merge_.filt -f merge_- \
-    --delete-during '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
-
-# Remove the files that will be deleted.
-
-rm "$chkdir"/.filt
-rm "$chkdir"/bar/.filt
-rm "$chkdir"/bar/down/to/.filt2
-rm "$chkdir"/bar/down/to/foo/.filt2
-rm "$chkdir"/bar/down/to/bar/.filt2
-rm "$chkdir"/mid/.filt
-
-$RSYNC -av --rsync-path="$RSYNC" --existing --include='*/' --exclude='*' "$host$fromdir/" "$chkdir/"
-
-# Now, try the prior command with --delete-before and some side-specific
-# rules.
-
-checkit "sed '/!/d' '$excl' |
-    $RSYNC -avv$rpath -f :s_.filt -f .s_- -f P_nodel.deep \
-    --delete-before '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
-
-# Next, we'll test some rule-restricted filter files.
-
-cat >"$fromdir/bar/down/.excl" <<EOF
-file3
-EOF
-cat >"$fromdir/bar/down/to/foo/.excl" <<EOF
-+ file3
-*.bak
-EOF
-$RSYNC -av --rsync-path="$RSYNC" --del "$host$fromdir/" "$chkdir/"
-rm "$chkdir/bar/down/to/foo/file1.bak"
-rm "$chkdir/bar/down/to/foo/file3"
-rm "$chkdir/bar/down/to/foo/+ file3"
-$RSYNC -av --rsync-path="$RSYNC" --existing --filter='-! */' "$host$fromdir/" "$chkdir/"
-$RSYNC -av --rsync-path="$RSYNC" --delete-excluded --exclude='*' "$host$fromdir/" "$todir/"
-
-checkit "$RSYNC -avv$rpath -f dir-merge,-_.excl \
-    '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
-
-relative_opts='--relative --chmod=Du+w --copy-unsafe-links'
-$RSYNC -av --rsync-path="$RSYNC" $relative_opts "$host$fromdir/foo" "$chkdir/"
-rm -rf "$chkdir$fromdir/foo/down"
-$RSYNC -av $relative_opts --existing --filter='-! */' "$fromdir/foo" "$chkdir/"
-
-checkit "$RSYNC -avv$rpath $relative_opts --exclude='$fromdir/foo/down' \
-    '$host$fromdir/foo' '$todir'" "$chkdir$fromdir/foo" "$todir$fromdir/foo"
-
-# Now we'll test the --update option.
-checkdiff "$RSYNC -aiiO$rpath --update --info=skip '$host$scratchdir/up1/' '$scratchdir/up2/'" \
-       "grep -v '^\.d$allspace'" <<EOT
-dst-newness is newer
->f$all_plus extra-src
-.f$allspace same-newness
->f..t.$dots src-newness
-EOT
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/exclude_test.py b/testsuite/exclude_test.py
new file mode 100644 (file)
index 0000000..d4886bf
--- /dev/null
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/exclude.test (and, via a Makefile-built
+# symlink, of exclude-lsh.test).
+#
+# Test rsync's exclude / include / filter rules, including the more
+# obscure wildcard cases, per-directory filter files, CVS-style
+# exclusions, --prune-empty-dirs, --delete-during / --delete-before /
+# --delete-excluded, and rule-restricted filter files.
+#
+# The lsh.sh "remote shell" variant runs every transfer through the
+# local rsync-over-ssh stand-in -- detected via sys.argv[0].
+
+import os
+import subprocess
+import sys
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
+    all_plus, allspace, dots,
+    checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail,
+)
+
+
+os.environ['CVSIGNORE'] = '*.junk'
+
+script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
+if 'lsh' in script_name:
+    os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
+    rpath = [f'--rsync-path={RSYNC}']
+    host = 'lh:'
+else:
+    rpath = []
+    host = ''
+
+
+# Build the from/ tree.
+makepath(
+    FROMDIR / 'foo/down/to/you',
+    FROMDIR / 'foo/sub',
+    FROMDIR / 'bar/down/to/foo/too',
+    FROMDIR / 'bar/down/to/bar/baz',
+    FROMDIR / 'mid/for/foo/and/that/is/who',
+    FROMDIR / 'new/keep/this',
+    FROMDIR / 'new/lose/this',
+)
+
+(FROMDIR / '.filt').write_text(
+    "exclude down\n"
+    ": .filt-temp\n"
+    "clear\n"
+    "- .filt\n"
+    "- *.bak\n"
+    "- *.old\n"
+)
+(FROMDIR / 'foo' / 'file1').write_text("filtered-1\n")
+(FROMDIR / 'foo' / 'file2').write_text("removed\n")
+(FROMDIR / 'foo' / 'file2.old').write_text("cvsout\n")
+(FROMDIR / 'foo' / '.filt').write_text("include .filt\n- /file1\n")
+(FROMDIR / 'foo' / 'sub' / 'file1').write_text("not-filtered-1\n")
+
+(FROMDIR / 'bar' / '.filt').write_text(
+    "- home-cvs-exclude\n"
+    "dir-merge .filt2\n"
+    "+ to\n"
+)
+(FROMDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').write_text("cvsout\n")
+(FROMDIR / 'bar' / 'down' / 'to' / '.filt2').write_text("- .filt2\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / '.filt2').write_text("+ *.junk\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1').write_text("keeper\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').write_text("cvsout\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').write_text("gone\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file4').write_text("lost\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').write_text("weird\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file4.junk').write_text("cvsout-but-filtin\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to').write_text("smashed\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').write_text("- *.deep\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'file5.deep').write_text("filtout\n")
+
+# This one should be ineffectual.
+(FROMDIR / 'mid' / '.filt2').write_text("- extra\n")
+(FROMDIR / 'mid' / 'one-in-one-out').write_text("cvsout\n")
+(FROMDIR / 'mid' / '.cvsignore').write_text("one-in-one-out\n")
+(FROMDIR / 'mid' / 'one-for-all').write_text("cvsin\n")
+(FROMDIR / 'mid' / '.filt').write_text(":C\n")
+(FROMDIR / 'mid' / 'for' / 'one-in-one-out').write_text("cvsin\n")
+(FROMDIR / 'mid' / 'for' / 'foo' / 'extra').write_text("expunged\n")
+(FROMDIR / 'mid' / 'for' / 'foo' / 'keep').write_text("retained\n")
+
+
+# Setup our test exclude/include files.
+excl = SCRATCHDIR / 'exclude-from'
+excl.write_text(
+    "!\n"
+    "# If the second line of these two lines does anything, it's a bug.\n"
+    "+ **/bar\n"
+    "- /bar\n"
+    "# This should match against the whole path, not just the name.\n"
+    "+ foo**too\n"
+    "# These should float at the end of the path.\n"
+    "+ foo/s?b/\n"
+    "- foo/*/\n"
+    "# Test how /** differs from /***\n"
+    "- new/keep/**\n"
+    "- new/lose/***\n"
+    "# Test some normal excludes. Competing lines are paired.\n"
+    "+ t[o]/\n"
+    "- to\n"
+    "+ file4\n"
+    "- file[2-9]\n"
+    "- /mid/for/foo/extra\n"
+)
+
+(SCRATCHDIR / '.cvsignore').write_text("home-cvs-exclude\n")
+
+
+# --- main checks ------------------------------------------------------------
+
+# Start with a check of --prune-empty-dirs.
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '-f', '-_foo/too/', '-f', '-_foo/down/',
+          '-f', '-_foo/and/', '-f', '-_new/',
+          f'{host}{FROMDIR}/', f'{CHKDIR}/')
+
+checkit(['-av', *rpath, '--prune-empty-dirs',
+         f'{host}{FROMDIR}/', f'{TODIR}/'], CHKDIR, TODIR)
+
+import shutil
+shutil.rmtree(TODIR, ignore_errors=True)
+
+# Add a directory symlink.
+os.symlink('too', FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'sym')
+
+# Pre-build an --update test pair.
+up1 = SCRATCHDIR / 'up1'
+up2 = SCRATCHDIR / 'up2'
+up1.mkdir()
+up2.mkdir()
+(up1 / 'dst-newness').touch()
+(up2 / 'src-newness').touch()
+(up1 / 'same-newness').touch()
+(up2 / 'same-newness').touch()
+(up1 / 'extra-src').touch()
+(up2 / 'extra-dest').touch()
+
+# Build CHKDIR mirroring source (everything), then remove the entries we
+# expect to be excluded.
+checkit(['-avv', *rpath, f'{host}{FROMDIR}/', f'{CHKDIR}/'], FROMDIR, CHKDIR)
+import time
+time.sleep(1)
+shutil.rmtree(CHKDIR / 'foo' / 'down', ignore_errors=True)
+shutil.rmtree(CHKDIR / 'mid' / 'for' / 'foo' / 'and', ignore_errors=True)
+shutil.rmtree(CHKDIR / 'new' / 'keep' / 'this', ignore_errors=True)
+shutil.rmtree(CHKDIR / 'new' / 'lose', ignore_errors=True)
+for f in (CHKDIR / 'foo').glob('file[235-9]'):
+    f.unlink()
+(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'to').unlink()
+for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('file[235-9]'):
+    f.unlink()
+(CHKDIR / 'mid' / 'for' / 'foo' / 'extra').unlink()
+
+(up1 / 'src-newness').touch()
+(up2 / 'dst-newness').touch()
+
+# Un-tweak the directory times in our first (weak) exclude test.
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--existing', '--include=*/', '--exclude=*',
+          f'{host}{FROMDIR}/', f'{CHKDIR}/')
+
+# Test that rsync excludes the same files.
+checkit(['-avv', *rpath, f'--exclude-from={excl}',
+         '--delete-during', f'{host}{FROMDIR}/', f'{TODIR}/'],
+        CHKDIR, TODIR)
+
+# Modify the chk dir by removing cvs-ignored files and tweaking dir times.
+for f in (CHKDIR / 'foo').glob('*.old'):
+    f.unlink()
+for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.bak'):
+    f.unlink()
+for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.junk'):
+    f.unlink()
+(CHKDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').unlink()
+(CHKDIR / 'mid' / 'one-in-one-out').unlink()
+
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--existing', '--filter=exclude,! */',
+          f'{host}{FROMDIR}/', f'{CHKDIR}/')
+
+# Now test --cvs-exclude + --delete-excluded.
+# -C order differs between push/pull, so use -f :C / -f -C explicitly.
+checkit(['-avv', *rpath, f'--filter=merge {excl}',
+         '-f:C', '-f-C', '--delete-excluded', '--delete-during',
+         f'{host}{FROMDIR}/', f'{TODIR}/'],
+        CHKDIR, TODIR)
+
+# Modify the chk dir for the merge-exclude test.
+(CHKDIR / 'foo' / 'file1').unlink()
+for f in (CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz').glob('*.deep'):
+    f.unlink()
+for src in (FROMDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.junk'):
+    cp_touch_dst = CHKDIR / 'bar' / 'down' / 'to' / 'foo'
+    cp_touch_dst.mkdir(exist_ok=True)
+    from rsyncfns import cp_touch
+    cp_touch(src, cp_touch_dst)
+from rsyncfns import cp_touch
+cp_touch(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to',
+         CHKDIR / 'bar' / 'down' / 'to' / 'foo')
+
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--existing', '-f', 'show .filt*', '-f', 'hide,! */', '--del',
+          f'{host}{FROMDIR}/', f'{TODIR}/')
+
+(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep').write_text("retained\n")
+cp_touch(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep',
+         CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz')
+
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--existing', '--filter=-! */',
+          f'{host}{FROMDIR}/', f'{CHKDIR}/')
+
+
+# Test merge-exclude file. The shell test piped excl-minus-bangs into
+# rsync via stdin; here we materialise the filtered file and merge it.
+filtered_excl = SCRATCHDIR / 'exclude-from-filtered'
+filtered_excl.write_text(
+    '\n'.join(ln for ln in excl.read_text().splitlines() if '!' not in ln)
+    + '\n'
+)
+
+
+def run_with_stdin_filter(args, label="merge"):
+    """Run rsync with `args`, feeding `filtered_excl` content on stdin
+    (which `merge_-` in the filter list picks up). checkit-equivalent
+    that also re-uses CHKDIR/TODIR for the listing comparison."""
+    print(f"Running: rsync {' '.join(args)}")
+    with open(filtered_excl, 'rb') as inp:
+        proc = subprocess.run(rsync_argv(*args), stdin=inp)
+    if proc.returncode != 0:
+        test_fail(f"{label}: rsync exited {proc.returncode}")
+
+
+run_with_stdin_filter(
+    ['-avv', *rpath, '-f', 'dir-merge_.filt', '-f', 'merge_-',
+     '--delete-during', f'{host}{FROMDIR}/', f'{TODIR}/'],
+    "dir-merge .filt + merge from stdin",
+)
+from rsyncfns import verify_dirs
+verify_dirs(CHKDIR, TODIR, label="dir-merge + merge-from-stdin")
+
+
+# Remove the files that will be deleted.
+(CHKDIR / '.filt').unlink()
+(CHKDIR / 'bar' / '.filt').unlink()
+(CHKDIR / 'bar' / 'down' / 'to' / '.filt2').unlink()
+(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '.filt2').unlink()
+(CHKDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').unlink()
+(CHKDIR / 'mid' / '.filt').unlink()
+
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--existing', '--include=*/', '--exclude=*',
+          f'{host}{FROMDIR}/', f'{CHKDIR}/')
+
+# Run the prior command with --delete-before and side-specific rules.
+run_with_stdin_filter(
+    ['-avv', *rpath, '-f', ':s_.filt', '-f', '.s_-',
+     '-f', 'P_nodel.deep',
+     '--delete-before', f'{host}{FROMDIR}/', f'{TODIR}/'],
+    "delete-before with merge",
+)
+verify_dirs(CHKDIR, TODIR, label="delete-before with merge")
+
+
+# Rule-restricted filter files.
+(FROMDIR / 'bar' / 'down' / '.excl').write_text("file3\n")
+(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / '.excl').write_text(
+    "+ file3\n*.bak\n"
+)
+
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--del', f'{host}{FROMDIR}/', f'{CHKDIR}/')
+(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').unlink()
+(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').unlink()
+(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').unlink()
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--existing', '--filter=-! */',
+          f'{host}{FROMDIR}/', f'{CHKDIR}/')
+run_rsync('-av', f'--rsync-path={RSYNC}',
+          '--delete-excluded', '--exclude=*',
+          f'{host}{FROMDIR}/', f'{TODIR}/')
+
+checkit(['-avv', *rpath, '-f', 'dir-merge,-_.excl',
+         f'{host}{FROMDIR}/', f'{TODIR}/'], CHKDIR, TODIR)
+
+
+# Combine with --relative.
+relative_opts = ['--relative', '--chmod=Du+w', '--copy-unsafe-links']
+run_rsync('-av', f'--rsync-path={RSYNC}', *relative_opts,
+          f'{host}{FROMDIR}/foo', f'{CHKDIR}/')
+shutil.rmtree(str(CHKDIR) + str(FROMDIR) + '/foo/down', ignore_errors=True)
+run_rsync('-av', *relative_opts, '--existing', '--filter=-! */',
+          f'{FROMDIR}/foo', f'{CHKDIR}/')
+
+checkit(['-avv', *rpath, *relative_opts,
+         f'--exclude={FROMDIR}/foo/down',
+         f'{host}{FROMDIR}/foo', str(TODIR)],
+        str(CHKDIR) + str(FROMDIR) + '/foo',
+        str(TODIR) + str(FROMDIR) + '/foo')
+
+
+# --update test.
+checkdiff(
+    ['-aiiO', *rpath, '--update', '--info=skip',
+     f'{host}{SCRATCHDIR}/up1/', f'{SCRATCHDIR}/up2/'],
+    "dst-newness is newer\n"
+    f">f{all_plus} extra-src\n"
+    f".f{allspace} same-newness\n"
+    f">f..t.{dots} src-newness\n",
+    filter=lambda txt: '\n'.join(
+        ln for ln in txt.splitlines()
+        if not ln.startswith('.d' + allspace)
+    ) + ('\n' if txt else ''),
+)
diff --git a/testsuite/executability.test b/testsuite/executability.test
deleted file mode 100644 (file)
index 8f09d8f..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/sh
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test the --executability or -E option. -- Matt McCutchen
-
-. $suitedir/rsync.fns
-
-# Put some files in the From directory
-mkdir "$fromdir"
-cat <<EOF >"$fromdir/1"
-#!/bin/sh
-echo 'Program One!'
-EOF
-cat <<EOF >"$fromdir/2"
-#!/bin/sh
-echo 'Program Two!'
-EOF
-
-chmod 1700 "$fromdir/1" || test_skipped "Can't chmod"
-chmod 600 "$fromdir/2"
-
-$RSYNC -rvv "$fromdir/" "$todir/"
-
-check_perms "$todir/1" rwx------ 1
-check_perms "$todir/2" rw------- 1
-
-# Mix up the permissions a bit
-chmod 600 "$fromdir/1"
-chmod 601 "$fromdir/2"
-chmod 604 "$todir/2"
-
-$RSYNC -rvv "$fromdir/" "$todir/"
-
-# No -E, so nothing should have changed
-check_perms "$todir/1" rwx------ 2
-check_perms "$todir/2" rw----r-- 2
-
-$RSYNC -rvvE "$fromdir/" "$todir/"
-
-# Now things should have happened!
-check_perms "$todir/1" rw------- 3
-check_perms "$todir/2" rwx---r-x 3
-
-# Hooray
-exit 0
diff --git a/testsuite/executability_test.py b/testsuite/executability_test.py
new file mode 100644 (file)
index 0000000..349dc67
--- /dev/null
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/executability.test.
+#
+# Test --executability (-E): -E should propagate only the executable bits
+# from source to destination (other permission changes ignored), while a
+# normal copy without -E should leave the destination permissions alone.
+
+import os
+
+from rsyncfns import FROMDIR, TODIR, check_perms, run_rsync, test_skipped
+
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+(FROMDIR / '1').write_text("#!/bin/sh\necho 'Program One!'\n")
+(FROMDIR / '2').write_text("#!/bin/sh\necho 'Program Two!'\n")
+
+# Setuid-and-rwx for owner, nothing else. Some platforms reject 1700 for
+# non-root callers (no permission to set sticky); the shell test treats
+# that case as a skip.
+try:
+    os.chmod(FROMDIR / '1', 0o1700)
+except PermissionError:
+    test_skipped("Can't chmod")
+os.chmod(FROMDIR / '2', 0o600)
+
+run_rsync('-rvv', f'{FROMDIR}/', f'{TODIR}/')
+
+check_perms(TODIR / '1', 'rwx------')
+check_perms(TODIR / '2', 'rw-------')
+
+# Permute the source/destination perms; without -E nothing should change.
+os.chmod(FROMDIR / '1', 0o600)
+os.chmod(FROMDIR / '2', 0o601)
+os.chmod(TODIR / '2', 0o604)
+
+run_rsync('-rvv', f'{FROMDIR}/', f'{TODIR}/')
+
+check_perms(TODIR / '1', 'rwx------')
+check_perms(TODIR / '2', 'rw----r--')
+
+# Now with -E: 1 loses its x (source has 600), 2 gains x (source has 601).
+run_rsync('-rvvE', f'{FROMDIR}/', f'{TODIR}/')
+
+check_perms(TODIR / '1', 'rw-------')
+check_perms(TODIR / '2', 'rwx---r-x')
diff --git a/testsuite/files-from.test b/testsuite/files-from.test
deleted file mode 100644 (file)
index 207eab5..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2008-2020 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that --files-from=FILE works right.
-
-. "$suitedir/rsync.fns"
-
-SSH="$scratchdir/src/support/lsh.sh"
-
-hands_setup
-
-# This list of files skips the contents of "subsubdir" but includes
-# the contents of "subsubdir2" due to its trailing slash.
-cat >"$scratchdir/filelist" <<EOT
-from/./
-from/./dir/subdir
-from/./dir/subdir/subsubdir
-from/./dir/subdir/subsubdir2/
-from/./dir/subdir/foobar.baz
-EOT
-
-# Create a chkdir without the content that we expect to be omitted.
-$RSYNC -a --exclude=dir/text --exclude='subsubdir/**' "$fromdir/" "$chkdir/"
-
-checkit "$RSYNC -av --files-from='$scratchdir/filelist' '$scratchdir' '$todir/'" "$chkdir" "$todir"
-
-for filehost in '' 'localhost:'; do
-    for srchost in '' 'localhost:'; do
-       if [ -z "$srchost" ]; then
-           desthost='localhost:'
-       else
-           desthost=''
-       fi
-
-       rm -rf "$todir"
-       checkit "$RSYNC -avse '$SSH' --rsync-path='$RSYNC' --files-from='$filehost$scratchdir/filelist' '$srchost$scratchdir' '$desthost$todir/'" "$chkdir" "$todir"
-    done
-done
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/files-from_test.py b/testsuite/files-from_test.py
new file mode 100644 (file)
index 0000000..855007e
--- /dev/null
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/files-from.test.
+#
+# Verify that --files-from=LIST drives rsync correctly both for a plain
+# local sync and across the lsh.sh "remote shell" with each of the four
+# files-host / src-host / dest-host placement combinations.
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
+    checkit, hands_setup, rmtree, run_rsync,
+)
+
+
+SSH = str(SRCDIR / 'support' / 'lsh.sh')
+
+hands_setup()
+
+# Files-from list: skip the contents of subsubdir but include subsubdir2/
+# in full (the trailing slash on subsubdir2/ is what flips it).
+filelist = SCRATCHDIR / 'filelist'
+filelist.write_text(
+    "from/./\n"
+    "from/./dir/subdir\n"
+    "from/./dir/subdir/subsubdir\n"
+    "from/./dir/subdir/subsubdir2/\n"
+    "from/./dir/subdir/foobar.baz\n"
+)
+
+# chkdir is what we expect the transfer to produce: source minus
+# dir/text and minus everything under subsubdir/.
+run_rsync('-a', '--exclude=dir/text', '--exclude=subsubdir/**',
+          f'{FROMDIR}/', f'{CHKDIR}/')
+
+# Local case.
+checkit(['-av', f'--files-from={filelist}', str(SCRATCHDIR), f'{TODIR}/'],
+        CHKDIR, TODIR)
+
+# All four combinations of files-host / source-host / dest-host across the
+# lsh.sh "remote shell".  In each loop iteration exactly one of source or
+# dest is remote (matches the original test's branch logic).
+for filehost in ('', 'localhost:'):
+    for srchost in ('', 'localhost:'):
+        desthost = 'localhost:' if not srchost else ''
+
+        rmtree(TODIR)
+        checkit(
+            ['-avse', SSH, f'--rsync-path={RSYNC}',
+             f'--files-from={filehost}{filelist}',
+             f'{srchost}{SCRATCHDIR}', f'{desthost}{TODIR}/'],
+            CHKDIR, TODIR,
+        )
diff --git a/testsuite/fuzzy.test b/testsuite/fuzzy.test
deleted file mode 100644 (file)
index 101ffd3..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2005-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync handling of the --fuzzy option.
-
-. "$suitedir/rsync.fns"
-
-mkdir "$fromdir"
-mkdir "$todir"
-
-cp_p "$srcdir"/rsync.c "$fromdir"/rsync.c
-cp_touch "$fromdir"/rsync.c "$todir"/rsync2.c
-sleep 1
-
-# Let's do it!
-checkit "$RSYNC -avvi --no-whole-file --fuzzy --delete-delay \
-    '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/fuzzy_test.py b/testsuite/fuzzy_test.py
new file mode 100644 (file)
index 0000000..7858fcc
--- /dev/null
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/fuzzy.test.
+#
+# Test --fuzzy: with a matching-content file already in the destination
+# under a different name, rsync should use it as a basis for the new name
+# instead of re-transferring (and --delete-delay still removes the stale
+# basis file at the end).
+
+import time
+
+from rsyncfns import FROMDIR, SRCDIR, TODIR, checkit, cp_p, cp_touch
+
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+TODIR.mkdir(parents=True, exist_ok=True)
+
+cp_p(SRCDIR / 'rsync.c', FROMDIR / 'rsync.c')
+cp_touch(FROMDIR / 'rsync.c', TODIR / 'rsync2.c')
+time.sleep(1)
+
+checkit(['-avvi', '--no-whole-file', '--fuzzy', '--delete-delay',
+         f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
diff --git a/testsuite/hands.test b/testsuite/hands.test
deleted file mode 100644 (file)
index 8e265b7..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 1998, 1999 by Philip Hands <phil@hands.com>
-# Copyright (C) 2001, 2002 by Martin Pool <mbp@samba.org>
-#
-# This program is distributable under the terms of the GNU GPL (see COPYING)
-
-. "$suitedir/rsync.fns"
-
-hands_setup
-
-DEBUG_OPTS="--debug=all0,deltasum0"
-
-# Main script starts here
-
-runtest "basic operation" 'checkit "$RSYNC -av \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
-
-ln "$fromdir/filelist" "$fromdir/dir"
-runtest "hard links" 'checkit "$RSYNC -avH --bwlimit=0 $DEBUG_OPTS \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
-
-rm "$todir/text"
-runtest "one file" 'checkit "$RSYNC -avH $DEBUG_OPTS \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
-
-echo "extra line" >> "$todir/text"
-runtest "extra data" 'checkit "$RSYNC -avH $DEBUG_OPTS --no-whole-file \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
-
-cp "$fromdir/text" "$todir/ThisShouldGo"
-runtest " --delete" 'checkit "$RSYNC --delete -avH $DEBUG_OPTS \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
-
-cd "$tmpdir"
-rm -rf to from/*dir
-
-# Do the real copy, touch up the parent-dir's time, and then check the copy.
-$RSYNC -av from/* to/
-checkit "$RSYNC -av --exclude='*' from/ to/" "$fromdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/hands_test.py b/testsuite/hands_test.py
new file mode 100644 (file)
index 0000000..b693cb0
--- /dev/null
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/hands.test.
+#
+# The canonical end-to-end transfer test: build a richly-populated source
+# tree via hands_setup() then run a series of rsync invocations covering
+# basic operation, hard links, single-file copies, --no-whole-file delta
+# updates and --delete cleanup. After each run the source and destination
+# tree listings must match exactly.
+
+import os
+import shutil
+
+from rsyncfns import FROMDIR, TMPDIR, TODIR, checkit, hands_setup, run_rsync
+
+
+hands_setup()
+
+DEBUG_OPTS = "--debug=all0,deltasum0"
+
+
+# 1. basic operation
+print("Test basic operation:")
+checkit(['-av', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
+
+# 2. hard links — link filelist into dir/ then transfer with -H so the
+# receiver should recreate the link relationship.
+os.link(FROMDIR / 'filelist', FROMDIR / 'dir' / 'filelist')
+print("Test hard links:")
+checkit(['-avH', '--bwlimit=0', DEBUG_OPTS, f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
+
+# 3. one file — delete the destination 'text' and re-sync; only it should
+# transfer, everything else stays uptodate.
+(TODIR / 'text').unlink()
+print("Test one file:")
+checkit(['-avH', DEBUG_OPTS, f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
+
+# 4. extra data — append to destination then re-sync with --no-whole-file so
+# the rsync delta algorithm has to repair it.
+with open(TODIR / 'text', 'a') as f:
+    f.write("extra line\n")
+print("Test extra data:")
+checkit(['-avH', DEBUG_OPTS, '--no-whole-file', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
+
+# 5. --delete — add a stray file on the destination and confirm --delete
+# removes it.
+shutil.copy(FROMDIR / 'text', TODIR / 'ThisShouldGo')
+print("Test --delete:")
+checkit(['--delete', '-avH', DEBUG_OPTS, f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
+
+# 6. globbed copy without recursion — wipe and re-sync top-level entries by
+# glob, then a final empty pass to compare listings.
+os.chdir(TMPDIR)
+shutil.rmtree('to', ignore_errors=True)
+for entry in TMPDIR.glob('from/*dir'):
+    if entry.is_dir():
+        shutil.rmtree(entry, ignore_errors=True)
+    else:
+        entry.unlink()
+
+# Replicate `rsync -av from/* to/` — list the from/ children explicitly.
+sources = sorted(str(p) for p in (TMPDIR / 'from').iterdir())
+run_rsync('-av', *sources, 'to/')
+checkit(['-av', '--exclude=*', 'from/', 'to/'], FROMDIR, TODIR)
diff --git a/testsuite/hardlinks.test b/testsuite/hardlinks.test
deleted file mode 100644 (file)
index c02db3f..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync handling of hardlinks.  By default, rsync does not detect
-# hard links and they get sent as separate files.  If you specify -H,
-# then hard links are detected and linked together on the receiver.
-
-. "$suitedir/rsync.fns"
-
-SSH="$scratchdir/src/support/lsh.sh"
-
-# Build some hardlinks
-
-fromdir="$scratchdir/from"
-todir="$scratchdir/to"
-
-# TODO: Need to test whether hardlinks are possible on this OS/filesystem
-
-mkdir "$fromdir"
-name1="$fromdir/name1"
-name2="$fromdir/name2"
-name3="$fromdir/name3"
-name4="$fromdir/name4"
-echo "This is the file" > "$name1"
-ln "$name1" "$name2" || test_skipped "Can't create hardlink"
-ln "$name2" "$name3" || test_fail "Can't create hardlink"
-cp "$name2" "$name4" || test_fail "Can't copy file"
-cat $srcdir/*.c >"$fromdir/text"
-
-checkit "$RSYNC -aHivv --debug=HLINK5 '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-echo "extra extra" >>"$todir/name1"
-
-checkit "$RSYNC -aHivv --debug=HLINK5 --no-whole-file '$fromdir/' '$todir/'" "$fromdir" "$todir"
-
-# Add a new link in a new subdirectory to test that we don't try to link
-# the files before the directory gets created.  We also create a bunch of
-# extra files to ensure that an incremental-recursion transfer works across
-# distant files.
-makepath "$fromdir/subdir/down/deep"
-
-files=''
-for x in a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9; do
-    for y in a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9; do
-       files="$files $x$y"
-    done
-done
-(cd "$fromdir/subdir"; touch $files)
-
-ln "$name1" "$fromdir/subdir/down/deep/new-file"
-rm "$todir/text"
-
-checkit "$RSYNC -aHivve '$SSH' --debug=HLINK5 --rsync-path='$RSYNC' '$fromdir/' localhost:'$todir/'" "$fromdir" "$todir"
-
-# Do some duplicate copies using --link-dest and --copy-dest to test that
-# we hard-link all locally-inherited items.
-checkit "$RSYNC -aHivv --debug=HLINK5 --link-dest='$todir' '$fromdir/' '$chkdir/'" "$todir" "$chkdir"
-
-rm -rf "$chkdir"
-checkit "$RSYNC -aHivv --debug=HLINK5 --copy-dest='$todir' '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
-
-# Create a hard link that has only one part in the hierarchy.
-echo "This is another file" >"$fromdir/solo"
-ln "$fromdir/solo" "$chkdir/solo" || test_fail "Can't create hardlink"
-
-# Make sure that the checksum data doesn't slide due to an HLINK_BUMP() change.
-checktee "$RSYNC -aHivc --debug=HLINK5 '$fromdir/' '$chkdir/'"
-grep solo "$outfile" && test_fail "Erroneous copy of solo file occurred!"
-
-# Make sure there's nothing wrong with sending a single file with -H
-# enabled (this has broken twice so far, so we need this test).
-rm -rf "$todir"
-$RSYNC -aHivv --debug=HLINK5 "$name1" "$todir/"
-diff $diffopt "$name1" "$todir" || test_fail "solo copy of name1 failed"
-
-# Make sure there's nothing wrong with sending a single directory with -H
-# enabled (this has broken in 3.4.0 so far, so we need this test).
-rm -rf "$fromdir" "$todir"
-makepath "$fromdir/sym" "$todir"
-$RSYNC -aH "$fromdir/sym" "$todir"
-diff $diffopt "$fromdir" "$todir" || test_fail "solo copy of sym failed"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/hardlinks_test.py b/testsuite/hardlinks_test.py
new file mode 100644 (file)
index 0000000..9084899
--- /dev/null
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/hardlinks.test.
+#
+# Verify rsync -H detects hard links and re-creates them on the receiver.
+# Also covers the incremental-recursion path (lots of small files), the
+# remote (lsh.sh) path, --link-dest / --copy-dest, --checksum without
+# slipping HLINK_BUMP boundaries, and the single-file / single-directory
+# corner cases that have broken in the past.
+
+import os
+import shutil
+import subprocess
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, OUTFILE, RSYNC, SRCDIR, TODIR,
+    checkit, makepath, rsync_argv, test_fail, test_skipped,
+)
+
+
+SSH = str(SRCDIR / 'support' / 'lsh.sh')
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+name1 = FROMDIR / 'name1'
+name2 = FROMDIR / 'name2'
+name3 = FROMDIR / 'name3'
+name4 = FROMDIR / 'name4'
+name1.write_text("This is the file\n")
+try:
+    os.link(name1, name2)
+except OSError:
+    test_skipped("Can't create hardlink")
+try:
+    os.link(name2, name3)
+except OSError:
+    test_fail("Can't create hardlink")
+shutil.copy(name2, name4)
+
+text = bytearray()
+for f in sorted(SRCDIR.glob('*.c')):
+    text.extend(f.read_bytes())
+(FROMDIR / 'text').write_bytes(bytes(text))
+
+checkit(['-aHivv', '--debug=HLINK5', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
+
+# Force a delta-overwrite on one of the linked names; -H should still
+# leave name1..name3 hard-linked on the destination.
+with open(TODIR / 'name1', 'a') as f:
+    f.write("extra extra\n")
+
+checkit(['-aHivv', '--debug=HLINK5', '--no-whole-file',
+         f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
+
+# Add a new link under a subdir that doesn't exist on the dest yet, plus
+# pile on lots of small files to exercise incremental recursion's link
+# bookkeeping across batches.
+makepath(FROMDIR / 'subdir' / 'down' / 'deep')
+
+cdir = FROMDIR / 'subdir'
+chars = list('abcdefghijklmnopqrstuvwxyz0123456789')
+for x in chars:
+    for y in chars:
+        (cdir / f'{x}{y}').touch()
+
+os.link(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file')
+(TODIR / 'text').unlink()
+
+checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC}',
+         f'{FROMDIR}/', f'localhost:{TODIR}/'], FROMDIR, TODIR)
+
+# --link-dest and --copy-dest should also keep hard-linked entries.
+checkit(['-aHivv', '--debug=HLINK5', f'--link-dest={TODIR}',
+         f'{FROMDIR}/', f'{CHKDIR}/'], TODIR, CHKDIR)
+
+shutil.rmtree(CHKDIR, ignore_errors=True)
+checkit(['-aHivv', '--debug=HLINK5', f'--copy-dest={TODIR}',
+         f'{FROMDIR}/', f'{CHKDIR}/'], FROMDIR, CHKDIR)
+
+# Make a hard link whose other end is outside the source -- the dest
+# stays single-linked -- and re-sync with --checksum.
+(FROMDIR / 'solo').write_text("This is another file\n")
+try:
+    os.link(FROMDIR / 'solo', CHKDIR / 'solo')
+except OSError:
+    test_fail("Can't create hardlink")
+
+# Make sure --checksum doesn't slip the offset due to an HLINK_BUMP() change.
+proc = subprocess.run(
+    rsync_argv('-aHivc', '--debug=HLINK5', f'{FROMDIR}/', f'{CHKDIR}/'),
+    capture_output=True, text=True,
+)
+OUTFILE.write_text(proc.stdout)
+print(proc.stdout, end='')
+if proc.returncode != 0:
+    test_fail(f"-aHivc run exited {proc.returncode}")
+if 'solo' in proc.stdout:
+    test_fail("Erroneous copy of solo file occurred!")
+
+# Single-file with -H is a regression-prone path; just confirm it copies.
+shutil.rmtree(TODIR, ignore_errors=True)
+TODIR.mkdir(parents=True, exist_ok=True)
+subprocess.run(rsync_argv('-aHivv', '--debug=HLINK5', str(name1), f'{TODIR}/'))
+diff = subprocess.run(['diff', '-u', str(name1), str(TODIR / 'name1')])
+if diff.returncode != 0:
+    test_fail("solo copy of name1 failed")
+
+# Single-directory with -H is the 3.4.0 regression.
+shutil.rmtree(FROMDIR, ignore_errors=True)
+shutil.rmtree(TODIR, ignore_errors=True)
+makepath(FROMDIR / 'sym', TODIR)
+subprocess.run(rsync_argv('-aH', str(FROMDIR / 'sym'), str(TODIR)))
+diff = subprocess.run(['diff', '-r', '-u', str(FROMDIR), str(TODIR)])
+if diff.returncode != 0:
+    test_fail("solo copy of sym failed")
diff --git a/testsuite/itemize.test b/testsuite/itemize.test
deleted file mode 100644 (file)
index c1c57c5..0000000
+++ /dev/null
@@ -1,246 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2005-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test the output of various copy commands to ensure itemized output
-# and double-verbose output is correct.
-
-. "$suitedir/rsync.fns"
-
-to2dir="$tmpdir/to2"
-
-makepath "$fromdir/foo"
-makepath "$fromdir/bar/baz"
-cp_p "$srcdir/configure.ac" "$fromdir/foo/config1"
-cp_p "$srcdir/config.sub" "$fromdir/foo/config2"
-cp_p "$srcdir/rsync.h" "$fromdir/bar/baz/rsync"
-chmod 600 "$fromdir"/foo/config? "$fromdir/bar/baz/rsync"
-umask 0
-ln -s ../bar/baz/rsync "$fromdir/foo/sym"
-umask 022
-ln "$fromdir/foo/config1" "$fromdir/foo/extra"
-rm -f "$to2dir"
-
-# Check if rsync is set to hard-link symlinks.
-if $RSYNC -VV | grep '"hardlink_symlinks": true' >/dev/null; then
-    L=hL
-    sym_dots="$allspace"
-    L_sym_dots=".L$allspace"
-    is_uptodate='is uptodate'
-    touch "$chkfile.extra"
-else
-    L=cL
-    sym_dots="c.t.$dots"
-    L_sym_dots="cL$sym_dots"
-    is_uptodate='-> ../bar/baz/rsync'
-    echo "cL$sym_dots foo/sym $is_uptodate" >"$chkfile.extra"
-fi
-
-# Check if rsync can preserve time on symlinks
-case "$RSYNC" in
-*protocol=2*)
-    T=.T
-    ;;
-*)
-    if $RSYNC -VV | grep '"symtimes": true' >/dev/null; then
-       T=.t
-    else
-       T=.T
-    fi
-    ;;
-esac
-
-checkdiff "$RSYNC -iplr '$fromdir/' '$todir/'" <<EOT
-created directory $todir
-cd$all_plus ./
-cd$all_plus bar/
-cd$all_plus bar/baz/
->f$all_plus bar/baz/rsync
-cd$all_plus foo/
->f$all_plus foo/config1
->f$all_plus foo/config2
->f$all_plus foo/extra
-cL$all_plus foo/sym -> ../bar/baz/rsync
-EOT
-
-# Ensure there are no accidental directory-time problems.
-$RSYNC -a -f '-! */' "$fromdir/" "$todir"
-
-cp_p "$srcdir/configure.ac" "$fromdir/foo/config2"
-chmod 601 "$fromdir/foo/config2"
-checkdiff "$RSYNC -iplrH '$fromdir/' '$todir/'" <<EOT
->f..T.$dots bar/baz/rsync
->f..T.$dots foo/config1
->f.sTp$dots foo/config2
-hf..T.$dots foo/extra => foo/config1
-EOT
-
-$RSYNC -a -f '-! */' "$fromdir/" "$todir"
-cp_p "$srcdir/config.sub" "$fromdir/foo/config2"
-sleep 1 # For directory mod below to ensure time difference
-rm "$todir/foo/sym"
-umask 0
-ln -s ../bar/baz "$todir/foo/sym"
-umask 022
-chmod 600 "$fromdir/foo/config2"
-chmod 777 "$todir/bar/baz/rsync"
-
-checkdiff "$RSYNC -iplrtc '$fromdir/' '$todir/'" <<EOT
-.f..tp$dots bar/baz/rsync
-.d..t.$dots foo/
-.f..t.$dots foo/config1
->fcstp$dots foo/config2
-cLc$T.$dots foo/sym -> ../bar/baz/rsync
-EOT
-
-cp_p "$srcdir/configure.ac" "$fromdir/foo/config2"
-chmod 600 "$fromdir/foo/config2"
-# Lack of -t is for unchanged hard-link stress-test!
-checkdiff "$RSYNC -vvplrH '$fromdir/' '$todir/'" \
-       v_filt <<EOT
-bar/baz/rsync is uptodate
-foo/config1 is uptodate
-foo/extra is uptodate
-foo/sym is uptodate
-foo/config2
-EOT
-
-chmod 747 "$todir/bar/baz/rsync"
-$RSYNC -a -f '-! */' "$fromdir/" "$todir"
-checkdiff "$RSYNC -ivvplrtH '$fromdir/' '$todir/'" \
-       v_filt <<EOT
-.d$allspace ./
-.d$allspace bar/
-.d$allspace bar/baz/
-.f...p$dots bar/baz/rsync
-.d$allspace foo/
-.f$allspace foo/config1
->f..t.$dots foo/config2
-hf$allspace foo/extra
-.L$allspace foo/sym -> ../bar/baz/rsync
-EOT
-
-chmod 757 "$todir/foo/config1"
-touch "$todir/foo/config2"
-checkdiff "$RSYNC -vplrtH '$fromdir/' '$todir/'" \
-       v_filt <<EOT
-foo/config2
-EOT
-
-chmod 757 "$todir/foo/config1"
-touch "$todir/foo/config2"
-checkdiff "$RSYNC -iplrtH '$fromdir/' '$todir/'" <<EOT
-.f...p$dots foo/config1
->f..t.$dots foo/config2
-EOT
-
-checkdiff "$RSYNC -ivvplrtH --copy-dest=../to '$fromdir/' '$to2dir/'" \
-       v_filt <<EOT
-cd$allspace ./
-cd$allspace bar/
-cd$allspace bar/baz/
-cf$allspace bar/baz/rsync
-cd$allspace foo/
-cf$allspace foo/config1
-cf$allspace foo/config2
-hf$allspace foo/extra => foo/config1
-cL$sym_dots foo/sym -> ../bar/baz/rsync
-EOT
-
-rm -rf "$to2dir"
-cat - "$chkfile.extra" <<EOT >"$chkfile"
-created directory $to2dir
-hf$allspace foo/extra => foo/config1
-EOT
-checkdiff2 "$RSYNC -iplrtH --copy-dest=../to '$fromdir/' '$to2dir/'"
-
-rm -rf "$to2dir"
-checkdiff "$RSYNC -vvplrtH --copy-dest='$todir' '$fromdir/' '$to2dir/'" \
-       v_filt <<EOT
-./ is uptodate
-bar/ is uptodate
-bar/baz/ is uptodate
-bar/baz/rsync is uptodate
-foo/ is uptodate
-foo/config1 is uptodate
-foo/config2 is uptodate
-foo/sym $is_uptodate
-foo/extra => foo/config1
-EOT
-
-rm -rf "$to2dir"
-checkdiff "$RSYNC -ivvplrtH --link-dest='$todir' '$fromdir/' '$to2dir/'" \
-       v_filt <<EOT
-cd$allspace ./
-cd$allspace bar/
-cd$allspace bar/baz/
-hf$allspace bar/baz/rsync
-cd$allspace foo/
-hf$allspace foo/config1
-hf$allspace foo/config2
-hf$allspace foo/extra => foo/config1
-$L$sym_dots foo/sym -> ../bar/baz/rsync
-EOT
-
-rm -rf "$to2dir"
-cat - "$chkfile.extra" <<EOT >"$chkfile"
-created directory $to2dir
-EOT
-checkdiff2 "$RSYNC -iplrtH --dry-run --link-dest=../to '$fromdir/' '$to2dir/'"
-
-rm -rf "$to2dir"
-checkdiff2 "$RSYNC -iplrtH --link-dest=../to '$fromdir/' '$to2dir/'"
-
-rm -rf "$to2dir"
-checkdiff "$RSYNC -vvplrtH --link-dest='$todir' '$fromdir/' '$to2dir/'" \
-       v_filt <<EOT
-./ is uptodate
-bar/ is uptodate
-bar/baz/ is uptodate
-bar/baz/rsync is uptodate
-foo/ is uptodate
-foo/config1 is uptodate
-foo/config2 is uptodate
-foo/extra is uptodate
-foo/sym $is_uptodate
-EOT
-
-rm -rf "$to2dir"
-checkdiff "$RSYNC -ivvplrtH --compare-dest='$todir' '$fromdir/' '$to2dir/'" \
-       v_filt <<EOT
-cd$allspace ./
-cd$allspace bar/
-cd$allspace bar/baz/
-.f$allspace bar/baz/rsync
-cd$allspace foo/
-.f$allspace foo/config1
-.f$allspace foo/config2
-.f$allspace foo/extra
-$L_sym_dots foo/sym -> ../bar/baz/rsync
-EOT
-
-rm -rf "$to2dir"
-cat - "$chkfile.extra" <<EOT >"$chkfile"
-created directory $to2dir
-EOT
-checkdiff2 "$RSYNC -iplrtH --compare-dest='$todir' '$fromdir/' '$to2dir/'"
-
-rm -rf "$to2dir"
-checkdiff "$RSYNC -vvplrtH --compare-dest='$todir' '$fromdir/' '$to2dir/'" \
-       v_filt <<EOT
-./ is uptodate
-bar/ is uptodate
-bar/baz/ is uptodate
-bar/baz/rsync is uptodate
-foo/ is uptodate
-foo/config1 is uptodate
-foo/config2 is uptodate
-foo/extra is uptodate
-foo/sym $is_uptodate
-EOT
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/itemize_test.py b/testsuite/itemize_test.py
new file mode 100644 (file)
index 0000000..9cf9aa9
--- /dev/null
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/itemize.test.
+#
+# Test the output of various copy commands to ensure itemized output
+# (-i, -ii) and double-verbose output (-vv) match the canonical
+# representations across whole-file and delta paths.
+
+import os
+import shutil
+
+from rsyncfns import (
+    CHKFILE, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
+    all_plus, allspace, dots,
+    checkdiff, cp_p, makepath, run_rsync, v_filt,
+)
+
+
+to2dir = TMPDIR / 'to2'
+
+makepath(FROMDIR / 'foo', FROMDIR / 'bar' / 'baz')
+cp_p(SRCDIR / 'configure.ac', FROMDIR / 'foo' / 'config1')
+cp_p(SRCDIR / 'config.sub', FROMDIR / 'foo' / 'config2')
+cp_p(SRCDIR / 'rsync.h', FROMDIR / 'bar' / 'baz' / 'rsync')
+os.chmod(FROMDIR / 'foo' / 'config1', 0o600)
+os.chmod(FROMDIR / 'foo' / 'config2', 0o600)
+os.chmod(FROMDIR / 'bar' / 'baz' / 'rsync', 0o600)
+
+old_umask = os.umask(0)
+try:
+    os.symlink('../bar/baz/rsync', FROMDIR / 'foo' / 'sym')
+finally:
+    os.umask(old_umask)
+
+os.link(FROMDIR / 'foo' / 'config1', FROMDIR / 'foo' / 'extra')
+if to2dir.is_file():
+    to2dir.unlink()
+
+
+# Detect what this rsync build supports.
+vv = run_rsync('-VV', check=True, capture_output=True).stdout
+hardlink_symlinks = '"hardlink_symlinks": true' in vv
+symtimes_supported = '"symtimes": true' in vv
+
+if hardlink_symlinks:
+    L = 'hL'
+    sym_dots = allspace
+    L_sym_dots = '.L' + allspace
+    is_uptodate = 'is uptodate'
+    chkfile_extra = ''  # no extra trailing line
+else:
+    L = 'cL'
+    sym_dots = 'c.t.' + dots
+    L_sym_dots = 'cL' + sym_dots
+    is_uptodate = '-> ../bar/baz/rsync'
+    chkfile_extra = f"cL{sym_dots} foo/sym {is_uptodate}\n"
+
+if 'protocol=2' in RSYNC:
+    T = '.T'
+elif symtimes_supported:
+    T = '.t'
+else:
+    T = '.T'
+
+
+# First check: -iplr basic itemize on a fresh transfer.
+checkdiff(['-iplr', f'{FROMDIR}/', f'{TODIR}/'],
+          f"created directory {TODIR}\n"
+          f"cd{all_plus} ./\n"
+          f"cd{all_plus} bar/\n"
+          f"cd{all_plus} bar/baz/\n"
+          f">f{all_plus} bar/baz/rsync\n"
+          f"cd{all_plus} foo/\n"
+          f">f{all_plus} foo/config1\n"
+          f">f{all_plus} foo/config2\n"
+          f">f{all_plus} foo/extra\n"
+          f"cL{all_plus} foo/sym -> ../bar/baz/rsync\n")
+
+# Touch dir times so subsequent itemize diffs don't pick up dir-time noise.
+run_rsync('-a', '-f', '-! */', f'{FROMDIR}/', str(TODIR))
+
+# Permute one file's content + mode; expect a content/mode itemize.
+cp_p(SRCDIR / 'configure.ac', FROMDIR / 'foo' / 'config2')
+os.chmod(FROMDIR / 'foo' / 'config2', 0o601)
+
+checkdiff(['-iplrH', f'{FROMDIR}/', f'{TODIR}/'],
+          f">f..T.{dots} bar/baz/rsync\n"
+          f">f..T.{dots} foo/config1\n"
+          f">f.sTp{dots} foo/config2\n"
+          f"hf..T.{dots} foo/extra => foo/config1\n")
+
+# Re-touch dirs, permute config2 again and replace the symlink target.
+run_rsync('-a', '-f', '-! */', f'{FROMDIR}/', str(TODIR))
+cp_p(SRCDIR / 'config.sub', FROMDIR / 'foo' / 'config2')
+import time
+time.sleep(1)  # to provoke a directory mtime change below
+(TODIR / 'foo' / 'sym').unlink()
+old_umask = os.umask(0)
+try:
+    os.symlink('../bar/baz', TODIR / 'foo' / 'sym')
+finally:
+    os.umask(old_umask)
+os.chmod(FROMDIR / 'foo' / 'config2', 0o600)
+os.chmod(TODIR / 'bar' / 'baz' / 'rsync', 0o777)
+
+checkdiff(['-iplrtc', f'{FROMDIR}/', f'{TODIR}/'],
+          f".f..tp{dots} bar/baz/rsync\n"
+          f".d..t.{dots} foo/\n"
+          f".f..t.{dots} foo/config1\n"
+          f">fcstp{dots} foo/config2\n"
+          f"cLc{T}.{dots} foo/sym -> ../bar/baz/rsync\n")
+
+# Re-permute config2, leaving the others untouched; lack of -t is for
+# the unchanged-hard-link stress test.
+cp_p(SRCDIR / 'configure.ac', FROMDIR / 'foo' / 'config2')
+os.chmod(FROMDIR / 'foo' / 'config2', 0o600)
+
+checkdiff(['-vvplrH', f'{FROMDIR}/', f'{TODIR}/'],
+          "bar/baz/rsync is uptodate\n"
+          "foo/config1 is uptodate\n"
+          "foo/extra is uptodate\n"
+          "foo/sym is uptodate\n"
+          "foo/config2\n",
+          filter=v_filt)
+
+# Touch a mode change on one dest file then run -ii to see "no change".
+os.chmod(TODIR / 'bar' / 'baz' / 'rsync', 0o747)
+run_rsync('-a', '-f', '-! */', f'{FROMDIR}/', str(TODIR))
+
+checkdiff(['-ivvplrtH', f'{FROMDIR}/', f'{TODIR}/'],
+          f".d{allspace} ./\n"
+          f".d{allspace} bar/\n"
+          f".d{allspace} bar/baz/\n"
+          f".f...p{dots} bar/baz/rsync\n"
+          f".d{allspace} foo/\n"
+          f".f{allspace} foo/config1\n"
+          f">f..t.{dots} foo/config2\n"
+          f"hf{allspace} foo/extra\n"
+          f".L{allspace} foo/sym -> ../bar/baz/rsync\n",
+          filter=v_filt)
+
+# Permute one perm and re-touch a file; expect just those two itemizes.
+os.chmod(TODIR / 'foo' / 'config1', 0o757)
+(TODIR / 'foo' / 'config2').touch()
+checkdiff(['-vplrtH', f'{FROMDIR}/', f'{TODIR}/'],
+          "foo/config2\n",
+          filter=v_filt)
+
+os.chmod(TODIR / 'foo' / 'config1', 0o757)
+(TODIR / 'foo' / 'config2').touch()
+checkdiff(['-iplrtH', f'{FROMDIR}/', f'{TODIR}/'],
+          f".f...p{dots} foo/config1\n"
+          f">f..t.{dots} foo/config2\n")
+
+
+# --copy-dest variants.
+checkdiff(['-ivvplrtH', '--copy-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
+          f"cd{allspace} ./\n"
+          f"cd{allspace} bar/\n"
+          f"cd{allspace} bar/baz/\n"
+          f"cf{allspace} bar/baz/rsync\n"
+          f"cd{allspace} foo/\n"
+          f"cf{allspace} foo/config1\n"
+          f"cf{allspace} foo/config2\n"
+          f"hf{allspace} foo/extra => foo/config1\n"
+          f"cL{sym_dots} foo/sym -> ../bar/baz/rsync\n",
+          filter=v_filt)
+
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-iplrtH', '--copy-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
+          f"created directory {to2dir}\n"
+          f"hf{allspace} foo/extra => foo/config1\n"
+          + chkfile_extra)
+
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-vvplrtH', f'--copy-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
+          "./ is uptodate\n"
+          "bar/ is uptodate\n"
+          "bar/baz/ is uptodate\n"
+          "bar/baz/rsync is uptodate\n"
+          "foo/ is uptodate\n"
+          "foo/config1 is uptodate\n"
+          "foo/config2 is uptodate\n"
+          f"foo/sym {is_uptodate}\n"
+          "foo/extra => foo/config1\n",
+          filter=v_filt)
+
+
+# --link-dest variants.
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-ivvplrtH', f'--link-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
+          f"cd{allspace} ./\n"
+          f"cd{allspace} bar/\n"
+          f"cd{allspace} bar/baz/\n"
+          f"hf{allspace} bar/baz/rsync\n"
+          f"cd{allspace} foo/\n"
+          f"hf{allspace} foo/config1\n"
+          f"hf{allspace} foo/config2\n"
+          f"hf{allspace} foo/extra => foo/config1\n"
+          f"{L}{sym_dots} foo/sym -> ../bar/baz/rsync\n",
+          filter=v_filt)
+
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-iplrtH', '--dry-run', '--link-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
+          f"created directory {to2dir}\n"
+          + chkfile_extra)
+
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-iplrtH', '--link-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
+          f"created directory {to2dir}\n"
+          + chkfile_extra)
+
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-vvplrtH', f'--link-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
+          "./ is uptodate\n"
+          "bar/ is uptodate\n"
+          "bar/baz/ is uptodate\n"
+          "bar/baz/rsync is uptodate\n"
+          "foo/ is uptodate\n"
+          "foo/config1 is uptodate\n"
+          "foo/config2 is uptodate\n"
+          "foo/extra is uptodate\n"
+          f"foo/sym {is_uptodate}\n",
+          filter=v_filt)
+
+
+# --compare-dest variants.
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-ivvplrtH', f'--compare-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
+          f"cd{allspace} ./\n"
+          f"cd{allspace} bar/\n"
+          f"cd{allspace} bar/baz/\n"
+          f".f{allspace} bar/baz/rsync\n"
+          f"cd{allspace} foo/\n"
+          f".f{allspace} foo/config1\n"
+          f".f{allspace} foo/config2\n"
+          f".f{allspace} foo/extra\n"
+          f"{L_sym_dots} foo/sym -> ../bar/baz/rsync\n",
+          filter=v_filt)
+
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-iplrtH', f'--compare-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
+          f"created directory {to2dir}\n"
+          + chkfile_extra)
+
+shutil.rmtree(to2dir, ignore_errors=True)
+checkdiff(['-vvplrtH', f'--compare-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
+          "./ is uptodate\n"
+          "bar/ is uptodate\n"
+          "bar/baz/ is uptodate\n"
+          "bar/baz/rsync is uptodate\n"
+          "foo/ is uptodate\n"
+          "foo/config1 is uptodate\n"
+          "foo/config2 is uptodate\n"
+          "foo/extra is uptodate\n"
+          f"foo/sym {is_uptodate}\n",
+          filter=v_filt)
diff --git a/testsuite/longdir.test b/testsuite/longdir.test
deleted file mode 100644 (file)
index 2674729..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 1998,1999 Philip Hands <phil@hands.com>
-# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
-#
-# This program is distributable under the terms of the GNU GPL (see COPYING)
-
-. "$suitedir/rsync.fns"
-
-hands_setup
-
-longname=This-is-a-directory-with-a-stupidly-long-name-created-in-an-attempt-to-provoke-an-error-found-in-2.0.11-that-should-hopefully-never-appear-again-if-this-test-does-its-job
-longdir="$fromdir/$longname/$longname/$longname"
-
-makepath "$longdir" || test_skipped "unable to create long directory"
-touch "$longdir/1" || test_skipped "unable to create files in long directory"
-date > "$longdir/1"
-if [ -r /etc ]; then
-    ls -la /etc >"$longdir/2" || [ $? -eq 1 ]
-else
-    ls -la / >"$longdir/2" || [ $? -eq 1 ]
-fi
-checkit "$RSYNC --delete -avH '$fromdir/' '$todir'" "$fromdir/" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/longdir_test.py b/testsuite/longdir_test.py
new file mode 100644 (file)
index 0000000..ead1fa4
--- /dev/null
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/longdir.test.
+#
+# Regression test for a 2.0.11 bug: rsync used to mishandle paths nested
+# inside a stupidly-long directory name. We build a three-deep nest of
+# 175-char directory names, drop a couple of files in the leaf, and
+# verify that --delete -avH still produces an identical destination.
+
+import os
+import subprocess
+
+from rsyncfns import FROMDIR, TODIR, checkit, hands_setup, test_skipped
+
+
+hands_setup()
+
+longname = ('This-is-a-directory-with-a-stupidly-long-name-created-in-an-'
+            'attempt-to-provoke-an-error-found-in-2.0.11-that-should-'
+            'hopefully-never-appear-again-if-this-test-does-its-job')
+longdir = FROMDIR / longname / longname / longname
+
+try:
+    longdir.mkdir(parents=True)
+except OSError:
+    test_skipped("unable to create long directory")
+
+try:
+    (longdir / '1').touch()
+except OSError:
+    test_skipped("unable to create files in long directory")
+
+# Drop some recognisably-varied content into the two leaf files.
+(longdir / '1').write_text(subprocess.check_output(['date'], text=True))
+
+listdir = '/etc' if os.access('/etc', os.R_OK) else '/'
+out = subprocess.run(['ls', '-la', listdir], capture_output=True, text=True)
+# ls exits 1 if it can't stat some entries (e.g. permission-denied files in
+# /etc); the shell test silently accepts that. We do the same.
+(longdir / '2').write_text(out.stdout)
+
+checkit(['--delete', '-avH', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
diff --git a/testsuite/merge.test b/testsuite/merge.test
deleted file mode 100644 (file)
index 17050a1..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2004-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Make sure we can merge files from multiple directories into one.
-
-. "$suitedir/rsync.fns"
-
-# Build some files/dirs/links to copy
-
-# Use local dirnames to better exercise the arg-parsing code.
-cd "$tmpdir"
-
-mkdir from1 from2 from3 deep
-mkdir from2/sub1 from3/sub1
-mkdir from3/sub2 from1/dir-and-not-dir
-mkdir chk chk/sub1 chk/sub2 chk/dir-and-not-dir
-echo "one" >from1/one
-cp_touch from1/one from2/one
-cp_touch from1/one from3/one
-echo "two" >from1/two
-echo "three" >from2/three
-echo "four" >from3/four
-echo "five" >from1/five
-echo "six" >from3/six
-echo "sub1" >from2/sub1/uno
-cp_touch from2/sub1/uno from3/sub1/uno
-echo "sub2" >from3/sub1/dos
-echo "sub3" >from2/sub1/tres
-echo "subby" >from3/sub2/subby
-echo "extra" >from1/dir-and-not-dir/inside
-echo "not-dir" >from3/dir-and-not-dir
-echo "arg-test" >deep/arg-test
-echo "shallow" >shallow
-
-cp_touch from1/one from1/two from2/three from3/four from1/five from3/six chk
-cp_touch deep/arg-test shallow chk
-cp_touch from1/dir-and-not-dir/inside chk/dir-and-not-dir
-cp_touch from2/sub1/uno from3/sub1/dos from2/sub1/tres chk/sub1
-cp_touch from3/sub2/subby chk/sub2
-
-# Make sure that time has moved on.
-sleep 1
-
-# Get rid of any directory-time differences
-$RSYNC -av --existing -f 'exclude,! */' from1/ from2/
-$RSYNC -av --existing -f 'exclude,! */' from2/ from3/
-$RSYNC -av --existing -f 'exclude,! */' from1/ chk/
-$RSYNC -av --existing -f 'exclude,! */' from3/ chk/
-
-checkit "$RSYNC -avv deep/arg-test shallow from1/ from2/ from3/ to/" "$chkdir" "$todir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/merge_test.py b/testsuite/merge_test.py
new file mode 100644 (file)
index 0000000..069a74b
--- /dev/null
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/merge.test.
+#
+# Verify that rsync merges files from multiple source directories into a
+# single destination, with later sources NOT clobbering earlier ones for
+# unchanged content and per-directory conflict resolution behaving as in
+# the canonical case.
+
+import os
+import time
+
+from rsyncfns import (
+    CHKDIR, TMPDIR, TODIR,
+    checkit, cp_touch, run_rsync,
+)
+
+
+# Use relative names below so the rsync command line exercises the
+# arg-parsing path the way the shell test did.
+os.chdir(TMPDIR)
+
+for d in ('from1', 'from2', 'from3', 'deep'):
+    os.mkdir(d)
+for d in ('from2/sub1', 'from3/sub1', 'from3/sub2', 'from1/dir-and-not-dir'):
+    os.mkdir(d)
+
+CHKDIR.mkdir(exist_ok=True)
+for d in ('sub1', 'sub2', 'dir-and-not-dir'):
+    (CHKDIR / d).mkdir()
+
+with open('from1/one', 'w') as f:
+    f.write("one\n")
+cp_touch('from1/one', 'from2/one')
+cp_touch('from1/one', 'from3/one')
+
+with open('from1/two', 'w') as f:
+    f.write("two\n")
+with open('from2/three', 'w') as f:
+    f.write("three\n")
+with open('from3/four', 'w') as f:
+    f.write("four\n")
+with open('from1/five', 'w') as f:
+    f.write("five\n")
+with open('from3/six', 'w') as f:
+    f.write("six\n")
+with open('from2/sub1/uno', 'w') as f:
+    f.write("sub1\n")
+cp_touch('from2/sub1/uno', 'from3/sub1/uno')
+with open('from3/sub1/dos', 'w') as f:
+    f.write("sub2\n")
+with open('from2/sub1/tres', 'w') as f:
+    f.write("sub3\n")
+with open('from3/sub2/subby', 'w') as f:
+    f.write("subby\n")
+with open('from1/dir-and-not-dir/inside', 'w') as f:
+    f.write("extra\n")
+with open('from3/dir-and-not-dir', 'w') as f:
+    f.write("not-dir\n")
+with open('deep/arg-test', 'w') as f:
+    f.write("arg-test\n")
+with open('shallow', 'w') as f:
+    f.write("shallow\n")
+
+for src in ('from1/one', 'from1/two', 'from2/three', 'from3/four',
+            'from1/five', 'from3/six'):
+    cp_touch(src, str(CHKDIR))
+cp_touch('deep/arg-test', str(CHKDIR))
+cp_touch('shallow', str(CHKDIR))
+cp_touch('from1/dir-and-not-dir/inside', str(CHKDIR / 'dir-and-not-dir'))
+for src in ('from2/sub1/uno', 'from3/sub1/dos', 'from2/sub1/tres'):
+    cp_touch(src, str(CHKDIR / 'sub1'))
+cp_touch('from3/sub2/subby', str(CHKDIR / 'sub2'))
+
+# Make sure time has moved on before the rsync runs.
+time.sleep(1)
+
+# Pre-sync directory-only updates to flatten directory-time differences,
+# matching the shell test's --existing -f 'exclude,! */' preparation.
+def _flatten_dirs(src, dst):
+    run_rsync('-av', '--existing', '-f', 'exclude,! */', f'{src}/', f'{dst}/')
+
+
+_flatten_dirs('from1', 'from2')
+_flatten_dirs('from2', 'from3')
+_flatten_dirs('from1', str(CHKDIR))
+_flatten_dirs('from3', str(CHKDIR))
+
+checkit(['-avv', 'deep/arg-test', 'shallow', 'from1/', 'from2/', 'from3/', 'to/'],
+        CHKDIR, TODIR)
diff --git a/testsuite/missing.test b/testsuite/missing.test
deleted file mode 100644 (file)
index 2fbf461..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/bin/sh
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test three bugs fixed by my redoing of the missing_below logic.
-
-. $suitedir/rsync.fns
-
-makepath "$fromdir/subdir" "$todir"
-echo data >"$fromdir/subdir/file"
-echo data >"$todir/other"
-
-# Test 1: Too much "not creating new..." output on a dry run
-$RSYNC -n -r --ignore-non-existing -vv "$fromdir/" "$todir/" | tee "$scratchdir/out"
-if grep 'not creating new.*subdir/file' "$scratchdir/out" >/dev/null; then
-       test_fail 'test 1 failed'
-fi
-
-case "$RSYNC" in
-*protocol=29*) # FIXME can we get past the new flist sanity check in protocol 29?
-       echo "Skipped test 2 for protocol 29."
-       ;;
-*)
-       # Test 2: Attempt to make a fuzzy dirlist for a dir not created on a dry run
-       $RSYNC -n -r -R --no-implied-dirs -y "$fromdir/./subdir/file" "$todir/" \
-               || test_fail 'test 2 failed'
-       ;;
-esac
-
-# Test 3: --delete-after pass skipped when last dir is dry-missing
-$RSYNC -n -r --delete-after -i "$fromdir/" "$todir/" | tee "$scratchdir/out"
-grep '^\*deleting * other' "$scratchdir/out" >/dev/null \
-       || test_fail 'test 3 failed'
diff --git a/testsuite/missing_test.py b/testsuite/missing_test.py
new file mode 100644 (file)
index 0000000..2d0a8ef
--- /dev/null
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/missing.test.
+#
+# Three regressions guarded by the missing_below logic rewrite:
+#   1. Dry-run with --ignore-non-existing must NOT emit "not creating new"
+#      for files whose containing directory already exists at the dest.
+#   2. Dry-run -R -y --no-implied-dirs must not crash trying to build a
+#      fuzzy dirlist for a directory it never created.
+#   3. --delete-after dry-run still emits "*deleting" lines even when the
+#      last source dir is dry-missing on the destination.
+
+import os
+import subprocess
+
+from rsyncfns import FROMDIR, RSYNC, TMPDIR, TODIR, makepath, rsync_argv, test_fail
+
+
+makepath(FROMDIR / 'subdir', TODIR)
+(FROMDIR / 'subdir' / 'file').write_text("data\n")
+(TODIR / 'other').write_text("data\n")
+
+
+def run_capture(*args):
+    proc = subprocess.run(rsync_argv(*args), capture_output=True, text=True)
+    print(proc.stdout, end='')
+    print(proc.stderr, end='')
+    return proc
+
+
+# Test 1: too much "not creating new..." output on a dry-run.
+out_path = TMPDIR / 'out1'
+proc = run_capture('-n', '-r', '--ignore-non-existing', '-vv',
+                   f'{FROMDIR}/', f'{TODIR}/')
+out_path.write_text(proc.stdout)
+for line in proc.stdout.splitlines():
+    if 'not creating new' in line and 'subdir/file' in line:
+        test_fail("test 1 failed: dry-run announced creating subdir/file")
+
+# Test 2: fuzzy dirlist crash on dry-run.  Skipped on protocol 29 just like
+# the shell version did, since the new flist sanity check rejects this.
+if 'protocol=29' not in RSYNC:
+    proc = run_capture('-n', '-r', '-R', '--no-implied-dirs', '-y',
+                       f'{FROMDIR}/./subdir/file', f'{TODIR}/')
+    if proc.returncode != 0:
+        test_fail("test 2 failed: --no-implied-dirs dry-run errored")
+else:
+    print("Skipped test 2 for protocol 29.")
+
+# Test 3: --delete-after pass skipped when last dir is dry-missing.
+proc = run_capture('-n', '-r', '--delete-after', '-i',
+                   f'{FROMDIR}/', f'{TODIR}/')
+saw_delete = any(line.lstrip().startswith('*deleting')
+                 and 'other' in line
+                 for line in proc.stdout.splitlines())
+if not saw_delete:
+    test_fail("test 3 failed: no '*deleting other' line in dry-run output")
diff --git a/testsuite/mkpath.test b/testsuite/mkpath.test
deleted file mode 100644 (file)
index 8046345..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/sh
-
-. "$suitedir/rsync.fns"
-
-makepath "$fromdir"
-makepath "$todir"
-
-cp_p "$srcdir/rsync.h" "$fromdir/text"
-cp_p "$srcdir/configure.ac" "$fromdir/extra"
-
-cd "$tmpdir"
-
-deep_dir=to/foo/bar/baz/down/deep
-
-# Check that we can create several levels of dest dir
-$RSYNC -aiv --mkpath from/text $deep_dir/new
-test -f $deep_dir/new || test_fail "'new' file not found in $deep_dir dir"
-rm -rf to/foo
-
-$RSYNC -aiv --mkpath from/text $deep_dir/
-test -f $deep_dir/text || test_fail "'text' file not found in $deep_dir dir"
-rm $deep_dir/text
-
-# Make sure we can handle an existing path
-mkdir $deep_dir/new
-$RSYNC -aiv --mkpath from/text $deep_dir/new
-test -f $deep_dir/new/text || test_fail "'text' file not found in $deep_dir/new dir"
-
-# ... and an existing path when an alternate dest filename is specified
-$RSYNC -aiv --mkpath from/text $deep_dir/new/text2
-test -f $deep_dir/new/text2 || test_fail "'text2' file not found in $deep_dir/new dir"
-rm -rf to/foo
-
-# Try the tests again with multiple source args
-$RSYNC -aiv --mkpath from/ $deep_dir
-test -f $deep_dir/extra || test_fail "'extra' file not found in $deep_dir dir"
-rm -rf to/foo
-
-$RSYNC -aiv --mkpath from/ $deep_dir/
-test -f $deep_dir/text || test_fail "'text' file not found in $deep_dir dir"
-
-# Make sure that we can handle no path
-$RSYNC -aiv --mkpath from/text to_text
-test -f to_text || test_fail "'to_text' file not found in current dir"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/mkpath_test.py b/testsuite/mkpath_test.py
new file mode 100644 (file)
index 0000000..e257d67
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/mkpath.test.
+#
+# Test the rsync --mkpath option: it should create any missing intermediate
+# destination directories rather than erroring out.
+
+import os
+import shutil
+from pathlib import Path
+
+from rsyncfns import (
+    FROMDIR, SRCDIR, TMPDIR, TODIR,
+    makepath, rmtree, run_rsync, test_fail,
+)
+
+
+makepath(FROMDIR, TODIR)
+shutil.copy2(SRCDIR / 'rsync.h', FROMDIR / 'text')
+shutil.copy2(SRCDIR / 'configure.ac', FROMDIR / 'extra')
+
+# All paths in the rsync invocations below are interpreted relative to
+# TMPDIR, matching the original shell test which did `cd "$tmpdir"`.
+os.chdir(TMPDIR)
+
+deep_dir = Path('to/foo/bar/baz/down/deep')
+
+
+def assert_file(path: Path, label: str) -> None:
+    if not path.is_file():
+        test_fail(f"{label}: {path} not found")
+
+
+# Create several levels of dest dir (file destination — final component
+# is the new filename).
+run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir / 'new'))
+assert_file(deep_dir / 'new', "'new' file in deep dir")
+rmtree('to/foo')
+
+# Trailing slash on the dest means it's a directory; the file keeps its name.
+run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir) + '/')
+assert_file(deep_dir / 'text', "'text' file in deep dir (trailing-slash dest)")
+(deep_dir / 'text').unlink()
+
+# An existing destination directory should also work.
+(deep_dir / 'new').mkdir(parents=True, exist_ok=True)
+run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir / 'new'))
+assert_file(deep_dir / 'new' / 'text', "'text' file in pre-existing deep/new dir")
+
+# ... and an existing path when an alternate dest filename is specified.
+run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir / 'new' / 'text2'))
+assert_file(deep_dir / 'new' / 'text2', "'text2' renamed file in pre-existing deep/new dir")
+rmtree('to/foo')
+
+# Multiple source args (whole directory) — bare dest name.
+run_rsync('-aiv', '--mkpath', 'from/', str(deep_dir))
+assert_file(deep_dir / 'extra', "'extra' file in deep dir (multi-source, no trailing slash)")
+rmtree('to/foo')
+
+# Multiple source args (whole directory) — dest with trailing slash.
+run_rsync('-aiv', '--mkpath', 'from/', str(deep_dir) + '/')
+assert_file(deep_dir / 'text', "'text' file in deep dir (multi-source, trailing slash)")
+
+# No intermediate path at all — dest is just a file in the current dir.
+run_rsync('-aiv', '--mkpath', 'from/text', 'to_text')
+assert_file(Path('to_text'), "'to_text' file in current dir")
diff --git a/testsuite/open-noatime.test b/testsuite/open-noatime.test
deleted file mode 100644 (file)
index 096a2c6..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/sh
-
-# Test rsync --open-noatime option keeps source atimes intact
-
-. "$suitedir/rsync.fns"
-
-$RSYNC -VV | grep '"atimes": true' >/dev/null || test_skipped "Rsync is configured without atimes support"
-
-# O_NOATIME is Linux-specific; skip on other platforms
-case `uname` in
-Linux) ;;
-*) test_skipped "O_NOATIME is only supported on Linux" ;;
-esac
-
-mkdir "$fromdir"
-
-# --open-noatime did not work properly on files with size > 0
-echo content > "$fromdir/foo"
-touch -a -t 200102031717.42 "$fromdir/foo"
-
-TLS_ARGS=--atimes
-
-"$TOOLDIR/tls" $TLS_ARGS "$fromdir/foo" > "$tmpdir/atime-from-before"
-
-# Do not use checkit because it uses "diff" which breaks atimes
-$RSYNC --open-noatime --archive --recursive --times --atimes -vvv "$fromdir/" "$todir/"
-
-"$TOOLDIR/tls" $TLS_ARGS "$fromdir/foo" > "$tmpdir/atime-from-after"
-diff "$tmpdir/atime-from-before" "$tmpdir/atime-from-after"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/open-noatime_test.py b/testsuite/open-noatime_test.py
new file mode 100644 (file)
index 0000000..fd267ec
--- /dev/null
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/open-noatime.test.
+#
+# Test that rsync --open-noatime keeps the source atime intact across the
+# transfer.  --open-noatime did not work properly on files with size > 0
+# at one point, so the test uses a non-empty source file.
+
+import datetime
+import os
+import platform
+import shlex
+import subprocess
+
+import rsyncfns
+from rsyncfns import FROMDIR, TMPDIR, TODIR, TOOLDIR, run_rsync, test_fail, test_skipped
+
+
+vv = run_rsync('-VV', check=True, capture_output=True)
+if '"atimes": true' not in vv.stdout:
+    test_skipped("Rsync is configured without atimes support")
+
+# O_NOATIME is Linux-specific.
+if platform.system() != 'Linux':
+    test_skipped("O_NOATIME is only supported on Linux")
+
+FROMDIR.mkdir(parents=True, exist_ok=True)
+foo = FROMDIR / 'foo'
+foo.write_text("content\n")
+
+# Pin the source atime to a known historical value (mtime preserved).
+atime = datetime.datetime(2001, 2, 3, 17, 17, 42).timestamp()
+mtime = foo.stat().st_mtime
+os.utime(foo, (atime, mtime))
+
+rsyncfns.TLS_ARGS = '--atimes'
+
+# Capture the atime of the source via tls BEFORE the rsync run.
+def _tls_listing(path: str) -> str:
+    cmd = [str(TOOLDIR / 'tls')] + shlex.split(rsyncfns.TLS_ARGS) + [str(path)]
+    return subprocess.check_output(cmd, text=True)
+
+
+before = _tls_listing(foo)
+(TMPDIR / 'atime-from-before').write_text(before)
+
+# Don't use checkit() here -- the file-content diff it does would update
+# atimes on the source and defeat the test.
+run_rsync('--open-noatime', '--archive', '--recursive', '--times',
+          '--atimes', '-vvv', f'{FROMDIR}/', f'{TODIR}/')
+
+after = _tls_listing(foo)
+(TMPDIR / 'atime-from-after').write_text(after)
+
+if before != after:
+    diff = subprocess.run(
+        ['diff', '-u',
+         str(TMPDIR / 'atime-from-before'),
+         str(TMPDIR / 'atime-from-after')],
+        capture_output=True, text=True,
+    )
+    print(diff.stdout)
+    test_fail("source atime changed across rsync --open-noatime run")
diff --git a/testsuite/protected-regular.test b/testsuite/protected-regular.test
deleted file mode 100644 (file)
index d276961..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2021 by Achim Leitner <aleitner@lis-engineering.de>
-# This program is distributable under the terms of the GNU GPL (see COPYING)
-#
-# Modern linux systems have the protected_regular feature set to 1 or 2
-# See https://www.kernel.org/doc/Documentation/sysctl/fs.txt
-# Make sure we can still write these files in --inplace mode
-
-. "$suitedir/rsync.fns"
-
-test -f /proc/sys/fs/protected_regular || test_skipped "Can't find protected_regular setting (only available on Linux)"
-pr_lvl=`cat /proc/sys/fs/protected_regular 2>/dev/null` || test_skipped "Can't check if fs.protected_regular is enabled"
-test "$pr_lvl" != 0 || test_skipped "fs.protected_regular is not enabled"
-
-workdir="$tmpdir/files"
-mkdir -p "$workdir"
-chmod 1777 "$workdir"
-
-echo "Source" > "$workdir/src"
-echo ""       > "$workdir/dst"
-
-if ! chown 5001 "$workdir/dst" 2>/dev/null; then
-    # Not root - try re-running under unshare with UID mapping
-    if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user --map-users 5001:100000:1 true 2>/dev/null; then
-       echo "Re-running under unshare with UID mapping..."
-       RSYNC_UNSHARED=1 exec unshare --user --map-root-user --map-users 5001:100000:1 "$SHELL_PATH" $RUNSHFLAGS "$0"
-    fi
-    test_skipped "Can't chown (need root or unshare with uidmap)"
-fi
-
-echo "Contents of $workdir:"
-ls -al "$workdir"
-
-$RSYNC --inplace "$workdir/src" "$workdir/dst" || test_fail
-
-exit 0
diff --git a/testsuite/protected-regular_test.py b/testsuite/protected-regular_test.py
new file mode 100644 (file)
index 0000000..f3e0485
--- /dev/null
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/protected-regular.test.
+#
+# Modern Linux kernels can set fs.protected_regular = {1,2}, which
+# blocks O_CREAT|O_WRONLY opens of files in world-writable sticky
+# directories that the opener doesn't own. rsync --inplace must still
+# be able to write into these files; this test guards that path.
+
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+from rsyncfns import TMPDIR, run_rsync, test_skipped
+
+
+pr_path = Path('/proc/sys/fs/protected_regular')
+if not pr_path.is_file():
+    test_skipped("Can't find protected_regular setting (only available on Linux)")
+
+try:
+    pr_lvl = pr_path.read_text().strip()
+except OSError:
+    test_skipped("Can't check if fs.protected_regular is enabled")
+if pr_lvl == '0':
+    test_skipped("fs.protected_regular is not enabled")
+
+workdir = TMPDIR / 'files'
+workdir.mkdir(parents=True, exist_ok=True)
+os.chmod(workdir, 0o1777)
+
+(workdir / 'src').write_text("Source\n")
+(workdir / 'dst').write_text("")
+
+
+def _chown_5001(path: Path) -> bool:
+    """Try to chown(2) `path` to uid 5001. Returns True on success."""
+    try:
+        os.chown(path, 5001, -1)
+        return True
+    except PermissionError:
+        return False
+
+
+if not _chown_5001(workdir / 'dst'):
+    # Not root: fall back to re-running ourselves under unshare with a
+    # uid mapping (Linux user-namespace trick). Only attempt once.
+    if not os.environ.get('RSYNC_UNSHARED'):
+        unshare = shutil.which('unshare')
+        if unshare is not None:
+            probe = subprocess.run(
+                [unshare, '--user', '--map-root-user',
+                 '--map-users', '5001:100000:1', 'true'],
+                capture_output=True,
+            )
+            if probe.returncode == 0:
+                print("Re-running under unshare with UID mapping...")
+                env = os.environ.copy()
+                env['RSYNC_UNSHARED'] = '1'
+                os.execvpe(
+                    unshare,
+                    [unshare, '--user', '--map-root-user',
+                     '--map-users', '5001:100000:1',
+                     sys.executable, __file__],
+                    env,
+                )
+    test_skipped("Can't chown (need root or unshare with uidmap)")
+
+print(f"Contents of {workdir}:")
+subprocess.run(['ls', '-al', str(workdir)])
+
+run_rsync('--inplace', str(workdir / 'src'), str(workdir / 'dst'))
diff --git a/testsuite/proxy-response-line-too-long.test b/testsuite/proxy-response-line-too-long.test
deleted file mode 100755 (executable)
index 7f55c43..0000000
+++ /dev/null
@@ -1,128 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for the off-by-one stack OOB write in
-# establish_proxy_connection() in socket.c when a malicious or
-# man-in-the-middle HTTP proxy returns a first response line of
-# 1023+ bytes without a '\n' terminator.
-#
-# Pre-fix: the read loop walked buffer[0..sizeof-2] one byte at a
-# time, then post-loop logic did "if (*cp != '\n') cp++; *cp-- =
-# '\0';".  If no newline arrived before the loop filled the buffer,
-# cp was left at &buffer[sizeof-1] (never written by the loop),
-# *cp held stale stack bytes, and cp++ pushed cp one past the array.
-# The null-termination then wrote one byte out of bounds on the
-# stack.  AddressSanitizer reports stack-buffer-overflow at the
-# null-termination site.
-#
-# Post-fix: the bound-exhaustion case is detected by position and
-# rejected with an "proxy response line too long" message, so no
-# OOB write occurs and rsync exits with a non-signal status.
-
-. "$suitedir/rsync.fns"
-
-command -v python3 >/dev/null 2>&1 || test_skipped "python3 not available"
-
-workdir="$scratchdir/workdir"
-mkdir -p "$workdir"
-cd "$workdir"
-
-port_file="$workdir/port"
-proxy_log="$workdir/proxy.log"
-
-# A minimal TCP listener: binds to an ephemeral port on 127.0.0.1,
-# writes the chosen port to $port_file *before* accept() so the test
-# can synchronise without a sleep, accepts one connection, reads
-# until end-of-headers or 64 KiB, sends exactly 1023 bytes of 'X'
-# with no '\n', then closes.
-python3 - "$port_file" >"$proxy_log" 2>&1 <<'PYEOF' &
-import socket, sys, os
-port_file = sys.argv[1]
-s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-s.bind(("127.0.0.1", 0))
-port = s.getsockname()[1]
-tmp = port_file + ".tmp"
-with open(tmp, "w") as fp:
-    fp.write("%d\n" % port)
-os.rename(tmp, port_file)   # atomic visibility to the shell side
-s.listen(1)
-conn, _ = s.accept()
-conn.settimeout(5)
-try:
-    data = b""
-    while b"\r\n\r\n" not in data and len(data) < 65536:
-        chunk = conn.recv(8192)
-        if not chunk:
-            break
-        data += chunk
-except socket.timeout:
-    pass
-conn.sendall(b"X" * 1023)    # exactly the buffer-1 trigger size
-try:
-    conn.shutdown(socket.SHUT_RDWR)
-except OSError:
-    pass
-conn.close()
-s.close()
-PYEOF
-proxy_pid=$!
-
-# Wait up to ~10s for the listener to publish its port.
-i=0
-while [ ! -s "$port_file" ] && [ $i -lt 10 ]; do
-    sleep 1
-    i=$((i + 1))
-done
-
-if [ ! -s "$port_file" ]; then
-    kill "$proxy_pid" 2>/dev/null
-    cat "$proxy_log" >&2 2>/dev/null
-    test_fail "proxy listener never published a port"
-fi
-
-port=`cat "$port_file"`
-case "$port" in
-    *[!0-9]*|"") kill "$proxy_pid" 2>/dev/null; test_fail "bogus port from listener: '$port'" ;;
-esac
-
-# Run rsync through the malicious proxy.  Any rsync:// URL works:
-# the proxy intercepts the CONNECT and never forwards anywhere.
-rsync_err="$workdir/rsync.err"
-
-# rsync MUST exit non-zero here (the proxy is misbehaving).
-# Use `|| status=$?` so we capture the real exit code under `sh -e`;
-# `if ! cmd; then status=$?` would only ever see 0 because the `!`
-# is the last command before `$?`.
-status=0
-RSYNC_PROXY="127.0.0.1:$port" \
-    $RSYNC rsync://example.invalid:873/whatever/ "$workdir/out/" \
-    >/dev/null 2>"$rsync_err" || status=$?
-
-# Reap the listener.
-wait "$proxy_pid" 2>/dev/null || true
-
-# 1. rsync must not have crashed (SIGSEGV/SIGABRT report >= 128).
-if [ "$status" -ge 128 ]; then
-    cat "$rsync_err" >&2
-    test_fail "rsync killed by signal (status=$status) -- possible stack OOB regression"
-fi
-
-# 2. rsync must have actually exited non-zero (i.e. saw the bad proxy).
-if [ "$status" -eq 0 ]; then
-    cat "$rsync_err" >&2
-    test_fail "rsync returned success despite malformed proxy response"
-fi
-
-# 3. The new error message must appear.
-if ! grep -q "proxy response line too long" "$rsync_err"; then
-    cat "$rsync_err" >&2
-    test_fail "expected 'proxy response line too long' in rsync stderr"
-fi
-
-echo "OK: over-long proxy response line rejected cleanly without crashing"
-exit 0
diff --git a/testsuite/proxy-response-line-too-long_test.py b/testsuite/proxy-response-line-too-long_test.py
new file mode 100644 (file)
index 0000000..946d05a
--- /dev/null
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/proxy-response-line-too-long.test.
+#
+# Regression test for the off-by-one stack OOB write in
+# establish_proxy_connection() when a malicious or man-in-the-middle
+# HTTP proxy returned a first response line of 1023+ bytes without a
+# '\n' terminator. Post-fix, rsync must reject this with "proxy
+# response line too long" and exit non-zero without dying from a signal.
+
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import threading
+import time
+
+from rsyncfns import SCRATCHDIR, rsync_argv, test_fail, test_skipped
+
+
+if shutil.which('python3') is None:
+    test_skipped("python3 not available")
+
+workdir = SCRATCHDIR / 'workdir'
+workdir.mkdir(parents=True, exist_ok=True)
+os.chdir(workdir)
+
+# In-process listener: bind a TCP socket, capture the chosen port,
+# accept one client, read up to end-of-headers (or 64 KiB), reply
+# with exactly 1023 'X' bytes and no '\n', then close. We use a
+# thread rather than spawning python3 again -- simpler synchronisation,
+# same effect on the rsync side.
+listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+listener.bind(('127.0.0.1', 0))
+port = listener.getsockname()[1]
+listener.listen(1)
+
+
+def _serve_one():
+    conn, _ = listener.accept()
+    conn.settimeout(5)
+    try:
+        data = b""
+        while b"\r\n\r\n" not in data and len(data) < 65536:
+            chunk = conn.recv(8192)
+            if not chunk:
+                break
+            data += chunk
+    except socket.timeout:
+        pass
+    conn.sendall(b"X" * 1023)
+    try:
+        conn.shutdown(socket.SHUT_RDWR)
+    except OSError:
+        pass
+    conn.close()
+
+
+t = threading.Thread(target=_serve_one)
+t.daemon = True
+t.start()
+
+# Run rsync against the malicious proxy. The proxy intercepts CONNECT
+# and never forwards, so the upstream URL is irrelevant.
+env = os.environ.copy()
+env['RSYNC_PROXY'] = f'127.0.0.1:{port}'
+proc = subprocess.run(
+    rsync_argv('rsync://example.invalid:873/whatever/', f'{workdir}/out/'),
+    capture_output=True, text=True, env=env,
+)
+
+t.join(timeout=15)
+listener.close()
+
+status = proc.returncode
+err = proc.stderr
+
+if status >= 128:
+    sys.stderr.write(err)
+    test_fail(f"rsync killed by signal (status={status}) -- possible stack OOB regression")
+
+if status == 0:
+    sys.stderr.write(err)
+    test_fail("rsync returned success despite malformed proxy response")
+
+if 'proxy response line too long' not in err:
+    sys.stderr.write(err)
+    test_fail("expected 'proxy response line too long' in rsync stderr")
+
+print("OK: over-long proxy response line rejected cleanly without crashing")
diff --git a/testsuite/relative.test b/testsuite/relative.test
deleted file mode 100644 (file)
index 5546291..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2005-2020 Wayne Davison
-#
-# This program is distributable under the terms of the GNU GPL (see COPYING)
-
-. "$suitedir/rsync.fns"
-
-deepstr='down/3/deep'
-deepdir="$fromdir/$deepstr"
-extradir="$fromdir/extra"
-makepath "$deepdir" "$extradir/$deepstr" "$chkdir"
-
-fromdir="$deepdir"
-hands_setup
-fromdir="$tmpdir/from"
-
-extrafile="$extradir/./$deepstr/extra.added.value"
-echo wowza >"$extrafile"
-
-$RSYNC -av --existing --include='*/' --exclude='*' "$fromdir/" "$extradir/"
-
-cd "$fromdir"
-
-# Main script starts here
-
-$RSYNC -ai --include=/down/ --exclude='/*' "$fromdir/" "$chkdir/"
-
-sleep 1
-runtest "basic relative" 'checkit "$RSYNC -avR ./$deepstr \"$todir\"" "$chkdir" "$todir"'
-
-ln $deepstr/filelist $deepstr/dir
-ln ../chk/$deepstr/filelist ../chk/$deepstr/dir
-# Work around time rounding/truncating issue by touching both dirs.
-touch -r $deepstr/dir $deepstr/dir ../chk/$deepstr/dir
-runtest "hard links" 'checkit "$RSYNC -avHR ./$deepstr/ \"$todir\"" "$chkdir" "$todir"'
-
-cp "$deepdir/text" "$todir/$deepstr/ThisShouldGo"
-cp "$deepdir/text" "$todir/$deepstr/dir/ThisShouldGoToo"
-runtest "deletion" 'checkit "$RSYNC -avHR --del ./$deepstr/ \"$todir\"" "$chkdir" "$todir"'
-
-runtest "non-deletion" 'checkit "$RSYNC -aiHR --del ./$deepstr/ \"$todir\"" "$chkdir" "$todir"' \
-    | tee "$outfile"
-
-# Make sure no files were deleted
-grep 'deleting ' "$outfile" && test_fail "Erroneous deletions occurred!"
-
-# Relative with merging.
-$RSYNC -ai "$extradir/down" "$chkdir/"
-
-checkit "$RSYNC -aiR $deepstr '$extrafile' '$todir'" "$chkdir" "$todir"
-
-checkit "$RSYNC -aiR --del $deepstr '$extrafile' '$todir'" "$chkdir" "$todir" \
-    | tee "$outfile"
-
-# Make sure no files were deleted
-grep 'deleting ' "$outfile" && test_fail "Erroneous deletions occurred! (2)"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/relative_test.py b/testsuite/relative_test.py
new file mode 100644 (file)
index 0000000..123189c
--- /dev/null
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/relative.test.
+#
+# Exercise rsync --relative (-R) behaviour: paths anchored at a "./" cut
+# point should reproduce that subtree at the destination. We pile on
+# hard-link preservation, --del / --del-on-extras and the side-by-side
+# combination of an -R-anchored arg with an absolute "extra" path.
+
+import os
+import subprocess
+import time
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, OUTFILE, TMPDIR, TODIR,
+    checkit, hands_setup, makepath, rsync_argv,
+    run_rsync, test_fail,
+)
+
+
+deepstr = 'down/3/deep'
+deepdir = FROMDIR / deepstr
+extradir = TMPDIR / 'extra'
+
+makepath(deepdir, extradir / deepstr, CHKDIR)
+
+# Generate the rich source tree underneath the deep nested dir, not under
+# fromdir directly. hands_setup reads FROMDIR from the module, so override
+# briefly via the rsyncfns module attribute.
+import rsyncfns
+real_fromdir = rsyncfns.FROMDIR
+try:
+    rsyncfns.FROMDIR = deepdir
+    hands_setup()
+finally:
+    rsyncfns.FROMDIR = real_fromdir
+
+extrafile = extradir / deepstr / 'extra.added.value'
+extrafile.write_text("wowza\n")
+# rsync's -R uses "./" as the anchor cut point: anything after it is the
+# subtree path to recreate at the destination. Preserve the literal "./"
+# in the string we pass to rsync, separately from the Path we use for
+# filesystem operations.
+extrafile_for_rsync = f"{extradir}/./{deepstr}/extra.added.value"
+
+# Seed extradir with just the directory skeleton of fromdir.
+run_rsync('-av', '--existing', '--include=*/', '--exclude=*',
+          f'{FROMDIR}/', f'{extradir}/')
+
+os.chdir(FROMDIR)
+
+# chkdir: same shape as a --include=/down/ --exclude=/* sync of fromdir.
+run_rsync('-ai', '--include=/down/', '--exclude=/*',
+          f'{FROMDIR}/', f'{CHKDIR}/')
+
+time.sleep(1)
+
+print("Test basic relative:")
+checkit(['-avR', f'./{deepstr}', str(TODIR)], CHKDIR, TODIR)
+
+# Add a hard link inside the source and the chk dir; mirror it on both
+# sides so the --delete pass below doesn't see it as new on either tree.
+os.link(deepdir / 'filelist', deepdir / 'dir' / 'filelist')
+os.link(CHKDIR / deepstr / 'filelist', CHKDIR / deepstr / 'dir' / 'filelist')
+# Re-touch both dirs so the inner-dir time matches.
+src_t = (deepdir / 'dir').stat().st_mtime
+os.utime(deepdir / 'dir', (src_t, src_t))
+os.utime(CHKDIR / deepstr / 'dir', (src_t, src_t))
+
+print("Test hard links:")
+checkit(['-avHR', f'./{deepstr}/', str(TODIR)], CHKDIR, TODIR)
+
+# Drop some stray files at the dest then re-sync with --del to confirm
+# they're removed.
+import shutil
+shutil.copy(deepdir / 'text', TODIR / deepstr / 'ThisShouldGo')
+shutil.copy(deepdir / 'text', TODIR / deepstr / 'dir' / 'ThisShouldGoToo')
+
+print("Test deletion:")
+checkit(['-avHR', '--del', f'./{deepstr}/', str(TODIR)], CHKDIR, TODIR)
+
+print("Test non-deletion:")
+# Same as the previous pass but capture output to grep for spurious
+# 'deleting ' lines.
+proc = subprocess.run(
+    rsync_argv('-aiHR', '--del', f'./{deepstr}/', str(TODIR)),
+    capture_output=True, text=True,
+)
+OUTFILE.write_text(proc.stdout)
+print(proc.stdout, end='')
+if proc.returncode != 0:
+    test_fail(f"non-deletion run exited {proc.returncode}")
+if 'deleting ' in proc.stdout:
+    test_fail("Erroneous deletions occurred!")
+
+# Relative with merging.
+run_rsync('-ai', str(extradir / 'down'), f'{CHKDIR}/')
+
+print("Test merge:")
+checkit(['-aiR', deepstr, extrafile_for_rsync, str(TODIR)], CHKDIR, TODIR)
+
+print("Test merge with --del:")
+proc = subprocess.run(
+    rsync_argv('-aiR', '--del', deepstr, extrafile_for_rsync, str(TODIR)),
+    capture_output=True, text=True,
+)
+OUTFILE.write_text(proc.stdout)
+print(proc.stdout, end='')
+if proc.returncode != 0:
+    test_fail(f"merge --del run exited {proc.returncode}")
+if 'deleting ' in proc.stdout:
+    test_fail("Erroneous deletions occurred! (2)")
diff --git a/testsuite/rsync.fns b/testsuite/rsync.fns
deleted file mode 100644 (file)
index c7077fe..0000000
+++ /dev/null
@@ -1,542 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
-
-# General-purpose test functions for rsync.
-
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version
-# 2 as published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful, but
-# WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this program; if not, write to the Free Software
-# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
-
-tmpdir="$scratchdir"
-fromdir="$tmpdir/from"
-todir="$tmpdir/to"
-chkdir="$tmpdir/chk"
-
-chkfile="$scratchdir/rsync.chk"
-outfile="$scratchdir/rsync.out"
-
-# For itemized output:
-all_plus='+++++++++'
-allspace='         '
-dots='.....' # trailing dots after changes
-tab_ch='       ' # a single tab character
-
-# Berkley's nice.
-PATH="$PATH:/usr/ucb"
-
-if diff -u "$suitedir/rsync.fns" "$suitedir/rsync.fns" >/dev/null 2>&1; then
-    diffopt="-u"
-else
-    diffopt="-c"
-fi
-
-HOME="$scratchdir"
-export HOME
-
-runtest() {
-    echo $ECHO_N "Test $1: $ECHO_C"
-    if eval "$2"; then
-        echo "$ECHO_T  done."
-        return 0
-    else
-        echo "$ECHO_T failed!"
-        return 1
-    fi
-}
-
-set_cp_destdir() {
-    while test $# -gt 1; do
-       shift
-    done
-    destdir="$1"
-}
-
-# Perform a "cp -p", making sure that timestamps are really the same,
-# even if the copy rounded microsecond times on the destination file.
-cp_touch() {
-    cp_p "${@}"
-    if test $# -gt 2 || test -d "$2"; then
-       set_cp_destdir "${@}" # sets destdir var
-       while test $# -gt 1; do
-           destname="$destdir/`basename $1`"
-           touch -r "$destname" "$1" "$destname"
-           shift
-       done
-    else
-       touch -r "$2" "$1" "$2"
-    fi
-}
-
-# Call this if you want to filter (stdin -> stdout) verbose messages (-v or
-# -vv) from an rsync run (whittling the output down to just the file messages).
-# This isn't needed if you use -i without -v.
-v_filt() {
-    sed -e '/^building file list /d' \
-       -e '/^sending incremental file list/d' \
-       -e '/^created directory /d' \
-       -e '/^done$/d' \
-       -e '/ --whole-file$/d' \
-       -e '/^total: /d' \
-       -e '/^client charset: /d' \
-       -e '/^server charset: /d' \
-       -e '/^$/,$d'
-}
-
-printmsg() {
-    echo "$1"
-}
-
-rsync_ls_lR() {
-    find "$@" -name .git -prune -o -name auto-build-save -prune -o -name testtmp -prune -o -print | \
-       sort | sed 's/ /\\ /g' | xargs "$TOOLDIR/tls" $TLS_ARGS
-}
-
-get_testuid() {
-    uid=`id -u 2>/dev/null || true`
-    case "$uid" in
-       [0-9]*) echo "$uid" ;;
-       *) id 2>/dev/null | sed 's/^[^0-9]*\([0-9][0-9]*\).*/\1/' ;;
-    esac
-}
-
-get_rootuid() {
-    uid=`id -u root 2>/dev/null || true`
-    case "$uid" in
-       [0-9]*) echo "$uid" ;;
-       *) echo 0 ;;
-    esac
-}
-
-get_rootgid() {
-    gid=`id -g root 2>/dev/null || true`
-    case "$gid" in
-       [0-9]*) echo "$gid" ;;
-       *) echo 0 ;;
-    esac
-}
-
-# When copying via "cp -p", we want to ensure that a non-root user does not
-# preserve ownership (we want our files to be created as the testing user).
-# For instance, a Cygwin CI run might have git files owned by a different
-# user than the (admin) user running the tests.
-cp_cmd="cp -p"
-if test x`get_testuid` != x0; then
-    case `cp --help 2>/dev/null` in
-       *--no-preserve=*) cp_cmd="cp -p --no-preserve=ownership" ;;
-    esac
-fi
-cp_p() {
-    $cp_cmd "${@}" || test_fail "$cp_cmd failed"
-}
-
-check_perms() {
-    perms=`"$TOOLDIR/tls" "$1" | sed 's/^[-d]\(.........\).*/\1/'`
-    if test $perms = $2; then
-       return 0
-    fi
-    echo "permissions: $perms on $1"
-    echo "should be:   $2"
-    test_fail "failed test $3"
-}
-
-rsync_getgroups() {
-    "$TOOLDIR/getgroups"
-}
-
-
-####################
-# Build test directories $todir and $fromdir, with $fromdir full of files.
-
-hands_setup() {
-    # Clean before creation
-    rm -rf "$fromdir"
-    rm -rf "$todir"
-
-    [ -d "$tmpdir" ] || mkdir "$tmpdir"
-    [ -d "$fromdir" ] || mkdir "$fromdir"
-    [ -d "$todir" ] || mkdir "$todir"
-
-    # On some BSD systems, the umask affects the mode of created
-    # symlinks, even though the mode apparently has no effect on how
-    # the links behave in the future, and it cannot be changed using
-    # chmod!  rsync always sets its umask to 000 so that it can
-    # accurately recreate permissions, but this script is probably run
-    # with a different umask.
-
-    # This causes a little problem that "ls -l" of the two will not be
-    # the same.  So, we need to set our umask before doing any creations.
-
-    # set up test data
-    touch "$fromdir/empty"
-    mkdir "$fromdir/emptydir"
-
-    # a hundred lines of text or so
-    rsync_ls_lR "$srcdir" > "$fromdir/filelist"
-
-    echo $ECHO_N "This file has no trailing lf$ECHO_C" > "$fromdir/nolf"
-    umask 0
-    ln -s nolf "$fromdir/nolf-symlink"
-    umask 022
-
-    cat "$srcdir"/*.c > "$fromdir/text"
-    mkdir "$fromdir/dir"
-    cp "$fromdir/text" "$fromdir/dir"
-    mkdir "$fromdir/dir/subdir"
-    echo some data > "$fromdir/dir/subdir/foobar.baz"
-    mkdir "$fromdir/dir/subdir/subsubdir"
-    if [ -r /etc ]; then
-       ls -ltr /etc > "$fromdir/dir/subdir/subsubdir/etc-ltr-list" || [ $? -eq 1 ]
-    else
-       ls -ltr / > "$fromdir/dir/subdir/subsubdir/etc-ltr-list" || [ $? -eq 1 ]
-    fi
-    mkdir "$fromdir/dir/subdir/subsubdir2"
-    if [ -r /bin ]; then
-       ls -lt /bin > "$fromdir/dir/subdir/subsubdir2/bin-lt-list" || [ $? -eq 1 ]
-    else
-       ls -lt / > "$fromdir/dir/subdir/subsubdir2/bin-lt-list" || [ $? -eq 1 ]
-    fi
-
-#      echo testing head:
-#      ls -lR "$srcdir" | head -10 || echo failed
-}
-
-
-####################
-# Many machines do not have "mkdir -p", so we have to build up long paths.
-# How boring.
-makepath() {
-    for p in "${@}"; do
-       (echo "        makepath $p"
-
-       # Absolute Unix path.
-       if echo $p | grep '^/' >/dev/null; then
-           cd /
-       fi
-
-       # This will break if $p contains a space.
-       for c in `echo $p | tr '/' ' '`; do
-           if [ -d "$c" ] || mkdir "$c"; then
-               cd "$c" || return $?
-           else
-               echo "failed to create $c" >&2; return $?
-           fi
-       done)
-    done
-}
-
-
-###########################
-# Create a file at $1 of $2 bytes containing non-trivial content
-# suitable for rsync's delta algorithm to chew on.  Prefers
-# /dev/urandom for speed and entropy, falling back to a
-# deterministic awk pseudo-random generator on platforms that
-# lack /dev/urandom (e.g. HPE NonStop).  The tests using this
-# helper don't need cryptographic randomness -- they only need
-# bytes that compress and delta-match like normal file content.
-
-make_data_file() {
-    if [ $# -ne 2 ]; then
-       echo "usage: make_data_file PATH SIZE" >&2
-       return 2
-    fi
-    if [ -r /dev/urandom ] && \
-       dd if=/dev/urandom of="$1" bs="$2" count=1 2>/dev/null && \
-       [ -s "$1" ]; then
-       return 0
-    fi
-    # Fallback: a 32-bit linear congruential generator with BSD/glibc
-    # parameters.  Seeded from PID and a POSIX cksum of the destination
-    # path so successive calls with different paths produce distinct
-    # content.  Output is constrained to the printable-ASCII range
-    # (33..126, i.e. '!' through '~') for two portability reasons:
-    #   - awk implementations vary on whether printf "%c", 0 emits a
-    #     NUL byte or terminates the string;
-    #   - gawk in UTF-8 locales encodes printf "%c", N for N > 127
-    #     as a 2-byte UTF-8 sequence, which would make the output
-    #     larger than the requested sz.
-    # The tests using this helper don't need 8-bit binary data, only
-    # non-trivial content for the rsync delta algorithm.
-    _path_seed=$(printf '%s' "$1" | cksum 2>/dev/null | awk '{print $1}')
-    awk -v sz="$2" -v seed_a="$$" -v seed_b="${_path_seed:-0}" 'BEGIN {
-       s = (seed_a + seed_b) % 2147483648
-       if (s < 0) s = -s
-       for (i = 0; i < sz; i++) {
-           s = (s * 1103515245 + 12345) % 2147483648
-           b = (int(s / 65536) % 94) + 33    # 33..126
-           printf "%c", b
-       }
-    }' > "$1"
-}
-
-
-###########################
-# Run a test (in '$1') then compare directories $2 and $3 to see if
-# there are any difference.  If there are, explain them.
-
-# So normally basically $1 should be an rsync command, and $2 and $3
-# the source and destination directories.  This is only good when you
-# expect to transfer the whole directory exactly as is.  If some files
-# should be excluded, you might need to use something else.
-
-checkit() {
-    failed=
-
-    # We can just write everything to stdout/stderr, because the
-    # wrapper hides it unless there is a problem.
-
-    case "x$TLS_ARGS" in
-    *--atimes*)
-       ( cd "$2" && rsync_ls_lR . ) > "$tmpdir/ls-from"
-       ;;
-    *)
-       ;;
-    esac
-
-    echo "Running: \"$1\""
-    eval "$1"
-    status=$?
-    if [ $status != 0 ]; then
-       failed="$failed status=$status"
-    fi
-
-    case "x$TLS_ARGS" in
-    *--atimes*)
-       ;;
-    *)
-       ( cd "$2" && rsync_ls_lR . ) > "$tmpdir/ls-from"
-       ;;
-    esac
-
-    echo "-------------"
-    echo "check how the directory listings compare with diff:"
-    echo ""
-    ( cd "$3" && rsync_ls_lR . ) > "$tmpdir/ls-to"
-    diff $diffopt "$tmpdir/ls-from" "$tmpdir/ls-to" || failed="$failed dir-diff"
-
-    echo "-------------"
-    echo "check how the files compare with diff:"
-    echo ""
-    if [ "x$4" != x ]; then
-       echo "  === Skipping (as directed) ==="
-    else
-       diff -r $diffopt "$2" "$3" || failed="$failed file-diff"
-    fi
-
-    echo "-------------"
-    if [ -z "$failed" ]; then
-       return 0
-    fi
-
-    echo "Failed: $failed"
-    return 1
-}
-
-
-# Run a test in $1 and make sure it has a zero exit status.  Capture the
-# output into $outfile and echo it to stdout.
-checktee() {
-    echo "Running: \"$1\""
-    eval "$1" >"$outfile"
-    status=$?
-    cat "$outfile"
-    if [ $status != 0 ]; then
-       echo "Failed: status=$status"
-       return 1
-    fi
-    return 0
-}
-
-
-# Slurp stdin into $chkfile and then call checkdiff2().
-checkdiff() {
-    cat >"$chkfile" # Save off stdin
-    checkdiff2 "${@}"
-}
-
-
-# Run a test in $1 and make sure it has a zero exit status.  Capture the output
-# into $outfile.  If $2 is set, use it to filter the outfile.  If resulting
-# outfile differs from the chkfile data, fail with an error.
-checkdiff2() {
-    failed=
-
-    echo "Running: \"$1\""
-    eval "$1" >"$outfile"
-    status=$?
-    cat "$outfile"
-    if [ $status != 0 ]; then
-       failed="$failed status=$status"
-    fi
-
-    if [ -n "$2" ]; then
-       eval "cat '$outfile' | $2 >'$outfile.new'"
-       mv "$outfile.new" "$outfile"
-    fi
-
-    diff $diffopt "$chkfile" "$outfile" || failed="$failed output differs"
-
-    if [ -n "$failed" ]; then
-       echo "Failed:$failed"
-       return 1
-    fi
-    return 0
-}
-
-
-build_rsyncd_conf() {
-    # Build an appropriate configuration file
-    conf="$scratchdir/test-rsyncd.conf"
-    echo "building configuration $conf"
-
-    port=2612
-    pidfile="$scratchdir/rsyncd.pid"
-    logfile="$scratchdir/rsyncd.log"
-    hostname=`uname -n`
-
-    my_uid=`get_testuid`
-    root_uid=`get_rootuid`
-    root_gid=`get_rootgid`
-
-    uid_setting="uid = $root_uid"
-    gid_setting="gid = $root_gid"
-
-    if test x"$my_uid" != x"$root_uid"; then
-       # Non-root cannot specify uid & gid settings
-       uid_setting="#$uid_setting"
-       gid_setting="#$gid_setting"
-    fi
-
-    cat >"$conf" <<EOF
-# rsyncd configuration file autogenerated by $0
-
-pid file = $pidfile
-use chroot = no
-munge symlinks = no
-hosts allow = localhost 127.0.0.0/24 192.168.0.0/16 10.0.0.0/8 $hostname
-log file = $logfile
-transfer logging = yes
-# We don't define log format here so that the test-hidden module will default
-# to the internal static string (since we had a crash trying to tweak it).
-exclude = ? foobar.baz
-max verbosity = 4
-$uid_setting
-$gid_setting
-
-[test-from]
-       path = $fromdir
-       log format = %i %h [%a] %m (%u) %l %f%L
-       read only = yes
-       comment = r/o
-
-[test-to]
-       path = $todir
-       log format = %i %h [%a] %m (%u) %l %f%L
-       read only = no
-       comment = r/w
-
-[test-scratch]
-       path = $scratchdir
-       log format = %i %h [%a] %m (%u) %l %f%L
-       read only = no
-
-[test-hidden]
-       path = $fromdir
-       list = no
-EOF
-
-    # Build a helper script to ignore exit code 23
-    ignore23="$scratchdir/ignore23"
-    echo "building help script $ignore23"
-
-    cat >"$ignore23" <<'EOT'
-if "${@}"; then
-    exit
-fi
-
-ret=$?
-
-if test $ret = 23; then
-    exit
-fi
-
-exit $ret
-EOT
-chmod +x "$ignore23"
-}
-
-
-build_symlinks() {
-    mkdir "$fromdir"
-    date >"$fromdir/referent"
-    ln -s referent "$fromdir/relative"
-    ln -s "$fromdir/referent" "$fromdir/absolute"
-    ln -s nonexistent "$fromdir/dangling"
-    ln -s "$srcdir/rsync.c" "$fromdir/unsafe"
-}
-
-test_fail() {
-    echo "$@" >&2
-    exit 1
-}
-
-test_skipped() {
-    echo "$@" >&2
-    echo "$@" > "$tmpdir/whyskipped"
-    exit 77
-}
-
-# It failed, but we expected that.  Don't dump out error logs,
-# because most users won't want to see them.  But do leave
-# the working directory around.
-test_xfail() {
-    echo "$@" >&2
-    exit 78
-}
-
-# Determine what shell command will appropriately test for links.
-ln -s foo "$scratchdir/testlink"
-for cmd in test /bin/test /usr/bin/test /usr/ucb/bin/test /usr/ucb/test; do
-    for switch in -h -L; do
-        if $cmd $switch "$scratchdir/testlink" 2>/dev/null; then
-            # how nice
-            TEST_SYMLINK_CMD="$cmd $switch"
-            # i wonder if break 2 is portable?
-            break 2
-        fi
-    done
-done
-# ok, now get rid of it
-rm "$scratchdir/testlink"
-
-
-if [ "x$TEST_SYMLINK_CMD" = 'x' ]; then
-    test_fail "Couldn't determine how to test for symlinks"
-else
-    echo "Testing for symlinks using '$TEST_SYMLINK_CMD'"
-fi
-
-
-# Test whether something is a link, allowing for shell peculiarities
-is_a_link() {
-    # note the variable contains the first option and therefore is not quoted
-    $TEST_SYMLINK_CMD "$1"
-}
-
-
-# We need to set the umask to be reproducible.  Note also that when we
-# do some daemon tests as root, we will setuid() and therefore the
-# directory has to be writable by the nobody user in some cases.  The
-# best thing is probably to explicitly chmod those directories after
-# creation.
-
-umask 022
diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py
new file mode 100644 (file)
index 0000000..251b82c
--- /dev/null
@@ -0,0 +1,592 @@
+"""Shared helpers for rsync's Python test scripts.
+
+This is the Python counterpart of testsuite/rsync.fns. It exposes only what
+the Python-rewritten tests actually need; grow it as more shell tests are
+ported.
+
+Conventions matching the shell harness:
+  * Exit 0 = pass, 1 = fail, 77 = skip, 78 = xfail.
+  * The runner sets these environment variables before invoking each test:
+      scratchdir   per-test scratch directory
+      srcdir       rsync source directory
+      TOOLDIR      build directory (holds the rsync binary and helpers)
+      RSYNC        the rsync command line (may include valgrind / --protocol=N)
+      TLS_ARGS     extra arguments to pass to the 'tls' helper
+      suitedir     this directory (testsuite/)
+"""
+
+from __future__ import annotations
+
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+# --- environment -----------------------------------------------------------
+
+def _required(name: str) -> str:
+    v = os.environ.get(name)
+    if not v:
+        sys.stderr.write(
+            f"rsyncfns: required environment variable {name} is not set; "
+            "run this test via runtests.py rather than directly.\n"
+        )
+        sys.exit(2)
+    return v
+
+
+SCRATCHDIR = Path(_required('scratchdir'))
+SRCDIR = Path(_required('srcdir'))
+TOOLDIR = Path(_required('TOOLDIR'))
+SUITEDIR = Path(os.environ.get('suitedir', SRCDIR / 'testsuite'))
+
+# rsync.fns overrides HOME to $scratchdir; tests that exercise ssh-style
+# transfers with no path component (e.g. localhost: at end of args) rely on
+# HOME pointing at the per-test scratch dir.
+os.environ['HOME'] = str(SCRATCHDIR)
+RSYNC = _required('RSYNC')         # full command line, possibly with valgrind/protocol
+
+# TLS_ARGS controls how the 'tls' helper formats listings (e.g. --atimes,
+# -l, -L). Tests that exercise non-default rsync features (atimes, etc.)
+# assign to rsyncfns.TLS_ARGS before calling checkit / rsync_ls_lR.
+TLS_ARGS = os.environ.get('TLS_ARGS', '')
+
+# Mnemonics for rsync's itemize-changes (-i / -ii) format:
+#   all_plus   ->  +++++++++   every attribute changed (an additive create)
+#   allspace   ->             every attribute unchanged
+#   dots       ->  .....       trailing dots after the change columns
+all_plus = '+++++++++'
+allspace = '         '
+dots = '.....'
+
+# The "$tmpdir/from", "$tmpdir/to", "$tmpdir/chk" layout from rsync.fns.
+TMPDIR = SCRATCHDIR
+FROMDIR = SCRATCHDIR / 'from'
+TODIR = SCRATCHDIR / 'to'
+CHKDIR = SCRATCHDIR / 'chk'
+CHKFILE = SCRATCHDIR / 'rsync.chk'
+OUTFILE = SCRATCHDIR / 'rsync.out'
+
+
+# --- result reporting ------------------------------------------------------
+
+def test_fail(msg: str) -> 'None':
+    sys.stderr.write(msg.rstrip() + '\n')
+    sys.exit(1)
+
+
+def test_skipped(msg: str) -> 'None':
+    sys.stderr.write(msg.rstrip() + '\n')
+    (TMPDIR / 'whyskipped').write_text(msg.rstrip() + '\n')
+    sys.exit(77)
+
+
+def test_xfail(msg: str) -> 'None':
+    sys.stderr.write(msg.rstrip() + '\n')
+    sys.exit(78)
+
+
+# --- rsync invocation ------------------------------------------------------
+
+def rsync_argv(*args: str) -> list:
+    """Return the argv for invoking rsync with the given extra arguments.
+
+    RSYNC may be a multi-word command (e.g. 'valgrind ... /build/rsync'); we
+    shlex-split it so subprocess sees a proper argv list. Each *args entry
+    is appended verbatim, so callers should pass tokens already split (no
+    embedded option/value joined by spaces).
+    """
+    return shlex.split(RSYNC) + list(args)
+
+
+def run_rsync(*args: str, check: bool = True,
+              capture_output: bool = False) -> subprocess.CompletedProcess:
+    """Run rsync with the given arguments.
+
+    By default, stdout/stderr inherit (so the runner captures them in the
+    per-test log). Set capture_output=True if the test needs to inspect the
+    output. If check is True (the default), a non-zero exit calls
+    test_fail() with the rsync command line.
+    """
+    argv = rsync_argv(*args)
+    if capture_output:
+        proc = subprocess.run(argv, capture_output=True, text=True)
+    else:
+        proc = subprocess.run(argv)
+    if check and proc.returncode != 0:
+        test_fail(f"rsync exited {proc.returncode}: {' '.join(argv)}")
+    return proc
+
+
+# --- filesystem helpers ----------------------------------------------------
+
+def makepath(*paths) -> 'None':
+    """Equivalent of rsync.fns makepath: mkdir -p, but for multiple paths."""
+    for p in paths:
+        os.makedirs(p, exist_ok=True)
+
+
+def rmtree(path) -> 'None':
+    """Remove a tree if it exists, ignoring missing entries."""
+    p = Path(path)
+    if p.exists() or p.is_symlink():
+        shutil.rmtree(p, ignore_errors=True)
+
+
+def is_a_link(path) -> bool:
+    """True if 'path' is a symbolic link (dangling or not)."""
+    return os.path.islink(path)
+
+
+def cp_p(src, dst) -> 'None':
+    """Equivalent of rsync.fns cp_p: copy preserving mode + timestamps."""
+    shutil.copy2(src, dst)
+
+
+def make_data_file(path, size: int) -> 'None':
+    """Equivalent of rsync.fns make_data_file: create `path` with `size`
+    bytes of non-trivial content suitable for rsync's delta algorithm.
+
+    Prefers /dev/urandom for speed. Falls back to a deterministic LCG
+    seeded from PID and the destination path so successive calls produce
+    distinct content -- matching the shell helper.
+    """
+    path = str(path)
+    if os.path.exists('/dev/urandom'):
+        try:
+            with open('/dev/urandom', 'rb') as src, open(path, 'wb') as dst:
+                remaining = size
+                while remaining:
+                    chunk = src.read(min(remaining, 1 << 16))
+                    if not chunk:
+                        break
+                    dst.write(chunk)
+                    remaining -= len(chunk)
+            if remaining == 0:
+                return
+        except OSError:
+            pass
+
+    # Fallback: BSD-LCG to printable-ASCII (33..126), so output stays
+    # exactly `size` bytes regardless of awk/utf8 quirks the shell
+    # version worked around.
+    path_seed = int.from_bytes(path.encode(), 'big') & 0xFFFFFFFF
+    state = (os.getpid() + path_seed) % 2147483648
+    with open(path, 'wb') as f:
+        out = bytearray(size)
+        for i in range(size):
+            state = (state * 1103515245 + 12345) % 2147483648
+            out[i] = ((state >> 16) % 94) + 33
+        f.write(bytes(out))
+
+
+def get_testuid() -> int:
+    return os.getuid()
+
+
+def get_rootuid() -> int:
+    return 0
+
+
+def get_rootgid() -> int:
+    return 0
+
+
+def build_rsyncd_conf() -> 'Path':
+    """Equivalent of rsync.fns build_rsyncd_conf.
+
+    Writes $scratchdir/test-rsyncd.conf with the four standard modules
+    (test-from, test-to, test-scratch, test-hidden) and a $scratchdir/
+    ignore23 wrapper that propagates rsync's exit status except for
+    code 23 (vanished/missing source files), which it eats so that the
+    surrounding test can tolerate the partial-transfer case.
+
+    Returns the path to the config file. Tests typically follow up by
+    setting RSYNC_CONNECT_PROG so rsync forks an in-tree daemon instead
+    of contacting one over the network.
+    """
+    conf = SCRATCHDIR / 'test-rsyncd.conf'
+    pidfile = SCRATCHDIR / 'rsyncd.pid'
+    logfile = SCRATCHDIR / 'rsyncd.log'
+
+    hostname = subprocess.check_output(['uname', '-n'], text=True).strip()
+
+    my_uid = get_testuid()
+    root_uid = get_rootuid()
+    root_gid = get_rootgid()
+
+    if my_uid != root_uid:
+        # Non-root cannot specify uid/gid in rsyncd.conf.
+        uid_line = f"#uid = {root_uid}"
+        gid_line = f"#gid = {root_gid}"
+    else:
+        uid_line = f"uid = {root_uid}"
+        gid_line = f"gid = {root_gid}"
+
+    conf.write_text(f"""\
+# rsyncd configuration file autogenerated by rsyncfns.build_rsyncd_conf
+
+pid file = {pidfile}
+use chroot = no
+munge symlinks = no
+hosts allow = localhost 127.0.0.0/24 192.168.0.0/16 10.0.0.0/8 {hostname}
+log file = {logfile}
+transfer logging = yes
+# We don't define log format here so the test-hidden module defaults
+# to the internal static string (since we had a crash trying to tweak it).
+exclude = ? foobar.baz
+max verbosity = 4
+{uid_line}
+{gid_line}
+
+[test-from]
+\tpath = {FROMDIR}
+\tlog format = %i %h [%a] %m (%u) %l %f%L
+\tread only = yes
+\tcomment = r/o
+
+[test-to]
+\tpath = {TODIR}
+\tlog format = %i %h [%a] %m (%u) %l %f%L
+\tread only = no
+\tcomment = r/w
+
+[test-scratch]
+\tpath = {SCRATCHDIR}
+\tlog format = %i %h [%a] %m (%u) %l %f%L
+\tread only = no
+
+[test-hidden]
+\tpath = {FROMDIR}
+\tlist = no
+""")
+
+    ignore23 = SCRATCHDIR / 'ignore23'
+    ignore23.write_text(
+        '#!/bin/sh\n'
+        'if "${@}"; then exit; fi\n'
+        'ret=$?\n'
+        'if test $ret = 23; then exit; fi\n'
+        'exit $ret\n'
+    )
+    ignore23.chmod(0o755)
+
+    return conf
+
+
+def rsync_getgroups() -> list:
+    """List of group ids the test user is a member of, via the getgroups
+    test helper binary. Mirrors rsync.fns rsync_getgroups."""
+    out = subprocess.check_output([str(TOOLDIR / 'getgroups')], text=True)
+    return out.split()
+
+
+def runtest(label: str, fn, *args, **kwargs):
+    """Run a sub-test step with an echoed label, like rsync.fns runtest.
+
+    The shell helper does `Test $1: $2 ... done.` -- this prints a similar
+    banner and propagates exceptions (which surface as a failing test).
+    """
+    print(f"Test {label}: ", end="", flush=True)
+    fn(*args, **kwargs)
+    print("done.")
+
+
+def cp_touch(src, dst) -> 'None':
+    """Equivalent of rsync.fns cp_touch: copy preserving timestamps, then
+    forcibly re-touch both source and destination to identical times.
+
+    On some filesystems cp rounds microsecond timestamps on the destination;
+    rsync.fns works around this by then `touch -r dst src dst`. Here we set
+    both src and dst to dst's mtime/atime after the copy, so a diff of the
+    tls output (which prints times) sees identical entries on both sides.
+    """
+    shutil.copy2(src, dst)
+    if os.path.isdir(dst):
+        dst = os.path.join(dst, os.path.basename(src))
+    st = os.stat(dst, follow_symlinks=False)
+    os.utime(src, ns=(st.st_atime_ns, st.st_mtime_ns), follow_symlinks=False)
+    os.utime(dst, ns=(st.st_atime_ns, st.st_mtime_ns), follow_symlinks=False)
+
+
+def build_symlinks() -> 'None':
+    """Equivalent of rsync.fns build_symlinks: a set of canonical relative,
+    absolute, dangling and unsafe symlinks under FROMDIR for symlink tests.
+    """
+    FROMDIR.mkdir(parents=True, exist_ok=True)
+    (FROMDIR / 'referent').write_text(
+        subprocess.check_output(['date'], text=True)
+    )
+    os.symlink('referent', FROMDIR / 'relative')
+    os.symlink(str(FROMDIR / 'referent'), FROMDIR / 'absolute')
+    os.symlink('nonexistent', FROMDIR / 'dangling')
+    os.symlink(str(SRCDIR / 'rsync.c'), FROMDIR / 'unsafe')
+
+
+def hands_setup() -> 'None':
+    """Equivalent of rsync.fns hands_setup: populate FROMDIR with a varied
+    tree of files and directories for the canonical 'hands' transfer test.
+
+    Recreates the shell behavior bit-for-bit so the tls listings match
+    across the shell and Python halves of the suite during the transition.
+    """
+    rmtree(FROMDIR)
+    rmtree(TODIR)
+    TMPDIR.mkdir(parents=True, exist_ok=True)
+    FROMDIR.mkdir(parents=True, exist_ok=True)
+    TODIR.mkdir(parents=True, exist_ok=True)
+
+    (FROMDIR / 'empty').touch()
+    (FROMDIR / 'emptydir').mkdir(exist_ok=True)
+
+    # File list of srcdir contents, generated through the tls helper so it
+    # matches the format the rest of the suite uses.
+    (FROMDIR / 'filelist').write_text(rsync_ls_lR(SRCDIR))
+
+    # The shell test uses `echo -n` semantics; write_text without a trailing
+    # newline is the cleanest equivalent.
+    (FROMDIR / 'nolf').write_text("This file has no trailing lf")
+
+    old_umask = os.umask(0)
+    try:
+        os.symlink('nolf', FROMDIR / 'nolf-symlink')
+    finally:
+        os.umask(old_umask)
+
+    # Concatenate all *.c files in srcdir into a single 'text' file.
+    text = bytearray()
+    for c in sorted(SRCDIR.glob('*.c')):
+        text.extend(c.read_bytes())
+    (FROMDIR / 'text').write_bytes(bytes(text))
+
+    (FROMDIR / 'dir').mkdir(exist_ok=True)
+    shutil.copy(FROMDIR / 'text', FROMDIR / 'dir')
+    (FROMDIR / 'dir' / 'subdir').mkdir(exist_ok=True)
+    (FROMDIR / 'dir' / 'subdir' / 'foobar.baz').write_text("some data\n")
+    (FROMDIR / 'dir' / 'subdir' / 'subsubdir').mkdir(exist_ok=True)
+
+    src_listdir = '/etc' if os.access('/etc', os.R_OK) else '/'
+    out = subprocess.run(['ls', '-ltr', src_listdir], capture_output=True, text=True)
+    (FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list').write_text(out.stdout)
+
+    (FROMDIR / 'dir' / 'subdir' / 'subsubdir2').mkdir(exist_ok=True)
+    src_listdir = '/bin' if os.access('/bin', os.R_OK) else '/'
+    out = subprocess.run(['ls', '-lt', src_listdir], capture_output=True, text=True)
+    (FROMDIR / 'dir' / 'subdir' / 'subsubdir2' / 'bin-lt-list').write_text(out.stdout)
+
+
+# --- listing / verification ------------------------------------------------
+
+def rsync_ls_lR(directory) -> str:
+    """Equivalent of rsync.fns rsync_ls_lR: print a sorted ls-style listing
+    of `directory`, pruning .git / auto-build-save / testtmp subtrees, using
+    the project's `tls` helper so the output format matches the rest of the
+    suite.
+    """
+    cmd = (
+        "find . -name .git -prune -o -name auto-build-save -prune "
+        "-o -name testtmp -prune -o -print | sort | sed 's/ /\\\\ /g' | "
+        f"xargs '{TOOLDIR}/tls' {TLS_ARGS}"
+    )
+    proc = subprocess.run(['sh', '-c', cmd], capture_output=True,
+                          text=True, cwd=str(directory))
+    return proc.stdout
+
+
+def checkit(args, expected_dir, actual_dir, skip_file_diff: bool = False,
+            allowed_codes=(0,)) -> 'None':
+    """Run rsync with `args` (a list of extra rsync arguments) and then
+    verify two things:
+
+      1. The tls-formatted listings of `expected_dir` and `actual_dir`
+         are identical.
+      2. (Unless skip_file_diff) diff -r against the two trees reports
+         no differences.
+
+    `allowed_codes` is the tuple of exit codes treated as success.
+    Pass (0, 23) for daemon-mode transfers that may report partial-
+    transfer codes even when the listings still match.
+
+    Calls test_fail() on any mismatch. Mirrors the rsync.fns checkit shell
+    helper; callers pass rsync arguments as a Python list rather than as a
+    pre-quoted command string, which avoids the shell-quoting gymnastics
+    that the shell version needed.
+    """
+    expected_dir = str(expected_dir)
+    actual_dir = str(actual_dir)
+
+    failed = []
+
+    # If TLS_ARGS asks for atimes, the listing must be captured BEFORE the
+    # rsync run because diff'ing files afterwards updates their atimes.
+    ls_from = None
+    if '--atimes' in TLS_ARGS:
+        ls_from = rsync_ls_lR(expected_dir)
+
+    print(f"Running: rsync {' '.join(args)}")
+    proc = subprocess.run(rsync_argv(*args))
+    if proc.returncode not in allowed_codes:
+        failed.append(f"status={proc.returncode}")
+
+    if ls_from is None:
+        ls_from = rsync_ls_lR(expected_dir)
+    ls_to = rsync_ls_lR(actual_dir)
+
+    print("-------------")
+    print("check how the directory listings compare with diff:")
+    print()
+    if ls_from != ls_to:
+        ls_from_path = TMPDIR / 'ls-from'
+        ls_to_path = TMPDIR / 'ls-to'
+        ls_from_path.write_text(ls_from)
+        ls_to_path.write_text(ls_to)
+        diff = subprocess.run(
+            ['diff', '-u', str(ls_from_path), str(ls_to_path)],
+            capture_output=True, text=True,
+        )
+        sys.stdout.write(diff.stdout)
+        failed.append("dir-diff")
+
+    print("-------------")
+    print("check how the files compare with diff:")
+    print()
+    if skip_file_diff:
+        print("  === Skipping (as directed) ===")
+    else:
+        diff = subprocess.run(['diff', '-r', '-u', expected_dir, actual_dir])
+        if diff.returncode != 0:
+            failed.append("file-diff")
+
+    print("-------------")
+    if failed:
+        test_fail("Failed: " + " ".join(failed))
+
+
+def verify_dirs(expected_dir, actual_dir, skip_file_diff: bool = False,
+                label: str = '') -> 'None':
+    """Verify two directory trees match: identical tls listings and
+    (unless skip_file_diff) identical file contents. Same comparison
+    logic as checkit() but with no rsync invocation -- useful when the
+    rsync that produced `actual_dir` had to be driven manually so that
+    its output could be captured for inspection."""
+    expected_dir = str(expected_dir)
+    actual_dir = str(actual_dir)
+    tag = f"{label}: " if label else ""
+
+    ls_expected = rsync_ls_lR(expected_dir)
+    ls_actual = rsync_ls_lR(actual_dir)
+    if ls_expected != ls_actual:
+        ls_expected_path = TMPDIR / 'ls-from'
+        ls_actual_path = TMPDIR / 'ls-to'
+        ls_expected_path.write_text(ls_expected)
+        ls_actual_path.write_text(ls_actual)
+        diff = subprocess.run(
+            ['diff', '-u', str(ls_expected_path), str(ls_actual_path)],
+            capture_output=True, text=True,
+        )
+        sys.stdout.write(diff.stdout)
+        test_fail(f"{tag}directory listings differ between "
+                  f"{expected_dir} and {actual_dir}")
+
+    if not skip_file_diff:
+        diff = subprocess.run(['diff', '-r', '-u', expected_dir, actual_dir])
+        if diff.returncode != 0:
+            test_fail(f"{tag}file content differs between "
+                      f"{expected_dir} and {actual_dir}")
+
+
+def v_filt(text: str) -> str:
+    """Strip the boilerplate lines rsync emits at -v / -vv so callers can
+    diff only the file/directory change lines. Mirrors rsync.fns v_filt:
+    delete the build/progress banners, then everything from the first
+    blank line to end-of-text."""
+    out = []
+    skip_prefix = (
+        'building file list ',
+        'sending incremental file list',
+        'created directory ',
+        'total: ',
+        'client charset: ',
+        'server charset: ',
+    )
+    for line in text.splitlines():
+        if line == '':
+            break
+        if line.startswith(skip_prefix):
+            continue
+        if line == 'done':
+            continue
+        if line.endswith(' --whole-file'):
+            continue
+        out.append(line)
+    return '\n'.join(out) + ('\n' if out else '')
+
+
+def checkdiff(args, expected: str, *, filter=None, allowed_codes=(0,),
+              direct: bool = False) -> 'None':
+    """Run a command, capture its stdout, optionally pipe through `filter`,
+    then compare to `expected`. Mirrors rsync.fns checkdiff/checkdiff2.
+
+    args is normally a list of rsync arguments -- the rsync binary is
+    prepended via rsync_argv. Pass direct=True to run `args` as a literal
+    command (used by tests that drive a wrapper such as BATCH.sh).
+    """
+    if direct:
+        argv = list(args)
+        label = ' '.join(argv)
+    else:
+        argv = rsync_argv(*args)
+        label = 'rsync ' + ' '.join(args)
+    print(f"Running: {label}")
+    proc = subprocess.run(argv, capture_output=True, text=True)
+    stdout = proc.stdout
+    if proc.stderr:
+        sys.stderr.write(proc.stderr)
+    sys.stdout.write(stdout)
+
+    failed = []
+    if proc.returncode not in allowed_codes:
+        failed.append(f"status={proc.returncode}")
+
+    if filter is not None:
+        stdout = filter(stdout)
+
+    if stdout != expected:
+        from difflib import unified_diff
+        diff = unified_diff(
+            expected.splitlines(keepends=True),
+            stdout.splitlines(keepends=True),
+            fromfile='expected', tofile='got',
+        )
+        sys.stdout.write(''.join(diff))
+        failed.append("output differs")
+
+    if failed:
+        test_fail("Failed: " + " ".join(failed))
+
+
+def check_perms(path, expected: str) -> 'None':
+    """Verify that the 9-char rwx permission string of `path` matches
+    `expected` (e.g. 'rwx------'). Calls test_fail() on mismatch."""
+    mode = os.stat(path, follow_symlinks=False).st_mode
+    bits = [
+        (0o400, 'r'), (0o200, 'w'), (0o100, 'x'),
+        (0o040, 'r'), (0o020, 'w'), (0o010, 'x'),
+        (0o004, 'r'), (0o002, 'w'), (0o001, 'x'),
+    ]
+    chars = [c if mode & bit else '-' for bit, c in bits]
+    # Layer the setuid/setgid/sticky bits over x as the long-listing format does.
+    if mode & 0o4000:
+        chars[2] = 's' if mode & 0o100 else 'S'
+    if mode & 0o2000:
+        chars[5] = 's' if mode & 0o010 else 'S'
+    if mode & 0o1000:
+        chars[8] = 't' if mode & 0o001 else 'T'
+    perms = ''.join(chars)
+    if perms != expected:
+        print(f"permissions: {perms} on {path}")
+        print(f"should be:   {expected}")
+        test_fail(f"check_perms failed for {path}")
diff --git a/testsuite/safe-links.test b/testsuite/safe-links.test
deleted file mode 100644 (file)
index 6e95a4b..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/bin/sh
-
-. "$suitedir/rsync.fns"
-
-test_symlink() {
-       is_a_link "$1" || test_fail "File $1 is not a symlink"
-}
-
-test_regular() {
-       if [ ! -f "$1" ]; then
-               test_fail "File $1 is not regular file or not exists"
-       fi
-}
-
-test_notexist() {
-        if [ -e "$1" ]; then
-                test_fail "File $1 exists"
-       fi
-        if [ -h "$1" ]; then
-                test_fail "File $1 exists as a symlink"
-       fi
-}
-
-cd "$tmpdir"
-
-mkdir from
-
-mkdir "from/safe"
-mkdir "from/unsafe"
-
-mkdir "from/safe/files"
-mkdir "from/safe/links"
-
-touch "from/safe/files/file1"
-touch "from/safe/files/file2"
-touch "from/unsafe/unsafefile"
-
-ln -s ../files/file1 "from/safe/links/"
-ln -s ../files/file2 "from/safe/links/"
-ln -s ../../unsafe/unsafefile "from/safe/links/"
-ln -s a/a/a/../../../unsafe2 "from/safe/links/"
-
-#echo "LISTING FROM"
-#ls -lR from
-
-echo "rsync with relative path and just -a"
-$RSYNC -avv --safe-links from/safe/ to
-
-#echo "LISTING TO"
-#ls -lR to
-
-test_symlink to/links/file1
-test_symlink to/links/file2
-test_notexist to/links/unsafefile
-test_notexist to/links/unsafe2
diff --git a/testsuite/safe-links_test.py b/testsuite/safe-links_test.py
new file mode 100644 (file)
index 0000000..8e1aad2
--- /dev/null
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/safe-links.test.
+#
+# --safe-links must drop symlinks whose target escapes the transfer root.
+# In-tree symlinks survive; escape-attempt symlinks (../../..., a/a/../../...)
+# must NOT appear at the destination at all.
+
+import os
+
+from rsyncfns import TMPDIR, is_a_link, run_rsync, test_fail
+
+
+def assert_symlink(path):
+    if not is_a_link(path):
+        test_fail(f"File {path} is not a symlink")
+
+
+def assert_notexist(path):
+    if os.path.exists(path):
+        test_fail(f"File {path} exists")
+    if os.path.islink(path):
+        test_fail(f"File {path} exists as a symlink")
+
+
+os.chdir(TMPDIR)
+
+os.mkdir("from")
+os.mkdir("from/safe")
+os.mkdir("from/unsafe")
+os.mkdir("from/safe/files")
+os.mkdir("from/safe/links")
+
+open("from/safe/files/file1", "w").close()
+open("from/safe/files/file2", "w").close()
+open("from/unsafe/unsafefile", "w").close()
+
+os.symlink("../files/file1", "from/safe/links/file1")
+os.symlink("../files/file2", "from/safe/links/file2")
+os.symlink("../../unsafe/unsafefile", "from/safe/links/unsafefile")
+os.symlink("a/a/a/../../../unsafe2", "from/safe/links/unsafe2")
+
+print("rsync with relative path and --safe-links")
+run_rsync('-avv', '--safe-links', 'from/safe/', 'to')
+
+assert_symlink("to/links/file1")
+assert_symlink("to/links/file2")
+assert_notexist("to/links/unsafefile")
+assert_notexist("to/links/unsafe2")
diff --git a/testsuite/secure-relpath-validation.test b/testsuite/secure-relpath-validation.test
deleted file mode 100755 (executable)
index 5b77f7c..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for codex audit Finding 5: secure_relative_open()'s
-# front-door input check rejects "../foo" and "foo/../bar" but
-# misses bare "..", "subdir/..", and other variants whose "/"-split
-# components contain a literal "..". The kernel-enforced
-# RESOLVE_BENEATH (Linux 5.6+) and O_RESOLVE_BENEATH
-# (FreeBSD 13+, macOS 15+) reject these in-kernel; the per-component
-# walk fallback used on NetBSD, OpenBSD, Solaris, Cygwin and pre-5.6
-# Linux does not -- so the validation must happen at the front door.
-#
-# This test invokes the t_secure_relpath helper, which calls
-# secure_relative_open() with each suspect input and verifies the
-# return value is -1 with errno == EINVAL. EINVAL is the marker
-# that the front-door rejected the input, not the kernel; pre-fix
-# the kernel returns -1 with EXDEV (or, on the per-component
-# fallback, may return a valid fd at all -- "escape").
-
-. "$suitedir/rsync.fns"
-
-testdir="$scratchdir/relpath-test"
-rm -rf "$testdir"
-mkdir -p "$testdir"
-
-if ! "$TOOLDIR/t_secure_relpath" "$testdir"; then
-    test_fail "t_secure_relpath rejected one or more inputs incorrectly (see stderr above for the specific case)"
-fi
-
-exit 0
diff --git a/testsuite/secure-relpath-validation_test.py b/testsuite/secure-relpath-validation_test.py
new file mode 100644 (file)
index 0000000..c71294f
--- /dev/null
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/secure-relpath-validation.test.
+#
+# Regression test for codex audit Finding 5: secure_relative_open()'s
+# front-door input check rejects "../foo" and "foo/../bar" but missed
+# bare "..", "subdir/..", and other variants whose "/"-split components
+# contain a literal "..". RESOLVE_BENEATH equivalents catch these in
+# the kernel, but the per-component O_NOFOLLOW fallback (on NetBSD,
+# OpenBSD, Solaris, Cygwin, pre-5.6 Linux) does not -- so the
+# validation must happen at the front door.
+#
+# The t_secure_relpath helper runs each suspect input through
+# secure_relative_open() and confirms it gets back -1/EINVAL (the
+# marker that the front-door check kicked in, not the kernel).
+
+import subprocess
+
+from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail
+
+
+testdir = SCRATCHDIR / 'relpath-test'
+rmtree(testdir)
+testdir.mkdir(parents=True)
+
+proc = subprocess.run([str(TOOLDIR / 't_secure_relpath'), str(testdir)])
+if proc.returncode != 0:
+    test_fail(
+        "t_secure_relpath rejected one or more inputs incorrectly "
+        "(see stderr above for the specific case)"
+    )
diff --git a/testsuite/sender-flist-symlink-leak.test b/testsuite/sender-flist-symlink-leak.test
deleted file mode 100755 (executable)
index 011d93d..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2026 by Andrew Tridgell
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Regression test for codex re-check finding: the sender-side file-
-# list generator can still follow an attacker-planted symlink out of
-# the module via change_pathname() -> change_dir(...,CD_SKIP_CHDIR)
-# followed by change_dir(...,CD_NORMAL). The CD_SKIP_CHDIR sets
-# skipped_chdir=1, and the next CD_NORMAL call's secure-branch in
-# util1.c is gated on `!skipped_chdir`, so the secure path is
-# bypassed and a raw chdir(curr_dir) follows attacker-controlled
-# symlinks during flist generation.
-#
-# Reach: rsync daemon module with `use chroot = no`. A local
-# attacker plants module/cd -> /outside. A client (innocent or
-# malicious) pulls rsync://<daemon>/<module>/cd/. The daemon, as
-# sender, enumerates files in /outside and ships their metadata
-# (names, sizes, modes, mtimes) to the client. The actual content
-# transfer fails later at the secure_relative_open step with EXDEV,
-# but by then the metadata has already leaked.
-#
-# We detect by running a dry-run pull of the symlinked subdir and
-# checking whether the client's --list-only output mentions any
-# file from /outside. With the bug, /outside/secret.txt appears in
-# the list with its size; with the fix, the daemon's chdir into
-# the symlinked subdir is rejected and no /outside file is listed.
-
-. "$suitedir/rsync.fns"
-
-case "$(uname -s)" in
-    SunOS|OpenBSD|NetBSD|CYGWIN*)
-        test_skipped "secure change_dir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
-        ;;
-esac
-
-mod="$scratchdir/module"
-outside="$scratchdir/outside"
-listfile="$scratchdir/listed.txt"
-conf="$scratchdir/test-rsyncd.conf"
-
-rm -rf "$mod" "$outside"
-mkdir -p "$mod" "$outside"
-
-# Outside-the-module file the daemon should NOT enumerate to clients.
-# A distinctive name + non-trivial size makes the leak easy to spot.
-echo "OUTSIDE_PROTECTED_FILE_USED_AS_LEAK_DETECTOR" > "$outside/leak_marker.txt"
-chmod 0644 "$outside/leak_marker.txt"
-
-# The symlink trap planted by the local attacker.
-ln -s "$outside" "$mod/cd"
-
-my_uid=`get_testuid`
-root_uid=`get_rootuid`
-root_gid=`get_rootgid`
-uid_setting="uid = $root_uid"
-gid_setting="gid = $root_gid"
-if test x"$my_uid" != x"$root_uid"; then
-    uid_setting="#$uid_setting"
-    gid_setting="#$gid_setting"
-fi
-
-cat > "$conf" <<EOF
-use chroot = no
-$uid_setting
-$gid_setting
-log file = $scratchdir/rsyncd.log
-[upload]
-    path = $mod
-    use chroot = no
-    read only = no
-EOF
-
-# Pull recursively into the symlinked subdir with dry-run + verbose,
-# capturing the daemon's flist (file list) on stdout. If the daemon
-# enumerates /outside, leak_marker.txt will appear in the listing.
-RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
-    $RSYNC -nrv rsync://localhost/upload/cd/ "$scratchdir/dst/" \
-    > "$listfile" 2>&1 || true
-
-if grep -q "leak_marker\.txt" "$listfile"; then
-    echo "----- leaked listing follows" >&2
-    sed 's/^/    /' "$listfile" >&2
-    echo "----- leaked listing ends" >&2
-    test_fail "sender flist leak: outside/leak_marker.txt was enumerated to the client (daemon's chdir followed the cd symlink during flist generation)"
-fi
-
-exit 0
diff --git a/testsuite/sender-flist-symlink-leak_test.py b/testsuite/sender-flist-symlink-leak_test.py
new file mode 100644 (file)
index 0000000..afa2ead
--- /dev/null
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/sender-flist-symlink-leak.test.
+#
+# Regression test for codex re-check finding: the sender-side file-list
+# generator could still follow an attacker-planted symlink out of the
+# module via change_pathname() -> change_dir(...,CD_SKIP_CHDIR) ->
+# change_dir(...,CD_NORMAL).  Reach: a daemon module with use chroot =
+# no, attacker plants module/cd -> /outside, client pulls
+# rsync://daemon/module/cd/; the daemon would enumerate /outside in
+# the file list (metadata leak) before the actual content transfer
+# failed at secure_relative_open.
+
+import os
+import platform
+import subprocess
+
+from rsyncfns import (
+    RSYNC, SCRATCHDIR,
+    rsync_argv, get_testuid, get_rootuid, get_rootgid,
+    rmtree, test_fail, test_skipped,
+)
+
+
+# Platforms without RESOLVE_BENEATH equivalents fall back to a per-
+# component walk that this test is not in scope for.
+if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
+    test_skipped(
+        f"secure change_dir relies on RESOLVE_BENEATH-equivalent kernel "
+        f"support not available on {platform.system()}"
+    )
+
+mod = SCRATCHDIR / 'module'
+outside = SCRATCHDIR / 'outside'
+listfile = SCRATCHDIR / 'listed.txt'
+conf = SCRATCHDIR / 'test-rsyncd.conf'
+
+rmtree(mod)
+rmtree(outside)
+mod.mkdir(parents=True)
+outside.mkdir(parents=True)
+
+(outside / 'leak_marker.txt').write_text(
+    "OUTSIDE_PROTECTED_FILE_USED_AS_LEAK_DETECTOR\n"
+)
+os.chmod(outside / 'leak_marker.txt', 0o644)
+
+os.symlink(str(outside), mod / 'cd')
+
+my_uid = get_testuid()
+root_uid = get_rootuid()
+root_gid = get_rootgid()
+uid_line = f"uid = {root_uid}"
+gid_line = f"gid = {root_gid}"
+if my_uid != root_uid:
+    uid_line = '#' + uid_line
+    gid_line = '#' + gid_line
+
+conf.write_text(f"""\
+use chroot = no
+{uid_line}
+{gid_line}
+log file = {SCRATCHDIR}/rsyncd.log
+[upload]
+    path = {mod}
+    use chroot = no
+    read only = no
+""")
+
+env = os.environ.copy()
+env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
+
+proc = subprocess.run(
+    rsync_argv('-nrv', 'rsync://localhost/upload/cd/', f'{SCRATCHDIR}/dst/'),
+    capture_output=True, text=True, env=env,
+)
+listfile.write_text(proc.stdout + proc.stderr)
+
+if 'leak_marker.txt' in listfile.read_text():
+    import sys
+    sys.stderr.write("----- leaked listing follows\n")
+    for line in listfile.read_text().splitlines():
+        sys.stderr.write(f"    {line}\n")
+    sys.stderr.write("----- leaked listing ends\n")
+    test_fail(
+        "sender flist leak: outside/leak_marker.txt was enumerated to "
+        "the client (daemon's chdir followed the cd symlink during flist "
+        "generation)"
+    )
diff --git a/testsuite/simd-checksum.test b/testsuite/simd-checksum.test
deleted file mode 100755 (executable)
index cf7dba2..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/sh
-
-# Test SIMD checksum implementations against the C reference
-
-. "$suitedir/rsync.fns"
-
-if ! test -x "$TOOLDIR/simdtest"; then
-    test_skipped "simdtest not built (SIMD not available)"
-fi
-
-"$TOOLDIR/simdtest"
diff --git a/testsuite/simd-checksum_test.py b/testsuite/simd-checksum_test.py
new file mode 100644 (file)
index 0000000..bae98d1
--- /dev/null
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/simd-checksum.test.
+#
+# Run the simdtest helper, which compares the SIMD checksum
+# implementations against the C reference. Skip if the helper wasn't
+# built (i.e. SIMD acceleration is unavailable on this host).
+
+import os
+import subprocess
+
+from rsyncfns import TOOLDIR, test_fail, test_skipped
+
+
+simdtest = TOOLDIR / 'simdtest'
+if not (simdtest.is_file() and os.access(simdtest, os.X_OK)):
+    test_skipped("simdtest not built (SIMD not available)")
+
+proc = subprocess.run([str(simdtest)])
+if proc.returncode != 0:
+    test_fail(f"simdtest exited {proc.returncode}")
diff --git a/testsuite/ssh-basic.test b/testsuite/ssh-basic.test
deleted file mode 100644 (file)
index 83f7180..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 1998,1999 Philip Hands <phil@hands.com>
-# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING)
-
-# This script tests ssh, if possible.  It's called by runtests.py
-
-. "$suitedir/rsync.fns"
-
-SSH="$scratchdir/src/support/lsh.sh"
-
-if test x"$rsync_enable_ssh_tests" = xyes; then
-    if type ssh >/dev/null; then
-       SSH=ssh
-    fi
-fi
-
-if [ "`$SSH -o'BatchMode yes' localhost echo yes`" != "yes" ]; then
-    test_skipped "Skipping SSH tests because ssh connection to localhost not authorised"
-fi
-
-echo "Using remote shell: $SSH"
-
-# Create some files for rsync to copy
-hands_setup
-
-runtest "ssh: basic test" 'checkit "$RSYNC -avH -e \"$SSH\" --rsync-path=\"$RSYNC\" \"$fromdir/\" \"localhost:$todir\"" "$fromdir/" "$todir"'
-
-mv "$todir/text" "$todir/ThisShouldGo"
-
-runtest "ssh: renamed file" 'checkit "$RSYNC --delete -avH -e \"$SSH\" --rsync-path=\"$RSYNC\" \"$fromdir/\" \"localhost:$todir\"" "$fromdir/" "$todir"'
diff --git a/testsuite/ssh-basic_test.py b/testsuite/ssh-basic_test.py
new file mode 100644 (file)
index 0000000..dbb9957
--- /dev/null
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/ssh-basic.test.
+#
+# Basic two-step "remote shell" transfer via lsh.sh (or real ssh if
+# rsync_enable_ssh_tests=yes is set in shconfig). Confirms that an -e
+# RSH transfer reproduces the source tree on the destination, and that
+# a follow-up --delete pass cleans up after a destination-side rename.
+
+import os
+import shutil
+import subprocess
+
+from rsyncfns import (
+    FROMDIR, SRCDIR, TODIR,
+    checkit, hands_setup, runtest, test_skipped,
+)
+
+
+SSH = str(SRCDIR / 'support' / 'lsh.sh')
+
+# Allow opting into real ssh via the shconfig variable, like the shell test.
+if os.environ.get('rsync_enable_ssh_tests') == 'yes':
+    real_ssh = shutil.which('ssh')
+    if real_ssh:
+        SSH = real_ssh
+
+probe = subprocess.run(
+    [SSH, '-oBatchMode yes', 'localhost', 'echo', 'yes'],
+    capture_output=True, text=True,
+)
+if probe.stdout.strip() != 'yes':
+    test_skipped(
+        "Skipping SSH tests because ssh connection to localhost not authorised"
+    )
+
+print(f"Using remote shell: {SSH}")
+
+hands_setup()
+
+# RSYNC may be a multi-word command line; pass it through --rsync-path.
+from rsyncfns import RSYNC
+
+
+def _basic():
+    checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC}',
+             f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
+
+
+def _delete_after_rename():
+    shutil.move(str(TODIR / 'text'), str(TODIR / 'ThisShouldGo'))
+    checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC}',
+             f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
+
+
+runtest("ssh: basic test", _basic)
+runtest("ssh: renamed file", _delete_after_rename)
diff --git a/testsuite/symlink-dirlink-basis.test b/testsuite/symlink-dirlink-basis.test
deleted file mode 100755 (executable)
index 88c55d2..0000000
+++ /dev/null
@@ -1,261 +0,0 @@
-#!/bin/sh
-
-# Test that updating a file through a directory symlink works when using
-# -K (--copy-dirlinks). This is a regression test for:
-#   https://github.com/RsyncProject/rsync/issues/715
-#
-# The CVE fix in commit c35e283 introduced secure_relative_open() which
-# uses O_NOFOLLOW on all path components, breaking legitimate directory
-# symlinks on the receiver side. The fix splits the path into basedir
-# (dirname, symlinks followed) and basename (O_NOFOLLOW) so that
-# directory symlinks are traversed while the final file component is
-# still protected.
-#
-# The regression only manifests when delta matching is triggered (i.e.,
-# the sender finds matching blocks in the old file). Small files with
-# completely different content are transferred in full and don't trigger
-# the bug. We use a large file with a small modification to ensure
-# delta transfer is used.
-#
-# In addition to the original regression, this test covers edge cases
-# in the fix itself:
-#   - --backup with directory symlinks (finish_transfer pointer identity)
-#   - --partial-dir with protocol < 29 (fnamecmp != partialptr guard)
-#   - --inplace with directory symlinks (updating_basis_or_equiv check)
-#   - Files without a dirname (top-level files, no split needed)
-
-. "$suitedir/rsync.fns"
-
-# secure_relative_open() uses kernel-enforced "stay below dirfd" via
-# openat2(RESOLVE_BENEATH) on Linux 5.6+ and openat(O_RESOLVE_BENEATH)
-# on FreeBSD 13+. Other platforms fall back to a per-component
-# O_NOFOLLOW walk that rejects every symlink including legitimate
-# directory symlinks -- the very case this test exercises. Skip on
-# those rather than report a known failure.
-case "$(uname -s)" in
-    SunOS|OpenBSD|NetBSD|CYGWIN*)
-       test_skipped "secure_relative_open lacks RESOLVE_BENEATH equivalent on $(uname -s); issue #715 still affects this platform"
-       ;;
-esac
-
-RSYNC_RSH="$scratchdir/src/support/lsh.sh"
-export RSYNC_RSH
-
-# $HOME is set to $scratchdir by rsync.fns
-# localhost: destination will cd to $HOME (i.e., $scratchdir)
-
-# Helper: create a large file suitable for delta transfers.
-# ~32KB is large enough for rsync's block matching to find matches.
-# make_data_file lives in rsync.fns and falls back to an awk PRNG
-# on platforms without /dev/urandom (e.g. HPE NonStop).
-make_testfile() {
-    make_data_file "$1" 32768 \
-       || test_fail "failed to create test file $1"
-}
-
-# Set up source tree
-srcbase="$tmpdir/src"
-
-######################################################################
-# Test 1: Basic directory symlink update (the original issue #715)
-######################################################################
-
-mkdir -p "$HOME/real-dir"
-ln -s real-dir "$HOME/dir"
-
-mkdir -p "$srcbase/dir"
-make_testfile "$srcbase/dir/file"
-
-# First transfer (initial): should create the file through the symlink
-(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 1: initial transfer failed"
-
-if [ ! -f "$HOME/real-dir/file" ]; then
-    test_fail "test 1: initial transfer did not create file through symlink"
-fi
-
-diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \
-    || test_fail "test 1: initial transfer content mismatch"
-
-# Small modification to trigger delta transfer
-echo "appended update" >> "$srcbase/dir/file"
-sleep 1
-touch "$srcbase/dir/file"
-
-# Second transfer (update): was failing with "failed verification"
-(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 1: update through directory symlink failed"
-
-diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \
-    || test_fail "test 1: update transfer content mismatch"
-
-######################################################################
-# Test 2: Compression (-z) as in the original reproducer
-######################################################################
-
-echo "another line" >> "$srcbase/dir/file"
-sleep 1
-touch "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptzv --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 2: compressed update through directory symlink failed"
-
-diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \
-    || test_fail "test 2: compressed update content mismatch"
-
-######################################################################
-# Test 3: Nested directory symlinks (nested/sub/data.txt where
-#          "nested" is a symlink to "nested_real")
-######################################################################
-
-mkdir -p "$HOME/nested_real/sub"
-ln -s nested_real "$HOME/nested"
-
-mkdir -p "$srcbase/nested/sub"
-make_testfile "$srcbase/nested/sub/data.txt"
-
-(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \
-    || test_fail "test 3: initial nested transfer failed"
-
-echo "appended nested" >> "$srcbase/nested/sub/data.txt"
-sleep 1
-touch "$srcbase/nested/sub/data.txt"
-
-(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \
-    || test_fail "test 3: update through nested directory symlink failed"
-
-diff "$srcbase/nested/sub/data.txt" "$HOME/nested_real/sub/data.txt" >/dev/null \
-    || test_fail "test 3: nested update content mismatch"
-
-######################################################################
-# Test 4: --backup with directory symlinks
-#
-# Exercises the finish_transfer() "fnamecmp == fname" pointer
-# comparison that determines whether to update fnamecmp to the
-# backup name. If broken, --backup would reference a renamed file
-# for xattr handling.
-######################################################################
-
-# Reset destination
-rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~"
-
-make_testfile "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 4: initial transfer for backup test failed"
-
-echo "backup update" >> "$srcbase/dir/file"
-sleep 1
-touch "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --backup --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 4: update with --backup through directory symlink failed"
-
-diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \
-    || test_fail "test 4: backup update content mismatch"
-
-if [ ! -f "$HOME/real-dir/file~" ]; then
-    test_fail "test 4: backup file was not created"
-fi
-
-######################################################################
-# Test 5: --inplace with directory symlinks
-#
-# Exercises the updating_basis_or_equiv check which uses
-# "fnamecmp == fname". With --inplace, rsync writes directly to
-# the destination file instead of a temp file.
-######################################################################
-
-rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~"
-
-make_testfile "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 5: initial inplace transfer failed"
-
-echo "inplace update" >> "$srcbase/dir/file"
-sleep 1
-touch "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 5: inplace update through directory symlink failed"
-
-diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \
-    || test_fail "test 5: inplace update content mismatch"
-
-######################################################################
-# Test 6: Top-level file (no dirname, no split needed)
-#
-# Ensures the dirname/basename split is not attempted for files
-# at the top level (file->dirname is NULL).
-######################################################################
-
-make_testfile "$srcbase/topfile"
-mkdir -p "$HOME"
-
-(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \
-    || test_fail "test 6: initial top-level transfer failed"
-
-echo "toplevel update" >> "$srcbase/topfile"
-sleep 1
-touch "$srcbase/topfile"
-
-(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \
-    || test_fail "test 6: top-level update failed"
-
-diff "$srcbase/topfile" "$HOME/topfile" >/dev/null \
-    || test_fail "test 6: top-level update content mismatch"
-
-######################################################################
-# Test 7: --partial-dir with protocol < 29
-#
-# For protocol < 29, fnamecmp_type stays FNAMECMP_FNAME even when
-# fnamecmp is set to partialptr. The dirname/basename split must
-# NOT trigger in this case (guarded by "fnamecmp == fname").
-######################################################################
-
-rm -f "$HOME/real-dir/file"
-make_testfile "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \
-    --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 7: initial proto28 partial-dir transfer failed"
-
-echo "partial-dir update" >> "$srcbase/dir/file"
-sleep 1
-touch "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \
-    --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 7: proto28 partial-dir update through dirlink failed"
-
-diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \
-    || test_fail "test 7: proto28 partial-dir update content mismatch"
-
-######################################################################
-# Test 8: Protocol < 29 basic directory symlink update
-#
-# Exercises the protocol < 29 code path and its fallback logic
-# (clearing basedir on retry).
-######################################################################
-
-rm -f "$HOME/real-dir/file"
-make_testfile "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \
-    --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 8: initial proto28 transfer failed"
-
-echo "proto28 update" >> "$srcbase/dir/file"
-sleep 1
-touch "$srcbase/dir/file"
-
-(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \
-    --rsync-path="$RSYNC" dir/file localhost:) \
-    || test_fail "test 8: proto28 update through directory symlink failed"
-
-diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \
-    || test_fail "test 8: proto28 update content mismatch"
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/symlink-dirlink-basis_test.py b/testsuite/symlink-dirlink-basis_test.py
new file mode 100644 (file)
index 0000000..b952b4d
--- /dev/null
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/symlink-dirlink-basis.test.
+#
+# Regression test for https://github.com/RsyncProject/rsync/issues/715:
+# updating a file through a directory symlink with -K (--copy-dirlinks)
+# regressed after the CVE-2026-29518 fix introduced
+# secure_relative_open() with O_NOFOLLOW on every path component. The
+# fix split the path so basedir (dirname) follows symlinks while only
+# the final component is O_NOFOLLOW'd.
+#
+# We exercise: basic dir-symlink update, compressed update, nested
+# dir-symlinks, --backup, --inplace, top-level file (no split needed),
+# --partial-dir with protocol < 29, and a basic protocol < 29 transfer.
+
+import filecmp
+import os
+import platform
+import subprocess
+import time
+
+from rsyncfns import (
+    RSYNC, SCRATCHDIR, SRCDIR, TMPDIR,
+    make_data_file, rsync_argv, test_fail, test_skipped,
+)
+
+
+if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
+    test_skipped(
+        f"secure_relative_open lacks RESOLVE_BENEATH equivalent on "
+        f"{platform.system()}; issue #715 still affects this platform"
+    )
+
+os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
+# HOME -> SCRATCHDIR is set up by rsyncfns import.
+
+srcbase = TMPDIR / 'src_files'  # avoid clash with the runner's $scratchdir/src symlink
+srcbase.mkdir(parents=True, exist_ok=True)
+home = SCRATCHDIR
+
+
+def make_testfile(path) -> None:
+    """~32 KiB of non-trivial content -- large enough to trigger rsync's
+    block-matching delta path."""
+    make_data_file(path, 32768)
+
+
+def push(*args, label: str) -> None:
+    # --rsync-path goes BEFORE the positional source/dest args, matching
+    # the shell test's order. With it at the end rsync mis-classifies
+    # the destination and reports "Unexpected remote arg: localhost:".
+    saved = os.getcwd()
+    os.chdir(srcbase)
+    try:
+        proc = subprocess.run(
+            rsync_argv(f'--rsync-path={RSYNC}', *args),
+            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
+        )
+        print(proc.stdout, end='')
+        if proc.returncode != 0:
+            test_fail(f"{label}: rsync exited {proc.returncode}")
+    finally:
+        os.chdir(saved)
+
+
+def assert_same(label: str, a, b) -> None:
+    if not filecmp.cmp(a, b, shallow=False):
+        test_fail(f"{label}: content mismatch between {a} and {b}")
+
+
+# Test 1: basic directory-symlink update.
+(home / 'real-dir').mkdir()
+os.symlink('real-dir', home / 'dir')
+(srcbase / 'dir').mkdir()
+make_testfile(srcbase / 'dir' / 'file')
+
+push('-KRlptv', 'dir/file', 'localhost:', label="test 1 initial")
+if not (home / 'real-dir' / 'file').is_file():
+    test_fail("test 1: initial transfer did not create file through symlink")
+assert_same("test 1 initial", srcbase / 'dir' / 'file', home / 'real-dir' / 'file')
+
+# Trigger delta transfer.
+with open(srcbase / 'dir' / 'file', 'ab') as f:
+    f.write(b"appended update\n")
+time.sleep(1)
+(srcbase / 'dir' / 'file').touch()
+
+push('-KRlptv', 'dir/file', 'localhost:', label="test 1 update")
+assert_same("test 1 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file')
+
+
+# Test 2: compression.
+with open(srcbase / 'dir' / 'file', 'ab') as f:
+    f.write(b"another line\n")
+time.sleep(1)
+(srcbase / 'dir' / 'file').touch()
+
+push('-KRlptzv', 'dir/file', 'localhost:', label="test 2")
+assert_same("test 2", srcbase / 'dir' / 'file', home / 'real-dir' / 'file')
+
+
+# Test 3: nested directory symlinks.
+(home / 'nested_real' / 'sub').mkdir(parents=True)
+os.symlink('nested_real', home / 'nested')
+
+(srcbase / 'nested' / 'sub').mkdir(parents=True)
+make_testfile(srcbase / 'nested' / 'sub' / 'data.txt')
+
+push('-KRlptv', 'nested/sub/data.txt', 'localhost:', label="test 3 initial")
+
+with open(srcbase / 'nested' / 'sub' / 'data.txt', 'ab') as f:
+    f.write(b"appended nested\n")
+time.sleep(1)
+(srcbase / 'nested' / 'sub' / 'data.txt').touch()
+
+push('-KRlptv', 'nested/sub/data.txt', 'localhost:', label="test 3 update")
+assert_same("test 3 update",
+            srcbase / 'nested' / 'sub' / 'data.txt',
+            home / 'nested_real' / 'sub' / 'data.txt')
+
+
+# Test 4: --backup with directory symlinks.
+(home / 'real-dir' / 'file').unlink()
+(home / 'real-dir' / 'file~').unlink(missing_ok=True)
+make_testfile(srcbase / 'dir' / 'file')
+
+push('-KRlptv', 'dir/file', 'localhost:', label="test 4 initial")
+
+with open(srcbase / 'dir' / 'file', 'ab') as f:
+    f.write(b"backup update\n")
+time.sleep(1)
+(srcbase / 'dir' / 'file').touch()
+
+push('-KRlptv', '--backup', 'dir/file', 'localhost:', label="test 4 update")
+assert_same("test 4 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file')
+if not (home / 'real-dir' / 'file~').is_file():
+    test_fail("test 4: backup file was not created")
+
+
+# Test 5: --inplace.
+(home / 'real-dir' / 'file').unlink()
+(home / 'real-dir' / 'file~').unlink(missing_ok=True)
+make_testfile(srcbase / 'dir' / 'file')
+
+push('-KRlptv', '--inplace', 'dir/file', 'localhost:', label="test 5 initial")
+
+with open(srcbase / 'dir' / 'file', 'ab') as f:
+    f.write(b"inplace update\n")
+time.sleep(1)
+(srcbase / 'dir' / 'file').touch()
+
+push('-KRlptv', '--inplace', 'dir/file', 'localhost:', label="test 5 update")
+assert_same("test 5 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file')
+
+
+# Test 6: top-level file (no dirname split needed).
+make_testfile(srcbase / 'topfile')
+home.mkdir(parents=True, exist_ok=True)
+
+push('-Rlptv', 'topfile', 'localhost:', label="test 6 initial")
+
+with open(srcbase / 'topfile', 'ab') as f:
+    f.write(b"toplevel update\n")
+time.sleep(1)
+(srcbase / 'topfile').touch()
+
+push('-Rlptv', 'topfile', 'localhost:', label="test 6 update")
+assert_same("test 6 update", srcbase / 'topfile', home / 'topfile')
+
+
+# Test 7: --partial-dir with protocol < 29.
+(home / 'real-dir' / 'file').unlink(missing_ok=True)
+make_testfile(srcbase / 'dir' / 'file')
+
+push('-KRlptv', '--protocol=28', '--partial-dir=.rsync-partial',
+     'dir/file', 'localhost:', label="test 7 initial")
+
+with open(srcbase / 'dir' / 'file', 'ab') as f:
+    f.write(b"partial-dir update\n")
+time.sleep(1)
+(srcbase / 'dir' / 'file').touch()
+
+push('-KRlptv', '--protocol=28', '--partial-dir=.rsync-partial',
+     'dir/file', 'localhost:', label="test 7 update")
+assert_same("test 7 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file')
+
+
+# Test 8: protocol < 29 basic update.
+(home / 'real-dir' / 'file').unlink(missing_ok=True)
+make_testfile(srcbase / 'dir' / 'file')
+
+push('-KRlptv', '--protocol=28', 'dir/file', 'localhost:', label="test 8 initial")
+
+with open(srcbase / 'dir' / 'file', 'ab') as f:
+    f.write(b"proto28 update\n")
+time.sleep(1)
+(srcbase / 'dir' / 'file').touch()
+
+push('-KRlptv', '--protocol=28', 'dir/file', 'localhost:', label="test 8 update")
+assert_same("test 8 update", srcbase / 'dir' / 'file', home / 'real-dir' / 'file')
diff --git a/testsuite/symlink-ignore.test b/testsuite/symlink-ignore.test
deleted file mode 100644 (file)
index 7055a92..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test rsync's somewhat over-featured symlink control: the default
-# behaviour is that symlinks should not be copied at all.
-
-. "$suitedir/rsync.fns"
-
-build_symlinks || test_fail "failed to build symlinks"
-
-# Copy recursively, but without -l or -L or -a, and all the symlinks
-# should be missing.
-$RSYNC -r "$fromdir/" "$todir" || test_fail "$RSYNC returned $?"
-
-[ -f "$todir/referent" ] || test_fail "referent was not copied"
-[ -d "$todir/from" ] && test_fail "extra level of directories"
-if is_a_link "$todir/dangling"; then
-    test_fail "dangling symlink was copied"
-fi
-
-if is_a_link "$todir/relative"; then
-    test_fail "relative symlink was copied"
-fi
-
-if is_a_link "$todir/absolute"; then
-    test_fail "absolute symlink was copied"
-fi
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/symlink-ignore_test.py b/testsuite/symlink-ignore_test.py
new file mode 100644 (file)
index 0000000..28daa04
--- /dev/null
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/symlink-ignore.test.
+#
+# Default behaviour: without -l, -L or -a, rsync should NOT copy any
+# symlinks. The referent file should land in the destination but every
+# symlink in the source must be absent.
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    build_symlinks, is_a_link, run_rsync, test_fail,
+)
+
+
+build_symlinks()
+
+# Copy recursively, but without -l or -L or -a, so symlinks should be missing.
+run_rsync('-r', f'{FROMDIR}/', str(TODIR))
+
+if not (TODIR / 'referent').is_file():
+    test_fail("referent was not copied")
+if (TODIR / 'from').is_dir():
+    test_fail("extra level of directories")
+if is_a_link(TODIR / 'dangling'):
+    test_fail("dangling symlink was copied")
+if is_a_link(TODIR / 'relative'):
+    test_fail("relative symlink was copied")
+if is_a_link(TODIR / 'absolute'):
+    test_fail("absolute symlink was copied")
diff --git a/testsuite/trimslash.test b/testsuite/trimslash.test
deleted file mode 100644 (file)
index 2efaa07..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test tiny function to trim trailing slashes.
-
-. "$suitedir/rsync.fns"
-
-"$TOOLDIR/trimslash" "/usr/local/bin" "/usr/local/bin/" "/usr/local/bin///" \
-       "//a//" "////" \
-        "/Users/Weird Macintosh Name/// Ooh, translucent plastic/" \
-       > "$scratchdir/slash.out"
-diff $diffopt "$scratchdir/slash.out" - <<EOF
-/usr/local/bin
-/usr/local/bin
-/usr/local/bin
-//a
-/
-/Users/Weird Macintosh Name/// Ooh, translucent plastic
-EOF
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/trimslash_test.py b/testsuite/trimslash_test.py
new file mode 100644 (file)
index 0000000..5085822
--- /dev/null
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/trimslash.test.
+#
+# Test the tiny trimslash helper which strips trailing slashes from paths.
+
+import subprocess
+
+from rsyncfns import TOOLDIR, test_fail
+
+
+INPUTS = [
+    "/usr/local/bin",
+    "/usr/local/bin/",
+    "/usr/local/bin///",
+    "//a//",
+    "////",
+    "/Users/Weird Macintosh Name/// Ooh, translucent plastic/",
+]
+
+EXPECTED = """\
+/usr/local/bin
+/usr/local/bin
+/usr/local/bin
+//a
+/
+/Users/Weird Macintosh Name/// Ooh, translucent plastic
+"""
+
+proc = subprocess.run(
+    [str(TOOLDIR / 'trimslash'), *INPUTS],
+    capture_output=True, text=True,
+)
+if proc.returncode != 0:
+    test_fail(f"trimslash exited {proc.returncode}\nstderr:\n{proc.stderr}")
+
+if proc.stdout != EXPECTED:
+    test_fail(
+        "trimslash output did not match expected.\n"
+        f"--- expected ---\n{EXPECTED}"
+        f"--- got ---\n{proc.stdout}"
+    )
diff --git a/testsuite/unsafe-byname.test b/testsuite/unsafe-byname.test
deleted file mode 100644 (file)
index d2e318e..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2002 by Martin Pool
-
-# Call directly into unsafe_symlink and test its handling of various filenames
-
-. "$suitedir/rsync.fns"
-
-test_unsafe() {
-    # $1 is the target of a symlink
-    # $2 is the directory we're copying
-    # $3 is the expected outcome: "safe" if the link lies within $2,
-    # or "unsafe" otherwise
-
-    result=`"$TOOLDIR/t_unsafe" "$1" "$2"` || test_fail "Failed to check $1 $2"
-    if [ "$result" != "$3" ]; then
-        test_fail "t_unsafe $1 $2 returned \"$result\", expected \"$3\""
-    fi
-}
-
-test_unsafe file                       from                            safe
-test_unsafe dir/file                   from                            safe
-test_unsafe dir/./file                 from                            safe
-test_unsafe dir/.                      from                            safe
-test_unsafe dir/                       from                            safe
-
-test_unsafe /etc/passwd                from                            unsafe
-test_unsafe //../etc/passwd            from                            unsafe
-test_unsafe //./etc/passwd             from                            unsafe
-
-test_unsafe ./foo                      from                            safe
-test_unsafe ../foo                     from                            unsafe
-test_unsafe ./../foo                   from                            unsafe
-test_unsafe .//../foo                  from                            unsafe
-test_unsafe ./../foo                   from/..                         unsafe
-test_unsafe ../dest                    from/dir                        safe
-test_unsafe ../../dest                 from//dir                       unsafe
-test_unsafe ..//../dest                from/dir                        unsafe
-
-test_unsafe ..                         from/file                       safe
-test_unsafe ../..                      from/file                       unsafe
-test_unsafe ..//..                     from//file                      unsafe
-test_unsafe dir/..                     from                            unsafe
-test_unsafe dir/../..                  from                            unsafe
-test_unsafe dir/..//..                 from                            unsafe
-
-test_unsafe ''                         from                            unsafe
-
-# Based on tests from unsafe-links by Vladimír Michl
-test_unsafe ../../unsafe/unsafefile    from/safe                       unsafe
-test_unsafe ..//../unsafe/unsafefile   from/safe                       unsafe
-test_unsafe ../files/file1             from/safe                       safe
-
-test_unsafe ../../unsafe/unsafefile    safe                            unsafe
-test_unsafe ../files/file1             safe                            unsafe
-
-test_unsafe ../../unsafe/unsafefile    `pwd`/from/safe                 safe
-test_unsafe ../files/file1             `pwd`/from/safe                 safe
diff --git a/testsuite/unsafe-byname_test.py b/testsuite/unsafe-byname_test.py
new file mode 100644 (file)
index 0000000..bd4eeec
--- /dev/null
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/unsafe-byname.test.
+#
+# Call directly into the t_unsafe helper (which wraps unsafe_symlink()) and
+# verify its judgement on a battery of crafted target/curdir pairs.
+
+import os
+import subprocess
+
+from rsyncfns import TOOLDIR, test_fail
+
+
+t_unsafe = str(TOOLDIR / 't_unsafe')
+
+# `pwd` in the shell version is the cwd of the test process — the runner
+# starts each test in TOOLDIR. os.getcwd() reproduces that exactly.
+pwd = os.getcwd()
+
+# (link_target, curdir, expected) — expected is "safe" or "unsafe".
+CASES = [
+    ('file', 'from', 'safe'),
+    ('dir/file', 'from', 'safe'),
+    ('dir/./file', 'from', 'safe'),
+    ('dir/.', 'from', 'safe'),
+    ('dir/', 'from', 'safe'),
+
+    ('/etc/passwd', 'from', 'unsafe'),
+    ('//../etc/passwd', 'from', 'unsafe'),
+    ('//./etc/passwd', 'from', 'unsafe'),
+
+    ('./foo', 'from', 'safe'),
+    ('../foo', 'from', 'unsafe'),
+    ('./../foo', 'from', 'unsafe'),
+    ('.//../foo', 'from', 'unsafe'),
+    ('./../foo', 'from/..', 'unsafe'),
+    ('../dest', 'from/dir', 'safe'),
+    ('../../dest', 'from//dir', 'unsafe'),
+    ('..//../dest', 'from/dir', 'unsafe'),
+
+    ('..', 'from/file', 'safe'),
+    ('../..', 'from/file', 'unsafe'),
+    ('..//..', 'from//file', 'unsafe'),
+    ('dir/..', 'from', 'unsafe'),
+    ('dir/../..', 'from', 'unsafe'),
+    ('dir/..//..', 'from', 'unsafe'),
+
+    ('', 'from', 'unsafe'),
+
+    # Based on tests from unsafe-links by Vladimir Michl.
+    ('../../unsafe/unsafefile', 'from/safe', 'unsafe'),
+    ('..//../unsafe/unsafefile', 'from/safe', 'unsafe'),
+    ('../files/file1', 'from/safe', 'safe'),
+
+    ('../../unsafe/unsafefile', 'safe', 'unsafe'),
+    ('../files/file1', 'safe', 'unsafe'),
+
+    ('../../unsafe/unsafefile', f'{pwd}/from/safe', 'safe'),
+    ('../files/file1', f'{pwd}/from/safe', 'safe'),
+]
+
+
+failures = []
+for target, curdir, expected in CASES:
+    proc = subprocess.run(
+        [t_unsafe, target, curdir],
+        capture_output=True, text=True,
+    )
+    if proc.returncode != 0:
+        test_fail(f"Failed to check {target!r} {curdir!r}: exit {proc.returncode}")
+    got = proc.stdout.strip()
+    if got != expected:
+        failures.append(
+            f"t_unsafe {target!r} {curdir!r} returned {got!r}, expected {expected!r}"
+        )
+
+if failures:
+    test_fail("\n".join(failures))
diff --git a/testsuite/unsafe-links.test b/testsuite/unsafe-links.test
deleted file mode 100644 (file)
index 2d209eb..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/bin/sh
-
-# Originally by Vladimír Michl <Vladimir.Michl@hlubocky.del.cz>
-
-. "$suitedir/rsync.fns"
-
-test_symlink() {
-       is_a_link "$1" || test_fail "File $1 is not a symlink"
-}
-
-test_regular() {
-       if [ ! -f "$1" ]; then
-               test_fail "File $1 is not regular file or not exists"
-       fi
-}
-
-cd "$tmpdir"
-
-mkdir from
-
-mkdir "from/safe"
-mkdir "from/unsafe"
-
-mkdir "from/safe/files"
-mkdir "from/safe/links"
-
-touch "from/safe/files/file1"
-touch "from/safe/files/file2"
-touch "from/unsafe/unsafefile"
-
-ln -s ../files/file1 "from/safe/links/"
-ln -s ../files/file2 "from/safe/links/"
-ln -s ../../unsafe/unsafefile "from/safe/links/"
-
-echo "rsync with relative path and just -a"
-$RSYNC -avv from/safe/ to
-test_symlink to/links/file1
-test_symlink to/links/file2
-test_symlink to/links/unsafefile
-
-echo "rsync with relative path and -a --copy-links"
-$RSYNC -avv --copy-links from/safe/ to
-test_regular to/links/file1
-test_regular to/links/file2
-test_regular to/links/unsafefile
-
-echo "rsync with relative path and --copy-unsafe-links"
-$RSYNC -avv --copy-unsafe-links from/safe/ to
-test_symlink to/links/file1
-test_symlink to/links/file2
-test_regular to/links/unsafefile
-
-rm -rf to
-echo "rsync with relative2 path"
-(cd from; $RSYNC -avv --copy-unsafe-links safe/ ../to)
-test_symlink to/links/file1
-test_symlink to/links/file2
-test_regular to/links/unsafefile
-
-rm -rf to
-echo "rsync with absolute path"
-$RSYNC -avv --copy-unsafe-links `pwd`/from/safe/ to
-test_symlink to/links/file1
-test_symlink to/links/file2
-test_regular to/links/unsafefile
diff --git a/testsuite/unsafe-links_test.py b/testsuite/unsafe-links_test.py
new file mode 100644 (file)
index 0000000..3f60528
--- /dev/null
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/unsafe-links.test.
+#
+# Verifies the three relevant policies for "unsafe" (escape-the-tree) symlinks:
+#   * default -a copies them as symlinks (no special handling),
+#   * --copy-links materialises ALL symlinks as files,
+#   * --copy-unsafe-links materialises only the unsafe ones, leaving safe
+#     in-tree symlinks as symlinks.
+
+import os
+
+from rsyncfns import TMPDIR, is_a_link, rmtree, run_rsync, test_fail
+
+
+def assert_symlink(path):
+    if not is_a_link(path):
+        test_fail(f"File {path} is not a symlink")
+
+
+def assert_regular(path):
+    if not os.path.isfile(path):
+        test_fail(f"File {path} is not regular file or not exists")
+
+
+os.chdir(TMPDIR)
+
+os.mkdir("from")
+os.mkdir("from/safe")
+os.mkdir("from/unsafe")
+os.mkdir("from/safe/files")
+os.mkdir("from/safe/links")
+
+open("from/safe/files/file1", "w").close()
+open("from/safe/files/file2", "w").close()
+open("from/unsafe/unsafefile", "w").close()
+
+os.symlink("../files/file1", "from/safe/links/file1")
+os.symlink("../files/file2", "from/safe/links/file2")
+os.symlink("../../unsafe/unsafefile", "from/safe/links/unsafefile")
+
+
+print("rsync with relative path and just -a")
+run_rsync('-avv', 'from/safe/', 'to')
+assert_symlink("to/links/file1")
+assert_symlink("to/links/file2")
+assert_symlink("to/links/unsafefile")
+
+print("rsync with relative path and -a --copy-links")
+run_rsync('-avv', '--copy-links', 'from/safe/', 'to')
+assert_regular("to/links/file1")
+assert_regular("to/links/file2")
+assert_regular("to/links/unsafefile")
+
+print("rsync with relative path and --copy-unsafe-links")
+run_rsync('-avv', '--copy-unsafe-links', 'from/safe/', 'to')
+assert_symlink("to/links/file1")
+assert_symlink("to/links/file2")
+assert_regular("to/links/unsafefile")
+
+rmtree("to")
+print("rsync with relative2 path")
+# Mirror the shell `(cd from; rsync ... safe/ ../to)` subshell — chdir, rsync,
+# then chdir back so the final block uses an absolute source again.
+saved = os.getcwd()
+os.chdir("from")
+try:
+    run_rsync('-avv', '--copy-unsafe-links', 'safe/', '../to')
+finally:
+    os.chdir(saved)
+assert_symlink("to/links/file1")
+assert_symlink("to/links/file2")
+assert_regular("to/links/unsafefile")
+
+rmtree("to")
+print("rsync with absolute path")
+run_rsync('-avv', '--copy-unsafe-links', f'{os.getcwd()}/from/safe/', 'to')
+assert_symlink("to/links/file1")
+assert_symlink("to/links/file2")
+assert_regular("to/links/unsafefile")
diff --git a/testsuite/wildmatch.test b/testsuite/wildmatch.test
deleted file mode 100644 (file)
index cfe7584..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) 2003-2022 Wayne Davison
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test the wildmatch functionality
-
-. "$suitedir/rsync.fns"
-
-# This test exercises the wildmatch() function (with no options) and the
-# wildmatch_join() function (using -x and/or -e).
-for opts in "" -x1 "-x1 -e1" "-x1 -e1se" -x2 "-x2 -ese" -x3 "-x3 -e1" -x4 "-x4 -e2e" -x5 "-x5 -es"; do
-    echo Running wildtest with "$opts"
-    "$TOOLDIR/wildtest" $opts "$srcdir/wildtest.txt" >"$scratchdir/wild.out"
-    diff $diffopt "$scratchdir/wild.out" - <<EOF
-No wildmatch errors found.
-EOF
-done
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/wildmatch_test.py b/testsuite/wildmatch_test.py
new file mode 100644 (file)
index 0000000..4c80566
--- /dev/null
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/wildmatch.test.
+#
+# Exercise the wildmatch() function (with no options) and wildmatch_join()
+# (via -x and/or -e) by running the wildtest helper across a variety of
+# option combinations and confirming each reports no errors.
+
+import subprocess
+
+from rsyncfns import SRCDIR, TOOLDIR, test_fail
+
+
+OPTION_SETS = [
+    [],
+    ['-x1'],
+    ['-x1', '-e1'],
+    ['-x1', '-e1se'],
+    ['-x2'],
+    ['-x2', '-ese'],
+    ['-x3'],
+    ['-x3', '-e1'],
+    ['-x4'],
+    ['-x4', '-e2e'],
+    ['-x5'],
+    ['-x5', '-es'],
+]
+
+EXPECTED = "No wildmatch errors found.\n"
+
+wildtest = str(TOOLDIR / 'wildtest')
+wildtest_txt = str(SRCDIR / 'wildtest.txt')
+
+for opts in OPTION_SETS:
+    print(f"Running wildtest with {' '.join(opts)}")
+    proc = subprocess.run(
+        [wildtest, *opts, wildtest_txt],
+        capture_output=True, text=True,
+    )
+    if proc.returncode != 0:
+        test_fail(
+            f"wildtest {' '.join(opts)} exited {proc.returncode}\n"
+            f"stderr:\n{proc.stderr}\nstdout:\n{proc.stdout}"
+        )
+    if proc.stdout != EXPECTED:
+        test_fail(
+            f"wildtest {' '.join(opts)} output did not match expected.\n"
+            f"--- expected ---\n{EXPECTED}"
+            f"--- got ---\n{proc.stdout}"
+        )
diff --git a/testsuite/xattrs.test b/testsuite/xattrs.test
deleted file mode 100644 (file)
index c0f3784..0000000
+++ /dev/null
@@ -1,242 +0,0 @@
-#!/bin/sh
-
-# This program is distributable under the terms of the GNU GPL (see
-# COPYING).
-
-# Test that rsync handles basic xattr preservation.
-
-. $suitedir/rsync.fns
-lnkdir="$tmpdir/lnk"
-
-$RSYNC -VV | grep '"xattrs": true' >/dev/null || test_skipped "Rsync is configured without xattr support"
-
-case "$HOST_OS" in
-darwin*)
-    xset() {
-       xnam="$1"
-       xval="$2"
-       shift 2
-       xattr -s "$xnam" "$xval" "${@}"
-    }
-    xls() {
-       xattr -l "${@}" | sed "s/^[ $tab_ch]*//"
-    }
-    RSYNC_PREFIX='rsync'
-    RUSR='rsync.nonuser'
-    ;;
-solaris*)
-    xset() {
-       xnam="$1"
-       xval="$2"
-       shift 2
-       for fn in "${@}"; do
-           runat "$fn" "$SHELL_PATH" <<EOF
-echo "${xval}" > "${xnam}"
-EOF
-       done
-    }
-    xls() {
-       for fn in "${@}"; do
-           runat "$fn" "$SHELL_PATH" <<EOF
-for x in *; do
-    case "\$x" in SUNWattr_*) continue;; esac
-    echo "\$x=\`cat \$x\`"
-done
-EOF
-       done
-    }
-    RSYNC_PREFIX='rsync'
-    RUSR='rsync.nonuser'
-    ;;
-freebsd*)
-    xset() {
-       xnam="$1"
-       xval="$2"
-       shift 2
-       setextattr -h user "$xnam" "$xval" "${@}"
-    }
-    xls() {
-       for f in "${@}"; do lsextattr -q -h user "$f" | tr '[[:space:]]' '\n' | sort | xargs -I % getextattr -h user % "$f"; done
-    }
-    RSYNC_PREFIX='rsync'
-    RUSR='rsync'
-    ;;
-*)
-    xset() {
-       xnam="$1"
-       xval="$2"
-       shift 2
-       setfattr -n "$xnam" -v "$xval" "${@}"
-    }
-    xls() {
-       getfattr -d "${@}"
-    }
-    RSYNC_PREFIX='user.rsync'
-    RUSR='user.rsync'
-    ;;
-esac
-
-makepath "$lnkdir" "$fromdir/foo/bar"
-echo now >"$fromdir/file0"
-echo something >"$fromdir/file1"
-echo else >"$fromdir/file2"
-echo deep >"$fromdir/foo/file3"
-echo normal >"$fromdir/file4"
-echo deeper >"$fromdir/foo/bar/file5"
-
-makepath "$chkdir/foo"
-echo wow >"$chkdir/file1"
-cp_touch "$fromdir/foo/file3" "$chkdir/foo"
-
-dirs='foo foo/bar'
-files='file0 file1 file2 foo/file3 file4 foo/bar/file5'
-
-uid_gid=`"$TOOLDIR/tls" "$fromdir/foo" | sed 's/^.* \([0-9][0-9]*\)\.\([0-9][0-9]*\) .*/\1:\2/'`
-
-cd "$fromdir"
-
-xset user.foo foo file0 2>/dev/null || test_skipped "Unable to set an xattr"
-xset user.bar bar file0
-
-xset user.short 'this is short' file1
-xset user.long 'this is a long attribute that will be truncated in the initial data send' file1
-xset user.good 'this is good' file1
-xset user.nice 'this is nice' file1
-
-xset user.foo foo file2
-xset user.bar bar file2
-xset user.long 'a long attribute for our new file that tests to ensure that this works' file2
-
-xset user.dir1 'need to test directory xattrs too' foo
-xset user.dir2 'another xattr' foo
-xset user.dir3 'this is one last one for the moment' foo
-
-xset user.dir4 'another dir test' foo/bar
-xset user.dir5 'one last one' foo/bar
-
-xset user.foo 'new foo' foo/file3 foo/bar/file5
-xset user.bar 'new bar' foo/file3 foo/bar/file5
-xset user.long 'this is also a long attribute that will be truncated in the initial data send' foo/file3 foo/bar/file5
-xset $RUSR.equal 'this long attribute should remain the same and not need to be transferred' foo/file3 foo/bar/file5
-
-xset user.dir0 'old extra value' "$chkdir/foo"
-xset user.dir1 'old dir value' "$chkdir/foo"
-
-xset user.short 'old short' "$chkdir/file1"
-xset user.extra 'remove me' "$chkdir/file1"
-
-xset user.foo 'old foo' "$chkdir/foo/file3"
-xset $RUSR.equal 'this long attribute should remain the same and not need to be transferred' "$chkdir/foo/file3"
-
-case $0 in
-*hlink*)
-    ln foo/bar/file5 foo/bar/file6 || test_skipped "Can't create hardlink"
-    files="$files foo/bar/file6"
-    dashH='-H'
-    altDest='--link-dest'
-    ;;
-*)
-    dashH=''
-    altDest='--copy-dest'
-    ;;
-esac
-
-xls $dirs $files >"$scratchdir/xattrs.txt"
-
-XFILT='-f-x_system.* -f-x_security.*'
-
-# OK, let's try a simple xattr copy.
-checkit "$RSYNC -avX $XFILT $dashH --super . '$chkdir/'" "$fromdir" "$chkdir"
-
-cd "$chkdir"
-xls $dirs $files | diff $diffopt "$scratchdir/xattrs.txt" -
-
-cd "$fromdir"
-
-if [ -n "$dashH" ]; then
-    for fn in $files; do
-       name=`basename $fn`
-       ln $fn ../lnk/$name
-    done
-fi
-
-checkit "$RSYNC -aiX $XFILT $dashH --super $altDest=../chk . ../to" "$fromdir" "$todir"
-
-cd "$todir"
-xls $dirs $files | diff $diffopt "$scratchdir/xattrs.txt" -
-
-[ -n "$dashH" ] && rm -rf "$lnkdir"
-
-cd "$fromdir"
-rm -rf "$todir"
-
-xset user.nice 'this is nice, but different' file1
-
-xls $dirs $files >"$scratchdir/xattrs.txt"
-
-checkit "$RSYNC -aiX $XFILT $dashH --fake-super --link-dest=../chk . ../to" "$chkdir" "$todir"
-
-cd "$todir"
-xls $dirs $files | diff $diffopt "$scratchdir/xattrs.txt" -
-
-sed -n -e '/^[^d ][^ ]*  *[^ ][^ ]*  *[^ ][^ ]*  *1 /p' "$scratchdir/ls-to" >"$scratchdir/ls-diff-all"
-grep -F -v './file1' "$scratchdir/ls-diff-all" >"$scratchdir/ls-diff" || :
-if [ -s "$scratchdir/ls-diff" ]; then
-    echo "Missing hard links on:"
-    cat "$scratchdir/ls-diff"
-    exit 1
-fi
-if [ ! -s "$scratchdir/ls-diff-all" ]; then
-    echo "Too many hard links on file1!"
-    exit 1
-fi
-
-cd "$chkdir"
-chmod go-rwx . $dirs $files
-
-xset user.nice 'this is nice, but different' file1
-xset $RSYNC_PREFIX.%stat "40000 0,0 $uid_gid" $dirs
-xset $RSYNC_PREFIX.%stat "100000 0,0 $uid_gid" $files
-
-xls $dirs $files >"$scratchdir/xattrs.txt"
-
-cd "$fromdir"
-rm -rf "$todir"
-
-# When run by a non-root tester, this checks if no-user-perm files/dirs can be copied.
-checkit "$RSYNC -aiX $XFILT $dashH --fake-super --chmod=a= . ../to" "$chkdir" "$todir" # 2>"$scratchdir/errors.txt"
-
-cd "$todir"
-xls $dirs $files | diff $diffopt "$scratchdir/xattrs.txt" -
-
-cd "$fromdir"
-rm -rf "$todir" "$chkdir"
-
-$RSYNC -aX file1 file2
-$RSYNC -aX file1 file2 ../chk/
-$RSYNC -aX --del ../chk/ .
-$RSYNC -aX file1 ../lnk/
-[ -n "$dashH" ] && ln "$chkdir/file1" ../lnk/extra-link
-
-xls file1 file2 >"$scratchdir/xattrs.txt"
-
-checkit "$RSYNC -aiiX $XFILT $dashH $altDest=../lnk . ../to" "$chkdir" "$todir"
-
-[ -n "$dashH" ] && rm ../lnk/extra-link
-
-cd "$todir"
-xls file1 file2 | diff $diffopt "$scratchdir/xattrs.txt" -
-
-cd "$fromdir"
-rm "$todir/file2"
-
-echo extra >file1
-$RSYNC -aX . ../chk/
-
-checkit "$RSYNC -aiiX $XFILT . ../to" "$chkdir" "$todir"
-
-cd "$todir"
-xls file1 file2 | diff $diffopt "$scratchdir/xattrs.txt" -
-
-# The script would have aborted on error, so getting here means we've won.
-exit 0
diff --git a/testsuite/xattrs_test.py b/testsuite/xattrs_test.py
new file mode 100644 (file)
index 0000000..a2cfc5e
--- /dev/null
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+# Python rewrite of testsuite/xattrs.test (and, via a Makefile-built
+# symlink, of xattrs-hlink.test).
+#
+# Test that rsync -X preserves extended attributes through a transfer,
+# plus the --link-dest / --copy-dest / --fake-super interactions.
+# The hlink variant additionally enables -H and tests that hard links
+# survive alongside xattrs.
+
+import os
+import platform
+import subprocess
+import sys
+
+from rsyncfns import (
+    CHKDIR, FROMDIR, SCRATCHDIR, TMPDIR, TODIR, TOOLDIR,
+    checkit, cp_touch, makepath, run_rsync, test_fail, test_skipped,
+)
+
+
+vv = run_rsync('-VV', check=True, capture_output=True)
+if '"xattrs": true' not in vv.stdout:
+    test_skipped("Rsync is configured without xattr support")
+
+if platform.system() != 'Linux':
+    test_skipped(f"xattr surface not implemented for {platform.system()}")
+
+# Per-OS xattr surfaces -- Linux only here (other platforms test_skipped'd
+# above). RSYNC_PREFIX is the name-prefix rsync itself looks for; RUSR is
+# the prefix the test uses for "%stat"-style faux-attributes (must match
+# how --fake-super stores them).
+RSYNC_PREFIX = 'user.rsync'
+RUSR = 'user.rsync'
+
+
+def xset(name: str, value: str, *paths):
+    """Set the named xattr to `value` on each of `paths`."""
+    val = value.encode()
+    for p in paths:
+        try:
+            os.setxattr(str(p), name.encode(), val)
+        except OSError as e:
+            raise OSError(f"setxattr {name}={value} on {p}: {e}")
+
+
+def xls(*paths) -> str:
+    """Mirror `getfattr -d` -- a per-path dump of name=value lines."""
+    return subprocess.check_output(['getfattr', '-d', *(str(p) for p in paths)],
+                                   text=True)
+
+
+script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
+hlink_variant = 'hlink' in script_name
+
+lnkdir = TMPDIR / 'lnk'
+makepath(lnkdir, FROMDIR / 'foo' / 'bar')
+
+(FROMDIR / 'file0').write_text("now\n")
+(FROMDIR / 'file1').write_text("something\n")
+(FROMDIR / 'file2').write_text("else\n")
+(FROMDIR / 'foo' / 'file3').write_text("deep\n")
+(FROMDIR / 'file4').write_text("normal\n")
+(FROMDIR / 'foo' / 'bar' / 'file5').write_text("deeper\n")
+
+makepath(CHKDIR / 'foo')
+(CHKDIR / 'file1').write_text("wow\n")
+cp_touch(FROMDIR / 'foo' / 'file3', CHKDIR / 'foo')
+
+dirs = ['foo', 'foo/bar']
+files = ['file0', 'file1', 'file2', 'foo/file3', 'file4', 'foo/bar/file5']
+
+# Read the source dir's tls listing to extract its uid:gid -- used in
+# the fake-super %stat encoding below. Format: "MODE SIZE UID.GID ..."
+tls_out = subprocess.check_output(
+    [str(TOOLDIR / 'tls'), str(FROMDIR / 'foo')], text=True,
+).strip()
+# Extract "UID.GID" -> "UID:GID"
+import re
+m = re.search(r' (\d+)\.(\d+) ', tls_out)
+if not m:
+    test_fail(f"can't parse uid/gid from tls output: {tls_out!r}")
+uid_gid = f"{m.group(1)}:{m.group(2)}"
+
+os.chdir(FROMDIR)
+
+try:
+    xset('user.foo', 'foo', 'file0')
+except OSError:
+    test_skipped("Unable to set an xattr")
+xset('user.bar', 'bar', 'file0')
+
+xset('user.short', 'this is short', 'file1')
+xset('user.long',
+     'this is a long attribute that will be truncated in the initial data send',
+     'file1')
+xset('user.good', 'this is good', 'file1')
+xset('user.nice', 'this is nice', 'file1')
+
+xset('user.foo', 'foo', 'file2')
+xset('user.bar', 'bar', 'file2')
+xset('user.long',
+     'a long attribute for our new file that tests to ensure that this works',
+     'file2')
+
+xset('user.dir1', 'need to test directory xattrs too', 'foo')
+xset('user.dir2', 'another xattr', 'foo')
+xset('user.dir3', 'this is one last one for the moment', 'foo')
+
+xset('user.dir4', 'another dir test', 'foo/bar')
+xset('user.dir5', 'one last one', 'foo/bar')
+
+xset('user.foo', 'new foo', 'foo/file3', 'foo/bar/file5')
+xset('user.bar', 'new bar', 'foo/file3', 'foo/bar/file5')
+xset('user.long',
+     'this is also a long attribute that will be truncated in the initial data send',
+     'foo/file3', 'foo/bar/file5')
+xset(f'{RUSR}.equal',
+     'this long attribute should remain the same and not need to be transferred',
+     'foo/file3', 'foo/bar/file5')
+
+xset('user.dir0', 'old extra value', CHKDIR / 'foo')
+xset('user.dir1', 'old dir value', CHKDIR / 'foo')
+
+xset('user.short', 'old short', CHKDIR / 'file1')
+xset('user.extra', 'remove me', CHKDIR / 'file1')
+
+xset('user.foo', 'old foo', CHKDIR / 'foo' / 'file3')
+xset(f'{RUSR}.equal',
+     'this long attribute should remain the same and not need to be transferred',
+     CHKDIR / 'foo' / 'file3')
+
+if hlink_variant:
+    try:
+        os.link(FROMDIR / 'foo' / 'bar' / 'file5', FROMDIR / 'foo' / 'bar' / 'file6')
+    except OSError:
+        test_skipped("Can't create hardlink")
+    files.append('foo/bar/file6')
+    dashH = ['-H']
+    altDest = '--link-dest'
+else:
+    dashH = []
+    altDest = '--copy-dest'
+
+
+def _save_xattrs(paths, dest_file):
+    """Snapshot the xattrs of `paths` (relative to cwd) into dest_file."""
+    out = subprocess.check_output(['getfattr', '-d', *paths], text=True)
+    dest_file.write_text(out)
+
+
+_save_xattrs(dirs + files, SCRATCHDIR / 'xattrs.txt')
+
+XFILT = ['-f-x_system.*', '-f-x_security.*']
+
+# Simple xattr copy.
+checkit(['-avX', *XFILT, *dashH, '--super', '.', f'{CHKDIR}/'], FROMDIR, CHKDIR)
+
+os.chdir(CHKDIR)
+got = subprocess.check_output(['getfattr', '-d', *(dirs + files)], text=True)
+expected = (SCRATCHDIR / 'xattrs.txt').read_text()
+if got != expected:
+    from difflib import unified_diff
+    sys.stdout.write(''.join(unified_diff(
+        expected.splitlines(keepends=True),
+        got.splitlines(keepends=True),
+        fromfile='expected', tofile='got',
+    )))
+    test_fail("xattr listing differs after simple -X copy")
+
+os.chdir(FROMDIR)
+
+if dashH:
+    for fn in files:
+        name = os.path.basename(fn)
+        os.link(fn, lnkdir / name)
+
+checkit(['-aiX', *XFILT, *dashH, '--super', f'{altDest}=../chk', '.', '../to'],
+        FROMDIR, TODIR)
+
+os.chdir(TODIR)
+got = subprocess.check_output(['getfattr', '-d', *(dirs + files)], text=True)
+if got != expected:
+    test_fail("xattr listing differs after --copy-dest / --link-dest copy")
+
+if dashH:
+    import shutil
+    shutil.rmtree(lnkdir, ignore_errors=True)
+
+os.chdir(FROMDIR)
+import shutil
+shutil.rmtree(TODIR, ignore_errors=True)
+
+xset('user.nice', 'this is nice, but different', 'file1')
+
+_save_xattrs(dirs + files, SCRATCHDIR / 'xattrs.txt')
+
+checkit(['-aiX', *XFILT, *dashH, '--fake-super', '--link-dest=../chk', '.', '../to'],
+        CHKDIR, TODIR)
+
+os.chdir(TODIR)
+got = subprocess.check_output(['getfattr', '-d', *(dirs + files)], text=True)
+expected = (SCRATCHDIR / 'xattrs.txt').read_text()
+if got != expected:
+    test_fail("xattr listing differs after --fake-super --link-dest copy")
+
+# Hard-link sanity for the hlink variant: file1 should be alone, every
+# other file should share its inode with at least one peer.
+if dashH:
+    ls_to = SCRATCHDIR / 'ls-to'
+    from rsyncfns import rsync_ls_lR
+    ls_to.write_text(rsync_ls_lR(TODIR))
+    one_link = []
+    for line in ls_to.read_text().splitlines():
+        # tls prints "mode size uid.gid links date time path" -- column
+        # index 3 (zero-based) is the link count; we collect non-directory
+        # entries whose link count is exactly 1.
+        if line.startswith('d') or not line.strip():
+            continue
+        cols = line.split()
+        if len(cols) >= 4 and cols[3] == '1':
+            one_link.append(line)
+    other_one = [ln for ln in one_link if './file1' not in ln]
+    if other_one:
+        print("Missing hard links on:")
+        print('\n'.join(other_one))
+        test_fail("hardlink check failed")
+    if not one_link:
+        test_fail("Too many hard links on file1!")
+
+os.chdir(CHKDIR)
+os.chmod('.', 0o700)
+for p in dirs + files:
+    os.chmod(p, os.stat(p).st_mode & ~0o077)
+
+xset('user.nice', 'this is nice, but different', 'file1')
+xset(f'{RSYNC_PREFIX}.%stat', f'40000 0,0 {uid_gid}', *dirs)
+xset(f'{RSYNC_PREFIX}.%stat', f'100000 0,0 {uid_gid}', *files)
+
+_save_xattrs(dirs + files, SCRATCHDIR / 'xattrs.txt')
+
+os.chdir(FROMDIR)
+shutil.rmtree(TODIR, ignore_errors=True)
+
+# When run by a non-root tester, this verifies no-user-perm files/dirs
+# can still be transferred.
+checkit(['-aiX', *XFILT, *dashH, '--fake-super', '--chmod=a=', '.', '../to'],
+        CHKDIR, TODIR)
+
+os.chdir(TODIR)
+got = subprocess.check_output(['getfattr', '-d', *(dirs + files)], text=True)
+expected = (SCRATCHDIR / 'xattrs.txt').read_text()
+if got != expected:
+    test_fail("xattr listing differs after --fake-super --chmod=a= copy")
+
+os.chdir(FROMDIR)
+shutil.rmtree(TODIR, ignore_errors=True)
+shutil.rmtree(CHKDIR, ignore_errors=True)
+
+run_rsync('-aX', 'file1', 'file2')
+run_rsync('-aX', 'file1', 'file2', '../chk/')
+run_rsync('-aX', '--del', '../chk/', '.')
+run_rsync('-aX', 'file1', '../lnk/')
+if dashH:
+    os.link(CHKDIR / 'file1', lnkdir / 'extra-link')
+
+_save_xattrs(['file1', 'file2'], SCRATCHDIR / 'xattrs.txt')
+
+checkit(['-aiiX', *XFILT, *dashH, f'{altDest}=../lnk', '.', '../to'],
+        CHKDIR, TODIR)
+
+if dashH:
+    (lnkdir / 'extra-link').unlink()
+
+os.chdir(TODIR)
+got = subprocess.check_output(['getfattr', '-d', 'file1', 'file2'], text=True)
+expected = (SCRATCHDIR / 'xattrs.txt').read_text()
+if got != expected:
+    test_fail("xattr listing differs after --link-dest=../lnk copy")
+
+os.chdir(FROMDIR)
+(TODIR / 'file2').unlink()
+
+with open('file1', 'a') as f:
+    f.write("extra\n")
+run_rsync('-aX', '.', '../chk/')
+
+checkit(['-aiiX', *XFILT, '.', '../to'], CHKDIR, TODIR)
+
+os.chdir(TODIR)
+got = subprocess.check_output(['getfattr', '-d', 'file1', 'file2'], text=True)
+if got != expected:
+    test_fail("xattr listing differs after the final round")