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
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,
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
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', '.')
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,
'suitedir': suitedir,
'TESTRUN_TIMEOUT': str(args.timeout),
'HOME': scratchbase,
+ 'PYTHONPATH': pythonpath,
})
for k, v in shconfig.items():
if v:
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
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(
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(
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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')
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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"
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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/')
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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))
+++ /dev/null
-#!/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
--- /dev/null
+#!/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"
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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"
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)"
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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),
+)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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),
+)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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))
+++ /dev/null
-#!/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
--- /dev/null
+#!/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}")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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'}")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-exclude.test
\ No newline at end of file
+++ /dev/null
-#!/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
--- /dev/null
+#!/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 ''),
+)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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')
+++ /dev/null
-#!/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
--- /dev/null
+#!/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,
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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'
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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'))
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)")
+++ /dev/null
-#!/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
--- /dev/null
+"""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}")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)"
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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)"
+ )
+++ /dev/null
-#!/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"
--- /dev/null
+#!/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}")
+++ /dev/null
-#!/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"'
--- /dev/null
+#!/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)
+++ /dev/null
-#!/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
--- /dev/null
+#!/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')
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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}"
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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))
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")
+++ /dev/null
-#!/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
--- /dev/null
+#!/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}"
+ )
+++ /dev/null
-#!/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
--- /dev/null
+#!/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")