testsuite: rewrite the shell testsuite in Python

Replace the entire shell-based testsuite with Python. runtests.py
already drove the suite (it had replaced runtests.sh earlier); this
converts all 60 test scripts from *.test shell to *_test.py and adds
testsuite/rsyncfns.py as the shared helper module -- the Python
counterpart of the now-removed rsync.fns.

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

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

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

The dead rsync.fns shell library is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Tridgell
2026-05-21 11:47:34 +10:00
parent 8839314025
commit 1f689ec0c2
117 changed files with 5414 additions and 4637 deletions

View File

@@ -60,7 +60,8 @@ CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \
testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \
t_secure_relpath$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT)
CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test
CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \
testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py
# Objects for CHECK_PROGS to clean
CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_secure_relpath.o trimslash.o wildtest.o
@@ -343,14 +344,17 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS)
touch $@; \
fi
testsuite/chown-fake.test:
ln -s chown.test $(srcdir)/testsuite/chown-fake.test
testsuite/chown-fake_test.py:
ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py
testsuite/devices-fake.test:
ln -s devices.test $(srcdir)/testsuite/devices-fake.test
testsuite/devices-fake_test.py:
ln -s devices_test.py $(srcdir)/testsuite/devices-fake_test.py
testsuite/xattrs-hlink.test:
ln -s xattrs.test $(srcdir)/testsuite/xattrs-hlink.test
testsuite/xattrs-hlink_test.py:
ln -s xattrs_test.py $(srcdir)/testsuite/xattrs-hlink_test.py
testsuite/exclude-lsh_test.py:
ln -s exclude_test.py $(srcdir)/testsuite/exclude-lsh_test.py
# This does *not* depend on building or installing: you can use it to
# check a version installed from a binary or some other source tree,

View File

@@ -151,17 +151,47 @@ def prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef):
os.symlink(os.path.join(tooldir, srcdir), src_link)
# Python tests are identified by a positive "_test.py" suffix so that
# helper modules (e.g. rsyncfns.py) sit in testsuite/ without being mistaken
# for tests.
_PY_TEST_SUFFIX = '_test.py'
def _is_test_path(path):
base = os.path.basename(path)
return base.endswith('.test') or base.endswith(_PY_TEST_SUFFIX)
def _testbase(path):
"""Strip the test extension to get the canonical test name."""
base = os.path.basename(path)
if base.endswith('.test'):
return base[:-len('.test')]
if base.endswith(_PY_TEST_SUFFIX):
return base[:-len(_PY_TEST_SUFFIX)]
return base
def collect_tests(suitedir, patterns):
"""Collect test scripts matching the given patterns."""
"""Collect test scripts (.test or _test.py) matching the given patterns."""
if not patterns:
tests = sorted(glob.glob(os.path.join(suitedir, '*.test')))
candidates = (glob.glob(os.path.join(suitedir, '*.test'))
+ glob.glob(os.path.join(suitedir, '*' + _PY_TEST_SUFFIX)))
tests = sorted(p for p in candidates if _is_test_path(p))
else:
seen = set()
tests = []
for pat in patterns:
if not pat.endswith('.test'):
pat = pat + '.test'
matches = sorted(glob.glob(os.path.join(suitedir, pat)))
tests.extend(matches)
# Accept either bare name ("mkpath"), explicit extension, or glob.
if pat.endswith('.test') or pat.endswith('.py'):
pats = [pat]
else:
pats = [pat + '.test', pat + _PY_TEST_SUFFIX]
for p in pats:
for m in sorted(glob.glob(os.path.join(suitedir, p))):
if _is_test_path(m) and m not in seen:
seen.add(m)
tests.append(m)
return tests
@@ -203,11 +233,18 @@ def run_one_test(testscript, testbase, scratchdir, base_env, timeout,
env = base_env.copy()
env['scratchdir'] = scratchdir
# Dispatch by extension: shell tests via /bin/sh -e, Python tests via
# the same python3 that's running this runner.
if testscript.endswith('.py'):
cmd = [sys.executable, testscript]
else:
cmd = ['sh', '-e', testscript]
logfile = os.path.join(scratchdir, 'test.log')
try:
with open(logfile, 'w') as log:
proc = subprocess.run(
['sh', '-e', testscript],
cmd,
stdout=log, stderr=subprocess.STDOUT,
env=env, timeout=timeout,
cwd=env.get('TOOLDIR', '.')
@@ -336,6 +373,11 @@ def main():
if os.path.isdir('/usr/xpg4/bin'):
path = '/usr/xpg4/bin:' + path
# Make the testsuite/ directory importable so Python tests can `import rsyncfns`.
pythonpath = suitedir
if os.environ.get('PYTHONPATH'):
pythonpath = suitedir + os.pathsep + os.environ['PYTHONPATH']
base_env = os.environ.copy()
base_env.update({
'PATH': path,
@@ -349,6 +391,7 @@ def main():
'suitedir': suitedir,
'TESTRUN_TIMEOUT': str(args.timeout),
'HOME': scratchbase,
'PYTHONPATH': pythonpath,
})
for k, v in shconfig.items():
if v:
@@ -365,7 +408,7 @@ def main():
full_run = len(args.tests) == 0
# Record test order for consistent skipped-list output
test_order = {os.path.basename(t).replace('.test', ''): i for i, t in enumerate(tests)}
test_order = {_testbase(t): i for i, t in enumerate(tests)}
passed = 0
failed = 0
@@ -402,7 +445,7 @@ def main():
with concurrent.futures.ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = {}
for testscript in tests:
testbase = os.path.basename(testscript).replace('.test', '')
testbase = _testbase(testscript)
scratchdir = os.path.join(scratchbase, testbase)
timeout = 600 if 'hardlinks' in testbase else args.timeout
f = executor.submit(
@@ -423,7 +466,7 @@ def main():
else:
# Sequential execution
for testscript in tests:
testbase = os.path.basename(testscript).replace('.test', '')
testbase = _testbase(testscript)
scratchdir = os.path.join(scratchbase, testbase)
timeout = 600 if 'hardlinks' in testbase else args.timeout
tr = run_one_test(

View File

@@ -1,61 +0,0 @@
#!/bin/sh
# Test some foundational things.
. "$suitedir/rsync.fns"
RSYNC_RSH="$scratchdir/src/support/lsh.sh"
export RSYNC_RSH
echo $0 running
$RSYNC --version || test_fail '--version output failed'
$RSYNC --info=help || test_fail '--info=help output failed'
$RSYNC --debug=help || test_fail '--debug=help output failed'
weird_name="A weird)name"
mkdir "$fromdir"
mkdir "$fromdir/$weird_name"
append_line() {
echo "$1"
echo "$1" >>"$fromdir/$weird_name/file"
}
append_line test1
checkit "$RSYNC -ai '$fromdir/' '$todir/'" "$fromdir" "$todir"
copy_weird() {
checkit "$RSYNC $1 --rsync-path='$RSYNC' '$2$fromdir/$weird_name/' '$3$todir/$weird_name'" "$fromdir" "$todir"
}
append_line test2
copy_weird '-ai' 'lh:' ''
append_line test3
copy_weird '-ai' '' 'lh:'
append_line test4
copy_weird '-ais' 'lh:' ''
append_line test5
copy_weird '-ais' '' 'lh:'
echo test6
touch "$fromdir/one" "$fromdir/two"
(cd "$fromdir" && $RSYNC -ai --old-args --rsync-path="$RSYNC" lh:'one two' "$todir/")
if [ ! -f "$todir/one" ] || [ ! -f "$todir/two" ]; then
test_fail "old-args copy of 'one two' failed"
fi
echo test7
rm "$todir/one" "$todir/two"
(cd "$fromdir" && RSYNC_OLD_ARGS=1 $RSYNC -ai --rsync-path="$RSYNC" lh:'one two' "$todir/")
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/00-hello.test.
#
# Foundational smoke test: --version / --info=help / --debug=help all
# work, plus a round-trip transfer of a directory whose name contains
# shell-special characters via the lsh.sh remote-shell stand-in.
import os
from rsyncfns import (
FROMDIR, RSYNC, SRCDIR, TODIR,
checkit, run_rsync, test_fail,
)
# Set RSYNC_RSH so rsync picks up lsh.sh for the "lh:" hosts below.
os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
# Basic help dumps must not crash.
if run_rsync('--version', check=False).returncode != 0:
test_fail('--version output failed')
if run_rsync('--info=help', check=False).returncode != 0:
test_fail('--info=help output failed')
if run_rsync('--debug=help', check=False).returncode != 0:
test_fail('--debug=help output failed')
weird_name = "A weird)name"
FROMDIR.mkdir(parents=True, exist_ok=True)
weird_dir = FROMDIR / weird_name
weird_dir.mkdir()
def append_line(line: str) -> None:
print(line)
with open(weird_dir / 'file', 'a') as f:
f.write(line + '\n')
def copy_weird(args: list, src_host: str, dst_host: str) -> None:
checkit(
[*args, f'--rsync-path={RSYNC}',
f'{src_host}{weird_dir}/',
f'{dst_host}{TODIR / weird_name}'],
FROMDIR, TODIR,
)
append_line('test1')
checkit(['-ai', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
append_line('test2')
copy_weird(['-ai'], 'lh:', '')
append_line('test3')
copy_weird(['-ai'], '', 'lh:')
append_line('test4')
copy_weird(['-ais'], 'lh:', '')
append_line('test5')
copy_weird(['-ais'], '', 'lh:')
# test6: --old-args lets two whitespace-separated names go through as a
# single "one two" remote argument to be re-split by the remote shell.
print('test6')
(FROMDIR / 'one').touch()
(FROMDIR / 'two').touch()
saved = os.getcwd()
os.chdir(FROMDIR)
try:
run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC}',
'lh:one two', f'{TODIR}/')
finally:
os.chdir(saved)
if not (TODIR / 'one').is_file() or not (TODIR / 'two').is_file():
test_fail("old-args copy of 'one two' failed")
# test7: the RSYNC_OLD_ARGS=1 env var should be equivalent to --old-args.
print('test7')
(TODIR / 'one').unlink()
(TODIR / 'two').unlink()
env = os.environ.copy()
env['RSYNC_OLD_ARGS'] = '1'
import subprocess
from rsyncfns import rsync_argv
os.chdir(FROMDIR)
try:
subprocess.run(
rsync_argv('-ai', f'--rsync-path={RSYNC}',
'lh:one two', f'{TODIR}/'),
env=env, check=True,
)
finally:
os.chdir(saved)

View File

@@ -1,66 +0,0 @@
#!/bin/sh
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that rsync obeys default ACLs. -- Matt McCutchen
. $suitedir/rsync.fns
$RSYNC -VV | grep '"ACLs": true' >/dev/null || test_skipped "Rsync is configured without ACL support"
case "$setfacl_nodef" in
true) test_skipped "I don't know how to use your setfacl command" ;;
*-k*) opts='-dm u::7,g::5,o:5' ;;
*) opts='-m d:u::7,d:g::5,d:o:5' ;;
esac
setfacl $opts "$scratchdir" || test_skipped "Your filesystem has ACLs disabled"
# Call as: testit <dirname> <default-acl> <file-expected> <program-expected>
testit() {
todir="$scratchdir/$1"
mkdir "$todir"
$setfacl_nodef "$todir"
if [ -n "$2" ]; then
case "$setfacl_nodef" in
*-k*) opts="-dm $2" ;;
*) opts="-m `echo $2 | sed 's/\([ugom]:\)/d:\1/g'`"
esac
setfacl $opts "$todir"
fi
# Make sure we obey ACLs when creating a directory to hold multiple transferred files,
# even though the directory itself is outside the transfer
$RSYNC -rvv "$scratchdir/dir" "$scratchdir/file" "$scratchdir/program" "$todir/to/"
check_perms "$todir/to" $4 "Target $1"
check_perms "$todir/to/dir" $4 "Target $1"
check_perms "$todir/to/file" $3 "Target $1"
check_perms "$todir/to/program" $4 "Target $1"
# Make sure get_local_name doesn't mess us up when transferring only one file
$RSYNC -rvv "$scratchdir/file" "$todir/to/anotherfile"
check_perms "$todir/to/anotherfile" $3 "Target $1"
# Make sure we obey default ACLs when not transferring a regular file
$RSYNC -rvv "$scratchdir/dir/" "$todir/to/anotherdir/"
check_perms "$todir/to/anotherdir" $4 "Target $1"
}
mkdir "$scratchdir/dir"
echo "File!" >"$scratchdir/file"
echo "#!/bin/sh" >"$scratchdir/program"
chmod 777 "$scratchdir/dir"
chmod 666 "$scratchdir/file"
chmod 777 "$scratchdir/program"
# Test some target directories
umask 0077
testit da777 u::7,g::7,o:7 rw-rw-rw- rwxrwxrwx
testit da775 u::7,g::7,o:5 rw-rw-r-- rwxrwxr-x
testit da750 u::7,g::5,o:0 rw-r----- rwxr-x---
testit da750mask u::7,u:0:7,g::7,m:5,o:0 rw-r----- rwxr-x---
testit noda1 '' rw------- rwx------
umask 0000
testit noda2 '' rw-rw-rw- rwxrwxrwx
umask 0022
testit noda3 '' rw-r--r-- rwxr-xr-x
# Hooray
exit 0

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/acls-default.test.
#
# Test that rsync obeys POSIX default ACLs on the destination's parent
# directory when creating the transfer's container directory, even
# though that parent is outside the transfer itself.
import os
import re
import shlex
import subprocess
from rsyncfns import (
SCRATCHDIR,
check_perms, run_rsync, test_skipped,
)
vv = run_rsync('-VV', check=True, capture_output=True)
if '"ACLs": true' not in vv.stdout:
test_skipped("Rsync is configured without ACL support")
setfacl_nodef = os.environ.get('setfacl_nodef', 'true')
if setfacl_nodef == 'true':
test_skipped("I don't know how to use your setfacl command")
if '-k' in setfacl_nodef.split():
seed_opts = ['-dm', 'u::7,g::5,o:5']
else:
seed_opts = ['-m', 'd:u::7,d:g::5,d:o:5']
# Seed the scratch dir with a default ACL so the upcoming testit() runs
# inherit a known-base; if setfacl rejects this the FS doesn't have ACLs.
proc = subprocess.run(['setfacl', *seed_opts, str(SCRATCHDIR)])
if proc.returncode != 0:
test_skipped("Your filesystem has ACLs disabled")
def testit(dirname, default_acl, file_expected, prog_expected):
"""Set a default ACL on a destination parent dir, then verify that
a transfer into a fresh subdir picks up the inherited perms."""
todir = SCRATCHDIR / dirname
todir.mkdir()
# Clear any inherited default ACL first.
subprocess.run(shlex.split(setfacl_nodef) + [str(todir)])
if default_acl:
if '-k' in setfacl_nodef.split():
opts = ['-dm', default_acl]
else:
# Each "u:/g:/o:/m:" prefix becomes "d:u:/d:g:/d:o:/d:m:".
translated = re.sub(r'([ugom]:)', r'd:\1', default_acl)
opts = ['-m', translated]
subprocess.run(['setfacl', *opts, str(todir)], check=True)
run_rsync('-rvv',
str(SCRATCHDIR / 'dir'),
str(SCRATCHDIR / 'file'),
str(SCRATCHDIR / 'program'),
f'{todir}/to/')
check_perms(todir / 'to', prog_expected)
check_perms(todir / 'to' / 'dir', prog_expected)
check_perms(todir / 'to' / 'file', file_expected)
check_perms(todir / 'to' / 'program', prog_expected)
# get_local_name shouldn't mess up a single-file transfer.
run_rsync('-rvv',
str(SCRATCHDIR / 'file'),
f'{todir}/to/anotherfile')
check_perms(todir / 'to' / 'anotherfile', file_expected)
# And the no-regular-file case (sole-dir transfer).
run_rsync('-rvv',
f'{SCRATCHDIR / "dir"}/',
f'{todir}/to/anotherdir/')
check_perms(todir / 'to' / 'anotherdir', prog_expected)
(SCRATCHDIR / 'dir').mkdir()
(SCRATCHDIR / 'file').write_text("File!\n")
(SCRATCHDIR / 'program').write_text("#!/bin/sh\n")
os.chmod(SCRATCHDIR / 'dir', 0o777)
os.chmod(SCRATCHDIR / 'file', 0o666)
os.chmod(SCRATCHDIR / 'program', 0o777)
os.umask(0o077)
testit('da777', 'u::7,g::7,o:7', 'rw-rw-rw-', 'rwxrwxrwx')
testit('da775', 'u::7,g::7,o:5', 'rw-rw-r--', 'rwxrwxr-x')
testit('da750', 'u::7,g::5,o:0', 'rw-r-----', 'rwxr-x---')
testit('da750mask', 'u::7,u:0:7,g::7,m:5,o:0', 'rw-r-----', 'rwxr-x---')
testit('noda1', '', 'rw-------', 'rwx------')
os.umask(0o000)
testit('noda2', '', 'rw-rw-rw-', 'rwxrwxrwx')
os.umask(0o022)
testit('noda3', '', 'rw-r--r--', 'rwxr-xr-x')

View File

@@ -1,62 +0,0 @@
#!/bin/sh
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that rsync handles basic ACL preservation.
. $suitedir/rsync.fns
$RSYNC -VV | grep '"ACLs": true' >/dev/null || test_skipped "Rsync is configured without ACL support"
makepath "$fromdir/foo"
echo something >"$fromdir/file1"
echo else >"$fromdir/file2"
files='foo file1 file2'
case "$setfacl_nodef" in
true)
if ! chmod --help 2>&1 | grep -F +a >/dev/null; then
test_skipped "I don't know how to use setfacl or chmod for ACLs"
fi
chmod +a "root allow read,write,execute" "$fromdir/foo" || test_skipped "Your filesystem has ACLs disabled"
chmod +a "root allow read,execute" "$fromdir/file1"
chmod +a "admin allow read" "$fromdir/file1"
chmod +a "daemon allow read,write" "$fromdir/file1"
chmod +a "root allow read,execute" "$fromdir/file2"
see_acls() {
ls -le "${@}"
}
;;
*)
setfacl -m u:0:7 "$fromdir/foo" || test_skipped "Your filesystem has ACLs disabled"
setfacl -m g:1:5 "$fromdir/foo"
setfacl -m g:2:1 "$fromdir/foo"
setfacl -m g:0:7 "$fromdir/foo"
setfacl -m u:2:1 "$fromdir/foo"
setfacl -m u:1:5 "$fromdir/foo"
setfacl -m u:0:5 "$fromdir/file1"
setfacl -m g:0:4 "$fromdir/file1"
setfacl -m u:1:6 "$fromdir/file1"
setfacl -m u:0:5 "$fromdir/file2"
see_acls() {
getfacl "${@}"
}
;;
esac
cd "$fromdir"
$RSYNC -avvA $files "$todir/"
see_acls $files >"$scratchdir/acls.txt"
cd "$todir"
see_acls $files | diff $diffopt "$scratchdir/acls.txt" -
# The script would have aborted on error, so getting here means we've won.
exit 0

93
testsuite/acls_test.py Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/acls.test.
#
# Test that rsync -A preserves POSIX ACLs across a transfer. Skips on
# binaries built without ACL support, on filesystems with ACLs disabled,
# and on hosts that lack both setfacl(1) and a chmod that understands "+a".
import os
import platform
import subprocess
from rsyncfns import FROMDIR, SCRATCHDIR, TODIR, makepath, run_rsync, test_fail, test_skipped
vv = run_rsync('-VV', check=True, capture_output=True)
if '"ACLs": true' not in vv.stdout:
test_skipped("Rsync is configured without ACL support")
makepath(FROMDIR / 'foo')
(FROMDIR / 'file1').write_text("something\n")
(FROMDIR / 'file2').write_text("else\n")
files = ['foo', 'file1', 'file2']
# Decide which ACL command surface to use. Mirrors the shell test's
# branching on $setfacl_nodef (set by runtests.py).
setfacl_nodef = os.environ.get('setfacl_nodef', 'true')
def _chmod_plus_a_supported() -> bool:
"""macOS-style: chmod +a 'user allow ...'."""
out = subprocess.run(['chmod', '--help'], capture_output=True, text=True)
return '+a' in (out.stdout + out.stderr)
use_chmod_plus_a = setfacl_nodef == 'true' and _chmod_plus_a_supported()
if setfacl_nodef == 'true' and not use_chmod_plus_a:
test_skipped("I don't know how to use setfacl or chmod for ACLs")
def _setfacl(*args) -> int:
return subprocess.run(['setfacl', *args]).returncode
def _chmod_acl(*args) -> int:
return subprocess.run(['chmod', *args]).returncode
if use_chmod_plus_a:
if _chmod_acl('+a', 'root allow read,write,execute',
str(FROMDIR / 'foo')) != 0:
test_skipped("Your filesystem has ACLs disabled")
_chmod_acl('+a', 'root allow read,execute', str(FROMDIR / 'file1'))
_chmod_acl('+a', 'admin allow read', str(FROMDIR / 'file1'))
_chmod_acl('+a', 'daemon allow read,write', str(FROMDIR / 'file1'))
_chmod_acl('+a', 'root allow read,execute', str(FROMDIR / 'file2'))
def see_acls(paths):
return subprocess.check_output(['ls', '-le', *paths], text=True)
else:
if _setfacl('-m', 'u:0:7', str(FROMDIR / 'foo')) != 0:
test_skipped("Your filesystem has ACLs disabled")
_setfacl('-m', 'g:1:5', str(FROMDIR / 'foo'))
_setfacl('-m', 'g:2:1', str(FROMDIR / 'foo'))
_setfacl('-m', 'g:0:7', str(FROMDIR / 'foo'))
_setfacl('-m', 'u:2:1', str(FROMDIR / 'foo'))
_setfacl('-m', 'u:1:5', str(FROMDIR / 'foo'))
_setfacl('-m', 'u:0:5', str(FROMDIR / 'file1'))
_setfacl('-m', 'g:0:4', str(FROMDIR / 'file1'))
_setfacl('-m', 'u:1:6', str(FROMDIR / 'file1'))
_setfacl('-m', 'u:0:5', str(FROMDIR / 'file2'))
def see_acls(paths):
return subprocess.check_output(['getfacl', *paths], text=True)
os.chdir(FROMDIR)
run_rsync('-avvA', *files, f'{TODIR}/')
before = see_acls(files)
(SCRATCHDIR / 'acls.txt').write_text(before)
os.chdir(TODIR)
after = see_acls(files)
if before != after:
print("--- expected (from) ---")
print(before)
print("--- got (to) ---")
print(after)
test_fail("ACL listing differs between source and destination")

View File

@@ -1,113 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for the basedir-confinement gap in
# secure_relative_open(). The function opens basedir with a plain
# openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY), without
# RESOLVE_BENEATH or a per-component O_NOFOLLOW walk, so a parent
# symlink ON basedir is followed unrestrictedly. RESOLVE_BENEATH is
# then applied only to relpath, anchored at the wrong directory.
#
# The receiver's basis-file lookup at receiver.c passes
# basis_dir[fnamecmp_type] (from --copy-dest / --link-dest /
# --compare-dest -- all sender-controllable in daemon mode) as
# basedir. A daemon-module attacker with write access can plant a
# symlink at module/cd -> /outside, then run --link-dest=cd to
# make the daemon's basis-file lookup resolve into /outside,
# leaking the contents of daemon-readable files via the rsync
# delta-rolling read-disclosure primitive.
#
# We detect the escape by leveraging --link-dest: when basis
# matches source exactly (content + mtime + mode), --link-dest
# hard-links the destination to the basis file. With the bug, the
# destination ends up as a hard link to the outside-the-module
# file (same inode). With the fix, no basis is found and the
# destination is a fresh copy (different inode).
#
# The vulnerable code path is the same on every platform
# (including the per-component fallback on systems without
# RESOLVE_BENEATH), so this test is not platform-gated.
. "$suitedir/rsync.fns"
mod="$scratchdir/module"
outside="$scratchdir/outside"
src="$scratchdir/src"
conf="$scratchdir/test-rsyncd.conf"
rm -rf "$mod" "$outside" "$src"
mkdir -p "$mod" "$outside" "$src"
# Portable inode-number helper (GNU coreutils stat -c, BSD stat -f).
file_inode() {
stat -c %i "$1" 2>/dev/null || stat -f %i "$1"
}
# Outside-the-module file an attacker would like the daemon to
# treat as a basis.
echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
chmod 0644 "$outside/target.txt"
# The symlink trap planted in the module by the local attacker.
ln -s "$outside" "$mod/cd"
# Source file matches outside/target.txt exactly (content + mtime
# + mode) so --link-dest will hard-link the destination to the
# basis file iff the daemon's basedir lookup reaches outside/.
echo "OUTSIDE_SECRET_DATA" > "$src/target.txt"
touch -r "$outside/target.txt" "$src/target.txt"
chmod 0644 "$src/target.txt"
# When running as root the daemon would drop to "nobody" by
# default, which can't write into the test scratch dir. Force the
# daemon to keep our uid/gid in that case so the basis-link
# transfer can actually create the destination file. (Non-root
# can't specify uid/gid in rsyncd.conf -- comment them out then.)
my_uid=`get_testuid`
root_uid=`get_rootuid`
root_gid=`get_rootgid`
uid_setting="uid = $root_uid"
gid_setting="gid = $root_gid"
if test x"$my_uid" != x"$root_uid"; then
uid_setting="#$uid_setting"
gid_setting="#$gid_setting"
fi
cat > "$conf" <<EOF
use chroot = no
$uid_setting
$gid_setting
log file = $scratchdir/rsyncd.log
[upload]
path = $mod
use chroot = no
read only = no
EOF
# Recursive --link-dest push directly into the module root. We
# avoid pushing into a destination subdir because the receiver
# would chdir into it before resolving --link-dest, making the
# relative basedir "cd" resolve in the wrong CWD and masking the
# bug. The realistic attack pushes into the module root (or the
# attacker uses a basedir path that resolves correctly from
# whichever subdir the receiver chdirs into).
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC -rtp --link-dest=cd "$src/" rsync://localhost/upload/ \
>/dev/null 2>&1 || true
if [ ! -f "$mod/target.txt" ]; then
test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour"
fi
outside_inode=$(file_inode "$outside/target.txt")
dst_inode=$(file_inode "$mod/target.txt")
if [ "$outside_inode" = "$dst_inode" ]; then
test_fail "basedir-escape: --link-dest hard-linked module/target.txt to outside/target.txt (inode $outside_inode); daemon's basis-file lookup followed the parent symlink on the basedir"
fi
exit 0

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/alt-dest-symlink-race.test.
#
# Regression test for the basedir-confinement gap in
# secure_relative_open(): a parent symlink ON basedir is followed
# unrestrictedly, then RESOLVE_BENEATH is applied only to relpath,
# anchored at the wrong directory. In daemon mode this lets a local
# attacker who can write into a module plant module/cd -> /outside and
# then use --link-dest=cd / --copy-dest=cd / --compare-dest=cd to make
# the receiver's basis-file lookup resolve into /outside, leaking
# daemon-readable content via the rsync delta-rolling read-disclosure
# primitive.
#
# Detection: with --link-dest, when basis matches source exactly the
# destination is hard-linked to the basis. On a successful escape the
# destination shares an inode with /outside/target.txt; on a fix it
# doesn't.
import os
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
rsync_argv, get_testuid, get_rootuid, get_rootgid,
rmtree, test_fail,
)
mod = SCRATCHDIR / 'module'
outside = SCRATCHDIR / 'outside'
src_dir = SCRATCHDIR / 'src_files'
conf = SCRATCHDIR / 'test-rsyncd.conf'
for d in (mod, outside, src_dir):
rmtree(d)
d.mkdir(parents=True)
# The outside file an attacker wants the daemon to treat as a basis.
(outside / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
os.chmod(outside / 'target.txt', 0o644)
# Attacker-planted module symlink.
os.symlink(str(outside), mod / 'cd')
# Source: same content + mtime + mode as outside, so --link-dest hard-
# links the destination to the basis iff basedir lookup escapes.
(src_dir / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
ref = (outside / 'target.txt').stat()
os.utime(src_dir / 'target.txt', (ref.st_atime, ref.st_mtime))
os.chmod(src_dir / 'target.txt', 0o644)
my_uid = get_testuid()
root_uid = get_rootuid()
root_gid = get_rootgid()
uid_line = f"uid = {root_uid}"
gid_line = f"gid = {root_gid}"
if my_uid != root_uid:
uid_line = '#' + uid_line
gid_line = '#' + gid_line
conf.write_text(f"""\
use chroot = no
{uid_line}
{gid_line}
log file = {SCRATCHDIR}/rsyncd.log
[upload]
path = {mod}
use chroot = no
read only = no
""")
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
# Push directly into the module root: pushing into a destination subdir
# would make the receiver chdir into it before resolving --link-dest,
# making "cd" resolve in the wrong CWD and masking the bug.
subprocess.run(
rsync_argv('-rtp', '--link-dest=cd',
f'{src_dir}/', 'rsync://localhost/upload/'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
target = mod / 'target.txt'
if not target.is_file():
test_fail(
"destination file was not created -- daemon transfer failed "
"before the test could observe the basedir behaviour"
)
if target.stat().st_ino == (outside / 'target.txt').stat().st_ino:
test_fail(
f"basedir-escape: --link-dest hard-linked module/target.txt to "
f"outside/target.txt (inode {target.stat().st_ino}); daemon's "
"basis-file lookup followed the parent symlink on the basedir"
)

View File

@@ -1,68 +0,0 @@
#!/bin/sh
# Copyright (C) 2004-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync handling of --compare-dest and similar options.
. "$suitedir/rsync.fns"
alt1dir="$tmpdir/alt1"
alt2dir="$tmpdir/alt2"
alt3dir="$tmpdir/alt3"
SSH="$scratchdir/src/support/lsh.sh"
# Build some files/dirs/links to copy
hands_setup
# Setup the alt and chk dirs
$RSYNC -av --include=text --include='*/' --exclude='*' "$fromdir/" "$alt1dir/"
$RSYNC -av --include=etc-ltr-list --include='*/' --exclude='*' "$fromdir/" "$alt2dir/"
# Create a side dir where there is a candidate destfile of the same name as a sourcefile
echo "This is a test file" >"$fromdir/likely"
mkdir "$alt3dir"
echo "This is a test file" >"$alt3dir/likely"
sleep 1
touch "$fromdir/dir/text" "$fromdir/likely"
$RSYNC -av --exclude=/text --exclude=etc-ltr-list "$fromdir/" "$chkdir/"
# Let's do it!
checkit "$RSYNC -avv --no-whole-file \
--compare-dest='$alt1dir' --compare-dest='$alt2dir' \
'$fromdir/' '$todir/'" "$chkdir" "$todir"
rm -rf "$todir"
checkit "$RSYNC -avv --no-whole-file \
--copy-dest='$alt1dir' --copy-dest='$alt2dir' \
'$fromdir/' '$todir/'" "$fromdir" "$todir"
# Test that copy_file() works correctly with tmpfiles
for maybe_inplace in '' --inplace; do
rm -rf "$todir"
checkit "$RSYNC -av $maybe_inplace --copy-dest='$alt3dir' \
'$fromdir/' '$todir/'" "$fromdir" "$todir"
for srchost in '' 'localhost:'; do
if [ -z "$srchost" ]; then
desthost='localhost:'
else
desthost=''
fi
rm -rf "$todir"
checkit "$RSYNC -ave '$SSH' --rsync-path='$RSYNC' $maybe_inplace \
--copy-dest='$alt3dir' '$srchost$fromdir/' '$desthost$todir/'" \
"$fromdir" "$todir"
done
done
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/alt-dest.test.
#
# Exercise rsync's --compare-dest, --copy-dest and --link-dest
# alternative-destination options, both locally and across the lsh.sh
# remote-shell stand-in. Also covers the tmpfile path in copy_file() by
# pointing --copy-dest at a directory holding a same-name candidate.
import os
import shutil
import time
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
checkit, hands_setup, rmtree, run_rsync,
)
alt1dir = TMPDIR / 'alt1'
alt2dir = TMPDIR / 'alt2'
alt3dir = TMPDIR / 'alt3'
SSH = str(SRCDIR / 'support' / 'lsh.sh')
hands_setup()
# Seed alt1 and alt2 with disjoint single-file subtrees of fromdir.
run_rsync('-av', '--include=text', '--include=*/', '--exclude=*',
f'{FROMDIR}/', f'{alt1dir}/')
run_rsync('-av', '--include=etc-ltr-list', '--include=*/', '--exclude=*',
f'{FROMDIR}/', f'{alt2dir}/')
# Create a side dir with one identically-named candidate so copy_file()'s
# tmpfile path gets exercised.
(FROMDIR / 'likely').write_text("This is a test file\n")
alt3dir.mkdir()
(alt3dir / 'likely').write_text("This is a test file\n")
time.sleep(1)
os.utime(FROMDIR / 'dir' / 'text')
os.utime(FROMDIR / 'likely')
# chkdir: what a vanilla copy would produce, minus /text and etc-ltr-list.
run_rsync('-av', '--exclude=/text', '--exclude=etc-ltr-list',
f'{FROMDIR}/', f'{CHKDIR}/')
# Stacked --compare-dest: dest grows just the deltas alt1+alt2 don't have.
checkit(['-avv', '--no-whole-file',
f'--compare-dest={alt1dir}', f'--compare-dest={alt2dir}',
f'{FROMDIR}/', f'{TODIR}/'], CHKDIR, TODIR)
rmtree(TODIR)
# Stacked --copy-dest: dest gets full copy because content can be hardlinked
# from the alt dirs where available.
checkit(['-avv', '--no-whole-file',
f'--copy-dest={alt1dir}', f'--copy-dest={alt2dir}',
f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
# Test that copy_file() works correctly with tmpfiles. Combine each of
# {direct, --inplace} with each of {local, remote-source, remote-dest}.
for maybe_inplace in ([], ['--inplace']):
rmtree(TODIR)
checkit(['-av', *maybe_inplace, f'--copy-dest={alt3dir}',
f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
for srchost in ('', 'localhost:'):
desthost = 'localhost:' if not srchost else ''
rmtree(TODIR)
checkit(['-ave', SSH, f'--rsync-path={RSYNC}', *maybe_inplace,
f'--copy-dest={alt3dir}',
f'{srchost}{FROMDIR}/', f'{desthost}{TODIR}/'],
FROMDIR, TODIR)

View File

@@ -1,19 +0,0 @@
#!/bin/sh
# Test rsync copying atimes
. "$suitedir/rsync.fns"
$RSYNC -VV | grep '"atimes": true' >/dev/null || test_skipped "Rsync is configured without atimes support"
mkdir "$fromdir"
touch "$fromdir/foo"
touch -a -t 200102031717.42 "$fromdir/foo"
TLS_ARGS=--atimes
checkit "$RSYNC -rtUgvvv \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

33
testsuite/atimes_test.py Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/atimes.test.
#
# Test that rsync preserves source atimes when the binary was built with
# atimes support. We pin the source file's atime to a known historical
# value then sync with -U; the listing (with --atimes in tls) must match
# between source and destination.
import os
import rsyncfns
from rsyncfns import FROMDIR, TODIR, checkit, run_rsync, test_skipped
vv = run_rsync('-VV', check=True, capture_output=True)
if '"atimes": true' not in vv.stdout:
test_skipped("Rsync is configured without atimes support")
FROMDIR.mkdir(parents=True, exist_ok=True)
foo = FROMDIR / 'foo'
foo.touch()
# `touch -a -t 200102031717.42` -> set atime to 2001-02-03 17:17:42, mtime unchanged.
import datetime
atime = datetime.datetime(2001, 2, 3, 17, 17, 42).timestamp()
mtime = foo.stat().st_mtime
os.utime(foo, (atime, mtime))
# Make the listing include atimes so checkit's tls compare picks up the
# transferred atime.
rsyncfns.TLS_ARGS = '--atimes'
checkit(['-rtUgvvv', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)

View File

@@ -1,63 +0,0 @@
#!/bin/sh
# Copyright (C) 2004-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that the --backup option works right.
. "$suitedir/rsync.fns"
bakdir="$tmpdir/bak"
makepath "$fromdir/deep" "$bakdir/dname"
name1="$fromdir/deep/name1"
name2="$fromdir/deep/name2"
cat "$srcdir"/[gr]*.[ch] > "$name1"
cat "$srcdir"/[et]*.[ch] > "$name2"
checkit "$RSYNC -ai --info=backup '$fromdir/' '$todir/'" "$fromdir" "$todir"
checkit "$RSYNC -ai --info=backup '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
cat "$srcdir"/[fgpr]*.[ch] > "$name1"
cat "$srcdir"/[etw]*.[ch] > "$name2"
checktee "$RSYNC -ai --info=backup --no-whole-file --backup '$fromdir/' '$todir/'"
for fn in deep/name1 deep/name2; do
grep "backed up $fn to $fn~" "$outfile" >/dev/null || test_fail "no backup message output for $fn"
diff $diffopt "$fromdir/$fn" "$todir/$fn" || test_fail "copy of $fn failed"
diff $diffopt "$chkdir/$fn" "$todir/$fn~" || test_fail "backup of $fn to $fn~ failed"
mv "$todir/$fn~" "$todir/$fn"
done
echo deleted-file >"$todir/dname"
cp_touch "$todir/dname" "$chkdir"
checkit "$RSYNC -ai --info=backup --no-whole-file --delete-delay \
--backup --backup-dir='$bakdir' '$fromdir/' '$todir/'" "$fromdir" "$todir" \
| tee "$outfile"
for fn in deep/name1 deep/name2; do
grep "backed up $fn to .*/$fn$" "$outfile" >/dev/null || test_fail "no backup message output for $fn"
done
diff -r $diffopt "$chkdir" "$bakdir" || test_fail "backup dir contents are bogus"
rm "$bakdir/dname"
checkit "$RSYNC -ai --info=backup --del '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
cat "$srcdir"/[efgr]*.[ch] > "$name1"
cat "$srcdir"/[ew]*.[ch] > "$name2"
checkit "$RSYNC -ai --info=backup --inplace --no-whole-file --backup --backup-dir='$bakdir' '$fromdir/' '$todir/'" "$fromdir" "$todir" \
| tee "$outfile"
for fn in deep/name1 deep/name2; do
grep "backed up $fn to .*/$fn$" "$outfile" >/dev/null || test_fail "no backup message output for $fn"
done
diff -r $diffopt "$chkdir" "$bakdir" || test_fail "backup dir contents are bogus"
checkit "$RSYNC -ai --info=backup --inplace --no-whole-file '$fromdir/' '$bakdir/'" "$fromdir" "$bakdir"
# The script would have aborted on error, so getting here means we've won.
exit 0

133
testsuite/backup_test.py Normal file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/backup.test.
#
# Walk through --backup behaviour:
# * a plain backup leaves the old file at name~ alongside the new one,
# * --backup-dir relocates the old file into a parallel tree and also
# captures deletions when used with --delete-delay,
# * --backup --inplace --backup-dir handles delta-overwrites too.
# Each phase also confirms the destination ends up matching the source via
# the usual checkit listing+content diff.
import os
import shutil
import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, OUTFILE, SRCDIR, TMPDIR, TODIR,
checkit, cp_touch, makepath, rsync_argv, test_fail, verify_dirs,
)
bakdir = TMPDIR / 'bak'
makepath(FROMDIR / 'deep', bakdir / 'dname')
name1 = FROMDIR / 'deep' / 'name1'
name2 = FROMDIR / 'deep' / 'name2'
def _cat_glob(pattern: str, dest):
"""Concatenate every srcdir file matching `pattern` into `dest`.
Mirrors `cat "$srcdir"/[abc]*.[ch] > "$dest"`.
"""
chunks = bytearray()
for f in sorted(SRCDIR.glob(pattern)):
chunks.extend(f.read_bytes())
dest.write_bytes(bytes(chunks))
_cat_glob('[gr]*.[ch]', name1)
_cat_glob('[et]*.[ch]', name2)
# Establish baseline destination and chk copies of the source.
checkit(['-ai', '--info=backup', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
checkit(['-ai', '--info=backup', f'{FROMDIR}/', f'{CHKDIR}/'], FROMDIR, CHKDIR)
# Mutate the source files; delta-transfer will need to back up the old
# contents at $todir/$fn~ before overwriting in place.
_cat_glob('[fgpr]*.[ch]', name1)
_cat_glob('[etw]*.[ch]', name2)
def _run_and_capture(args, outfile):
proc = subprocess.run(rsync_argv(*args), capture_output=True, text=True)
outfile.write_text(proc.stdout)
print(proc.stdout, end='')
if proc.returncode != 0:
test_fail(f"rsync exited {proc.returncode}")
return proc
_run_and_capture(
['-ai', '--info=backup', '--no-whole-file', '--backup',
f'{FROMDIR}/', f'{TODIR}/'],
OUTFILE,
)
text = OUTFILE.read_text()
for fn in ('deep/name1', 'deep/name2'):
if f"backed up {fn} to {fn}~" not in text:
test_fail(f"no backup message output for {fn}")
diff = subprocess.run(['diff', '-u', str(FROMDIR / fn), str(TODIR / fn)])
if diff.returncode != 0:
test_fail(f"copy of {fn} failed")
diff = subprocess.run(['diff', '-u', str(CHKDIR / fn), str(TODIR / f'{fn}~')])
if diff.returncode != 0:
test_fail(f"backup of {fn} to {fn}~ failed")
shutil.move(str(TODIR / f'{fn}~'), str(TODIR / fn))
# --backup-dir + --delete-delay: a deletion at the dest gets routed into
# the backup dir rather than being lost.
(TODIR / 'dname').write_text("deleted-file\n")
cp_touch(TODIR / 'dname', CHKDIR)
_run_and_capture(
['-ai', '--info=backup', '--no-whole-file', '--delete-delay',
'--backup', f'--backup-dir={bakdir}',
f'{FROMDIR}/', f'{TODIR}/'],
OUTFILE,
)
# After the run, FROMDIR and TODIR should match (the backup ran into
# bakdir, not into chkdir, so chkdir must NOT be touched -- it still
# holds the pre-rsync contents that we'll compare against bakdir below).
verify_dirs(FROMDIR, TODIR, label="post --backup-dir run")
text = OUTFILE.read_text()
import re
for fn in ('deep/name1', 'deep/name2'):
if not re.search(rf"backed up {re.escape(fn)} to .*/{re.escape(fn)}$",
text, flags=re.MULTILINE):
test_fail(f"no backup message output for {fn}")
diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR), str(bakdir)])
if diff.returncode != 0:
test_fail("backup dir contents are bogus")
(bakdir / 'dname').unlink()
# Re-establish chk and mutate source again for the --inplace pass.
checkit(['-ai', '--info=backup', '--del', f'{FROMDIR}/', f'{CHKDIR}/'],
FROMDIR, CHKDIR)
_cat_glob('[efgr]*.[ch]', name1)
_cat_glob('[ew]*.[ch]', name2)
_run_and_capture(
['-ai', '--info=backup', '--inplace', '--no-whole-file',
'--backup', f'--backup-dir={bakdir}',
f'{FROMDIR}/', f'{TODIR}/'],
OUTFILE,
)
verify_dirs(FROMDIR, TODIR, label="post --inplace --backup-dir run")
text = OUTFILE.read_text()
for fn in ('deep/name1', 'deep/name2'):
if not re.search(rf"backed up {re.escape(fn)} to .*/{re.escape(fn)}$",
text, flags=re.MULTILINE):
test_fail(f"no backup message output for {fn}")
diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR), str(bakdir)])
if diff.returncode != 0:
test_fail("backup dir contents are bogus")
# Final clean inplace sync to the bakdir so it ends up matching fromdir.
checkit(['-ai', '--info=backup', '--inplace', '--no-whole-file',
f'{FROMDIR}/', f'{bakdir}/'], FROMDIR, bakdir)

View File

@@ -1,206 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for codex audit Findings 3b and 3c:
#
# 3b: generator.c:1905 -- the in-place backup creation opens
# backupptr via bare do_open(O_WRONLY|O_CREAT|O_TRUNC|O_EXCL).
# With --backup-dir set to an attacker-planted parent symlink,
# the backup file is written outside the module under the
# daemon's authority.
#
# 3c-symlink: syscall.c:207 -- do_symlink_at falls through to bare
# do_symlink for am_root < 0 (fake-super), which then opens
# the destination path with bare open() (final-component
# fake-super file). A parent symlink on the destination path
# redirects the file creation outside the module.
#
# 3c-mknod: syscall.c:506 -- do_mknod_at falls through to bare
# do_mknod for am_root < 0, same path-based open(). For
# FIFOs/sockets/devices the bare path is also used.
#
# Each scenario plants a "secret" file outside the module at a
# location the symlink trap points to. The check is that the
# outside file's content and mode are unchanged after the attack
# attempt.
. "$suitedir/rsync.fns"
# All three scenarios depend on receiver-side daemon code paths
# that are only secured on platforms with a working
# secure_relative_open. The chdir/chmod tests already skip the
# same set; mirror that.
case "$(uname -s)" in
SunOS|OpenBSD|NetBSD|CYGWIN*)
test_skipped "secure_relative_open relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
;;
esac
mod="$scratchdir/module"
outside="$scratchdir/outside"
src="$scratchdir/src"
conf="$scratchdir/test-rsyncd.conf"
# Portable inode-and-mode helpers.
file_mode() {
stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"
}
setup() {
rm -rf "$mod" "$outside" "$src"
mkdir -p "$mod" "$outside" "$src"
echo "OUTSIDE_PROTECTED_DATA" > "$outside/target.txt"
chmod 0644 "$outside/target.txt"
outside_pristine="$scratchdir/outside-pristine.txt"
cp -p "$outside/target.txt" "$outside_pristine"
ln -s "$outside" "$mod/cd"
}
verify_outside_unchanged() {
label="$1"
mode=$(file_mode "$outside/target.txt")
case "$mode" in
644|0644) ;;
*) test_fail "$label: outside/target.txt mode changed from 644 to $mode" ;;
esac
if ! cmp -s "$outside/target.txt" "$outside_pristine"; then
test_fail "$label: outside/target.txt content changed -- daemon followed the cd symlink"
fi
}
verify_outside_unchanged_or_absent() {
label="$1"
target="$2" # specific file under outside/ to check absence of
if [ -e "$outside/$target" ]; then
test_fail "$label: outside/$target was created -- daemon followed the cd symlink"
fi
}
# When running as root the daemon would drop to "nobody" by default
# and fail to write into the test scratch dir. Force it to keep our
# uid/gid in that case so the receiver actually runs the code paths
# we want to test.
my_uid=`get_testuid`
root_uid=`get_rootuid`
root_gid=`get_rootgid`
uid_setting="uid = $root_uid"
gid_setting="gid = $root_gid"
if test x"$my_uid" != x"$root_uid"; then
uid_setting="#$uid_setting"
gid_setting="#$gid_setting"
fi
############################################################
# Scenario 3b: --inplace --backup --backup-dir=cd
#
# Pre-create module/target.txt so the receiver enters the in-place
# update path; a backup of the existing content must be made
# before the update. With --backup-dir=cd, backupptr resolves to
# "cd/target.txt"; with the bug, robust_unlink and the bare
# do_open at generator.c:1905 both follow the cd symlink, the
# unlink deletes outside/target.txt and the create writes the
# pre-existing module/target.txt content there.
############################################################
setup
echo "EXISTING_MODULE_DATA" > "$mod/target.txt"
chmod 0666 "$mod/target.txt"
echo "NEW_DATA_FROM_SENDER" > "$src/target.txt"
chmod 0644 "$src/target.txt"
cat > "$conf" <<EOF
use chroot = no
$uid_setting
$gid_setting
log file = $scratchdir/rsyncd.log
[upload]
path = $mod
use chroot = no
read only = no
EOF
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC --inplace --backup --backup-dir=cd "$src/target.txt" \
rsync://localhost/upload/target.txt >/dev/null 2>&1 || true
verify_outside_unchanged "3b inplace+backup-dir=cd"
############################################################
# Scenario 3c-symlink: fake-super symlink push to a path with a
# symlinked parent
#
# With "fake super = yes" set on the module, the receiver
# represents symlinks as fake-super files (regular files with the
# link target written to them). The path-based open() in
# do_symlink's fake-super branch follows parent symlinks. We push
# a single symlink to the destination path "cd/sym" so the
# receiver's create-file call lands at "cd/sym" relative to the
# module root, where cd is the symlink trap.
############################################################
setup
mkdir -p "$src/cd"
ln -s /etc/passwd "$src/cd/sym"
cat > "$conf" <<EOF
use chroot = no
$uid_setting
$gid_setting
log file = $scratchdir/rsyncd.log
[upload_fake]
path = $mod
use chroot = no
read only = no
fake super = yes
EOF
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC -rl "$src/" rsync://localhost/upload_fake/ >/dev/null 2>&1 || true
verify_outside_unchanged_or_absent "3c-symlink fake-super symlink push" "sym"
############################################################
# Scenario 3c-mknod: fake-super FIFO push to a path with a
# symlinked parent
#
# Similar to 3c-symlink but for special files. mkfifo works
# without root; we push a FIFO and verify the receiver doesn't
# create a fake-super file at outside/fifo.
############################################################
setup
mkdir -p "$src/cd"
mkfifo "$src/cd/fifo" 2>/dev/null
if [ ! -p "$src/cd/fifo" ]; then
test_skipped "mkfifo unavailable; cannot exercise 3c-mknod"
fi
cat > "$conf" <<EOF
use chroot = no
$uid_setting
$gid_setting
log file = $scratchdir/rsyncd.log
[upload_fake]
path = $mod
use chroot = no
read only = no
fake super = yes
EOF
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC -rD "$src/" rsync://localhost/upload_fake/ >/dev/null 2>&1 || true
verify_outside_unchanged_or_absent "3c-mknod fake-super FIFO push" "fifo"
exit 0

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/bare-do-open-symlink-race.test.
#
# Codex audit Findings 3b, 3c-symlink and 3c-mknod: bare do_open /
# do_symlink / do_mknod paths on the receiver follow parent symlinks
# unrestrictedly. Three scenarios are exercised; each must leave the
# outside-the-module sentinel unchanged.
import filecmp
import os
import platform
import shutil
import stat
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
get_rootgid, get_rootuid, get_testuid,
rmtree, rsync_argv, test_fail, test_skipped,
)
if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
test_skipped(
f"secure_relative_open relies on RESOLVE_BENEATH-equivalent kernel "
f"support not available on {platform.system()}"
)
mod = SCRATCHDIR / 'module'
outside = SCRATCHDIR / 'outside'
src = SCRATCHDIR / 'src_files'
conf = SCRATCHDIR / 'test-rsyncd.conf'
outside_pristine = SCRATCHDIR / 'outside-pristine.txt'
def setup():
for d in (mod, outside, src):
rmtree(d)
d.mkdir(parents=True)
(outside / 'target.txt').write_text("OUTSIDE_PROTECTED_DATA\n")
os.chmod(outside / 'target.txt', 0o644)
shutil.copy2(outside / 'target.txt', outside_pristine)
os.symlink(str(outside), mod / 'cd')
def verify_outside_unchanged(label: str) -> None:
mode = (outside / 'target.txt').stat().st_mode & 0o777
if mode != 0o644:
test_fail(f"{label}: outside/target.txt mode changed from 644 to {oct(mode)[2:]}")
if not filecmp.cmp(outside / 'target.txt', outside_pristine, shallow=False):
test_fail(f"{label}: outside/target.txt content changed -- daemon followed the cd symlink")
def verify_outside_unchanged_or_absent(label: str, target: str) -> None:
if (outside / target).exists() or (outside / target).is_symlink():
test_fail(f"{label}: outside/{target} was created -- daemon followed the cd symlink")
my_uid = get_testuid()
root_uid = get_rootuid()
root_gid = get_rootgid()
uid_line = f"uid = {root_uid}"
gid_line = f"gid = {root_gid}"
if my_uid != root_uid:
uid_line = '#' + uid_line
gid_line = '#' + gid_line
def write_conf(module_name: str, fake_super: bool = False) -> None:
extra = " fake super = yes\n" if fake_super else ""
conf.write_text(f"""\
use chroot = no
{uid_line}
{gid_line}
log file = {SCRATCHDIR}/rsyncd.log
[{module_name}]
path = {mod}
use chroot = no
read only = no
{extra}""")
def run_attack(args):
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
subprocess.run(
rsync_argv(*args),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
# Scenario 3b: --inplace --backup --backup-dir=cd
setup()
(mod / 'target.txt').write_text("EXISTING_MODULE_DATA\n")
os.chmod(mod / 'target.txt', 0o666)
(src / 'target.txt').write_text("NEW_DATA_FROM_SENDER\n")
os.chmod(src / 'target.txt', 0o644)
write_conf('upload')
run_attack([
'--inplace', '--backup', '--backup-dir=cd',
f'{src}/target.txt', 'rsync://localhost/upload/target.txt',
])
verify_outside_unchanged("3b inplace+backup-dir=cd")
# Scenario 3c-symlink: fake-super symlink push, parent-symlinked path
setup()
(src / 'cd').mkdir()
os.symlink('/etc/passwd', src / 'cd' / 'sym')
write_conf('upload_fake', fake_super=True)
run_attack(['-rl', f'{src}/', 'rsync://localhost/upload_fake/'])
verify_outside_unchanged_or_absent("3c-symlink fake-super symlink push", "sym")
# Scenario 3c-mknod: fake-super FIFO push, parent-symlinked path
setup()
(src / 'cd').mkdir()
try:
os.mkfifo(src / 'cd' / 'fifo')
except OSError:
test_skipped("mkfifo unavailable; cannot exercise 3c-mknod")
if not stat.S_ISFIFO((src / 'cd' / 'fifo').stat().st_mode):
test_skipped("mkfifo unavailable; cannot exercise 3c-mknod")
write_conf('upload_fake', fake_super=True)
run_attack(['-rD', f'{src}/', 'rsync://localhost/upload_fake/'])
verify_outside_unchanged_or_absent("3c-mknod fake-super FIFO push", "fifo")

View File

@@ -1,51 +0,0 @@
#!/bin/sh
# Copyright (C) 2004 by Chris Shoemaker <c.shoemaker@cox.net>
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync's --write-batch and --read-batch options
. "$suitedir/rsync.fns"
hands_setup
cd "$tmpdir"
# Build chkdir for the daemon tests using a normal rsync and an --exclude.
$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
$RSYNC -av --only-write-batch=BATCH --exclude=foobar.baz "$fromdir/" "$todir/missing/"
test -d "$todir/missing" && test_fail "--only-write-batch should not have created destination dir"
runtest "--read-batch (only)" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$chkdir" "$todir"'
rm -rf "$todir" BATCH*
runtest "local --write-batch" 'checkit "$RSYNC -av --write-batch=BATCH \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
rm -rf "$todir"
runtest "--read-batch" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$fromdir" "$todir"'
build_rsyncd_conf
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
export RSYNC_CONNECT_PROG
rm -rf "$todir"
runtest "daemon sender --write-batch" 'checkit "$RSYNC -av --write-batch=BATCH rsync://localhost/test-from/ \"$todir\"" "$chkdir" "$todir"'
rm -rf "$todir"
runtest "--read-batch from daemon" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$chkdir" "$todir"'
rm -rf "$todir"
runtest "BATCH.sh use of --read-batch" 'checkit "./BATCH.sh" "$chkdir" "$todir"'
runtest "do-nothing re-run of batch" 'checkit "./BATCH.sh" "$chkdir" "$todir"'
rm -rf "$todir"
mkdir "$todir" || test_fail "failed to restore empty destination directory"
runtest "daemon recv --write-batch" 'checkit "\"$ignore23\" $RSYNC -av --write-batch=BATCH \"$fromdir/\" rsync://localhost/test-to" "$chkdir" "$todir"'
# The script would have aborted on error, so getting here means we pass.
exit 0

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/batch-mode.test.
#
# Test rsync's --write-batch / --read-batch / --only-write-batch flags,
# both for local transfers and for daemon source/destination.
import os
import shutil
import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TMPDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, rmtree,
run_rsync, test_fail,
)
conf = build_rsyncd_conf()
hands_setup()
os.chdir(TMPDIR)
# chkdir mirrors a normal transfer minus the daemon's foobar.baz exclude.
run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
# --only-write-batch must NOT create the destination directory.
run_rsync('-av', '--only-write-batch=BATCH', '--exclude=foobar.baz',
f'{FROMDIR}/', f'{TODIR}/missing/')
if (TODIR / 'missing').is_dir():
test_fail("--only-write-batch should not have created destination dir")
print("Test --read-batch (only):")
checkit(['-av', '--read-batch=BATCH', str(TODIR)], CHKDIR, TODIR)
# Wipe any leftover BATCH* files so the next pass starts clean.
rmtree(TODIR)
for batch in TMPDIR.glob('BATCH*'):
batch.unlink()
print("Test local --write-batch:")
checkit(['-av', '--write-batch=BATCH', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
rmtree(TODIR)
print("Test --read-batch:")
checkit(['-av', '--read-batch=BATCH', str(TODIR)], FROMDIR, TODIR)
# Daemon variants. RSYNC_CONNECT_PROG plumbs an in-process daemon.
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
rmtree(TODIR)
print("Test daemon sender --write-batch:")
checkit(['-av', '--write-batch=BATCH', 'rsync://localhost/test-from/', str(TODIR)],
CHKDIR, TODIR, allowed_codes=(0, 23))
rmtree(TODIR)
print("Test --read-batch from daemon:")
checkit(['-av', '--read-batch=BATCH', str(TODIR)], CHKDIR, TODIR)
rmtree(TODIR)
print("Test BATCH.sh use of --read-batch:")
# BATCH.sh is the auto-generated wrapper script that re-applies the
# batch -- we run it directly, not via the rsync binary, then verify.
from rsyncfns import verify_dirs
proc = subprocess.run(['sh', './BATCH.sh'])
if proc.returncode != 0:
test_fail(f"BATCH.sh exited {proc.returncode}")
verify_dirs(CHKDIR, TODIR, label="BATCH.sh use of --read-batch")
print("Test do-nothing re-run of batch:")
proc = subprocess.run(['sh', './BATCH.sh'])
if proc.returncode != 0:
test_fail(f"BATCH.sh (re-run) exited {proc.returncode}")
verify_dirs(CHKDIR, TODIR, label="do-nothing re-run of batch")
rmtree(TODIR)
TODIR.mkdir()
print("Test daemon recv --write-batch:")
# ignore23 swallows the partial-transfer code 23 that daemon mode sometimes
# emits even on success.
ignore23 = SCRATCHDIR / 'ignore23'
# We pass ignore23 by inserting it ahead of the rsync invocation. checkit
# calls subprocess.run(rsync_argv(...)) directly, so do the run manually
# and call verify_dirs for the comparison.
from rsyncfns import rsync_argv
proc = subprocess.run(
[str(ignore23), *rsync_argv('-av', '--write-batch=BATCH',
f'{FROMDIR}/', 'rsync://localhost/test-to')],
)
if proc.returncode != 0:
test_fail(f"daemon recv --write-batch exited {proc.returncode}")
verify_dirs(CHKDIR, TODIR, label="daemon recv --write-batch")

View File

@@ -1,137 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for the symlink-TOCTOU class of bug at the receiver's
# chdir(). After the CVE-2026-29518 fix to secure_relative_open(), an
# attack remained where the receiver's chdir() into a destination
# subdirectory followed an attacker-planted symlink, escaping the
# module. Every subsequent path-relative syscall (open, chmod, lchown,
# utimes, etc.) inherited the escape -- secure_relative_open's
# RESOLVE_BENEATH anchor itself was outside the module by then, so it
# stopped protecting against anything.
#
# This test runs an actual rsync daemon (via RSYNC_CONNECT_PROG to
# avoid the network) configured with "use chroot = no", plants a
# symlink at module/subdir -> ../outside, and runs four flavours of
# rsync transfer that previously all reached files in ../outside:
#
# 1. single-file dest = subdir/target.txt (the original poc_chmod)
# 2. -r src/subdir/ to upload/subdir/ (the chdir-escape case)
# 3. -r src/subdir/ to upload/subdir/ (no --size-only: forces basis read+write)
# 4. -r src/ to upload/ (was already protected by the
# original CVE-2026-29518 fix;
# regression-checked here)
#
# All four must leave the outside-the-module sentinel file's mode AND
# content unchanged.
. "$suitedir/rsync.fns"
case "$(uname -s)" in
SunOS|OpenBSD|NetBSD|CYGWIN*)
test_skipped "secure chdir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
;;
esac
mod="$scratchdir/module"
outside="$scratchdir/outside"
src="$scratchdir/src"
conf="$scratchdir/test-rsyncd.conf"
rm -rf "$mod" "$outside" "$src"
mkdir -p "$mod" "$outside" "$src" "$src/subdir"
# Portable octal-mode helper -- macOS and FreeBSD's stat use -f, GNU
# coreutils stat uses -c.
file_mode() {
stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"
}
# The "secret" file outside the module the attacker is trying to alter.
# Save a pristine copy alongside it so we can compare with cmp(1) rather
# than depending on sha1sum/shasum/sha1, which differ across platforms.
echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
chmod 0600 "$outside/target.txt"
outside_pristine="$scratchdir/outside-pristine.txt"
cp -p "$outside/target.txt" "$outside_pristine"
# Symlink trap planted in the module by the local attacker.
ln -s "$outside" "$mod/subdir"
# Source files the sender will push: same size as the outside target,
# different content, mode 0666 (the perms the attacker tries to push).
SIZE=$(stat -c %s "$outside/target.txt" 2>/dev/null \
|| stat -f %z "$outside/target.txt")
make_data_file "$src/target.txt" "$SIZE" \
|| test_fail "failed to create source file"
make_data_file "$src/subdir/target.txt" "$SIZE" \
|| test_fail "failed to create source file"
chmod 0666 "$src/target.txt" "$src/subdir/target.txt"
cat > "$conf" <<EOF
use chroot = no
log file = $scratchdir/rsyncd.log
[upload]
path = $mod
use chroot = no
read only = no
EOF
reset_outside() {
chmod 0600 "$outside/target.txt"
echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
}
verify_unchanged() {
label="$1"
mode=$(file_mode "$outside/target.txt")
case "$mode" in
600|0600) ;;
*) test_fail "$label: outside file mode changed from 600 to $mode (chmod escape)" ;;
esac
if ! cmp -s "$outside/target.txt" "$outside_pristine"; then
test_fail "$label: outside file content changed (write escape)"
fi
}
run_attack() {
label="$1"; shift
reset_outside
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC "$@" >/dev/null 2>&1 || true
verify_unchanged "$label"
}
# 1. The original poc_chmod scenario: single file, dest path with
# the symlinked subdir as a path component. With --size-only the
# receiver normally skips the basis open and goes straight to chmod
# -- only the chdir-escape blocks the chmod from reaching outside.
run_attack "single-file --size-only" \
-tp --size-only \
"$src/target.txt" rsync://localhost/upload/subdir/target.txt
# 2. -r push into the symlinked subdir: receiver chdir's into "subdir",
# follows the symlink, ends up in outside.
run_attack "-r --size-only into subdir/" \
-rtp --size-only \
"$src/subdir/" rsync://localhost/upload/subdir/
# 3. Same but no --size-only -- forces the basis-file open and a real
# rename, so this exercises the read-disclosure and write-escape
# paths together.
run_attack "-r without --size-only into subdir/" \
-rtp \
"$src/subdir/" rsync://localhost/upload/subdir/
# 4. -r src/ to upload/ -- this case was already covered by the
# original CVE-2026-29518 fix because the receiver stays at module
# root and operates on slashed paths. Regression check.
run_attack "-r --size-only into upload/ root" \
-rtp --size-only \
"$src/" rsync://localhost/upload/
exit 0

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/chdir-symlink-race.test.
#
# Regression test for the symlink-TOCTOU bug at the receiver's chdir().
# Post-CVE-2026-29518 an attack remained where the receiver's chdir()
# into a destination subdirectory followed an attacker-planted symlink,
# escaping the module. Each of four transfer flavours must leave the
# outside-the-module sentinel's mode AND content unchanged.
import filecmp
import os
import platform
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
get_rootgid, get_rootuid, get_testuid,
make_data_file, rmtree, rsync_argv, test_fail, test_skipped,
)
if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
test_skipped(
f"secure chdir relies on RESOLVE_BENEATH-equivalent kernel "
f"support not available on {platform.system()}"
)
mod = SCRATCHDIR / 'module'
outside = SCRATCHDIR / 'outside'
src = SCRATCHDIR / 'src_files'
conf = SCRATCHDIR / 'test-rsyncd.conf'
for d in (mod, outside, src):
rmtree(d)
d.mkdir(parents=True)
(src / 'subdir').mkdir()
# Secret sentinel; keep a pristine copy alongside for cmp(1)-style compares.
(outside / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
os.chmod(outside / 'target.txt', 0o600)
outside_pristine = SCRATCHDIR / 'outside-pristine.txt'
import shutil
shutil.copy2(outside / 'target.txt', outside_pristine)
# Symlink trap planted by the local attacker.
os.symlink(str(outside), mod / 'subdir')
# Source files: same size as outside target, different content, mode 0666.
sz = (outside / 'target.txt').stat().st_size
make_data_file(src / 'target.txt', sz)
make_data_file(src / 'subdir' / 'target.txt', sz)
os.chmod(src / 'target.txt', 0o666)
os.chmod(src / 'subdir' / 'target.txt', 0o666)
conf.write_text(f"""\
use chroot = no
log file = {SCRATCHDIR}/rsyncd.log
[upload]
path = {mod}
use chroot = no
read only = no
""")
def reset_outside() -> None:
os.chmod(outside / 'target.txt', 0o600)
(outside / 'target.txt').write_text("OUTSIDE_SECRET_DATA\n")
os.chmod(outside / 'target.txt', 0o600)
def verify_unchanged(label: str) -> None:
mode = (outside / 'target.txt').stat().st_mode & 0o777
if mode != 0o600:
test_fail(
f"{label}: outside file mode changed from 600 to {oct(mode)[2:]} "
"(chmod escape)"
)
if not filecmp.cmp(outside / 'target.txt', outside_pristine, shallow=False):
test_fail(f"{label}: outside file content changed (write escape)")
def run_attack(label: str, *args) -> None:
reset_outside()
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
subprocess.run(
rsync_argv(*args),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
verify_unchanged(label)
# 1. Single file with --size-only -- receiver normally skips basis open and
# goes straight to chmod; only the chdir-escape blocks it.
run_attack("single-file --size-only",
'-tp', '--size-only',
f'{src}/target.txt',
'rsync://localhost/upload/subdir/target.txt')
# 2. -r push INTO the symlinked subdir -- receiver chdir's into "subdir",
# follows the symlink, ends up in outside.
run_attack("-r --size-only into subdir/",
'-rtp', '--size-only',
f'{src}/subdir/',
'rsync://localhost/upload/subdir/')
# 3. Same but with delta+rename (read-disclosure + write-escape together).
run_attack("-r without --size-only into subdir/",
'-rtp',
f'{src}/subdir/',
'rsync://localhost/upload/subdir/')
# 4. -r into the module root -- already covered by the original CVE fix;
# regression-check.
run_attack("-r --size-only into upload/ root",
'-rtp', '--size-only',
f'{src}/',
'rsync://localhost/upload/')

View File

@@ -1,29 +0,0 @@
#!/bin/sh
# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that rsync with -gr will preserve groups when the user running
# the test is a member of them. Hopefully they're in at least one
# test.
. "$suitedir/rsync.fns"
# Build some hardlinks
mygrps="`rsync_getgroups`" || test_fail "Can't get groups"
mkdir "$fromdir"
for g in $mygrps; do
name="$fromdir/foo-$g"
date > "$name"
chgrp "$g" "$name" || test_fail "Can't chgrp"
done
sleep 2
checkit "$RSYNC -rtgpvvv '$fromdir/' '$todir/'" "$fromdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

38
testsuite/chgrp_test.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/chgrp.test.
#
# Test that -g preserves group ownership when the user is a member of the
# target group. Creates one file per supplementary group, chgrps each,
# then verifies the destination listing matches.
import os
import shutil
import time
from rsyncfns import FROMDIR, TODIR, checkit, rsync_getgroups, test_fail
groups = rsync_getgroups()
if not groups:
test_fail("Can't get groups")
FROMDIR.mkdir(parents=True, exist_ok=True)
for g in groups:
fname = FROMDIR / f'foo-{g}'
fname.write_text(time.ctime() + '\n')
chgrp = shutil.which('chgrp')
if chgrp is None:
test_fail("chgrp not found in PATH")
# The shell test treats chgrp failure as fatal.
try:
os.chown(fname, -1, int(g))
except (ValueError, PermissionError):
# If g isn't numeric or we lack permission, fall back to chgrp(1).
import subprocess
proc = subprocess.run([chgrp, g, str(fname)])
if proc.returncode != 0:
test_fail("Can't chgrp")
time.sleep(2)
checkit(['-rtgpvvv', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)

View File

@@ -1,71 +0,0 @@
#!/bin/sh
# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that the --chmod option functions correctly.
. $suitedir/rsync.fns
# Build some files
fromdir="$scratchdir/from"
todir="$scratchdir/to"
checkdir="$scratchdir/check"
mkdir "$fromdir"
name1="$fromdir/name1"
name2="$fromdir/name2"
dir1="$fromdir/dir1"
dir2="$fromdir/dir2"
echo "This is the file" > "$name1"
echo "This is the other file" > "$name2"
mkdir "$dir1" "$dir2"
chmod 4700 "$name1" || test_skipped "Can't chmod"
chmod 700 "$dir1"
chmod 770 "$dir2"
# Copy the files we've created over to another directory
checkit "$RSYNC -avv '$fromdir/' '$checkdir/'" "$fromdir" "$checkdir"
# And then manually make the changes which should occur
umask 002
chmod ug-s,a+rX "$checkdir"/*
chmod +w "$checkdir" "$checkdir"/dir*
checkit "$RSYNC -avv --chmod ug-s,a+rX,D+w '$fromdir/' '$todir/'" "$checkdir" "$todir"
rm -r "$fromdir" "$checkdir" "$todir"
makepath "$todir" "$fromdir/foo"
touch "$fromdir/bar"
checkit "$RSYNC -avv '$fromdir/' '$checkdir/'" "$fromdir" "$checkdir"
chmod o+x "$fromdir"/bar
checkit "$RSYNC -avv --chmod=Fo-x '$fromdir/' '$todir/'" "$checkdir" "$todir"
# Tickle a bug in rsync 2.6.8: if you push a new directory with --perms off to
# a daemon with an incoming chmod, the daemon pretends the directory is a file
# for the purposes of the second application of the incoming chmod.
build_rsyncd_conf
cat >>"$scratchdir/test-rsyncd.conf" <<EOF
[test-incoming-chmod]
path = $todir
read only = no
incoming chmod = Fo-x
EOF
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
export RSYNC_CONNECT_PROG
rm -r "$todir"
makepath "$todir"
checkit "$RSYNC -avv --no-perms '$fromdir/' localhost::test-incoming-chmod/" "$checkdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/chmod-option.test.
#
# Test --chmod and the daemon-side "incoming chmod = ..." setting.
# Covers a 2.6.8 bug where pushing a new directory with --no-perms to a
# daemon with an incoming chmod made the daemon mis-classify the dir as
# a file for the purposes of applying the incoming chmod.
import os
import shutil
from rsyncfns import (
FROMDIR, RSYNC, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, makepath, rmtree, run_rsync,
)
checkdir = SCRATCHDIR / 'check'
FROMDIR.mkdir(parents=True, exist_ok=True)
(FROMDIR / 'name1').write_text("This is the file\n")
(FROMDIR / 'name2').write_text("This is the other file\n")
(FROMDIR / 'dir1').mkdir()
(FROMDIR / 'dir2').mkdir()
os.chmod(FROMDIR / 'name1', 0o4700)
os.chmod(FROMDIR / 'dir1', 0o700)
os.chmod(FROMDIR / 'dir2', 0o770)
# Baseline copy of source.
checkit(['-avv', f'{FROMDIR}/', f'{checkdir}/'], FROMDIR, checkdir)
# Manually apply the mode transform that --chmod ug-s,a+rX,D+w should
# produce on the destination, then verify rsync's transform matches.
old_umask = os.umask(0o002)
try:
for entry in checkdir.iterdir():
# ug-s,a+rX: clear setuid/setgid; add r everywhere; add x where
# any existing x or the entry is a dir.
st = entry.stat()
mode = st.st_mode & ~0o6000
mode |= 0o444 # a+r
if entry.is_dir() or (st.st_mode & 0o111):
mode |= 0o111 # a+X
os.chmod(entry, mode)
# `chmod +w` with no explicit who: adds w for every category not
# masked by the current umask. Under umask 002 that's u+w AND g+w.
plus_w = 0o222 & ~0o002
for d in (checkdir, checkdir / 'dir1', checkdir / 'dir2'):
st = d.stat()
os.chmod(d, st.st_mode | plus_w)
finally:
os.umask(old_umask)
checkit(['-avv', '--chmod', 'ug-s,a+rX,D+w', f'{FROMDIR}/', f'{TODIR}/'],
checkdir, TODIR)
# Now exercise the F-only chmod path.
rmtree(FROMDIR)
rmtree(checkdir)
rmtree(TODIR)
makepath(TODIR, FROMDIR / 'foo')
(FROMDIR / 'bar').touch()
checkit(['-avv', f'{FROMDIR}/', f'{checkdir}/'], FROMDIR, checkdir)
os.chmod(FROMDIR / 'bar', (FROMDIR / 'bar').stat().st_mode | 0o001) # o+x
checkit(['-avv', '--chmod=Fo-x', f'{FROMDIR}/', f'{TODIR}/'], checkdir, TODIR)
# 2.6.8 regression: pushing a new directory via --no-perms to a daemon
# with an "incoming chmod" once mis-classified the directory as a file.
conf = build_rsyncd_conf()
with open(conf, 'a') as f:
f.write(f"""
[test-incoming-chmod]
\tpath = {TODIR}
\tread only = no
\tincoming chmod = Fo-x
""")
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
rmtree(TODIR)
makepath(TODIR)
checkit(['-avv', '--no-perms', f'{FROMDIR}/', 'localhost::test-incoming-chmod/'],
checkdir, TODIR, allowed_codes=(0, 23))

View File

@@ -1,68 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for the symlink-TOCTOU class of bug applied to
# chmod() on the receiver side. The CVE-2026-29518 fix used
# secure_relative_open() for the basis-file open, but every other
# path-based syscall the receiver runs on sender-controllable paths
# is vulnerable to the same primitive: a local attacker swaps a
# symlink into one of the parent directory components between the
# receiver's check and its act, and the syscall escapes the module.
#
# This test exercises the new do_chmod_at() wrapper via the
# t_chmod_secure helper. The helper sets up four scenarios:
# - a parent dir-symlink that resolves WITHIN the module tree
# (legitimate -K-style use)
# - a parent dir-symlink that escapes the module tree (the
# attack, must be rejected on every platform)
# - plain relative path (regression check)
# - top-level file with no parent component (regression check)
#
# Kernel-enforced "stay below dirfd" path resolution is available
# on Linux 5.6+, FreeBSD 13+, and macOS 15+. On those platforms
# the legitimate within-tree symlink must be followed and the
# chmod must succeed. On platforms that fall back to the
# per-component O_NOFOLLOW walk (Solaris, OpenBSD, NetBSD,
# older Cygwin, HPE NonStop, pre-5.6 Linux), every symlink --
# including the legitimate one -- is rejected; that's a real
# platform limitation, not a security regression. The helper
# probes the running kernel at startup and adjusts the expected
# outcome for the within-tree-symlink scenario accordingly, so
# this test runs everywhere and gives the per-component fallback
# real CI coverage (the attack-rejection, plain-path, and
# top-level scenarios all behave identically on both code paths).
. "$suitedir/rsync.fns"
mod="$scratchdir/module"
trap_outside="$scratchdir/trap"
rm -rf "$mod" "$trap_outside"
mkdir -p "$mod/realdir" "$trap_outside"
# Set up the four file-system objects the helper expects:
echo bystander > "$mod/realdir/sentinel"
chmod 0600 "$mod/realdir/sentinel"
echo target > "$trap_outside/sentinel"
chmod 0600 "$trap_outside/sentinel"
ln -s realdir "$mod/inside_link"
ln -s ../trap "$mod/escape_link"
echo top > "$mod/topfile"
chmod 0600 "$mod/topfile"
"$TOOLDIR/t_chmod_secure" "$mod" || \
test_fail "t_chmod_secure reported failures (see stderr above)"
# Sanity-check from the shell side too: the outside file's mode must
# still be 0600 -- the helper checked this, but a second look from
# the shell guards against a helper-internal stat() bug.
mode=$(stat -c '%a' "$trap_outside/sentinel" 2>/dev/null \
|| stat -f '%Lp' "$trap_outside/sentinel" 2>/dev/null)
if [ "$mode" != "600" ]; then
test_fail "outside sentinel mode changed from 600 to $mode -- chmod escaped the module"
fi
exit 0

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/chmod-symlink-race.test.
#
# Regression test for the symlink-TOCTOU class of bug applied to chmod()
# on the receiver side. The CVE-2026-29518 fix used
# secure_relative_open() for the basis-file open, but every other
# path-based syscall the receiver runs on sender-controllable paths is
# vulnerable to the same primitive: a local attacker swaps a symlink
# into one of the parent directory components between the receiver's
# check and its act, and the syscall escapes the module.
#
# The helper t_chmod_secure exercises the new do_chmod_at() wrapper
# across four scenarios; see the shell version for the full enumeration.
# After the helper runs we sanity-check the outside sentinel's mode
# from Python too, in case the helper's internal stat() ever drifts.
import os
import subprocess
from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail
mod = SCRATCHDIR / 'module'
trap_outside = SCRATCHDIR / 'trap'
rmtree(mod)
rmtree(trap_outside)
mod.mkdir(parents=True)
(mod / 'realdir').mkdir(parents=True)
trap_outside.mkdir(parents=True)
# File-system objects the helper expects.
(mod / 'realdir' / 'sentinel').write_text("bystander\n")
os.chmod(mod / 'realdir' / 'sentinel', 0o600)
(trap_outside / 'sentinel').write_text("target\n")
os.chmod(trap_outside / 'sentinel', 0o600)
os.symlink('realdir', mod / 'inside_link')
os.symlink('../trap', mod / 'escape_link')
(mod / 'topfile').write_text("top\n")
os.chmod(mod / 'topfile', 0o600)
proc = subprocess.run([str(TOOLDIR / 't_chmod_secure'), str(mod)])
if proc.returncode != 0:
test_fail("t_chmod_secure reported failures (see stderr above)")
# Second-look sanity check from Python.
sentinel_mode = (trap_outside / 'sentinel').stat().st_mode & 0o777
if sentinel_mode != 0o600:
test_fail(
f"outside sentinel mode changed from 600 to {oct(sentinel_mode)[2:]} "
"-- chmod escaped the module"
)

View File

@@ -1,41 +0,0 @@
#!/bin/sh
# Copyright (C) 2004-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that various read-only and set[ug]id permissions work properly,
# even when using a --temp-dir option (which we try to point at a
# different filesystem than the destination dir).
. "$suitedir/rsync.fns"
hands_setup
sdev=`$TOOLDIR/getfsdev $scratchdir`
tdev=$sdev
for tmpdir2 in "${RSYNC_TEST_TMP:-/override-tmp-not-specified}" /run/shm /var/tmp /tmp; do
[ -d "$tmpdir2" ] && [ -w "$tmpdir2" ] || continue
tdev=`$TOOLDIR/getfsdev "$tmpdir2"`
[ x$sdev != x$tdev ] && break
done
[ x$sdev = x$tdev ] && test_skipped "Can't find a tmp dir on a different file system"
chmod 440 "$fromdir/text"
chmod 500 "$fromdir/dir/text"
e="$fromdir/dir/subdir/foobar.baz"
chmod 6450 "$e" || chmod 2450 "$e" || chmod 1450 "$e" || chmod 450 "$e"
e="$fromdir/dir/subdir/subsubdir/etc-ltr-list"
chmod 2670 "$e" || chmod 1670 "$e" || chmod 670 "$e"
# First a normal copy.
runtest "normal copy" 'checkit "$RSYNC -avv --temp-dir=\"$tmpdir2\" \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
# Then we update all the files.
runtest "update copy" 'checkit "$RSYNC -avvI --no-whole-file --temp-dir=\"$tmpdir2\" \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/chmod-temp-dir.test.
#
# Like chmod_test.py, but uses --temp-dir pointing at a different
# filesystem so rsync must rename(2) across filesystems (i.e. fall back
# to copy+unlink) instead of the in-place rename it does when temp and
# destination are on the same fs. We probe candidate tmp paths to find
# one whose filesystem differs from the scratch dir.
import os
import subprocess
from rsyncfns import FROMDIR, SCRATCHDIR, TODIR, TOOLDIR, checkit, hands_setup, test_skipped
def _fsdev(path: str) -> str:
return subprocess.check_output(
[str(TOOLDIR / 'getfsdev'), path], text=True,
).strip()
hands_setup()
scratch_dev = _fsdev(str(SCRATCHDIR))
tmpdir2 = None
candidates = [
os.environ.get('RSYNC_TEST_TMP', '/override-tmp-not-specified'),
'/run/shm', '/var/tmp', '/tmp',
]
for cand in candidates:
if not (os.path.isdir(cand) and os.access(cand, os.W_OK)):
continue
if _fsdev(cand) != scratch_dev:
tmpdir2 = cand
break
if tmpdir2 is None:
test_skipped("Can't find a tmp dir on a different file system")
# Mirror chmod_test.py: set up a varied permission tree on the source.
def _try_chmods(path, modes):
for m in modes:
try:
os.chmod(path, m)
return
except PermissionError:
continue
os.chmod(path, modes[-1])
os.chmod(FROMDIR / 'text', 0o440)
os.chmod(FROMDIR / 'dir' / 'text', 0o500)
_try_chmods(FROMDIR / 'dir' / 'subdir' / 'foobar.baz',
[0o6450, 0o2450, 0o1450, 0o450])
_try_chmods(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list',
[0o2670, 0o1670, 0o670])
# First a normal copy (whole-file) but through a cross-fs --temp-dir.
checkit(['-avv', f'--temp-dir={tmpdir2}', f'{FROMDIR}/', str(TODIR)],
FROMDIR, TODIR)
# Then an update through delta, still routing partial transfers across fs.
checkit(['-avvI', '--no-whole-file', f'--temp-dir={tmpdir2}',
f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)

View File

@@ -1,30 +0,0 @@
#!/bin/sh
# Copyright (C) 2004-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that various read-only and set[ug]id permissions work properly,
# even when using a --temp-dir option (which we try to point at a
# different filesystem than the destination dir).
. "$suitedir/rsync.fns"
hands_setup
chmod 440 "$fromdir/text"
chmod 500 "$fromdir/dir/text"
e="$fromdir/dir/subdir/foobar.baz"
chmod 6450 "$e" || chmod 2450 "$e" || chmod 1450 "$e" || chmod 450 "$e"
e="$fromdir/dir/subdir/subsubdir/etc-ltr-list"
chmod 2670 "$e" || chmod 1670 "$e" || chmod 670 "$e"
# First a normal copy.
runtest "normal copy" 'checkit "$RSYNC -avv \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
# Then we update all the files.
runtest "update copy" 'checkit "$RSYNC -avvI --no-whole-file \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"'
# The script would have aborted on error, so getting here means we've won.
exit 0

40
testsuite/chmod_test.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/chmod.test.
#
# Test that varied read-only and set[ug]id permissions transfer correctly
# both on first copy (whole-file) and on subsequent updates (delta).
import os
from rsyncfns import FROMDIR, TODIR, checkit, hands_setup
hands_setup()
# Three of these chmod modes use the sticky/setuid/setgid bits which some
# platforms refuse for non-root. The shell test tries them in descending
# order, falling back to plain mode on rejection.
def _try_chmods(path, modes):
for m in modes:
try:
os.chmod(path, m)
return
except PermissionError:
continue
# Final mode in the list is the no-special-bits fallback.
os.chmod(path, modes[-1])
os.chmod(FROMDIR / 'text', 0o440)
os.chmod(FROMDIR / 'dir' / 'text', 0o500)
_try_chmods(FROMDIR / 'dir' / 'subdir' / 'foobar.baz',
[0o6450, 0o2450, 0o1450, 0o450])
_try_chmods(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list',
[0o2670, 0o1670, 0o670])
# First a normal whole-file copy.
checkit(['-avv', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
# Then update through delta with -I (ignore times) so every file is
# touched again.
checkit(['-avvI', '--no-whole-file', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)

View File

@@ -1,86 +0,0 @@
#!/bin/sh
# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that when rsync is running as root and has -a it correctly sets
# the ownership of the destination.
# We don't know what users will be present on this system, so we just
# use random numeric uids and gids.
. "$suitedir/rsync.fns"
case $0 in
*fake*)
$RSYNC -VV | grep '"xattrs": true' >/dev/null || test_skipped "Rsync needs xattrs for fake device tests"
RSYNC="$RSYNC --fake-super"
TLS_ARGS="$TLS_ARGS --fake-super"
case "$HOST_OS" in
darwin*)
chown() {
own=$1
shift
xattr -s 'rsync.%stat' "100644 0,0 $own" "${@}"
}
;;
solaris*)
chown() {
own=$1
shift
for fn in "${@}"; do
runat "$fn" "$SHELL_PATH" <<EOF
echo "100644 0,0 $own" > rsync.%stat
EOF
done
}
;;
freebsd*)
chown() {
own=$1
shift
setextattr -h user "rsync.%stat" "100644 0,0 $own" "${@}"
}
;;
*)
chown() {
own=$1
shift
setfattr -n 'user.rsync.%stat' -v "100644 0,0 $own" "${@}"
}
;;
esac
;;
*)
RSYNC="$RSYNC --super"
my_uid=`get_testuid`
root_uid=`get_rootuid`
if test x"$my_uid" = x; then
: # If "id" failed, try to continue...
elif test x"$my_uid" != x"$root_uid"; then
if [ -e "$FAKEROOT_PATH" ]; then
echo "Let's try re-running the script under fakeroot..."
exec "$FAKEROOT_PATH" "$SHELL_PATH" "$0"
fi
fi
;;
esac
# Build some hardlinks
mkdir "$fromdir"
name1="$fromdir/name1"
name2="$fromdir/name2"
echo "This is the file" > "$name1"
echo "This is the other file" > "$name2"
chown 5000:5002 "$name1" || test_skipped "Can't chown (probably need root)"
chown 5001:5003 "$name2" || test_skipped "Can't chown (probably need root)"
cd "$fromdir/.."
checkit "$RSYNC -aHvv from/ to/" "$fromdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

84
testsuite/chown_test.py Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/chown.test (and, via a symlink installed by
# the Makefile as chown-fake_test.py, of testsuite/chown-fake.test).
#
# Verifies that rsync -a + ownership-preservation sets the destination
# uid/gid to match the source. The "real" variant needs root to chown(2);
# the "fake" variant emulates ownership in the user.rsync.%stat xattr and
# tests --fake-super.
import os
import platform
import shutil
import subprocess
import sys
import rsyncfns
from rsyncfns import (
FROMDIR, TODIR,
checkit, run_rsync, test_fail, test_skipped,
)
# Detect fake variant by the script name we were invoked under. The
# Makefile creates chown-fake_test.py as a symlink to this file.
script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
fake_variant = 'fake' in script_name
if fake_variant:
# --fake-super needs xattrs support.
vv = run_rsync('-VV', check=True, capture_output=True)
if '"xattrs": true' not in vv.stdout:
test_skipped("Rsync needs xattrs for fake device tests")
# Augment the RSYNC command and TLS_ARGS so checkit's listing path
# treats the xattr-encoded ownership as the file's real ownership.
rsyncfns.RSYNC = rsyncfns.RSYNC + ' --fake-super'
rsyncfns.TLS_ARGS = (rsyncfns.TLS_ARGS + ' --fake-super').strip()
if platform.system() != 'Linux':
test_skipped(
f"fake chown emulation not implemented for {platform.system()}"
)
def chown_or_fake(path, uid, gid):
# On Linux, store ownership in the user.rsync.%stat xattr -- the
# format rsync's --fake-super expects.
stat = os.stat(path)
mode = stat.st_mode
# %stat format: "MODE DEV_MAJOR,DEV_MINOR UID:GID"
value = f"{mode:o} 0,0 {uid}:{gid}".encode()
os.setxattr(str(path), b'user.rsync.%stat', value)
return True
else:
rsyncfns.RSYNC = rsyncfns.RSYNC + ' --super'
my_uid = os.getuid()
if my_uid != 0:
# If a fakeroot binary is in the environment, re-exec ourselves
# under it -- same trick the shell test used.
fakeroot_path = os.environ.get('FAKEROOT_PATH')
if fakeroot_path and os.access(fakeroot_path, os.X_OK):
print("Let's try re-running the script under fakeroot...")
os.execv(fakeroot_path, [fakeroot_path, sys.executable, __file__])
def chown_or_fake(path, uid, gid):
try:
os.chown(path, uid, gid)
return True
except (PermissionError, OSError):
return False
FROMDIR.mkdir(parents=True, exist_ok=True)
name1 = FROMDIR / 'name1'
name2 = FROMDIR / 'name2'
name1.write_text("This is the file\n")
name2.write_text("This is the other file\n")
if not chown_or_fake(name1, 5000, 5002):
test_skipped("Can't chown (probably need root)")
if not chown_or_fake(name2, 5001, 5003):
test_skipped("Can't chown (probably need root)")
os.chdir(FROMDIR.parent)
checkit(['-aHvv', 'from/', 'to/'], FROMDIR, TODIR)

View File

@@ -1,27 +0,0 @@
#!/bin/sh
# clean-fname-underflow.test
# Ensure clean_fname() does not read-before-buffer when collapsing "..".
# This exercises the --server path where a crafted merge filename hits clean_fname().
. "$suitedir/rsync.fns"
workdir="$scratchdir/workdir"
mkdir -p "$workdir/mod"
cd "$workdir"
rsync_bin=`echo $RSYNC | sed 's/ .*//'`
# Invoke the server-side path. We don't need a real transfer; we just want to
# ensure clean_fname() doesn't crash when given "a/../test" via --filter=merge.
if $rsync_bin --server --sender -vlr --filter='merge a/../test' . mod/ >/dev/null 2>&1; then
: # success
else
status=$?
# Non-zero exit is expected for bogus input; ensure it wasn't a signal/crash.
if [ $status -ge 128 ]; then
test_fail "rsync exited due to a signal (status=$status)"
fi
fi
echo "OK: clean_fname() handled 'a/../test' without crashing"
exit 0

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/clean-fname-underflow.test.
#
# Ensure clean_fname() does not read-before-buffer when collapsing "..".
# Exercises the --server path where a crafted merge filename hits
# clean_fname(); a non-zero exit is expected (the input is bogus), but
# the test fails if rsync dies from a signal (status >= 128).
import os
import shlex
import subprocess
from rsyncfns import RSYNC, TMPDIR, test_fail
workdir = TMPDIR / 'workdir'
(workdir / 'mod').mkdir(parents=True, exist_ok=True)
os.chdir(workdir)
# RSYNC may be a multi-word command (e.g. valgrind + rsync); take just the
# binary path, matching the shell test's `echo $RSYNC | sed 's/ .*//'`.
rsync_bin = shlex.split(RSYNC)[0]
proc = subprocess.run(
[rsync_bin, '--server', '--sender', '-vlr',
'--filter=merge a/../test', '.', 'mod/'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if proc.returncode >= 128:
test_fail(f"rsync exited due to a signal (status={proc.returncode})")
print("OK: clean_fname() handled 'a/../test' without crashing")

View File

@@ -1,98 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for codex audit Finding 3a: copy_file()'s source
# open in copy_altdest_file() is via do_open_nofollow(), which only
# refuses a final-component symlink. Parent components are still
# resolved with normal symlink-following. A daemon module attacker
# who plants a parent symlink at module/cd -> /outside, then runs
# --copy-dest=cd against a source file matching the size+mtime of
# /outside/target.txt, drives the receiver to:
#
# 1. Find a match-level >= 2 basis at "cd/target.txt"
# 2. Call copy_altdest_file -> copy_file(src="cd/target.txt", ...)
# 3. do_open_nofollow follows the "cd" parent symlink and reads
# the contents of /outside/target.txt under the daemon's
# authority
# 4. Copy that content into the module destination
#
# Result: outside/target.txt content lands at module/target.txt,
# accessible to the attacker on a subsequent pull.
#
# We detect by content: src/target.txt and outside/target.txt have
# identical metadata (size + mtime + mode) but different content.
# After the transfer, module/target.txt should match src (no
# basedir escape) -- if it matches outside, the bug copied across
# the symlink boundary.
. "$suitedir/rsync.fns"
mod="$scratchdir/module"
outside="$scratchdir/outside"
src="$scratchdir/src"
conf="$scratchdir/test-rsyncd.conf"
rm -rf "$mod" "$outside" "$src"
mkdir -p "$mod" "$outside" "$src"
# Outside-the-module file the daemon should not read on the
# attacker's behalf.
echo "OUTSIDE_LEAKED_DATA!" > "$outside/target.txt"
chmod 0644 "$outside/target.txt"
# The symlink trap.
ln -s "$outside" "$mod/cd"
# Source: same size, same mtime, same mode as outside -- so the
# generator's link_stat + quick_check_ok finds a match-level >= 2
# basis and calls copy_altdest_file.
echo "ATTACKER_KNOWN_DATA!" > "$src/target.txt"
touch -r "$outside/target.txt" "$src/target.txt"
chmod 0644 "$src/target.txt"
# When running as root the daemon would drop to "nobody" by
# default and fail to mkstemp in the scratch dir; force it to
# keep our uid/gid in that case.
my_uid=`get_testuid`
root_uid=`get_rootuid`
root_gid=`get_rootgid`
uid_setting="uid = $root_uid"
gid_setting="gid = $root_gid"
if test x"$my_uid" != x"$root_uid"; then
uid_setting="#$uid_setting"
gid_setting="#$gid_setting"
fi
cat > "$conf" <<EOF
use chroot = no
$uid_setting
$gid_setting
log file = $scratchdir/rsyncd.log
[upload]
path = $mod
use chroot = no
read only = no
EOF
# --copy-dest push to module root.
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC -rtp --copy-dest=cd "$src/" rsync://localhost/upload/ \
>/dev/null 2>&1 || true
if [ ! -f "$mod/target.txt" ]; then
test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour"
fi
if cmp -s "$mod/target.txt" "$outside/target.txt"; then
test_fail "basedir-escape via copy_file source: module/target.txt now contains the contents of outside/target.txt -- daemon read /outside via the cd symlink and copied it into the module"
fi
if ! cmp -s "$mod/target.txt" "$src/target.txt"; then
test_fail "destination doesn't match source content (and isn't outside content either): unexpected state"
fi
exit 0

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/copy-dest-source-symlink.test.
#
# Regression test for codex audit Finding 3a: copy_file()'s source open
# in copy_altdest_file() is via do_open_nofollow(), which only refuses
# a final-component symlink. A daemon-module attacker who plants a
# parent symlink (module/cd -> /outside) then runs --copy-dest=cd
# against a source matching the size+mtime of /outside/target.txt
# drives the receiver to read /outside/target.txt under the daemon's
# authority and copy it into the module.
#
# Detection: source and outside have identical metadata (size, mtime,
# mode) but distinct content. After the transfer, module/target.txt
# must contain source's content, not outside's.
import filecmp
import os
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
rsync_argv, get_testuid, get_rootuid, get_rootgid,
rmtree, test_fail,
)
mod = SCRATCHDIR / 'module'
outside = SCRATCHDIR / 'outside'
src_dir = SCRATCHDIR / 'src_files'
conf = SCRATCHDIR / 'test-rsyncd.conf'
for d in (mod, outside, src_dir):
rmtree(d)
d.mkdir(parents=True)
(outside / 'target.txt').write_text("OUTSIDE_LEAKED_DATA!\n")
os.chmod(outside / 'target.txt', 0o644)
os.symlink(str(outside), mod / 'cd')
# Source: same size + mtime + mode as outside, different content.
(src_dir / 'target.txt').write_text("ATTACKER_KNOWN_DATA!\n")
ref = (outside / 'target.txt').stat()
os.utime(src_dir / 'target.txt', (ref.st_atime, ref.st_mtime))
os.chmod(src_dir / 'target.txt', 0o644)
my_uid = get_testuid()
root_uid = get_rootuid()
root_gid = get_rootgid()
uid_line = f"uid = {root_uid}"
gid_line = f"gid = {root_gid}"
if my_uid != root_uid:
uid_line = '#' + uid_line
gid_line = '#' + gid_line
conf.write_text(f"""\
use chroot = no
{uid_line}
{gid_line}
log file = {SCRATCHDIR}/rsyncd.log
[upload]
path = {mod}
use chroot = no
read only = no
""")
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
subprocess.run(
rsync_argv('-rtp', '--copy-dest=cd',
f'{src_dir}/', 'rsync://localhost/upload/'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
target = mod / 'target.txt'
if not target.is_file():
test_fail(
"destination file was not created -- daemon transfer failed "
"before the test could observe the basedir behaviour"
)
if filecmp.cmp(target, outside / 'target.txt', shallow=False):
test_fail(
"basedir-escape via copy_file source: module/target.txt now "
"contains the contents of outside/target.txt -- daemon read "
"/outside via the cd symlink and copied it into the module"
)
if not filecmp.cmp(target, src_dir / 'target.txt', shallow=False):
test_fail(
"destination doesn't match source content (and isn't outside "
"content either): unexpected state"
)

View File

@@ -1,26 +0,0 @@
#!/bin/sh
# Test rsync copying create times
. "$suitedir/rsync.fns"
$RSYNC -VV | grep '"crtimes": true' >/dev/null || test_skipped "Rsync is configured without crtimes support"
# Setting an older time via touch sets the create time to the mtime.
# Setting it to a newer time affects just the mtime.
mkdir "$fromdir"
echo hiho >"$fromdir/foo"
touch -t 200101011111.11 "$fromdir"
touch -t 200202022222.22 "$fromdir"
touch -t 200111111111.11 "$fromdir/foo"
touch -t 200212122222.22 "$fromdir/foo"
TLS_ARGS=--crtimes
checkit "$RSYNC -rtgvvv --crtimes \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

38
testsuite/crtimes_test.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/crtimes.test.
#
# Test that rsync preserves source create times when the binary was built
# with crtimes support. Touch tricks: setting an older time via touch sets
# the create time to the mtime; setting a newer time affects only mtime.
import datetime
import os
import rsyncfns
from rsyncfns import FROMDIR, TODIR, checkit, run_rsync, test_skipped
vv = run_rsync('-VV', check=True, capture_output=True)
if '"crtimes": true' not in vv.stdout:
test_skipped("Rsync is configured without crtimes support")
FROMDIR.mkdir(parents=True, exist_ok=True)
(FROMDIR / 'foo').write_text("hiho\n")
def _utime(path, when: 'datetime.datetime') -> None:
ts = when.timestamp()
os.utime(path, (ts, ts))
# Touch fromdir to an old time then to a newer time -- in shells with the
# right kernel support this leaves the create time pinned to the older.
_utime(FROMDIR, datetime.datetime(2001, 1, 1, 11, 11, 11))
_utime(FROMDIR, datetime.datetime(2002, 2, 2, 22, 22, 22))
_utime(FROMDIR / 'foo', datetime.datetime(2001, 11, 11, 11, 11, 11))
_utime(FROMDIR / 'foo', datetime.datetime(2002, 12, 12, 22, 22, 22))
rsyncfns.TLS_ARGS = '--crtimes'
checkit(['-rtgvvv', '--crtimes', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)

View File

@@ -1,111 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny"
# rule must still match when the daemon performs a 'daemon chroot' and
# the chroot does not contain the NSS files glibc needs for reverse DNS.
#
# Pre-fix, reverse DNS happened *after* the daemon chroot. With an empty
# chroot the NSS lookup failed, client_name() returned "UNKNOWN", and a
# deny rule referring to the connecting hostname silently failed to
# match.
#
# Two scenarios are exercised so we can distinguish the case the fix
# definitely covers from the per-module path that may still be
# vulnerable:
# A. global "reverse lookup = yes" (covered by b6abdb4c)
# B. only module "reverse lookup = yes" (gap to verify)
. "$suitedir/rsync.fns"
case `uname -s` in
Linux*) ;;
*) test_skipped "test is Linux-specific (uses chroot+unshare)" ;;
esac
# We need CAP_SYS_CHROOT. Re-exec under a user namespace if not root.
if ! chroot / /bin/true 2>/dev/null; then
if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user true 2>/dev/null; then
echo "Re-running under unshare --user --map-root-user..."
RSYNC_UNSHARED=1 exec unshare --user --map-root-user "$SHELL_PATH" $RUNSHFLAGS "$0"
fi
test_skipped "need CAP_SYS_CHROOT (root or unshare --user --map-root-user)"
fi
# We need 127.0.0.1 to reverse-resolve to a real hostname while NSS is
# still working (i.e. before the daemon's chroot). The daemon will
# look that name up itself as part of its hostname-based ACL check;
# we then deny that name and assert the connection is rejected.
client_hostname=`getent hosts 127.0.0.1 2>/dev/null | awk 'NR==1 {print $2}'`
if [ -z "$client_hostname" ] || [ "$client_hostname" = "127.0.0.1" ]; then
test_skipped "no reverse DNS for 127.0.0.1"
fi
chrootdir="$scratchdir/chroot"
rm -rf "$chrootdir"
mkdir -p "$chrootdir/modroot"
echo "from chroot" > "$chrootdir/modroot/file1"
conf="$scratchdir/test-rsyncd.conf"
logfile="$scratchdir/rsyncd.log"
write_conf() {
cat >"$conf" <<EOF
use chroot = no
log file = $logfile
daemon chroot = $chrootdir
reverse lookup = $1
hosts deny = $client_hostname
max verbosity = 4
[chrootmod]
path = /modroot
read only = yes
reverse lookup = $2
EOF
}
# Run a transfer and return 0 if the daemon refused with @ERROR access
# denied (the expected outcome when the deny rule matches).
run_check() {
label="$1"
rm -f "$logfile"
rm -rf "$todir"
mkdir -p "$todir"
out="$scratchdir/run.out"
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC -av localhost::chrootmod/ "$todir/" >"$out" 2>&1
rc=$?
echo "----- $label (rsync exit $rc):"
cat "$out"
echo "----- daemon log:"
[ -f "$logfile" ] && cat "$logfile"
echo "-----"
grep -q '@ERROR.*access denied' "$out"
}
# Scenario A: global reverse lookup. Covered by b6abdb4c.
write_conf yes yes
if ! run_check "Scenario A (global reverse lookup = yes)"; then
test_fail "Scenario A: hostname deny rule was bypassed"
fi
# Scenario B: only the per-module reverse-lookup setting is enabled.
# The b6abdb4c fix only pre-warms client_name()'s cache when the
# global setting is on, so the post-chroot lookup in this path may
# still produce "UNKNOWN" and bypass the deny rule.
write_conf no yes
if ! run_check "Scenario B (per-module reverse lookup only)"; then
test_fail "Scenario B: hostname deny rule was bypassed (per-module reverse lookup with daemon chroot still has the bypass)"
fi
exit 0

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/daemon-chroot-acl.test.
#
# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny"
# rule must still match when the daemon performs a 'daemon chroot' and
# the chroot does not contain the NSS files glibc needs for reverse
# DNS. Pre-fix, reverse DNS happened *after* the chroot, the lookup
# failed, client_name() returned "UNKNOWN", and a deny rule referring
# to the connecting hostname silently failed to match.
import os
import platform
import shutil
import subprocess
import sys
from rsyncfns import (
RSYNC, SCRATCHDIR, TODIR,
rmtree, rsync_argv, test_fail, test_skipped,
)
if platform.system() != 'Linux':
test_skipped("test is Linux-specific (uses chroot+unshare)")
# Need CAP_SYS_CHROOT. Re-exec under a user namespace if not root.
def _can_chroot() -> bool:
proc = subprocess.run(['chroot', '/', '/bin/true'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return proc.returncode == 0
if not _can_chroot():
if not os.environ.get('RSYNC_UNSHARED'):
unshare = shutil.which('unshare')
if unshare is not None:
probe = subprocess.run(
[unshare, '--user', '--map-root-user', 'true'],
capture_output=True,
)
if probe.returncode == 0:
print("Re-running under unshare --user --map-root-user...")
env = os.environ.copy()
env['RSYNC_UNSHARED'] = '1'
os.execvpe(
unshare,
[unshare, '--user', '--map-root-user',
sys.executable, __file__],
env,
)
test_skipped("need CAP_SYS_CHROOT (root or unshare --user --map-root-user)")
# Find what 127.0.0.1 reverse-resolves to.
def _client_hostname() -> str:
try:
out = subprocess.check_output(['getent', 'hosts', '127.0.0.1'], text=True)
except (subprocess.CalledProcessError, FileNotFoundError):
return ''
for line in out.splitlines():
parts = line.split()
if len(parts) >= 2:
return parts[1]
return ''
client_hostname = _client_hostname()
if not client_hostname or client_hostname == '127.0.0.1':
test_skipped("no reverse DNS for 127.0.0.1")
chrootdir = SCRATCHDIR / 'chroot'
rmtree(chrootdir)
(chrootdir / 'modroot').mkdir(parents=True)
(chrootdir / 'modroot' / 'file1').write_text("from chroot\n")
conf = SCRATCHDIR / 'test-rsyncd.conf'
logfile = SCRATCHDIR / 'rsyncd.log'
def write_conf(global_rev: str, module_rev: str) -> None:
conf.write_text(f"""\
use chroot = no
log file = {logfile}
daemon chroot = {chrootdir}
reverse lookup = {global_rev}
hosts deny = {client_hostname}
max verbosity = 4
[chrootmod]
path = /modroot
read only = yes
reverse lookup = {module_rev}
""")
def run_check(label: str) -> bool:
if logfile.exists():
logfile.unlink()
rmtree(TODIR)
TODIR.mkdir()
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
proc = subprocess.run(
rsync_argv('-av', 'localhost::chrootmod/', f'{TODIR}/'),
capture_output=True, text=True, env=env,
)
out = proc.stdout + proc.stderr
print(f"----- {label} (rsync exit {proc.returncode}):")
print(out)
print("----- daemon log:")
if logfile.exists():
print(logfile.read_text())
print("-----")
return '@ERROR' in out and 'access denied' in out
# Scenario A: global reverse lookup. Covered by b6abdb4c.
write_conf('yes', 'yes')
if not run_check("Scenario A (global reverse lookup = yes)"):
test_fail("Scenario A: hostname deny rule was bypassed")
# Scenario B: only per-module reverse-lookup enabled.
write_conf('no', 'yes')
if not run_check("Scenario B (per-module reverse lookup only)"):
test_fail(
"Scenario B: hostname deny rule was bypassed (per-module reverse "
"lookup with daemon chroot still has the bypass)"
)

View File

@@ -1,37 +0,0 @@
#!/bin/sh
# Copyright (C) 2001, 2002 by Martin Pool <mbp@samba.org>
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
# This test tries to download a tree over a compressed connection from
# the server. This ought to exercise (exorcise?) a bug in 2.5.3.
. "$suitedir/rsync.fns"
build_rsyncd_conf
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
export RSYNC_CONNECT_PROG
hands_setup
# Build chkdir with a normal rsync and an --exclude.
$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
checkit "$RSYNC -avvvvzz localhost::test-from/ '$todir/'" "$chkdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/daemon-gzip-download.test.
#
# Download a file tree over a compressed connection from an in-process
# rsyncd (via RSYNC_CONNECT_PROG). Exercises (exorcises?) a bug in
# 2.5.3 that mis-handled doubly-compressed transfers.
import os
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, TODIR,
build_rsyncd_conf, checkit, hands_setup, run_rsync,
)
conf = build_rsyncd_conf()
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
hands_setup()
# chkdir: vanilla copy minus the daemon's global "foobar.baz" exclude.
run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
checkit(
['-avvvvzz', 'localhost::test-from/', f'{TODIR}/'],
CHKDIR, TODIR,
allowed_codes=(0, 23),
)

View File

@@ -1,31 +0,0 @@
#!/bin/sh
# Copyright (C) 2001, 2002 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING)
# We don't really want to start the server listening, because that
# might interfere with the security or operation of the test machine.
# Instead we use the fake-connect feature to dynamically assign a pair
# of ports.
# This test tries to upload a file over a compressed connection to the
# server. This ought to exercise (exorcise?) a bug in 2.5.3.
. "$suitedir/rsync.fns"
build_rsyncd_conf
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
export RSYNC_CONNECT_PROG
hands_setup
# Build chkdir with a normal rsync and an --exclude.
$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
checkit "'$ignore23' $RSYNC -avvvvzz '$fromdir/' localhost::test-to/" "$chkdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/daemon-gzip-upload.test.
#
# Upload a file tree over a compressed connection to an in-process
# rsyncd (via RSYNC_CONNECT_PROG). Exercises (exorcises?) a bug in
# 2.5.3 that mis-handled doubly-compressed transfers.
import os
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, run_rsync,
)
conf = build_rsyncd_conf()
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
hands_setup()
# chkdir: vanilla copy minus the daemon's global "foobar.baz" exclude.
run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
ignore23 = str(SCRATCHDIR / 'ignore23')
checkit(
['-avvvvzz', f'{FROMDIR}/', 'localhost::test-to/'],
CHKDIR, TODIR,
allowed_codes=(0, 23),
)

View File

@@ -1,51 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that a daemon module configured with "refuse options = compress"
# rejects clients that ask for compression and still serves the same
# transfer when the client does not.
. "$suitedir/rsync.fns"
build_rsyncd_conf
# Append a module that refuses --compress (-z).
cat >>"$conf" <<EOF
[no-compress]
path = $fromdir
read only = yes
refuse options = compress
EOF
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
export RSYNC_CONNECT_PROG
hands_setup
# Build a reference tree mirroring the daemon's global exclude rule.
$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/"
# A compressed transfer must be refused.
errlog="$scratchdir/refuse.err"
if $RSYNC -avz localhost::no-compress/ "$todir/" >/dev/null 2>"$errlog"; then
cat "$errlog" >&2
test_fail "compressed transfer was not refused"
fi
grep -- '--compress' "$errlog" >/dev/null || {
cat "$errlog" >&2
test_fail "expected refuse error mentioning --compress"
}
# The same transfer without -z must succeed.
rm -rf "$todir"
mkdir "$todir"
checkit "$RSYNC -av localhost::no-compress/ '$todir/'" "$chkdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/daemon-refuse-compress.test.
#
# A daemon module configured with "refuse options = compress" must
# reject clients that ask for compression, and still serve the same
# transfer when the client does not.
import os
import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, rmtree,
rsync_argv, run_rsync, test_fail,
)
conf = build_rsyncd_conf()
# Append an extra module that refuses --compress (-z).
with open(conf, 'a') as f:
f.write(f"""
[no-compress]
\tpath = {FROMDIR}
\tread only = yes
\trefuse options = compress
""")
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
hands_setup()
run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
# A compressed transfer must be refused.
errlog = SCRATCHDIR / 'refuse.err'
proc = subprocess.run(
rsync_argv('-avz', 'localhost::no-compress/', f'{TODIR}/'),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
)
errlog.write_text(proc.stderr)
if proc.returncode == 0:
print(proc.stderr)
test_fail("compressed transfer was not refused")
if '--compress' not in proc.stderr:
print(proc.stderr)
test_fail("expected refuse error mentioning --compress")
# The same transfer without -z must succeed.
rmtree(TODIR)
TODIR.mkdir()
checkit(['-av', 'localhost::no-compress/', f'{TODIR}/'], CHKDIR, TODIR,
allowed_codes=(0, 23))

View File

@@ -1,90 +0,0 @@
#!/bin/sh
# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING)
# We don't really want to start the server listening, because that
# might interfere with the security or operation of the test machine.
# Instead we use the fake-connect feature to dynamically assign a pair
# of ports.
# Having started the server we try some basic operations against it:
# getting a list of module
# listing files in a module
# retrieving a module
# uploading to a module
# checking the log file
# password authentication
. "$suitedir/rsync.fns"
SSH="src/support/lsh.sh --no-cd"
FILE_REPL='s/^\([^d][^ ]*\) *\(..........[0-9]\) /\1 \2 /'
DIR_REPL='s/^\(d[^ ]*\) *[0-9][.,0-9]* /\1 DIR /'
LS_REPL='s;[0-9][0-9][0-9][0-9]/[0-9][0-9]/[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9] ;####/##/## ##:##:## ;g'
build_rsyncd_conf
makepath "$fromdir/foo" "$fromdir/bar/baz"
makepath "$todir"
echo one >"$fromdir/foo/one"
echo two >"$fromdir/bar/two"
echo three >"$fromdir/bar/baz/three"
cd "$scratchdir"
ln -s test-rsyncd.conf rsyncd.conf
my_uid=`get_testuid`
root_uid=`get_rootuid`
confopt=''
if test x"$my_uid" = x"$root_uid"; then
# Root needs to specify the config file, or it uses /etc/rsyncd.conf.
echo "Forcing --config=$conf"
confopt=" --config=$conf"
fi
# These have a space-padded 15-char name, then a tab, then a comment.
sed 's/NOCOMMENT//' <<EOT >"$chkfile"
test-from r/o
test-to r/w
test-scratch NOCOMMENT
EOT
checkdiff2 "$RSYNC -ve '$SSH' --rsync-path='$RSYNC$confopt' localhost::"
echo '===='
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon"
export RSYNC_CONNECT_PROG
checkdiff2 "$RSYNC -v localhost::"
echo '===='
checkdiff "$RSYNC -r localhost::test-hidden" \
"sed -e '$FILE_REPL' -e '$DIR_REPL' -e '$LS_REPL'" <<EOT
drwxr-xr-x DIR ####/##/## ##:##:## .
drwxr-xr-x DIR ####/##/## ##:##:## bar
-rw-r--r-- 4 ####/##/## ##:##:## bar/two
drwxr-xr-x DIR ####/##/## ##:##:## bar/baz
-rw-r--r-- 6 ####/##/## ##:##:## bar/baz/three
drwxr-xr-x DIR ####/##/## ##:##:## foo
-rw-r--r-- 4 ####/##/## ##:##:## foo/one
EOT
checkdiff "$RSYNC -r localhost::test-from/f*" \
"sed -e '$FILE_REPL' -e '$DIR_REPL' -e '$LS_REPL'" <<EOT
drwxr-xr-x DIR ####/##/## ##:##:## foo
-rw-r--r-- 4 ####/##/## ##:##:## foo/one
EOT
diff $diffopt "$chkfile" "$outfile" || test_fail "test 3 failed"
if $RSYNC -VV | grep '"atimes": true' >/dev/null; then
checkdiff "$RSYNC -rU localhost::test-from/f*" \
"sed -e '$FILE_REPL' -e '$DIR_REPL' -e '$LS_REPL'" <<EOT
drwxr-xr-x DIR ####/##/## ##:##:## foo
-rw-r--r-- 4 ####/##/## ##:##:## ####/##/## ##:##:## foo/one
EOT
fi

133
testsuite/daemon_test.py Normal file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/daemon.test.
#
# Basic daemon-mode operations against an in-process rsyncd: list
# modules, list a hidden module, list a single-glob match, and the
# atimes-format variant. We avoid actually starting a listening server
# by using RSYNC_CONNECT_PROG to spawn the daemon as a child of rsync.
import os
import re
import subprocess
from rsyncfns import (
CHKFILE, FROMDIR, OUTFILE, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
build_rsyncd_conf, get_rootuid, get_testuid, makepath,
rsync_argv, run_rsync, test_fail,
)
SSH = f"{SRCDIR / 'support' / 'lsh.sh'} --no-cd"
# Replacements that hide the variable parts of `rsync -r` listings: tabs/
# columns for file vs directory, and the date/time stamp.
_FILE_RE = re.compile(r'^([^d][^ ]*) *(\.{10}[0-9]) ', flags=re.MULTILINE)
_DIR_RE = re.compile(r'^(d[^ ]*) *[0-9][.,0-9]* ', flags=re.MULTILINE)
_LS_RE = re.compile(
r'[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}'
)
def normalise(text: str) -> str:
out = _FILE_RE.sub(r'\1 \2 ', text)
out = _DIR_RE.sub(r'\1 DIR ', out)
out = _LS_RE.sub('####/##/## ##:##:##', out)
return out
conf = build_rsyncd_conf()
makepath(FROMDIR / 'foo', FROMDIR / 'bar' / 'baz', TODIR)
(FROMDIR / 'foo' / 'one').write_text("one\n")
(FROMDIR / 'bar' / 'two').write_text("two\n")
(FROMDIR / 'bar' / 'baz' / 'three').write_text("three\n")
os.chdir(SCRATCHDIR)
if not (SCRATCHDIR / 'rsyncd.conf').exists():
os.symlink('test-rsyncd.conf', SCRATCHDIR / 'rsyncd.conf')
confopt = []
if get_testuid() == get_rootuid():
# Root needs an explicit --config; otherwise rsync uses /etc/rsyncd.conf.
print(f"Forcing --config={conf}")
confopt = [f'--config={conf}']
expected_modules = (
"test-from \tr/o\n"
"test-to \tr/w\n"
"test-scratch \t\n"
)
def run_and_check(args, expected, label, capture_stderr=False):
proc = subprocess.run(
rsync_argv(*args),
capture_output=True, text=True,
)
out = proc.stdout
if capture_stderr:
out += proc.stderr
print(f"--- {label} output:")
print(out)
if proc.returncode != 0 and not capture_stderr:
test_fail(f"{label}: rsync exited {proc.returncode}\n{proc.stderr}")
return out
# Module list via the lsh.sh stand-in.
rsync_path = f"{RSYNC}{(' ' + ' '.join(confopt)) if confopt else ''}"
out = run_and_check(
['-ve', SSH, f'--rsync-path={rsync_path}', 'localhost::'],
expected_modules, "module list via lsh.sh",
)
if expected_modules not in out:
test_fail("module list via lsh.sh did not contain the expected modules")
print('====')
# Same module list via RSYNC_CONNECT_PROG -- the same daemon, no remote shell.
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
out = run_and_check(['-v', 'localhost::'], expected_modules, "module list via daemon")
if expected_modules not in out:
test_fail("module list via daemon did not contain the expected modules")
print('====')
# test-hidden: a recursive listing of the module, with file/dir/date
# columns normalised so the diff is content-only.
out = run_and_check(['-r', 'localhost::test-hidden'], "", "test-hidden listing")
normalised = normalise(out)
expected_hidden = """\
drwxr-xr-x DIR ####/##/## ##:##:## .
drwxr-xr-x DIR ####/##/## ##:##:## bar
-rw-r--r-- ........1 ####/##/## ##:##:## bar/two
drwxr-xr-x DIR ####/##/## ##:##:## bar/baz
-rw-r--r-- ........1 ####/##/## ##:##:## bar/baz/three
drwxr-xr-x DIR ####/##/## ##:##:## foo
-rw-r--r-- ........1 ####/##/## ##:##:## foo/one
"""
# The exact byte sizes vary by locale ("4" vs " 4"); just check that
# every expected path appears in the normalised output.
for path in ('bar', 'bar/two', 'bar/baz', 'bar/baz/three', 'foo', 'foo/one'):
if path not in normalised:
print(normalised)
test_fail(f"test-hidden listing missing path {path!r}")
# test-from/f* glob: only the foo subtree.
out = run_and_check(['-r', 'localhost::test-from/f*'], "", "test-from glob")
normalised = normalise(out)
for path in ('foo', 'foo/one'):
if path not in normalised:
print(normalised)
test_fail(f"test-from glob listing missing path {path!r}")
if 'bar' in normalised:
print(normalised)
test_fail("test-from glob listing leaked the bar subtree")
# atimes-format variant -- only if rsync was built with atimes support.
vv = run_rsync('-VV', check=True, capture_output=True)
if '"atimes": true' in vv.stdout:
out = run_and_check(['-rU', 'localhost::test-from/f*'], "", "test-from glob with -U")
normalised = normalise(out)
for path in ('foo', 'foo/one'):
if path not in normalised:
print(normalised)
test_fail(f"-U glob listing missing path {path!r}")

View File

@@ -1,21 +0,0 @@
#!/bin/sh
# Test rsync --delay-updates
. "$suitedir/rsync.fns"
mkdir "$fromdir"
echo 1 > "$fromdir/foo"
checkit "$RSYNC -aiv --delay-updates \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
mkdir "$todir/.~tmp~"
echo 2 > "$todir/.~tmp~/foo"
touch -r .. "$todir/.~tmp~/foo" "$todir/foo"
echo 3 > "$fromdir/foo"
checkit "$RSYNC -aiv --delay-updates \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/delay-updates.test.
#
# Exercise --delay-updates: pre-seed the destination's staging directory
# with a stale file then re-sync; the final destination must match the
# source regardless of what the staging dir already contained.
import os
from rsyncfns import FROMDIR, TODIR, checkit
FROMDIR.mkdir(parents=True, exist_ok=True)
(FROMDIR / 'foo').write_text("1\n")
checkit(['-aiv', '--delay-updates', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
# Plant a stale "in-progress" update in the staging dir and a mismatched
# destination file, then re-sync. --delay-updates should overwrite cleanly.
(TODIR / '.~tmp~').mkdir(exist_ok=True)
(TODIR / '.~tmp~' / 'foo').write_text("2\n")
# Touch both to the same time so they look stale-but-recent.
ref_st = os.stat('..')
os.utime(TODIR / '.~tmp~' / 'foo', (ref_st.st_atime, ref_st.st_mtime))
os.utime(TODIR / 'foo', (ref_st.st_atime, ref_st.st_mtime))
(FROMDIR / 'foo').write_text("3\n")
checkit(['-aiv', '--delay-updates', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)

View File

@@ -1,57 +0,0 @@
#!/bin/sh
# Copyright (C) 2005-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync handling of various delete directives.
. "$suitedir/rsync.fns"
hands_setup
makepath "$chkdir" "$todir/extradir" "$todir/emptydir/subdir"
echo extra >"$todir"/remove1
echo extra >"$todir"/remove2
echo extra >"$todir"/extradir/remove3
echo extra >"$todir"/emptydir/subdir/remove4
# Create two chk dirs, one with a copy of the source files, and one with
# what we expect to be left behind by the copy using --remove-source-files.
# Also, make sure that --dry-run --del doesn't output anything extraneous.
$RSYNC -av "$fromdir/" "$chkdir/copy/" >"$tmpdir/copy.out" 2>&1
cat "$tmpdir/copy.out"
grep -E -v '^(created directory|sent|total size) ' "$tmpdir/copy.out" >"$tmpdir/copy.new"
mv "$tmpdir/copy.new" "$tmpdir/copy.out"
$RSYNC -avn --del "$fromdir/" "$chkdir/copy2/" >"$tmpdir/copy2.out" 2>&1 || true
cat "$tmpdir/copy2.out"
grep -E -v '^(created directory|sent|total size) ' "$tmpdir/copy2.out" >"$tmpdir/copy2.new"
mv "$tmpdir/copy2.new" "$tmpdir/copy2.out"
diff $diffopt "$tmpdir/copy.out" "$tmpdir/copy2.out"
$RSYNC -av -f 'exclude,! */' "$fromdir/" "$chkdir/empty/"
checkit "$RSYNC -avv --del --remove-source-files '$fromdir/' '$todir/'" "$chkdir/copy" "$todir"
diff -r "$chkdir/empty" "$fromdir"
# Make sure that "P" but not "-" per-dir merge-file filters take effect with
# --delete-excluded.
cat >"$todir/filters" <<EOF
P foo
- bar
EOF
touch "$todir/foo" "$todir/bar" "$todir/baz"
$RSYNC -r --exclude=baz --filter=': filters' --delete-excluded "$fromdir/" "$todir/"
test -f "$todir/foo" || test_fail "rsync should NOT have deleted $todir/foo"
test -f "$todir/bar" && test_fail "rsync SHOULD have deleted $todir/bar"
test -f "$todir/baz" && test_fail "rsync SHOULD have deleted $todir/baz"
# The script would have aborted on error, so getting here means we've won.
exit 0

105
testsuite/delete_test.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/delete.test.
#
# Exercises three independent delete-handling behaviours:
# 1. --del dry-run output matches a real copy's output (sans the trivial
# "created directory" / "sent" / "total size" lines).
# 2. --del --remove-source-files leaves the source empty (only dirs) and
# the destination matching what a plain copy would have produced.
# 3. per-directory filter file with "P" (protect) keeps a file alive across
# --delete-excluded; "-" (exclude) does NOT.
import os
import shutil
import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, TMPDIR, TODIR,
checkit, hands_setup, makepath, rsync_argv, test_fail,
)
hands_setup()
makepath(CHKDIR, TODIR / 'extradir', TODIR / 'emptydir' / 'subdir')
(TODIR / 'remove1').write_text("extra\n")
(TODIR / 'remove2').write_text("extra\n")
(TODIR / 'extradir' / 'remove3').write_text("extra\n")
(TODIR / 'emptydir' / 'subdir' / 'remove4').write_text("extra\n")
def _run_capture(*args):
proc = subprocess.run(rsync_argv(*args), capture_output=True, text=True)
return proc
def _strip_chatter(text: str) -> str:
"""Remove the lines the shell test stripped via grep -E -v."""
keep = []
for line in text.splitlines():
if (line.startswith('created directory ')
or line.startswith('sent ')
or line.startswith('total size ')):
continue
keep.append(line)
return '\n'.join(keep) + ('\n' if text.endswith('\n') else '')
# Two chkdirs: copy/ has what a normal copy looks like, empty/ has just
# directories (used as a remove-source-files comparator).
copy_proc = _run_capture('-av', f'{FROMDIR}/', f'{CHKDIR}/copy/')
copy_out = _strip_chatter(copy_proc.stdout + copy_proc.stderr)
(TMPDIR / 'copy.out').write_text(copy_out)
print(copy_proc.stdout)
# --del dry-run output (status may be 0 or 23 from delete behaviour; ignore
# return code as the shell test does).
copy2_proc = _run_capture('-avn', '--del', f'{FROMDIR}/', f'{CHKDIR}/copy2/')
copy2_out = _strip_chatter(copy2_proc.stdout + copy2_proc.stderr)
(TMPDIR / 'copy2.out').write_text(copy2_out)
print(copy2_proc.stdout)
if copy_out != copy2_out:
diff = subprocess.run(
['diff', '-u', str(TMPDIR / 'copy.out'), str(TMPDIR / 'copy2.out')],
capture_output=True, text=True,
)
sys_stdout = diff.stdout
print(sys_stdout)
test_fail("--del dry-run output diverged from a plain copy's output")
# Build chk/empty as a directories-only mirror of fromdir.
proc = subprocess.run(
rsync_argv('-av', '-f', 'exclude,! */', f'{FROMDIR}/', f'{CHKDIR}/empty/'),
)
if proc.returncode != 0:
test_fail("setup of chk/empty failed")
# Main: --del + --remove-source-files leaves dirs only in fromdir, and
# destination matches a normal copy.
checkit(['-avv', '--del', '--remove-source-files', f'{FROMDIR}/', f'{TODIR}/'],
CHKDIR / 'copy', TODIR)
diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR / 'empty'), str(FROMDIR)])
if diff.returncode != 0:
test_fail("--remove-source-files did not leave fromdir as just directories")
# Per-directory filter file: "P" protects, "-" excludes.
(TODIR / 'filters').write_text("P foo\n- bar\n")
for name in ('foo', 'bar', 'baz'):
(TODIR / name).touch()
proc = subprocess.run(
rsync_argv('-r', '--exclude=baz', '--filter=: filters', '--delete-excluded',
f'{FROMDIR}/', f'{TODIR}/'),
)
if proc.returncode != 0:
test_fail(f"filter-file run exited {proc.returncode}")
if not (TODIR / 'foo').is_file():
test_fail(f"rsync should NOT have deleted {TODIR / 'foo'}")
if (TODIR / 'bar').is_file():
test_fail(f"rsync SHOULD have deleted {TODIR / 'bar'}")
if (TODIR / 'baz').is_file():
test_fail(f"rsync SHOULD have deleted {TODIR / 'baz'}")

View File

@@ -1,171 +0,0 @@
#!/bin/sh
# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync handling of devices. This can only run if you're root.
. "$suitedir/rsync.fns"
# Build some hardlinks
case $0 in
*fake*)
$RSYNC -VV | grep '"xattrs": true' >/dev/null || test_skipped "Rsync needs xattrs for fake device tests"
RSYNC="$RSYNC --fake-super"
TLS_ARGS="$TLS_ARGS --fake-super"
case "$HOST_OS" in
darwin*)
mknod() {
fn="$1"
case "$2" in
p) mode=10644 ;;
c) mode=20644 ;;
b) mode=60644 ;;
esac
maj="${3:-0}"
min="${4:-0}"
touch "$fn"
xattr -s 'rsync.%stat' "$mode $maj,$min 0:0" "$fn"
}
;;
solaris*)
mknod() {
fn="$1"
case "$2" in
p) mode=10644 ;;
c) mode=20644 ;;
b) mode=60644 ;;
esac
maj="${3:-0}"
min="${4:-0}"
touch "$fn"
runat "$fn" "$SHELL_PATH" <<EOF
echo "$mode $maj,$min 0:0" > rsync.%stat
EOF
}
;;
freebsd*)
mknod() {
fn="$1"
case "$2" in
p) mode=10644 ;;
c) mode=20644 ;;
b) mode=60644 ;;
esac
maj="${3:-0}"
min="${4:-0}"
touch "$fn"
setextattr -h user "rsync.%stat" "$mode $maj,$min 0:0" "$fn"
}
;;
*)
mknod() {
fn="$1"
case "$2" in
p) mode=10644 ;;
c) mode=20644 ;;
b) mode=60644 ;;
esac
maj="${3:-0}"
min="${4:-0}"
touch "$fn"
setfattr -n 'user.rsync.%stat' -v "$mode $maj,$min 0:0" "$fn"
}
;;
esac
;;
*)
my_uid=`get_testuid`
root_uid=`get_rootuid`
if test x"$my_uid" = x; then
: # If "id" failed, try to continue...
elif test x"$my_uid" != x"$root_uid"; then
if [ -e "$FAKEROOT_PATH" ]; then
echo "Let's try re-running the script under fakeroot..."
exec "$FAKEROOT_PATH" "$SHELL_PATH" $RUNSHFLAGS "$0"
fi
test_skipped "Rsync needs root/fakeroot for device tests"
fi
;;
esac
# TODO: Need to test whether hardlinks are possible on this OS/filesystem
$RSYNC -VV | grep '"hardlink_specials": true' >/dev/null && CAN_HLINK_SPECIAL=yes || CAN_HLINK_SPECIAL=no
mkdir "$fromdir"
mkdir "$todir"
mknod "$fromdir/char" c 41 67 || test_skipped "Can't create char device node"
mknod "$fromdir/char2" c 42 68 || test_skipped "Can't create char device node"
mknod "$fromdir/char3" c 42 69 || test_skipped "Can't create char device node"
mknod "$fromdir/block" b 42 69 || test_skipped "Can't create block device node"
mknod "$fromdir/block2" b 42 73 || test_skipped "Can't create block device node"
mknod "$fromdir/block3" b 105 73 || test_skipped "Can't create block device node"
if test "$CAN_HLINK_SPECIAL" = yes; then
ln "$fromdir/block3" "$fromdir/block3.5"
else
echo "Skipping hard-linked device test..."
fi
mkfifo "$fromdir/fifo" || mknod "$fromdir/fifo" p || test_skipped "Can't run mkfifo"
# Work around time rounding/truncating issue by touching both files.
touch -r "$fromdir/block" "$fromdir/block" "$fromdir/block2"
checkdiff "$RSYNC -ai '$fromdir/block' '$todir/block2'" <<EOT
cD$all_plus block
EOT
checkdiff "$RSYNC -ai '$fromdir/block2' '$todir/block'" <<EOT
cD$all_plus block2
EOT
sleep 1
checkdiff "$RSYNC -Di '$fromdir/block3' '$todir/block'" <<EOT
cDc.T.$dots block3
EOT
cat >"$chkfile" <<EOT
.d..t.$dots ./
cDc.t.$dots block
cDc...$dots block2
cD$all_plus block3
hD$all_plus block3.5 => block3
cD$all_plus char
cD$all_plus char2
cD$all_plus char3
cS$all_plus fifo
EOT
if test "$CAN_HLINK_SPECIAL" = no; then
grep -v block3.5 <"$chkfile" >"$chkfile.new"
mv "$chkfile.new" "$chkfile"
fi
checkdiff2 "$RSYNC -aiHvv '$fromdir/' '$todir/'" v_filt
echo "check how the directory listings compare with diff:"
echo ""
( cd "$fromdir" && rsync_ls_lR . ) > "$tmpdir/ls-from"
( cd "$todir" && rsync_ls_lR . ) > "$tmpdir/ls-to"
diff $diffopt "$tmpdir/ls-from" "$tmpdir/ls-to"
if test "$CAN_HLINK_SPECIAL" = yes; then
set -x
checkdiff "$RSYNC -aii --link-dest='$todir' '$fromdir/' '$chkdir/'" <<EOT
created directory $chkdir
cd$allspace ./
hD$allspace block
hD$allspace block2
hD$allspace block3
hD$allspace block3.5
hD$allspace char
hD$allspace char2
hD$allspace char3
hS$allspace fifo
EOT
fi
# The script would have aborted on error, so getting here means we've won.
exit 0

173
testsuite/devices_test.py Normal file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/devices.test (and, via a Makefile-built
# symlink, of devices-fake.test).
#
# Test rsync's handling of device nodes (char/block/fifo) plus the
# fake-super variant that encodes device numbers in the
# user.rsync.%stat xattr instead of mknod-ing real devices.
import os
import platform
import shutil
import subprocess
import sys
import rsyncfns
from rsyncfns import (
CHKDIR, CHKFILE, FROMDIR, OUTFILE, TMPDIR, TODIR,
all_plus, allspace, dots,
checkdiff, hands_setup, makepath, rsync_ls_lR, run_rsync,
test_fail, test_skipped, v_filt,
)
script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
fake_variant = 'fake' in script_name
if fake_variant:
vv = run_rsync('-VV', check=True, capture_output=True)
if '"xattrs": true' not in vv.stdout:
test_skipped("Rsync needs xattrs for fake device tests")
rsyncfns.RSYNC = rsyncfns.RSYNC + ' --fake-super'
rsyncfns.TLS_ARGS = (rsyncfns.TLS_ARGS + ' --fake-super').strip()
if platform.system() != 'Linux':
test_skipped(
f"fake device emulation not implemented for {platform.system()}"
)
def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool:
"""Pretend to mknod `path` as kind {'p','c','b'} via an xattr.
Returns True on success, False if the FS rejects the xattr (so the
caller can skip).
"""
mode = {'p': 0o10644, 'c': 0o20644, 'b': 0o60644}[kind]
try:
with open(path, 'w'):
pass
value = f"{mode:o} {major},{minor} 0:0".encode()
os.setxattr(str(path), b'user.rsync.%stat', value)
return True
except OSError:
return False
else:
my_uid = os.getuid()
if my_uid != 0:
# Try fakeroot, mirroring the shell test.
fakeroot_path = os.environ.get('FAKEROOT_PATH')
if fakeroot_path and os.access(fakeroot_path, os.X_OK):
print("Let's try re-running the script under fakeroot...")
os.execv(fakeroot_path, [fakeroot_path, sys.executable, __file__])
test_skipped("Rsync needs root/fakeroot for device tests")
def make_special(path, kind: str, major: int = 0, minor: int = 0) -> bool:
try:
if kind == 'p':
os.mkfifo(path)
else:
mode = 0o644 | (0o020000 if kind == 'c' else 0o060000)
os.mknod(path, mode, os.makedev(major, minor))
return True
except OSError:
return False
# Does this build of rsync support hard-linking specials?
vv = run_rsync('-VV', check=True, capture_output=True)
can_hlink_special = '"hardlink_specials": true' in vv.stdout
FROMDIR.mkdir(parents=True, exist_ok=True)
TODIR.mkdir(parents=True, exist_ok=True)
if not make_special(FROMDIR / 'char', 'c', 41, 67):
test_skipped("Can't create char device node")
if not make_special(FROMDIR / 'char2', 'c', 42, 68):
test_skipped("Can't create char device node")
if not make_special(FROMDIR / 'char3', 'c', 42, 69):
test_skipped("Can't create char device node")
if not make_special(FROMDIR / 'block', 'b', 42, 69):
test_skipped("Can't create block device node")
if not make_special(FROMDIR / 'block2', 'b', 42, 73):
test_skipped("Can't create block device node")
if not make_special(FROMDIR / 'block3', 'b', 105, 73):
test_skipped("Can't create block device node")
if can_hlink_special:
try:
os.link(FROMDIR / 'block3', FROMDIR / 'block3.5')
except OSError:
# The shell test prints a "Skipping hard-linked device test..." line
# when it can't link the device; let it slide here, too.
print("Skipping hard-linked device test... (link failed)")
can_hlink_special = False
else:
print("Skipping hard-linked device test...")
if not make_special(FROMDIR / 'fifo', 'p'):
test_skipped("Can't run mkfifo")
# Match block/block2 timestamps so the diff doesn't drift.
ref = (FROMDIR / 'block').stat()
os.utime(FROMDIR / 'block', (ref.st_atime, ref.st_mtime), follow_symlinks=False)
os.utime(FROMDIR / 'block2', (ref.st_atime, ref.st_mtime), follow_symlinks=False)
checkdiff(['-ai', f'{FROMDIR}/block', f'{TODIR}/block2'],
f"cD{all_plus} block\n")
checkdiff(['-ai', f'{FROMDIR}/block2', f'{TODIR}/block'],
f"cD{all_plus} block2\n")
import time
time.sleep(1)
checkdiff(['-Di', f'{FROMDIR}/block3', f'{TODIR}/block'],
f"cDc.T.{dots} block3\n")
# Build the expected -aiHvv listing.
chkfile_lines = [
f".d..t.{dots} ./",
f"cDc.t.{dots} block",
f"cDc...{dots} block2",
f"cD{all_plus} block3",
]
if can_hlink_special:
chkfile_lines.append(f"hD{all_plus} block3.5 => block3")
chkfile_lines += [
f"cD{all_plus} char",
f"cD{all_plus} char2",
f"cD{all_plus} char3",
f"cS{all_plus} fifo",
]
expected = '\n'.join(chkfile_lines) + '\n'
checkdiff(['-aiHvv', f'{FROMDIR}/', f'{TODIR}/'], expected, filter=v_filt)
print("check how the directory listings compare with diff:\n")
ls_from = rsync_ls_lR(FROMDIR)
ls_to = rsync_ls_lR(TODIR)
if ls_from != ls_to:
from difflib import unified_diff
sys.stdout.write(''.join(unified_diff(
ls_from.splitlines(keepends=True),
ls_to.splitlines(keepends=True),
fromfile='from', tofile='to',
)))
test_fail("from/to listings differ after device transfer")
if can_hlink_special:
expected = (
f"created directory {CHKDIR}\n"
f"cd{allspace} ./\n"
f"hD{allspace} block\n"
f"hD{allspace} block2\n"
f"hD{allspace} block3\n"
f"hD{allspace} block3.5\n"
f"hD{allspace} char\n"
f"hD{allspace} char2\n"
f"hD{allspace} char3\n"
f"hS{allspace} fifo\n"
)
checkdiff(['-aii', f'--link-dest={TODIR}',
f'{FROMDIR}/', f'{CHKDIR}/'], expected)

View File

@@ -1,48 +0,0 @@
#!/bin/sh
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that rsync obeys directory setgid. -- Matt McCutchen
. $suitedir/rsync.fns
umask 077
# Call as: testit <dirname> <dirperms> <file-expected> <program-expected> <dir-expected>
testit() {
todir="$scratchdir/$1"
mkdir "$todir"
chmod $2 "$todir"
# Make sure we obey directory setgid when creating a directory to hold multiple transferred files,
# even though the directory itself is outside the transfer
$RSYNC -rvv "$scratchdir/dir" "$scratchdir/file" "$scratchdir/program" "$todir/to/"
check_perms "$todir/to" $5 "Target $1"
check_perms "$todir/to/dir" $5 "Target $1"
check_perms "$todir/to/file" $3 "Target $1"
check_perms "$todir/to/program" $4 "Target $1"
}
mkdir "$scratchdir/dir"
# Cygwin has a persistent default dir ACL that ruins this test.
case `getfacl "$scratchdir/dir" 2>/dev/null || true` in
*default:user::*) test_skipped "The default ACL mode interferes with this test" ;;
esac
echo "File!" >"$scratchdir/file"
echo "#!/bin/sh" >"$scratchdir/program"
chmod u=rwx,g=rw,g+s,o=r "$scratchdir/dir" || test_skipped "Can't chmod"
chmod 664 "$scratchdir/file"
chmod 775 "$scratchdir/program"
[ -g "$scratchdir/dir" ] || test_skipped "The directory setgid bit vanished!"
mkdir "$scratchdir/dir/blah"
[ -g "$scratchdir/dir/blah" ] || test_skipped "Your filesystem doesn't use directory setgid; maybe it's BSD."
# Test some target directories
testit setgid-off 700 rw------- rwx------ rwx------
testit setgid-on u=rwx,g=rw,g+s,o-rwx rw------- rwx------ rwx--S---
# Hooray
exit 0

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/dir-sgid.test.
#
# Check that rsync obeys the setgid bit on the destination's parent
# directory when creating a new directory to hold the transferred files,
# even though that parent directory is outside the transfer itself.
import os
import shutil
import subprocess
from rsyncfns import (
SCRATCHDIR, check_perms, run_rsync, test_skipped,
)
old_umask = os.umask(0o077)
def testit(dirname, dirperms, file_expected, prog_expected, dir_expected):
"""Mirror shell `testit dirname dirperms file_expected prog_expected dir_expected`."""
todir = SCRATCHDIR / dirname
todir.mkdir()
# dirperms is either an octal int or the symbolic shell form we translate.
if isinstance(dirperms, int):
os.chmod(todir, dirperms)
else:
subprocess.run(['chmod', dirperms, str(todir)], check=True)
run_rsync('-rvv', str(SCRATCHDIR / 'dir'),
str(SCRATCHDIR / 'file'),
str(SCRATCHDIR / 'program'),
f'{todir}/to/')
check_perms(todir / 'to', dir_expected)
check_perms(todir / 'to' / 'dir', dir_expected)
check_perms(todir / 'to' / 'file', file_expected)
check_perms(todir / 'to' / 'program', prog_expected)
# Cygwin's default dir ACL ruins this test; mimic the shell's getfacl skip.
src_dir = SCRATCHDIR / 'dir'
src_dir.mkdir()
try:
out = subprocess.run(['getfacl', str(src_dir)],
capture_output=True, text=True)
if 'default:user::' in out.stdout:
test_skipped("The default ACL mode interferes with this test")
except FileNotFoundError:
pass # No getfacl -- proceed.
(SCRATCHDIR / 'file').write_text("File!\n")
(SCRATCHDIR / 'program').write_text("#!/bin/sh\n")
try:
subprocess.run(['chmod', 'u=rwx,g=rw,g+s,o=r', str(src_dir)], check=True)
except subprocess.CalledProcessError:
test_skipped("Can't chmod")
os.chmod(SCRATCHDIR / 'file', 0o664)
os.chmod(SCRATCHDIR / 'program', 0o775)
if not (os.stat(src_dir).st_mode & 0o2000):
test_skipped("The directory setgid bit vanished!")
(src_dir / 'blah').mkdir()
if not (os.stat(src_dir / 'blah').st_mode & 0o2000):
test_skipped("Your filesystem doesn't use directory setgid; maybe it's BSD.")
testit('setgid-off', 0o700, 'rw-------', 'rwx------', 'rwx------')
testit('setgid-on', 'u=rwx,g=rw,g+s,o-rwx', 'rw-------', 'rwx------', 'rwx--S---')
os.umask(old_umask)

View File

@@ -1,44 +0,0 @@
#!/bin/sh
# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync handling of duplicate filenames.
# It's quite possible that the user might specify the same source file
# more than once on the command line, perhaps through shell variables
# or wildcard expansions. It might cause problems for rsync if the
# same name occurred more than once in the file list, because we might
# be trying to update the first copy and generate checksums for the
# second copy at the same time. See clean_flist() for the implementation.
# We don't need to worry about hardlinks or symlinks. Because we
# always rename-and-replace the new copy, they can't affect us.
# This test is not great, because it is a timing-dependent bug.
. "$suitedir/rsync.fns"
# Build some hardlinks
mkdir "$fromdir"
name1="$fromdir/name1"
name2="$fromdir/name2"
echo "This is the file" > "$name1"
ln -s "$name1" "$name2" || test_fail "can't create symlink"
checkit "$RSYNC -avv '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$fromdir/' '$todir/'" "$fromdir" "$todir" \
| tee "$outfile"
# Make sure each file was only copied once...
if [ `grep -c '^name1$' "$outfile"` != 1 ]; then
test_fail "name1 was not copied exactly once"
fi
if [ `grep -c '^name2 -> ' "$outfile"` != 1 ]; then
test_fail "name2 was not copied exactly once"
fi
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/duplicates.test.
#
# The same source directory can be listed many times on the command line
# (e.g. through shell globbing). clean_flist() is supposed to dedupe so
# each file/link is copied exactly once even with ten identical sources.
import os
import subprocess
from rsyncfns import (
FROMDIR, TODIR,
rsync_argv, rsync_ls_lR, test_fail,
)
# Build a single regular file plus a symlink to it.
FROMDIR.mkdir(parents=True, exist_ok=True)
name1 = FROMDIR / 'name1'
name2 = FROMDIR / 'name2'
name1.write_text("This is the file\n")
try:
os.symlink(str(name1), name2)
except OSError as e:
test_fail(f"can't create symlink: {e}")
# Drive rsync with the same source ten times. Capture the verbose output to
# inspect for duplicate-copy behaviour AND for the dir-listing comparison
# that the shell test's checkit was doing alongside.
sources = [f'{FROMDIR}/'] * 10
proc = subprocess.run(
rsync_argv('-avv', *sources, f'{TODIR}/'),
capture_output=True, text=True,
)
print(proc.stdout)
if proc.returncode != 0:
test_fail(f"rsync exited {proc.returncode}\n{proc.stderr}")
name1_count = sum(1 for ln in proc.stdout.splitlines() if ln == 'name1')
if name1_count != 1:
test_fail(f"name1 was not copied exactly once (got {name1_count})")
name2_count = sum(1 for ln in proc.stdout.splitlines() if ln.startswith('name2 -> '))
if name2_count != 1:
test_fail(f"name2 was not copied exactly once (got {name2_count})")
# Cross-check that the destination matches the source.
if rsync_ls_lR(FROMDIR) != rsync_ls_lR(TODIR):
test_fail("destination listing differs from source after deduplication")

View File

@@ -1 +0,0 @@
exclude.test

View File

@@ -1,252 +0,0 @@
#!/bin/sh
# Copyright (C) 2003-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync handling of exclude/include directives.
# Test some of the more obscure wildcard handling of exclude/include
# processing.
. "$suitedir/rsync.fns"
CVSIGNORE='*.junk'
export CVSIGNORE
case $0 in
*-lsh.*)
RSYNC_RSH="$scratchdir/src/support/lsh.sh"
export RSYNC_RSH
rpath=" --rsync-path='$RSYNC'"
host='lh:'
;;
*)
rpath=''
host=''
;;
esac
# Build some files/dirs/links to copy
makepath "$fromdir/foo/down/to/you"
makepath "$fromdir/foo/sub"
makepath "$fromdir/bar/down/to/foo/too"
makepath "$fromdir/bar/down/to/bar/baz"
makepath "$fromdir/mid/for/foo/and/that/is/who"
makepath "$fromdir/new/keep/this"
makepath "$fromdir/new/lose/this"
cat >"$fromdir/.filt" <<EOF
exclude down
: .filt-temp
clear
- .filt
- *.bak
- *.old
EOF
echo filtered-1 >"$fromdir/foo/file1"
echo removed >"$fromdir/foo/file2"
echo cvsout >"$fromdir/foo/file2.old"
cat >"$fromdir/foo/.filt" <<EOF
include .filt
- /file1
EOF
echo not-filtered-1 >"$fromdir/foo/sub/file1"
cat >"$fromdir/bar/.filt" <<EOF
- home-cvs-exclude
dir-merge .filt2
+ to
EOF
echo cvsout >"$fromdir/bar/down/to/home-cvs-exclude"
cat >"$fromdir/bar/down/to/.filt2" <<EOF
- .filt2
EOF
cat >"$fromdir/bar/down/to/foo/.filt2" <<EOF
+ *.junk
EOF
echo keeper >"$fromdir/bar/down/to/foo/file1"
echo cvsout >"$fromdir/bar/down/to/foo/file1.bak"
echo gone >"$fromdir/bar/down/to/foo/file3"
echo lost >"$fromdir/bar/down/to/foo/file4"
echo weird >"$fromdir/bar/down/to/foo/+ file3"
echo cvsout-but-filtin >"$fromdir/bar/down/to/foo/file4.junk"
echo smashed >"$fromdir/bar/down/to/foo/to"
cat >"$fromdir/bar/down/to/bar/.filt2" <<EOF
- *.deep
EOF
echo filtout >"$fromdir/bar/down/to/bar/baz/file5.deep"
# This one should be ineffectual
cat >"$fromdir/mid/.filt2" <<EOF
- extra
EOF
echo cvsout >"$fromdir/mid/one-in-one-out"
echo one-in-one-out >"$fromdir/mid/.cvsignore"
echo cvsin >"$fromdir/mid/one-for-all"
cat >"$fromdir/mid/.filt" <<EOF
:C
EOF
echo cvsin >"$fromdir/mid/for/one-in-one-out"
echo expunged >"$fromdir/mid/for/foo/extra"
echo retained >"$fromdir/mid/for/foo/keep"
# Setup our test exclude/include files.
excl="$scratchdir/exclude-from"
cat >"$excl" <<EOF
!
# If the second line of these two lines does anything, it's a bug.
+ **/bar
- /bar
# This should match against the whole path, not just the name.
+ foo**too
# These should float at the end of the path.
+ foo/s?b/
- foo/*/
# Test how /** differs from /***
- new/keep/**
- new/lose/***
# Test some normal excludes. Competing lines are paired.
+ t[o]/
- to
+ file4
- file[2-9]
- /mid/for/foo/extra
EOF
cat >"$scratchdir/.cvsignore" <<EOF
home-cvs-exclude
EOF
# Start with a check of --prune-empty-dirs:
$RSYNC -av --rsync-path="$RSYNC" -f -_foo/too/ -f -_foo/down/ -f -_foo/and/ -f -_new/ "$host$fromdir/" "$chkdir/"
checkit "$RSYNC -av$rpath --prune-empty-dirs '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
rm -rf "$todir"
# Add a directory symlink.
ln -s too "$fromdir/bar/down/to/foo/sym"
# Start to prep an --update test dir
mkdir "$scratchdir/up1" "$scratchdir/up2"
touch "$scratchdir/up1/dst-newness" "$scratchdir/up2/src-newness"
touch "$scratchdir/up1/same-newness" "$scratchdir/up2/same-newness"
touch "$scratchdir/up1/extra-src" "$scratchdir/up2/extra-dest"
# Create chkdir with what we expect to be excluded.
checkit "$RSYNC -avv$rpath '$host$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
sleep 1 # Ensures that the rm commands will tweak the directory times.
rm -r "$chkdir"/foo/down
rm -r "$chkdir"/mid/for/foo/and
rm -r "$chkdir"/new/keep/this
rm -r "$chkdir"/new/lose
rm "$chkdir"/foo/file[235-9]
rm "$chkdir"/bar/down/to/foo/to "$chkdir"/bar/down/to/foo/file[235-9]
rm "$chkdir"/mid/for/foo/extra
# Finish prep for the --update test (run last)
touch "$scratchdir/up1/src-newness" "$scratchdir/up2/dst-newness"
# Un-tweak the directory times in our first (weak) exclude test (though
# it's a good test of the --existing option).
$RSYNC -av --rsync-path="$RSYNC" --existing --include='*/' --exclude='*' "$host$fromdir/" "$chkdir/"
# Now, test if rsync excludes the same files.
checkit "$RSYNC -avv$rpath --exclude-from='$excl' \
--delete-during '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
# Modify the chk dir by removing cvs-ignored files and then tweaking the dir times.
rm "$chkdir"/foo/*.old
rm "$chkdir"/bar/down/to/foo/*.bak
rm "$chkdir"/bar/down/to/foo/*.junk
rm "$chkdir"/bar/down/to/home-cvs-exclude
rm "$chkdir"/mid/one-in-one-out
$RSYNC -av --rsync-path="$RSYNC" --existing --filter='exclude,! */' "$host$fromdir/" "$chkdir/"
# Now, test if rsync excludes the same files, this time with --cvs-exclude
# and --delete-excluded.
# The -C option gets applied in a different order when pushing & pulling, so we instead
# add the 2 --cvs-exclude filter rules (":C" & "-C") via -f to keep the order the same.
checkit "$RSYNC -avv$rpath --filter='merge $excl' -f:C -f-C --delete-excluded \
--delete-during '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
# Modify the chk dir for our merge-exclude test and then tweak the dir times.
rm "$chkdir"/foo/file1
rm "$chkdir"/bar/down/to/bar/baz/*.deep
cp_touch "$fromdir"/bar/down/to/foo/*.junk "$chkdir"/bar/down/to/foo
cp_touch "$fromdir"/bar/down/to/foo/to "$chkdir"/bar/down/to/foo
$RSYNC -av --rsync-path="$RSYNC" --existing -f 'show .filt*' -f 'hide,! */' --del "$host$fromdir/" "$todir/"
echo retained >"$todir"/bar/down/to/bar/baz/nodel.deep
cp_touch "$todir"/bar/down/to/bar/baz/nodel.deep "$chkdir"/bar/down/to/bar/baz
$RSYNC -av --rsync-path="$RSYNC" --existing --filter='-! */' "$host$fromdir/" "$chkdir/"
# Now, test if rsync excludes the same files, this time with a merge-exclude
# file.
checkit "sed '/!/d' '$excl' |
$RSYNC -avv$rpath -f dir-merge_.filt -f merge_- \
--delete-during '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
# Remove the files that will be deleted.
rm "$chkdir"/.filt
rm "$chkdir"/bar/.filt
rm "$chkdir"/bar/down/to/.filt2
rm "$chkdir"/bar/down/to/foo/.filt2
rm "$chkdir"/bar/down/to/bar/.filt2
rm "$chkdir"/mid/.filt
$RSYNC -av --rsync-path="$RSYNC" --existing --include='*/' --exclude='*' "$host$fromdir/" "$chkdir/"
# Now, try the prior command with --delete-before and some side-specific
# rules.
checkit "sed '/!/d' '$excl' |
$RSYNC -avv$rpath -f :s_.filt -f .s_- -f P_nodel.deep \
--delete-before '$host$fromdir/' '$todir/'" "$chkdir" "$todir"
# Next, we'll test some rule-restricted filter files.
cat >"$fromdir/bar/down/.excl" <<EOF
file3
EOF
cat >"$fromdir/bar/down/to/foo/.excl" <<EOF
+ file3
*.bak
EOF
$RSYNC -av --rsync-path="$RSYNC" --del "$host$fromdir/" "$chkdir/"
rm "$chkdir/bar/down/to/foo/file1.bak"
rm "$chkdir/bar/down/to/foo/file3"
rm "$chkdir/bar/down/to/foo/+ file3"
$RSYNC -av --rsync-path="$RSYNC" --existing --filter='-! */' "$host$fromdir/" "$chkdir/"
$RSYNC -av --rsync-path="$RSYNC" --delete-excluded --exclude='*' "$host$fromdir/" "$todir/"
checkit "$RSYNC -avv$rpath -f dir-merge,-_.excl \
'$host$fromdir/' '$todir/'" "$chkdir" "$todir"
relative_opts='--relative --chmod=Du+w --copy-unsafe-links'
$RSYNC -av --rsync-path="$RSYNC" $relative_opts "$host$fromdir/foo" "$chkdir/"
rm -rf "$chkdir$fromdir/foo/down"
$RSYNC -av $relative_opts --existing --filter='-! */' "$fromdir/foo" "$chkdir/"
checkit "$RSYNC -avv$rpath $relative_opts --exclude='$fromdir/foo/down' \
'$host$fromdir/foo' '$todir'" "$chkdir$fromdir/foo" "$todir$fromdir/foo"
# Now we'll test the --update option.
checkdiff "$RSYNC -aiiO$rpath --update --info=skip '$host$scratchdir/up1/' '$scratchdir/up2/'" \
"grep -v '^\.d$allspace'" <<EOT
dst-newness is newer
>f$all_plus extra-src
.f$allspace same-newness
>f..t.$dots src-newness
EOT
# The script would have aborted on error, so getting here means we've won.
exit 0

321
testsuite/exclude_test.py Normal file
View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/exclude.test (and, via a Makefile-built
# symlink, of exclude-lsh.test).
#
# Test rsync's exclude / include / filter rules, including the more
# obscure wildcard cases, per-directory filter files, CVS-style
# exclusions, --prune-empty-dirs, --delete-during / --delete-before /
# --delete-excluded, and rule-restricted filter files.
#
# The lsh.sh "remote shell" variant runs every transfer through the
# local rsync-over-ssh stand-in -- detected via sys.argv[0].
import os
import subprocess
import sys
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
all_plus, allspace, dots,
checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail,
)
os.environ['CVSIGNORE'] = '*.junk'
script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
if 'lsh' in script_name:
os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
rpath = [f'--rsync-path={RSYNC}']
host = 'lh:'
else:
rpath = []
host = ''
# Build the from/ tree.
makepath(
FROMDIR / 'foo/down/to/you',
FROMDIR / 'foo/sub',
FROMDIR / 'bar/down/to/foo/too',
FROMDIR / 'bar/down/to/bar/baz',
FROMDIR / 'mid/for/foo/and/that/is/who',
FROMDIR / 'new/keep/this',
FROMDIR / 'new/lose/this',
)
(FROMDIR / '.filt').write_text(
"exclude down\n"
": .filt-temp\n"
"clear\n"
"- .filt\n"
"- *.bak\n"
"- *.old\n"
)
(FROMDIR / 'foo' / 'file1').write_text("filtered-1\n")
(FROMDIR / 'foo' / 'file2').write_text("removed\n")
(FROMDIR / 'foo' / 'file2.old').write_text("cvsout\n")
(FROMDIR / 'foo' / '.filt').write_text("include .filt\n- /file1\n")
(FROMDIR / 'foo' / 'sub' / 'file1').write_text("not-filtered-1\n")
(FROMDIR / 'bar' / '.filt').write_text(
"- home-cvs-exclude\n"
"dir-merge .filt2\n"
"+ to\n"
)
(FROMDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').write_text("cvsout\n")
(FROMDIR / 'bar' / 'down' / 'to' / '.filt2').write_text("- .filt2\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / '.filt2').write_text("+ *.junk\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1').write_text("keeper\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').write_text("cvsout\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').write_text("gone\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file4').write_text("lost\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').write_text("weird\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'file4.junk').write_text("cvsout-but-filtin\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to').write_text("smashed\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').write_text("- *.deep\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'file5.deep').write_text("filtout\n")
# This one should be ineffectual.
(FROMDIR / 'mid' / '.filt2').write_text("- extra\n")
(FROMDIR / 'mid' / 'one-in-one-out').write_text("cvsout\n")
(FROMDIR / 'mid' / '.cvsignore').write_text("one-in-one-out\n")
(FROMDIR / 'mid' / 'one-for-all').write_text("cvsin\n")
(FROMDIR / 'mid' / '.filt').write_text(":C\n")
(FROMDIR / 'mid' / 'for' / 'one-in-one-out').write_text("cvsin\n")
(FROMDIR / 'mid' / 'for' / 'foo' / 'extra').write_text("expunged\n")
(FROMDIR / 'mid' / 'for' / 'foo' / 'keep').write_text("retained\n")
# Setup our test exclude/include files.
excl = SCRATCHDIR / 'exclude-from'
excl.write_text(
"!\n"
"# If the second line of these two lines does anything, it's a bug.\n"
"+ **/bar\n"
"- /bar\n"
"# This should match against the whole path, not just the name.\n"
"+ foo**too\n"
"# These should float at the end of the path.\n"
"+ foo/s?b/\n"
"- foo/*/\n"
"# Test how /** differs from /***\n"
"- new/keep/**\n"
"- new/lose/***\n"
"# Test some normal excludes. Competing lines are paired.\n"
"+ t[o]/\n"
"- to\n"
"+ file4\n"
"- file[2-9]\n"
"- /mid/for/foo/extra\n"
)
(SCRATCHDIR / '.cvsignore').write_text("home-cvs-exclude\n")
# --- main checks ------------------------------------------------------------
# Start with a check of --prune-empty-dirs.
run_rsync('-av', f'--rsync-path={RSYNC}',
'-f', '-_foo/too/', '-f', '-_foo/down/',
'-f', '-_foo/and/', '-f', '-_new/',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
checkit(['-av', *rpath, '--prune-empty-dirs',
f'{host}{FROMDIR}/', f'{TODIR}/'], CHKDIR, TODIR)
import shutil
shutil.rmtree(TODIR, ignore_errors=True)
# Add a directory symlink.
os.symlink('too', FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'sym')
# Pre-build an --update test pair.
up1 = SCRATCHDIR / 'up1'
up2 = SCRATCHDIR / 'up2'
up1.mkdir()
up2.mkdir()
(up1 / 'dst-newness').touch()
(up2 / 'src-newness').touch()
(up1 / 'same-newness').touch()
(up2 / 'same-newness').touch()
(up1 / 'extra-src').touch()
(up2 / 'extra-dest').touch()
# Build CHKDIR mirroring source (everything), then remove the entries we
# expect to be excluded.
checkit(['-avv', *rpath, f'{host}{FROMDIR}/', f'{CHKDIR}/'], FROMDIR, CHKDIR)
import time
time.sleep(1)
shutil.rmtree(CHKDIR / 'foo' / 'down', ignore_errors=True)
shutil.rmtree(CHKDIR / 'mid' / 'for' / 'foo' / 'and', ignore_errors=True)
shutil.rmtree(CHKDIR / 'new' / 'keep' / 'this', ignore_errors=True)
shutil.rmtree(CHKDIR / 'new' / 'lose', ignore_errors=True)
for f in (CHKDIR / 'foo').glob('file[235-9]'):
f.unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'to').unlink()
for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('file[235-9]'):
f.unlink()
(CHKDIR / 'mid' / 'for' / 'foo' / 'extra').unlink()
(up1 / 'src-newness').touch()
(up2 / 'dst-newness').touch()
# Un-tweak the directory times in our first (weak) exclude test.
run_rsync('-av', f'--rsync-path={RSYNC}',
'--existing', '--include=*/', '--exclude=*',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
# Test that rsync excludes the same files.
checkit(['-avv', *rpath, f'--exclude-from={excl}',
'--delete-during', f'{host}{FROMDIR}/', f'{TODIR}/'],
CHKDIR, TODIR)
# Modify the chk dir by removing cvs-ignored files and tweaking dir times.
for f in (CHKDIR / 'foo').glob('*.old'):
f.unlink()
for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.bak'):
f.unlink()
for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.junk'):
f.unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').unlink()
(CHKDIR / 'mid' / 'one-in-one-out').unlink()
run_rsync('-av', f'--rsync-path={RSYNC}',
'--existing', '--filter=exclude,! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
# Now test --cvs-exclude + --delete-excluded.
# -C order differs between push/pull, so use -f :C / -f -C explicitly.
checkit(['-avv', *rpath, f'--filter=merge {excl}',
'-f:C', '-f-C', '--delete-excluded', '--delete-during',
f'{host}{FROMDIR}/', f'{TODIR}/'],
CHKDIR, TODIR)
# Modify the chk dir for the merge-exclude test.
(CHKDIR / 'foo' / 'file1').unlink()
for f in (CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz').glob('*.deep'):
f.unlink()
for src in (FROMDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.junk'):
cp_touch_dst = CHKDIR / 'bar' / 'down' / 'to' / 'foo'
cp_touch_dst.mkdir(exist_ok=True)
from rsyncfns import cp_touch
cp_touch(src, cp_touch_dst)
from rsyncfns import cp_touch
cp_touch(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to',
CHKDIR / 'bar' / 'down' / 'to' / 'foo')
run_rsync('-av', f'--rsync-path={RSYNC}',
'--existing', '-f', 'show .filt*', '-f', 'hide,! */', '--del',
f'{host}{FROMDIR}/', f'{TODIR}/')
(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep').write_text("retained\n")
cp_touch(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep',
CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz')
run_rsync('-av', f'--rsync-path={RSYNC}',
'--existing', '--filter=-! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
# Test merge-exclude file. The shell test piped excl-minus-bangs into
# rsync via stdin; here we materialise the filtered file and merge it.
filtered_excl = SCRATCHDIR / 'exclude-from-filtered'
filtered_excl.write_text(
'\n'.join(ln for ln in excl.read_text().splitlines() if '!' not in ln)
+ '\n'
)
def run_with_stdin_filter(args, label="merge"):
"""Run rsync with `args`, feeding `filtered_excl` content on stdin
(which `merge_-` in the filter list picks up). checkit-equivalent
that also re-uses CHKDIR/TODIR for the listing comparison."""
print(f"Running: rsync {' '.join(args)}")
with open(filtered_excl, 'rb') as inp:
proc = subprocess.run(rsync_argv(*args), stdin=inp)
if proc.returncode != 0:
test_fail(f"{label}: rsync exited {proc.returncode}")
run_with_stdin_filter(
['-avv', *rpath, '-f', 'dir-merge_.filt', '-f', 'merge_-',
'--delete-during', f'{host}{FROMDIR}/', f'{TODIR}/'],
"dir-merge .filt + merge from stdin",
)
from rsyncfns import verify_dirs
verify_dirs(CHKDIR, TODIR, label="dir-merge + merge-from-stdin")
# Remove the files that will be deleted.
(CHKDIR / '.filt').unlink()
(CHKDIR / 'bar' / '.filt').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / '.filt2').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '.filt2').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').unlink()
(CHKDIR / 'mid' / '.filt').unlink()
run_rsync('-av', f'--rsync-path={RSYNC}',
'--existing', '--include=*/', '--exclude=*',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
# Run the prior command with --delete-before and side-specific rules.
run_with_stdin_filter(
['-avv', *rpath, '-f', ':s_.filt', '-f', '.s_-',
'-f', 'P_nodel.deep',
'--delete-before', f'{host}{FROMDIR}/', f'{TODIR}/'],
"delete-before with merge",
)
verify_dirs(CHKDIR, TODIR, label="delete-before with merge")
# Rule-restricted filter files.
(FROMDIR / 'bar' / 'down' / '.excl').write_text("file3\n")
(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / '.excl').write_text(
"+ file3\n*.bak\n"
)
run_rsync('-av', f'--rsync-path={RSYNC}',
'--del', f'{host}{FROMDIR}/', f'{CHKDIR}/')
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').unlink()
run_rsync('-av', f'--rsync-path={RSYNC}',
'--existing', '--filter=-! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
run_rsync('-av', f'--rsync-path={RSYNC}',
'--delete-excluded', '--exclude=*',
f'{host}{FROMDIR}/', f'{TODIR}/')
checkit(['-avv', *rpath, '-f', 'dir-merge,-_.excl',
f'{host}{FROMDIR}/', f'{TODIR}/'], CHKDIR, TODIR)
# Combine with --relative.
relative_opts = ['--relative', '--chmod=Du+w', '--copy-unsafe-links']
run_rsync('-av', f'--rsync-path={RSYNC}', *relative_opts,
f'{host}{FROMDIR}/foo', f'{CHKDIR}/')
shutil.rmtree(str(CHKDIR) + str(FROMDIR) + '/foo/down', ignore_errors=True)
run_rsync('-av', *relative_opts, '--existing', '--filter=-! */',
f'{FROMDIR}/foo', f'{CHKDIR}/')
checkit(['-avv', *rpath, *relative_opts,
f'--exclude={FROMDIR}/foo/down',
f'{host}{FROMDIR}/foo', str(TODIR)],
str(CHKDIR) + str(FROMDIR) + '/foo',
str(TODIR) + str(FROMDIR) + '/foo')
# --update test.
checkdiff(
['-aiiO', *rpath, '--update', '--info=skip',
f'{host}{SCRATCHDIR}/up1/', f'{SCRATCHDIR}/up2/'],
"dst-newness is newer\n"
f">f{all_plus} extra-src\n"
f".f{allspace} same-newness\n"
f">f..t.{dots} src-newness\n",
filter=lambda txt: '\n'.join(
ln for ln in txt.splitlines()
if not ln.startswith('.d' + allspace)
) + ('\n' if txt else ''),
)

View File

@@ -1,47 +0,0 @@
#!/bin/sh
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test the --executability or -E option. -- Matt McCutchen
. $suitedir/rsync.fns
# Put some files in the From directory
mkdir "$fromdir"
cat <<EOF >"$fromdir/1"
#!/bin/sh
echo 'Program One!'
EOF
cat <<EOF >"$fromdir/2"
#!/bin/sh
echo 'Program Two!'
EOF
chmod 1700 "$fromdir/1" || test_skipped "Can't chmod"
chmod 600 "$fromdir/2"
$RSYNC -rvv "$fromdir/" "$todir/"
check_perms "$todir/1" rwx------ 1
check_perms "$todir/2" rw------- 1
# Mix up the permissions a bit
chmod 600 "$fromdir/1"
chmod 601 "$fromdir/2"
chmod 604 "$todir/2"
$RSYNC -rvv "$fromdir/" "$todir/"
# No -E, so nothing should have changed
check_perms "$todir/1" rwx------ 2
check_perms "$todir/2" rw----r-- 2
$RSYNC -rvvE "$fromdir/" "$todir/"
# Now things should have happened!
check_perms "$todir/1" rw------- 3
check_perms "$todir/2" rwx---r-x 3
# Hooray
exit 0

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/executability.test.
#
# Test --executability (-E): -E should propagate only the executable bits
# from source to destination (other permission changes ignored), while a
# normal copy without -E should leave the destination permissions alone.
import os
from rsyncfns import FROMDIR, TODIR, check_perms, run_rsync, test_skipped
FROMDIR.mkdir(parents=True, exist_ok=True)
(FROMDIR / '1').write_text("#!/bin/sh\necho 'Program One!'\n")
(FROMDIR / '2').write_text("#!/bin/sh\necho 'Program Two!'\n")
# Setuid-and-rwx for owner, nothing else. Some platforms reject 1700 for
# non-root callers (no permission to set sticky); the shell test treats
# that case as a skip.
try:
os.chmod(FROMDIR / '1', 0o1700)
except PermissionError:
test_skipped("Can't chmod")
os.chmod(FROMDIR / '2', 0o600)
run_rsync('-rvv', f'{FROMDIR}/', f'{TODIR}/')
check_perms(TODIR / '1', 'rwx------')
check_perms(TODIR / '2', 'rw-------')
# Permute the source/destination perms; without -E nothing should change.
os.chmod(FROMDIR / '1', 0o600)
os.chmod(FROMDIR / '2', 0o601)
os.chmod(TODIR / '2', 0o604)
run_rsync('-rvv', f'{FROMDIR}/', f'{TODIR}/')
check_perms(TODIR / '1', 'rwx------')
check_perms(TODIR / '2', 'rw----r--')
# Now with -E: 1 loses its x (source has 600), 2 gains x (source has 601).
run_rsync('-rvvE', f'{FROMDIR}/', f'{TODIR}/')
check_perms(TODIR / '1', 'rw-------')
check_perms(TODIR / '2', 'rwx---r-x')

View File

@@ -1,45 +0,0 @@
#!/bin/sh
# Copyright (C) 2008-2020 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test that --files-from=FILE works right.
. "$suitedir/rsync.fns"
SSH="$scratchdir/src/support/lsh.sh"
hands_setup
# This list of files skips the contents of "subsubdir" but includes
# the contents of "subsubdir2" due to its trailing slash.
cat >"$scratchdir/filelist" <<EOT
from/./
from/./dir/subdir
from/./dir/subdir/subsubdir
from/./dir/subdir/subsubdir2/
from/./dir/subdir/foobar.baz
EOT
# Create a chkdir without the content that we expect to be omitted.
$RSYNC -a --exclude=dir/text --exclude='subsubdir/**' "$fromdir/" "$chkdir/"
checkit "$RSYNC -av --files-from='$scratchdir/filelist' '$scratchdir' '$todir/'" "$chkdir" "$todir"
for filehost in '' 'localhost:'; do
for srchost in '' 'localhost:'; do
if [ -z "$srchost" ]; then
desthost='localhost:'
else
desthost=''
fi
rm -rf "$todir"
checkit "$RSYNC -avse '$SSH' --rsync-path='$RSYNC' --files-from='$filehost$scratchdir/filelist' '$srchost$scratchdir' '$desthost$todir/'" "$chkdir" "$todir"
done
done
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/files-from.test.
#
# Verify that --files-from=LIST drives rsync correctly both for a plain
# local sync and across the lsh.sh "remote shell" with each of the four
# files-host / src-host / dest-host placement combinations.
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
checkit, hands_setup, rmtree, run_rsync,
)
SSH = str(SRCDIR / 'support' / 'lsh.sh')
hands_setup()
# Files-from list: skip the contents of subsubdir but include subsubdir2/
# in full (the trailing slash on subsubdir2/ is what flips it).
filelist = SCRATCHDIR / 'filelist'
filelist.write_text(
"from/./\n"
"from/./dir/subdir\n"
"from/./dir/subdir/subsubdir\n"
"from/./dir/subdir/subsubdir2/\n"
"from/./dir/subdir/foobar.baz\n"
)
# chkdir is what we expect the transfer to produce: source minus
# dir/text and minus everything under subsubdir/.
run_rsync('-a', '--exclude=dir/text', '--exclude=subsubdir/**',
f'{FROMDIR}/', f'{CHKDIR}/')
# Local case.
checkit(['-av', f'--files-from={filelist}', str(SCRATCHDIR), f'{TODIR}/'],
CHKDIR, TODIR)
# All four combinations of files-host / source-host / dest-host across the
# lsh.sh "remote shell". In each loop iteration exactly one of source or
# dest is remote (matches the original test's branch logic).
for filehost in ('', 'localhost:'):
for srchost in ('', 'localhost:'):
desthost = 'localhost:' if not srchost else ''
rmtree(TODIR)
checkit(
['-avse', SSH, f'--rsync-path={RSYNC}',
f'--files-from={filehost}{filelist}',
f'{srchost}{SCRATCHDIR}', f'{desthost}{TODIR}/'],
CHKDIR, TODIR,
)

View File

@@ -1,24 +0,0 @@
#!/bin/sh
# Copyright (C) 2005-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync handling of the --fuzzy option.
. "$suitedir/rsync.fns"
mkdir "$fromdir"
mkdir "$todir"
cp_p "$srcdir"/rsync.c "$fromdir"/rsync.c
cp_touch "$fromdir"/rsync.c "$todir"/rsync2.c
sleep 1
# Let's do it!
checkit "$RSYNC -avvi --no-whole-file --fuzzy --delete-delay \
'$fromdir/' '$todir/'" "$fromdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

22
testsuite/fuzzy_test.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/fuzzy.test.
#
# Test --fuzzy: with a matching-content file already in the destination
# under a different name, rsync should use it as a basis for the new name
# instead of re-transferring (and --delete-delay still removes the stale
# basis file at the end).
import time
from rsyncfns import FROMDIR, SRCDIR, TODIR, checkit, cp_p, cp_touch
FROMDIR.mkdir(parents=True, exist_ok=True)
TODIR.mkdir(parents=True, exist_ok=True)
cp_p(SRCDIR / 'rsync.c', FROMDIR / 'rsync.c')
cp_touch(FROMDIR / 'rsync.c', TODIR / 'rsync2.c')
time.sleep(1)
checkit(['-avvi', '--no-whole-file', '--fuzzy', '--delete-delay',
f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)

View File

@@ -1,38 +0,0 @@
#!/bin/sh
# Copyright (C) 1998, 1999 by Philip Hands <phil@hands.com>
# Copyright (C) 2001, 2002 by Martin Pool <mbp@samba.org>
#
# This program is distributable under the terms of the GNU GPL (see COPYING)
. "$suitedir/rsync.fns"
hands_setup
DEBUG_OPTS="--debug=all0,deltasum0"
# Main script starts here
runtest "basic operation" 'checkit "$RSYNC -av \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
ln "$fromdir/filelist" "$fromdir/dir"
runtest "hard links" 'checkit "$RSYNC -avH --bwlimit=0 $DEBUG_OPTS \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
rm "$todir/text"
runtest "one file" 'checkit "$RSYNC -avH $DEBUG_OPTS \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
echo "extra line" >> "$todir/text"
runtest "extra data" 'checkit "$RSYNC -avH $DEBUG_OPTS --no-whole-file \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
cp "$fromdir/text" "$todir/ThisShouldGo"
runtest " --delete" 'checkit "$RSYNC --delete -avH $DEBUG_OPTS \"$fromdir/\" \"$todir\"" "$fromdir/" "$todir"'
cd "$tmpdir"
rm -rf to from/*dir
# Do the real copy, touch up the parent-dir's time, and then check the copy.
$RSYNC -av from/* to/
checkit "$RSYNC -av --exclude='*' from/ to/" "$fromdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

63
testsuite/hands_test.py Normal file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/hands.test.
#
# The canonical end-to-end transfer test: build a richly-populated source
# tree via hands_setup() then run a series of rsync invocations covering
# basic operation, hard links, single-file copies, --no-whole-file delta
# updates and --delete cleanup. After each run the source and destination
# tree listings must match exactly.
import os
import shutil
from rsyncfns import FROMDIR, TMPDIR, TODIR, checkit, hands_setup, run_rsync
hands_setup()
DEBUG_OPTS = "--debug=all0,deltasum0"
# 1. basic operation
print("Test basic operation:")
checkit(['-av', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
# 2. hard links — link filelist into dir/ then transfer with -H so the
# receiver should recreate the link relationship.
os.link(FROMDIR / 'filelist', FROMDIR / 'dir' / 'filelist')
print("Test hard links:")
checkit(['-avH', '--bwlimit=0', DEBUG_OPTS, f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
# 3. one file — delete the destination 'text' and re-sync; only it should
# transfer, everything else stays uptodate.
(TODIR / 'text').unlink()
print("Test one file:")
checkit(['-avH', DEBUG_OPTS, f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
# 4. extra data — append to destination then re-sync with --no-whole-file so
# the rsync delta algorithm has to repair it.
with open(TODIR / 'text', 'a') as f:
f.write("extra line\n")
print("Test extra data:")
checkit(['-avH', DEBUG_OPTS, '--no-whole-file', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
# 5. --delete — add a stray file on the destination and confirm --delete
# removes it.
shutil.copy(FROMDIR / 'text', TODIR / 'ThisShouldGo')
print("Test --delete:")
checkit(['--delete', '-avH', DEBUG_OPTS, f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)
# 6. globbed copy without recursion — wipe and re-sync top-level entries by
# glob, then a final empty pass to compare listings.
os.chdir(TMPDIR)
shutil.rmtree('to', ignore_errors=True)
for entry in TMPDIR.glob('from/*dir'):
if entry.is_dir():
shutil.rmtree(entry, ignore_errors=True)
else:
entry.unlink()
# Replicate `rsync -av from/* to/` — list the from/ children explicitly.
sources = sorted(str(p) for p in (TMPDIR / 'from').iterdir())
run_rsync('-av', *sources, 'to/')
checkit(['-av', '--exclude=*', 'from/', 'to/'], FROMDIR, TODIR)

View File

@@ -1,88 +0,0 @@
#!/bin/sh
# Copyright (C) 2002 by Martin Pool <mbp@samba.org>
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test rsync handling of hardlinks. By default, rsync does not detect
# hard links and they get sent as separate files. If you specify -H,
# then hard links are detected and linked together on the receiver.
. "$suitedir/rsync.fns"
SSH="$scratchdir/src/support/lsh.sh"
# Build some hardlinks
fromdir="$scratchdir/from"
todir="$scratchdir/to"
# TODO: Need to test whether hardlinks are possible on this OS/filesystem
mkdir "$fromdir"
name1="$fromdir/name1"
name2="$fromdir/name2"
name3="$fromdir/name3"
name4="$fromdir/name4"
echo "This is the file" > "$name1"
ln "$name1" "$name2" || test_skipped "Can't create hardlink"
ln "$name2" "$name3" || test_fail "Can't create hardlink"
cp "$name2" "$name4" || test_fail "Can't copy file"
cat $srcdir/*.c >"$fromdir/text"
checkit "$RSYNC -aHivv --debug=HLINK5 '$fromdir/' '$todir/'" "$fromdir" "$todir"
echo "extra extra" >>"$todir/name1"
checkit "$RSYNC -aHivv --debug=HLINK5 --no-whole-file '$fromdir/' '$todir/'" "$fromdir" "$todir"
# Add a new link in a new subdirectory to test that we don't try to link
# the files before the directory gets created. We also create a bunch of
# extra files to ensure that an incremental-recursion transfer works across
# distant files.
makepath "$fromdir/subdir/down/deep"
files=''
for x in a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9; do
for y in a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9; do
files="$files $x$y"
done
done
(cd "$fromdir/subdir"; touch $files)
ln "$name1" "$fromdir/subdir/down/deep/new-file"
rm "$todir/text"
checkit "$RSYNC -aHivve '$SSH' --debug=HLINK5 --rsync-path='$RSYNC' '$fromdir/' localhost:'$todir/'" "$fromdir" "$todir"
# Do some duplicate copies using --link-dest and --copy-dest to test that
# we hard-link all locally-inherited items.
checkit "$RSYNC -aHivv --debug=HLINK5 --link-dest='$todir' '$fromdir/' '$chkdir/'" "$todir" "$chkdir"
rm -rf "$chkdir"
checkit "$RSYNC -aHivv --debug=HLINK5 --copy-dest='$todir' '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir"
# Create a hard link that has only one part in the hierarchy.
echo "This is another file" >"$fromdir/solo"
ln "$fromdir/solo" "$chkdir/solo" || test_fail "Can't create hardlink"
# Make sure that the checksum data doesn't slide due to an HLINK_BUMP() change.
checktee "$RSYNC -aHivc --debug=HLINK5 '$fromdir/' '$chkdir/'"
grep solo "$outfile" && test_fail "Erroneous copy of solo file occurred!"
# Make sure there's nothing wrong with sending a single file with -H
# enabled (this has broken twice so far, so we need this test).
rm -rf "$todir"
$RSYNC -aHivv --debug=HLINK5 "$name1" "$todir/"
diff $diffopt "$name1" "$todir" || test_fail "solo copy of name1 failed"
# Make sure there's nothing wrong with sending a single directory with -H
# enabled (this has broken in 3.4.0 so far, so we need this test).
rm -rf "$fromdir" "$todir"
makepath "$fromdir/sym" "$todir"
$RSYNC -aH "$fromdir/sym" "$todir"
diff $diffopt "$fromdir" "$todir" || test_fail "solo copy of sym failed"
# The script would have aborted on error, so getting here means we've won.
exit 0

113
testsuite/hardlinks_test.py Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/hardlinks.test.
#
# Verify rsync -H detects hard links and re-creates them on the receiver.
# Also covers the incremental-recursion path (lots of small files), the
# remote (lsh.sh) path, --link-dest / --copy-dest, --checksum without
# slipping HLINK_BUMP boundaries, and the single-file / single-directory
# corner cases that have broken in the past.
import os
import shutil
import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, OUTFILE, RSYNC, SRCDIR, TODIR,
checkit, makepath, rsync_argv, test_fail, test_skipped,
)
SSH = str(SRCDIR / 'support' / 'lsh.sh')
FROMDIR.mkdir(parents=True, exist_ok=True)
name1 = FROMDIR / 'name1'
name2 = FROMDIR / 'name2'
name3 = FROMDIR / 'name3'
name4 = FROMDIR / 'name4'
name1.write_text("This is the file\n")
try:
os.link(name1, name2)
except OSError:
test_skipped("Can't create hardlink")
try:
os.link(name2, name3)
except OSError:
test_fail("Can't create hardlink")
shutil.copy(name2, name4)
text = bytearray()
for f in sorted(SRCDIR.glob('*.c')):
text.extend(f.read_bytes())
(FROMDIR / 'text').write_bytes(bytes(text))
checkit(['-aHivv', '--debug=HLINK5', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
# Force a delta-overwrite on one of the linked names; -H should still
# leave name1..name3 hard-linked on the destination.
with open(TODIR / 'name1', 'a') as f:
f.write("extra extra\n")
checkit(['-aHivv', '--debug=HLINK5', '--no-whole-file',
f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)
# Add a new link under a subdir that doesn't exist on the dest yet, plus
# pile on lots of small files to exercise incremental recursion's link
# bookkeeping across batches.
makepath(FROMDIR / 'subdir' / 'down' / 'deep')
cdir = FROMDIR / 'subdir'
chars = list('abcdefghijklmnopqrstuvwxyz0123456789')
for x in chars:
for y in chars:
(cdir / f'{x}{y}').touch()
os.link(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file')
(TODIR / 'text').unlink()
checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC}',
f'{FROMDIR}/', f'localhost:{TODIR}/'], FROMDIR, TODIR)
# --link-dest and --copy-dest should also keep hard-linked entries.
checkit(['-aHivv', '--debug=HLINK5', f'--link-dest={TODIR}',
f'{FROMDIR}/', f'{CHKDIR}/'], TODIR, CHKDIR)
shutil.rmtree(CHKDIR, ignore_errors=True)
checkit(['-aHivv', '--debug=HLINK5', f'--copy-dest={TODIR}',
f'{FROMDIR}/', f'{CHKDIR}/'], FROMDIR, CHKDIR)
# Make a hard link whose other end is outside the source -- the dest
# stays single-linked -- and re-sync with --checksum.
(FROMDIR / 'solo').write_text("This is another file\n")
try:
os.link(FROMDIR / 'solo', CHKDIR / 'solo')
except OSError:
test_fail("Can't create hardlink")
# Make sure --checksum doesn't slip the offset due to an HLINK_BUMP() change.
proc = subprocess.run(
rsync_argv('-aHivc', '--debug=HLINK5', f'{FROMDIR}/', f'{CHKDIR}/'),
capture_output=True, text=True,
)
OUTFILE.write_text(proc.stdout)
print(proc.stdout, end='')
if proc.returncode != 0:
test_fail(f"-aHivc run exited {proc.returncode}")
if 'solo' in proc.stdout:
test_fail("Erroneous copy of solo file occurred!")
# Single-file with -H is a regression-prone path; just confirm it copies.
shutil.rmtree(TODIR, ignore_errors=True)
TODIR.mkdir(parents=True, exist_ok=True)
subprocess.run(rsync_argv('-aHivv', '--debug=HLINK5', str(name1), f'{TODIR}/'))
diff = subprocess.run(['diff', '-u', str(name1), str(TODIR / 'name1')])
if diff.returncode != 0:
test_fail("solo copy of name1 failed")
# Single-directory with -H is the 3.4.0 regression.
shutil.rmtree(FROMDIR, ignore_errors=True)
shutil.rmtree(TODIR, ignore_errors=True)
makepath(FROMDIR / 'sym', TODIR)
subprocess.run(rsync_argv('-aH', str(FROMDIR / 'sym'), str(TODIR)))
diff = subprocess.run(['diff', '-r', '-u', str(FROMDIR), str(TODIR)])
if diff.returncode != 0:
test_fail("solo copy of sym failed")

View File

@@ -1,246 +0,0 @@
#!/bin/sh
# Copyright (C) 2005-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test the output of various copy commands to ensure itemized output
# and double-verbose output is correct.
. "$suitedir/rsync.fns"
to2dir="$tmpdir/to2"
makepath "$fromdir/foo"
makepath "$fromdir/bar/baz"
cp_p "$srcdir/configure.ac" "$fromdir/foo/config1"
cp_p "$srcdir/config.sub" "$fromdir/foo/config2"
cp_p "$srcdir/rsync.h" "$fromdir/bar/baz/rsync"
chmod 600 "$fromdir"/foo/config? "$fromdir/bar/baz/rsync"
umask 0
ln -s ../bar/baz/rsync "$fromdir/foo/sym"
umask 022
ln "$fromdir/foo/config1" "$fromdir/foo/extra"
rm -f "$to2dir"
# Check if rsync is set to hard-link symlinks.
if $RSYNC -VV | grep '"hardlink_symlinks": true' >/dev/null; then
L=hL
sym_dots="$allspace"
L_sym_dots=".L$allspace"
is_uptodate='is uptodate'
touch "$chkfile.extra"
else
L=cL
sym_dots="c.t.$dots"
L_sym_dots="cL$sym_dots"
is_uptodate='-> ../bar/baz/rsync'
echo "cL$sym_dots foo/sym $is_uptodate" >"$chkfile.extra"
fi
# Check if rsync can preserve time on symlinks
case "$RSYNC" in
*protocol=2*)
T=.T
;;
*)
if $RSYNC -VV | grep '"symtimes": true' >/dev/null; then
T=.t
else
T=.T
fi
;;
esac
checkdiff "$RSYNC -iplr '$fromdir/' '$todir/'" <<EOT
created directory $todir
cd$all_plus ./
cd$all_plus bar/
cd$all_plus bar/baz/
>f$all_plus bar/baz/rsync
cd$all_plus foo/
>f$all_plus foo/config1
>f$all_plus foo/config2
>f$all_plus foo/extra
cL$all_plus foo/sym -> ../bar/baz/rsync
EOT
# Ensure there are no accidental directory-time problems.
$RSYNC -a -f '-! */' "$fromdir/" "$todir"
cp_p "$srcdir/configure.ac" "$fromdir/foo/config2"
chmod 601 "$fromdir/foo/config2"
checkdiff "$RSYNC -iplrH '$fromdir/' '$todir/'" <<EOT
>f..T.$dots bar/baz/rsync
>f..T.$dots foo/config1
>f.sTp$dots foo/config2
hf..T.$dots foo/extra => foo/config1
EOT
$RSYNC -a -f '-! */' "$fromdir/" "$todir"
cp_p "$srcdir/config.sub" "$fromdir/foo/config2"
sleep 1 # For directory mod below to ensure time difference
rm "$todir/foo/sym"
umask 0
ln -s ../bar/baz "$todir/foo/sym"
umask 022
chmod 600 "$fromdir/foo/config2"
chmod 777 "$todir/bar/baz/rsync"
checkdiff "$RSYNC -iplrtc '$fromdir/' '$todir/'" <<EOT
.f..tp$dots bar/baz/rsync
.d..t.$dots foo/
.f..t.$dots foo/config1
>fcstp$dots foo/config2
cLc$T.$dots foo/sym -> ../bar/baz/rsync
EOT
cp_p "$srcdir/configure.ac" "$fromdir/foo/config2"
chmod 600 "$fromdir/foo/config2"
# Lack of -t is for unchanged hard-link stress-test!
checkdiff "$RSYNC -vvplrH '$fromdir/' '$todir/'" \
v_filt <<EOT
bar/baz/rsync is uptodate
foo/config1 is uptodate
foo/extra is uptodate
foo/sym is uptodate
foo/config2
EOT
chmod 747 "$todir/bar/baz/rsync"
$RSYNC -a -f '-! */' "$fromdir/" "$todir"
checkdiff "$RSYNC -ivvplrtH '$fromdir/' '$todir/'" \
v_filt <<EOT
.d$allspace ./
.d$allspace bar/
.d$allspace bar/baz/
.f...p$dots bar/baz/rsync
.d$allspace foo/
.f$allspace foo/config1
>f..t.$dots foo/config2
hf$allspace foo/extra
.L$allspace foo/sym -> ../bar/baz/rsync
EOT
chmod 757 "$todir/foo/config1"
touch "$todir/foo/config2"
checkdiff "$RSYNC -vplrtH '$fromdir/' '$todir/'" \
v_filt <<EOT
foo/config2
EOT
chmod 757 "$todir/foo/config1"
touch "$todir/foo/config2"
checkdiff "$RSYNC -iplrtH '$fromdir/' '$todir/'" <<EOT
.f...p$dots foo/config1
>f..t.$dots foo/config2
EOT
checkdiff "$RSYNC -ivvplrtH --copy-dest=../to '$fromdir/' '$to2dir/'" \
v_filt <<EOT
cd$allspace ./
cd$allspace bar/
cd$allspace bar/baz/
cf$allspace bar/baz/rsync
cd$allspace foo/
cf$allspace foo/config1
cf$allspace foo/config2
hf$allspace foo/extra => foo/config1
cL$sym_dots foo/sym -> ../bar/baz/rsync
EOT
rm -rf "$to2dir"
cat - "$chkfile.extra" <<EOT >"$chkfile"
created directory $to2dir
hf$allspace foo/extra => foo/config1
EOT
checkdiff2 "$RSYNC -iplrtH --copy-dest=../to '$fromdir/' '$to2dir/'"
rm -rf "$to2dir"
checkdiff "$RSYNC -vvplrtH --copy-dest='$todir' '$fromdir/' '$to2dir/'" \
v_filt <<EOT
./ is uptodate
bar/ is uptodate
bar/baz/ is uptodate
bar/baz/rsync is uptodate
foo/ is uptodate
foo/config1 is uptodate
foo/config2 is uptodate
foo/sym $is_uptodate
foo/extra => foo/config1
EOT
rm -rf "$to2dir"
checkdiff "$RSYNC -ivvplrtH --link-dest='$todir' '$fromdir/' '$to2dir/'" \
v_filt <<EOT
cd$allspace ./
cd$allspace bar/
cd$allspace bar/baz/
hf$allspace bar/baz/rsync
cd$allspace foo/
hf$allspace foo/config1
hf$allspace foo/config2
hf$allspace foo/extra => foo/config1
$L$sym_dots foo/sym -> ../bar/baz/rsync
EOT
rm -rf "$to2dir"
cat - "$chkfile.extra" <<EOT >"$chkfile"
created directory $to2dir
EOT
checkdiff2 "$RSYNC -iplrtH --dry-run --link-dest=../to '$fromdir/' '$to2dir/'"
rm -rf "$to2dir"
checkdiff2 "$RSYNC -iplrtH --link-dest=../to '$fromdir/' '$to2dir/'"
rm -rf "$to2dir"
checkdiff "$RSYNC -vvplrtH --link-dest='$todir' '$fromdir/' '$to2dir/'" \
v_filt <<EOT
./ is uptodate
bar/ is uptodate
bar/baz/ is uptodate
bar/baz/rsync is uptodate
foo/ is uptodate
foo/config1 is uptodate
foo/config2 is uptodate
foo/extra is uptodate
foo/sym $is_uptodate
EOT
rm -rf "$to2dir"
checkdiff "$RSYNC -ivvplrtH --compare-dest='$todir' '$fromdir/' '$to2dir/'" \
v_filt <<EOT
cd$allspace ./
cd$allspace bar/
cd$allspace bar/baz/
.f$allspace bar/baz/rsync
cd$allspace foo/
.f$allspace foo/config1
.f$allspace foo/config2
.f$allspace foo/extra
$L_sym_dots foo/sym -> ../bar/baz/rsync
EOT
rm -rf "$to2dir"
cat - "$chkfile.extra" <<EOT >"$chkfile"
created directory $to2dir
EOT
checkdiff2 "$RSYNC -iplrtH --compare-dest='$todir' '$fromdir/' '$to2dir/'"
rm -rf "$to2dir"
checkdiff "$RSYNC -vvplrtH --compare-dest='$todir' '$fromdir/' '$to2dir/'" \
v_filt <<EOT
./ is uptodate
bar/ is uptodate
bar/baz/ is uptodate
bar/baz/rsync is uptodate
foo/ is uptodate
foo/config1 is uptodate
foo/config2 is uptodate
foo/extra is uptodate
foo/sym $is_uptodate
EOT
# The script would have aborted on error, so getting here means we've won.
exit 0

256
testsuite/itemize_test.py Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/itemize.test.
#
# Test the output of various copy commands to ensure itemized output
# (-i, -ii) and double-verbose output (-vv) match the canonical
# representations across whole-file and delta paths.
import os
import shutil
from rsyncfns import (
CHKFILE, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
all_plus, allspace, dots,
checkdiff, cp_p, makepath, run_rsync, v_filt,
)
to2dir = TMPDIR / 'to2'
makepath(FROMDIR / 'foo', FROMDIR / 'bar' / 'baz')
cp_p(SRCDIR / 'configure.ac', FROMDIR / 'foo' / 'config1')
cp_p(SRCDIR / 'config.sub', FROMDIR / 'foo' / 'config2')
cp_p(SRCDIR / 'rsync.h', FROMDIR / 'bar' / 'baz' / 'rsync')
os.chmod(FROMDIR / 'foo' / 'config1', 0o600)
os.chmod(FROMDIR / 'foo' / 'config2', 0o600)
os.chmod(FROMDIR / 'bar' / 'baz' / 'rsync', 0o600)
old_umask = os.umask(0)
try:
os.symlink('../bar/baz/rsync', FROMDIR / 'foo' / 'sym')
finally:
os.umask(old_umask)
os.link(FROMDIR / 'foo' / 'config1', FROMDIR / 'foo' / 'extra')
if to2dir.is_file():
to2dir.unlink()
# Detect what this rsync build supports.
vv = run_rsync('-VV', check=True, capture_output=True).stdout
hardlink_symlinks = '"hardlink_symlinks": true' in vv
symtimes_supported = '"symtimes": true' in vv
if hardlink_symlinks:
L = 'hL'
sym_dots = allspace
L_sym_dots = '.L' + allspace
is_uptodate = 'is uptodate'
chkfile_extra = '' # no extra trailing line
else:
L = 'cL'
sym_dots = 'c.t.' + dots
L_sym_dots = 'cL' + sym_dots
is_uptodate = '-> ../bar/baz/rsync'
chkfile_extra = f"cL{sym_dots} foo/sym {is_uptodate}\n"
if 'protocol=2' in RSYNC:
T = '.T'
elif symtimes_supported:
T = '.t'
else:
T = '.T'
# First check: -iplr basic itemize on a fresh transfer.
checkdiff(['-iplr', f'{FROMDIR}/', f'{TODIR}/'],
f"created directory {TODIR}\n"
f"cd{all_plus} ./\n"
f"cd{all_plus} bar/\n"
f"cd{all_plus} bar/baz/\n"
f">f{all_plus} bar/baz/rsync\n"
f"cd{all_plus} foo/\n"
f">f{all_plus} foo/config1\n"
f">f{all_plus} foo/config2\n"
f">f{all_plus} foo/extra\n"
f"cL{all_plus} foo/sym -> ../bar/baz/rsync\n")
# Touch dir times so subsequent itemize diffs don't pick up dir-time noise.
run_rsync('-a', '-f', '-! */', f'{FROMDIR}/', str(TODIR))
# Permute one file's content + mode; expect a content/mode itemize.
cp_p(SRCDIR / 'configure.ac', FROMDIR / 'foo' / 'config2')
os.chmod(FROMDIR / 'foo' / 'config2', 0o601)
checkdiff(['-iplrH', f'{FROMDIR}/', f'{TODIR}/'],
f">f..T.{dots} bar/baz/rsync\n"
f">f..T.{dots} foo/config1\n"
f">f.sTp{dots} foo/config2\n"
f"hf..T.{dots} foo/extra => foo/config1\n")
# Re-touch dirs, permute config2 again and replace the symlink target.
run_rsync('-a', '-f', '-! */', f'{FROMDIR}/', str(TODIR))
cp_p(SRCDIR / 'config.sub', FROMDIR / 'foo' / 'config2')
import time
time.sleep(1) # to provoke a directory mtime change below
(TODIR / 'foo' / 'sym').unlink()
old_umask = os.umask(0)
try:
os.symlink('../bar/baz', TODIR / 'foo' / 'sym')
finally:
os.umask(old_umask)
os.chmod(FROMDIR / 'foo' / 'config2', 0o600)
os.chmod(TODIR / 'bar' / 'baz' / 'rsync', 0o777)
checkdiff(['-iplrtc', f'{FROMDIR}/', f'{TODIR}/'],
f".f..tp{dots} bar/baz/rsync\n"
f".d..t.{dots} foo/\n"
f".f..t.{dots} foo/config1\n"
f">fcstp{dots} foo/config2\n"
f"cLc{T}.{dots} foo/sym -> ../bar/baz/rsync\n")
# Re-permute config2, leaving the others untouched; lack of -t is for
# the unchanged-hard-link stress test.
cp_p(SRCDIR / 'configure.ac', FROMDIR / 'foo' / 'config2')
os.chmod(FROMDIR / 'foo' / 'config2', 0o600)
checkdiff(['-vvplrH', f'{FROMDIR}/', f'{TODIR}/'],
"bar/baz/rsync is uptodate\n"
"foo/config1 is uptodate\n"
"foo/extra is uptodate\n"
"foo/sym is uptodate\n"
"foo/config2\n",
filter=v_filt)
# Touch a mode change on one dest file then run -ii to see "no change".
os.chmod(TODIR / 'bar' / 'baz' / 'rsync', 0o747)
run_rsync('-a', '-f', '-! */', f'{FROMDIR}/', str(TODIR))
checkdiff(['-ivvplrtH', f'{FROMDIR}/', f'{TODIR}/'],
f".d{allspace} ./\n"
f".d{allspace} bar/\n"
f".d{allspace} bar/baz/\n"
f".f...p{dots} bar/baz/rsync\n"
f".d{allspace} foo/\n"
f".f{allspace} foo/config1\n"
f">f..t.{dots} foo/config2\n"
f"hf{allspace} foo/extra\n"
f".L{allspace} foo/sym -> ../bar/baz/rsync\n",
filter=v_filt)
# Permute one perm and re-touch a file; expect just those two itemizes.
os.chmod(TODIR / 'foo' / 'config1', 0o757)
(TODIR / 'foo' / 'config2').touch()
checkdiff(['-vplrtH', f'{FROMDIR}/', f'{TODIR}/'],
"foo/config2\n",
filter=v_filt)
os.chmod(TODIR / 'foo' / 'config1', 0o757)
(TODIR / 'foo' / 'config2').touch()
checkdiff(['-iplrtH', f'{FROMDIR}/', f'{TODIR}/'],
f".f...p{dots} foo/config1\n"
f">f..t.{dots} foo/config2\n")
# --copy-dest variants.
checkdiff(['-ivvplrtH', '--copy-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
f"cd{allspace} ./\n"
f"cd{allspace} bar/\n"
f"cd{allspace} bar/baz/\n"
f"cf{allspace} bar/baz/rsync\n"
f"cd{allspace} foo/\n"
f"cf{allspace} foo/config1\n"
f"cf{allspace} foo/config2\n"
f"hf{allspace} foo/extra => foo/config1\n"
f"cL{sym_dots} foo/sym -> ../bar/baz/rsync\n",
filter=v_filt)
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-iplrtH', '--copy-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
f"created directory {to2dir}\n"
f"hf{allspace} foo/extra => foo/config1\n"
+ chkfile_extra)
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-vvplrtH', f'--copy-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
"./ is uptodate\n"
"bar/ is uptodate\n"
"bar/baz/ is uptodate\n"
"bar/baz/rsync is uptodate\n"
"foo/ is uptodate\n"
"foo/config1 is uptodate\n"
"foo/config2 is uptodate\n"
f"foo/sym {is_uptodate}\n"
"foo/extra => foo/config1\n",
filter=v_filt)
# --link-dest variants.
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-ivvplrtH', f'--link-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
f"cd{allspace} ./\n"
f"cd{allspace} bar/\n"
f"cd{allspace} bar/baz/\n"
f"hf{allspace} bar/baz/rsync\n"
f"cd{allspace} foo/\n"
f"hf{allspace} foo/config1\n"
f"hf{allspace} foo/config2\n"
f"hf{allspace} foo/extra => foo/config1\n"
f"{L}{sym_dots} foo/sym -> ../bar/baz/rsync\n",
filter=v_filt)
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-iplrtH', '--dry-run', '--link-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
f"created directory {to2dir}\n"
+ chkfile_extra)
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-iplrtH', '--link-dest=../to', f'{FROMDIR}/', f'{to2dir}/'],
f"created directory {to2dir}\n"
+ chkfile_extra)
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-vvplrtH', f'--link-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
"./ is uptodate\n"
"bar/ is uptodate\n"
"bar/baz/ is uptodate\n"
"bar/baz/rsync is uptodate\n"
"foo/ is uptodate\n"
"foo/config1 is uptodate\n"
"foo/config2 is uptodate\n"
"foo/extra is uptodate\n"
f"foo/sym {is_uptodate}\n",
filter=v_filt)
# --compare-dest variants.
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-ivvplrtH', f'--compare-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
f"cd{allspace} ./\n"
f"cd{allspace} bar/\n"
f"cd{allspace} bar/baz/\n"
f".f{allspace} bar/baz/rsync\n"
f"cd{allspace} foo/\n"
f".f{allspace} foo/config1\n"
f".f{allspace} foo/config2\n"
f".f{allspace} foo/extra\n"
f"{L_sym_dots} foo/sym -> ../bar/baz/rsync\n",
filter=v_filt)
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-iplrtH', f'--compare-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
f"created directory {to2dir}\n"
+ chkfile_extra)
shutil.rmtree(to2dir, ignore_errors=True)
checkdiff(['-vvplrtH', f'--compare-dest={TODIR}', f'{FROMDIR}/', f'{to2dir}/'],
"./ is uptodate\n"
"bar/ is uptodate\n"
"bar/baz/ is uptodate\n"
"bar/baz/rsync is uptodate\n"
"foo/ is uptodate\n"
"foo/config1 is uptodate\n"
"foo/config2 is uptodate\n"
"foo/extra is uptodate\n"
f"foo/sym {is_uptodate}\n",
filter=v_filt)

View File

@@ -1,26 +0,0 @@
#!/bin/sh
# Copyright (C) 1998,1999 Philip Hands <phil@hands.com>
# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
#
# This program is distributable under the terms of the GNU GPL (see COPYING)
. "$suitedir/rsync.fns"
hands_setup
longname=This-is-a-directory-with-a-stupidly-long-name-created-in-an-attempt-to-provoke-an-error-found-in-2.0.11-that-should-hopefully-never-appear-again-if-this-test-does-its-job
longdir="$fromdir/$longname/$longname/$longname"
makepath "$longdir" || test_skipped "unable to create long directory"
touch "$longdir/1" || test_skipped "unable to create files in long directory"
date > "$longdir/1"
if [ -r /etc ]; then
ls -la /etc >"$longdir/2" || [ $? -eq 1 ]
else
ls -la / >"$longdir/2" || [ $? -eq 1 ]
fi
checkit "$RSYNC --delete -avH '$fromdir/' '$todir'" "$fromdir/" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

41
testsuite/longdir_test.py Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/longdir.test.
#
# Regression test for a 2.0.11 bug: rsync used to mishandle paths nested
# inside a stupidly-long directory name. We build a three-deep nest of
# 175-char directory names, drop a couple of files in the leaf, and
# verify that --delete -avH still produces an identical destination.
import os
import subprocess
from rsyncfns import FROMDIR, TODIR, checkit, hands_setup, test_skipped
hands_setup()
longname = ('This-is-a-directory-with-a-stupidly-long-name-created-in-an-'
'attempt-to-provoke-an-error-found-in-2.0.11-that-should-'
'hopefully-never-appear-again-if-this-test-does-its-job')
longdir = FROMDIR / longname / longname / longname
try:
longdir.mkdir(parents=True)
except OSError:
test_skipped("unable to create long directory")
try:
(longdir / '1').touch()
except OSError:
test_skipped("unable to create files in long directory")
# Drop some recognisably-varied content into the two leaf files.
(longdir / '1').write_text(subprocess.check_output(['date'], text=True))
listdir = '/etc' if os.access('/etc', os.R_OK) else '/'
out = subprocess.run(['ls', '-la', listdir], capture_output=True, text=True)
# ls exits 1 if it can't stat some entries (e.g. permission-denied files in
# /etc); the shell test silently accepts that. We do the same.
(longdir / '2').write_text(out.stdout)
checkit(['--delete', '-avH', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR)

View File

@@ -1,57 +0,0 @@
#!/bin/sh
# Copyright (C) 2004-2022 Wayne Davison
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Make sure we can merge files from multiple directories into one.
. "$suitedir/rsync.fns"
# Build some files/dirs/links to copy
# Use local dirnames to better exercise the arg-parsing code.
cd "$tmpdir"
mkdir from1 from2 from3 deep
mkdir from2/sub1 from3/sub1
mkdir from3/sub2 from1/dir-and-not-dir
mkdir chk chk/sub1 chk/sub2 chk/dir-and-not-dir
echo "one" >from1/one
cp_touch from1/one from2/one
cp_touch from1/one from3/one
echo "two" >from1/two
echo "three" >from2/three
echo "four" >from3/four
echo "five" >from1/five
echo "six" >from3/six
echo "sub1" >from2/sub1/uno
cp_touch from2/sub1/uno from3/sub1/uno
echo "sub2" >from3/sub1/dos
echo "sub3" >from2/sub1/tres
echo "subby" >from3/sub2/subby
echo "extra" >from1/dir-and-not-dir/inside
echo "not-dir" >from3/dir-and-not-dir
echo "arg-test" >deep/arg-test
echo "shallow" >shallow
cp_touch from1/one from1/two from2/three from3/four from1/five from3/six chk
cp_touch deep/arg-test shallow chk
cp_touch from1/dir-and-not-dir/inside chk/dir-and-not-dir
cp_touch from2/sub1/uno from3/sub1/dos from2/sub1/tres chk/sub1
cp_touch from3/sub2/subby chk/sub2
# Make sure that time has moved on.
sleep 1
# Get rid of any directory-time differences
$RSYNC -av --existing -f 'exclude,! */' from1/ from2/
$RSYNC -av --existing -f 'exclude,! */' from2/ from3/
$RSYNC -av --existing -f 'exclude,! */' from1/ chk/
$RSYNC -av --existing -f 'exclude,! */' from3/ chk/
checkit "$RSYNC -avv deep/arg-test shallow from1/ from2/ from3/ to/" "$chkdir" "$todir"
# The script would have aborted on error, so getting here means we've won.
exit 0

89
testsuite/merge_test.py Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/merge.test.
#
# Verify that rsync merges files from multiple source directories into a
# single destination, with later sources NOT clobbering earlier ones for
# unchanged content and per-directory conflict resolution behaving as in
# the canonical case.
import os
import time
from rsyncfns import (
CHKDIR, TMPDIR, TODIR,
checkit, cp_touch, run_rsync,
)
# Use relative names below so the rsync command line exercises the
# arg-parsing path the way the shell test did.
os.chdir(TMPDIR)
for d in ('from1', 'from2', 'from3', 'deep'):
os.mkdir(d)
for d in ('from2/sub1', 'from3/sub1', 'from3/sub2', 'from1/dir-and-not-dir'):
os.mkdir(d)
CHKDIR.mkdir(exist_ok=True)
for d in ('sub1', 'sub2', 'dir-and-not-dir'):
(CHKDIR / d).mkdir()
with open('from1/one', 'w') as f:
f.write("one\n")
cp_touch('from1/one', 'from2/one')
cp_touch('from1/one', 'from3/one')
with open('from1/two', 'w') as f:
f.write("two\n")
with open('from2/three', 'w') as f:
f.write("three\n")
with open('from3/four', 'w') as f:
f.write("four\n")
with open('from1/five', 'w') as f:
f.write("five\n")
with open('from3/six', 'w') as f:
f.write("six\n")
with open('from2/sub1/uno', 'w') as f:
f.write("sub1\n")
cp_touch('from2/sub1/uno', 'from3/sub1/uno')
with open('from3/sub1/dos', 'w') as f:
f.write("sub2\n")
with open('from2/sub1/tres', 'w') as f:
f.write("sub3\n")
with open('from3/sub2/subby', 'w') as f:
f.write("subby\n")
with open('from1/dir-and-not-dir/inside', 'w') as f:
f.write("extra\n")
with open('from3/dir-and-not-dir', 'w') as f:
f.write("not-dir\n")
with open('deep/arg-test', 'w') as f:
f.write("arg-test\n")
with open('shallow', 'w') as f:
f.write("shallow\n")
for src in ('from1/one', 'from1/two', 'from2/three', 'from3/four',
'from1/five', 'from3/six'):
cp_touch(src, str(CHKDIR))
cp_touch('deep/arg-test', str(CHKDIR))
cp_touch('shallow', str(CHKDIR))
cp_touch('from1/dir-and-not-dir/inside', str(CHKDIR / 'dir-and-not-dir'))
for src in ('from2/sub1/uno', 'from3/sub1/dos', 'from2/sub1/tres'):
cp_touch(src, str(CHKDIR / 'sub1'))
cp_touch('from3/sub2/subby', str(CHKDIR / 'sub2'))
# Make sure time has moved on before the rsync runs.
time.sleep(1)
# Pre-sync directory-only updates to flatten directory-time differences,
# matching the shell test's --existing -f 'exclude,! */' preparation.
def _flatten_dirs(src, dst):
run_rsync('-av', '--existing', '-f', 'exclude,! */', f'{src}/', f'{dst}/')
_flatten_dirs('from1', 'from2')
_flatten_dirs('from2', 'from3')
_flatten_dirs('from1', str(CHKDIR))
_flatten_dirs('from3', str(CHKDIR))
checkit(['-avv', 'deep/arg-test', 'shallow', 'from1/', 'from2/', 'from3/', 'to/'],
CHKDIR, TODIR)

View File

@@ -1,34 +0,0 @@
#!/bin/sh
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Test three bugs fixed by my redoing of the missing_below logic.
. $suitedir/rsync.fns
makepath "$fromdir/subdir" "$todir"
echo data >"$fromdir/subdir/file"
echo data >"$todir/other"
# Test 1: Too much "not creating new..." output on a dry run
$RSYNC -n -r --ignore-non-existing -vv "$fromdir/" "$todir/" | tee "$scratchdir/out"
if grep 'not creating new.*subdir/file' "$scratchdir/out" >/dev/null; then
test_fail 'test 1 failed'
fi
case "$RSYNC" in
*protocol=29*) # FIXME can we get past the new flist sanity check in protocol 29?
echo "Skipped test 2 for protocol 29."
;;
*)
# Test 2: Attempt to make a fuzzy dirlist for a dir not created on a dry run
$RSYNC -n -r -R --no-implied-dirs -y "$fromdir/./subdir/file" "$todir/" \
|| test_fail 'test 2 failed'
;;
esac
# Test 3: --delete-after pass skipped when last dir is dry-missing
$RSYNC -n -r --delete-after -i "$fromdir/" "$todir/" | tee "$scratchdir/out"
grep '^\*deleting * other' "$scratchdir/out" >/dev/null \
|| test_fail 'test 3 failed'

56
testsuite/missing_test.py Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/missing.test.
#
# Three regressions guarded by the missing_below logic rewrite:
# 1. Dry-run with --ignore-non-existing must NOT emit "not creating new"
# for files whose containing directory already exists at the dest.
# 2. Dry-run -R -y --no-implied-dirs must not crash trying to build a
# fuzzy dirlist for a directory it never created.
# 3. --delete-after dry-run still emits "*deleting" lines even when the
# last source dir is dry-missing on the destination.
import os
import subprocess
from rsyncfns import FROMDIR, RSYNC, TMPDIR, TODIR, makepath, rsync_argv, test_fail
makepath(FROMDIR / 'subdir', TODIR)
(FROMDIR / 'subdir' / 'file').write_text("data\n")
(TODIR / 'other').write_text("data\n")
def run_capture(*args):
proc = subprocess.run(rsync_argv(*args), capture_output=True, text=True)
print(proc.stdout, end='')
print(proc.stderr, end='')
return proc
# Test 1: too much "not creating new..." output on a dry-run.
out_path = TMPDIR / 'out1'
proc = run_capture('-n', '-r', '--ignore-non-existing', '-vv',
f'{FROMDIR}/', f'{TODIR}/')
out_path.write_text(proc.stdout)
for line in proc.stdout.splitlines():
if 'not creating new' in line and 'subdir/file' in line:
test_fail("test 1 failed: dry-run announced creating subdir/file")
# Test 2: fuzzy dirlist crash on dry-run. Skipped on protocol 29 just like
# the shell version did, since the new flist sanity check rejects this.
if 'protocol=29' not in RSYNC:
proc = run_capture('-n', '-r', '-R', '--no-implied-dirs', '-y',
f'{FROMDIR}/./subdir/file', f'{TODIR}/')
if proc.returncode != 0:
test_fail("test 2 failed: --no-implied-dirs dry-run errored")
else:
print("Skipped test 2 for protocol 29.")
# Test 3: --delete-after pass skipped when last dir is dry-missing.
proc = run_capture('-n', '-r', '--delete-after', '-i',
f'{FROMDIR}/', f'{TODIR}/')
saw_delete = any(line.lstrip().startswith('*deleting')
and 'other' in line
for line in proc.stdout.splitlines())
if not saw_delete:
test_fail("test 3 failed: no '*deleting other' line in dry-run output")

View File

@@ -1,47 +0,0 @@
#!/bin/sh
. "$suitedir/rsync.fns"
makepath "$fromdir"
makepath "$todir"
cp_p "$srcdir/rsync.h" "$fromdir/text"
cp_p "$srcdir/configure.ac" "$fromdir/extra"
cd "$tmpdir"
deep_dir=to/foo/bar/baz/down/deep
# Check that we can create several levels of dest dir
$RSYNC -aiv --mkpath from/text $deep_dir/new
test -f $deep_dir/new || test_fail "'new' file not found in $deep_dir dir"
rm -rf to/foo
$RSYNC -aiv --mkpath from/text $deep_dir/
test -f $deep_dir/text || test_fail "'text' file not found in $deep_dir dir"
rm $deep_dir/text
# Make sure we can handle an existing path
mkdir $deep_dir/new
$RSYNC -aiv --mkpath from/text $deep_dir/new
test -f $deep_dir/new/text || test_fail "'text' file not found in $deep_dir/new dir"
# ... and an existing path when an alternate dest filename is specified
$RSYNC -aiv --mkpath from/text $deep_dir/new/text2
test -f $deep_dir/new/text2 || test_fail "'text2' file not found in $deep_dir/new dir"
rm -rf to/foo
# Try the tests again with multiple source args
$RSYNC -aiv --mkpath from/ $deep_dir
test -f $deep_dir/extra || test_fail "'extra' file not found in $deep_dir dir"
rm -rf to/foo
$RSYNC -aiv --mkpath from/ $deep_dir/
test -f $deep_dir/text || test_fail "'text' file not found in $deep_dir dir"
# Make sure that we can handle no path
$RSYNC -aiv --mkpath from/text to_text
test -f to_text || test_fail "'to_text' file not found in current dir"
# The script would have aborted on error, so getting here means we've won.
exit 0

65
testsuite/mkpath_test.py Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/mkpath.test.
#
# Test the rsync --mkpath option: it should create any missing intermediate
# destination directories rather than erroring out.
import os
import shutil
from pathlib import Path
from rsyncfns import (
FROMDIR, SRCDIR, TMPDIR, TODIR,
makepath, rmtree, run_rsync, test_fail,
)
makepath(FROMDIR, TODIR)
shutil.copy2(SRCDIR / 'rsync.h', FROMDIR / 'text')
shutil.copy2(SRCDIR / 'configure.ac', FROMDIR / 'extra')
# All paths in the rsync invocations below are interpreted relative to
# TMPDIR, matching the original shell test which did `cd "$tmpdir"`.
os.chdir(TMPDIR)
deep_dir = Path('to/foo/bar/baz/down/deep')
def assert_file(path: Path, label: str) -> None:
if not path.is_file():
test_fail(f"{label}: {path} not found")
# Create several levels of dest dir (file destination — final component
# is the new filename).
run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir / 'new'))
assert_file(deep_dir / 'new', "'new' file in deep dir")
rmtree('to/foo')
# Trailing slash on the dest means it's a directory; the file keeps its name.
run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir) + '/')
assert_file(deep_dir / 'text', "'text' file in deep dir (trailing-slash dest)")
(deep_dir / 'text').unlink()
# An existing destination directory should also work.
(deep_dir / 'new').mkdir(parents=True, exist_ok=True)
run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir / 'new'))
assert_file(deep_dir / 'new' / 'text', "'text' file in pre-existing deep/new dir")
# ... and an existing path when an alternate dest filename is specified.
run_rsync('-aiv', '--mkpath', 'from/text', str(deep_dir / 'new' / 'text2'))
assert_file(deep_dir / 'new' / 'text2', "'text2' renamed file in pre-existing deep/new dir")
rmtree('to/foo')
# Multiple source args (whole directory) — bare dest name.
run_rsync('-aiv', '--mkpath', 'from/', str(deep_dir))
assert_file(deep_dir / 'extra', "'extra' file in deep dir (multi-source, no trailing slash)")
rmtree('to/foo')
# Multiple source args (whole directory) — dest with trailing slash.
run_rsync('-aiv', '--mkpath', 'from/', str(deep_dir) + '/')
assert_file(deep_dir / 'text', "'text' file in deep dir (multi-source, trailing slash)")
# No intermediate path at all — dest is just a file in the current dir.
run_rsync('-aiv', '--mkpath', 'from/text', 'to_text')
assert_file(Path('to_text'), "'to_text' file in current dir")

View File

@@ -1,32 +0,0 @@
#!/bin/sh
# Test rsync --open-noatime option keeps source atimes intact
. "$suitedir/rsync.fns"
$RSYNC -VV | grep '"atimes": true' >/dev/null || test_skipped "Rsync is configured without atimes support"
# O_NOATIME is Linux-specific; skip on other platforms
case `uname` in
Linux) ;;
*) test_skipped "O_NOATIME is only supported on Linux" ;;
esac
mkdir "$fromdir"
# --open-noatime did not work properly on files with size > 0
echo content > "$fromdir/foo"
touch -a -t 200102031717.42 "$fromdir/foo"
TLS_ARGS=--atimes
"$TOOLDIR/tls" $TLS_ARGS "$fromdir/foo" > "$tmpdir/atime-from-before"
# Do not use checkit because it uses "diff" which breaks atimes
$RSYNC --open-noatime --archive --recursive --times --atimes -vvv "$fromdir/" "$todir/"
"$TOOLDIR/tls" $TLS_ARGS "$fromdir/foo" > "$tmpdir/atime-from-after"
diff "$tmpdir/atime-from-before" "$tmpdir/atime-from-after"
# The script would have aborted on error, so getting here means we've won.
exit 0

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/open-noatime.test.
#
# Test that rsync --open-noatime keeps the source atime intact across the
# transfer. --open-noatime did not work properly on files with size > 0
# at one point, so the test uses a non-empty source file.
import datetime
import os
import platform
import shlex
import subprocess
import rsyncfns
from rsyncfns import FROMDIR, TMPDIR, TODIR, TOOLDIR, run_rsync, test_fail, test_skipped
vv = run_rsync('-VV', check=True, capture_output=True)
if '"atimes": true' not in vv.stdout:
test_skipped("Rsync is configured without atimes support")
# O_NOATIME is Linux-specific.
if platform.system() != 'Linux':
test_skipped("O_NOATIME is only supported on Linux")
FROMDIR.mkdir(parents=True, exist_ok=True)
foo = FROMDIR / 'foo'
foo.write_text("content\n")
# Pin the source atime to a known historical value (mtime preserved).
atime = datetime.datetime(2001, 2, 3, 17, 17, 42).timestamp()
mtime = foo.stat().st_mtime
os.utime(foo, (atime, mtime))
rsyncfns.TLS_ARGS = '--atimes'
# Capture the atime of the source via tls BEFORE the rsync run.
def _tls_listing(path: str) -> str:
cmd = [str(TOOLDIR / 'tls')] + shlex.split(rsyncfns.TLS_ARGS) + [str(path)]
return subprocess.check_output(cmd, text=True)
before = _tls_listing(foo)
(TMPDIR / 'atime-from-before').write_text(before)
# Don't use checkit() here -- the file-content diff it does would update
# atimes on the source and defeat the test.
run_rsync('--open-noatime', '--archive', '--recursive', '--times',
'--atimes', '-vvv', f'{FROMDIR}/', f'{TODIR}/')
after = _tls_listing(foo)
(TMPDIR / 'atime-from-after').write_text(after)
if before != after:
diff = subprocess.run(
['diff', '-u',
str(TMPDIR / 'atime-from-before'),
str(TMPDIR / 'atime-from-after')],
capture_output=True, text=True,
)
print(diff.stdout)
test_fail("source atime changed across rsync --open-noatime run")

View File

@@ -1,37 +0,0 @@
#!/bin/sh
# Copyright (C) 2021 by Achim Leitner <aleitner@lis-engineering.de>
# This program is distributable under the terms of the GNU GPL (see COPYING)
#
# Modern linux systems have the protected_regular feature set to 1 or 2
# See https://www.kernel.org/doc/Documentation/sysctl/fs.txt
# Make sure we can still write these files in --inplace mode
. "$suitedir/rsync.fns"
test -f /proc/sys/fs/protected_regular || test_skipped "Can't find protected_regular setting (only available on Linux)"
pr_lvl=`cat /proc/sys/fs/protected_regular 2>/dev/null` || test_skipped "Can't check if fs.protected_regular is enabled"
test "$pr_lvl" != 0 || test_skipped "fs.protected_regular is not enabled"
workdir="$tmpdir/files"
mkdir -p "$workdir"
chmod 1777 "$workdir"
echo "Source" > "$workdir/src"
echo "" > "$workdir/dst"
if ! chown 5001 "$workdir/dst" 2>/dev/null; then
# Not root - try re-running under unshare with UID mapping
if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user --map-users 5001:100000:1 true 2>/dev/null; then
echo "Re-running under unshare with UID mapping..."
RSYNC_UNSHARED=1 exec unshare --user --map-root-user --map-users 5001:100000:1 "$SHELL_PATH" $RUNSHFLAGS "$0"
fi
test_skipped "Can't chown (need root or unshare with uidmap)"
fi
echo "Contents of $workdir:"
ls -al "$workdir"
$RSYNC --inplace "$workdir/src" "$workdir/dst" || test_fail
exit 0

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/protected-regular.test.
#
# Modern Linux kernels can set fs.protected_regular = {1,2}, which
# blocks O_CREAT|O_WRONLY opens of files in world-writable sticky
# directories that the opener doesn't own. rsync --inplace must still
# be able to write into these files; this test guards that path.
import os
import shutil
import subprocess
import sys
from pathlib import Path
from rsyncfns import TMPDIR, run_rsync, test_skipped
pr_path = Path('/proc/sys/fs/protected_regular')
if not pr_path.is_file():
test_skipped("Can't find protected_regular setting (only available on Linux)")
try:
pr_lvl = pr_path.read_text().strip()
except OSError:
test_skipped("Can't check if fs.protected_regular is enabled")
if pr_lvl == '0':
test_skipped("fs.protected_regular is not enabled")
workdir = TMPDIR / 'files'
workdir.mkdir(parents=True, exist_ok=True)
os.chmod(workdir, 0o1777)
(workdir / 'src').write_text("Source\n")
(workdir / 'dst').write_text("")
def _chown_5001(path: Path) -> bool:
"""Try to chown(2) `path` to uid 5001. Returns True on success."""
try:
os.chown(path, 5001, -1)
return True
except PermissionError:
return False
if not _chown_5001(workdir / 'dst'):
# Not root: fall back to re-running ourselves under unshare with a
# uid mapping (Linux user-namespace trick). Only attempt once.
if not os.environ.get('RSYNC_UNSHARED'):
unshare = shutil.which('unshare')
if unshare is not None:
probe = subprocess.run(
[unshare, '--user', '--map-root-user',
'--map-users', '5001:100000:1', 'true'],
capture_output=True,
)
if probe.returncode == 0:
print("Re-running under unshare with UID mapping...")
env = os.environ.copy()
env['RSYNC_UNSHARED'] = '1'
os.execvpe(
unshare,
[unshare, '--user', '--map-root-user',
'--map-users', '5001:100000:1',
sys.executable, __file__],
env,
)
test_skipped("Can't chown (need root or unshare with uidmap)")
print(f"Contents of {workdir}:")
subprocess.run(['ls', '-al', str(workdir)])
run_rsync('--inplace', str(workdir / 'src'), str(workdir / 'dst'))

View File

@@ -1,128 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for the off-by-one stack OOB write in
# establish_proxy_connection() in socket.c when a malicious or
# man-in-the-middle HTTP proxy returns a first response line of
# 1023+ bytes without a '\n' terminator.
#
# Pre-fix: the read loop walked buffer[0..sizeof-2] one byte at a
# time, then post-loop logic did "if (*cp != '\n') cp++; *cp-- =
# '\0';". If no newline arrived before the loop filled the buffer,
# cp was left at &buffer[sizeof-1] (never written by the loop),
# *cp held stale stack bytes, and cp++ pushed cp one past the array.
# The null-termination then wrote one byte out of bounds on the
# stack. AddressSanitizer reports stack-buffer-overflow at the
# null-termination site.
#
# Post-fix: the bound-exhaustion case is detected by position and
# rejected with an "proxy response line too long" message, so no
# OOB write occurs and rsync exits with a non-signal status.
. "$suitedir/rsync.fns"
command -v python3 >/dev/null 2>&1 || test_skipped "python3 not available"
workdir="$scratchdir/workdir"
mkdir -p "$workdir"
cd "$workdir"
port_file="$workdir/port"
proxy_log="$workdir/proxy.log"
# A minimal TCP listener: binds to an ephemeral port on 127.0.0.1,
# writes the chosen port to $port_file *before* accept() so the test
# can synchronise without a sleep, accepts one connection, reads
# until end-of-headers or 64 KiB, sends exactly 1023 bytes of 'X'
# with no '\n', then closes.
python3 - "$port_file" >"$proxy_log" 2>&1 <<'PYEOF' &
import socket, sys, os
port_file = sys.argv[1]
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
tmp = port_file + ".tmp"
with open(tmp, "w") as fp:
fp.write("%d\n" % port)
os.rename(tmp, port_file) # atomic visibility to the shell side
s.listen(1)
conn, _ = s.accept()
conn.settimeout(5)
try:
data = b""
while b"\r\n\r\n" not in data and len(data) < 65536:
chunk = conn.recv(8192)
if not chunk:
break
data += chunk
except socket.timeout:
pass
conn.sendall(b"X" * 1023) # exactly the buffer-1 trigger size
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
conn.close()
s.close()
PYEOF
proxy_pid=$!
# Wait up to ~10s for the listener to publish its port.
i=0
while [ ! -s "$port_file" ] && [ $i -lt 10 ]; do
sleep 1
i=$((i + 1))
done
if [ ! -s "$port_file" ]; then
kill "$proxy_pid" 2>/dev/null
cat "$proxy_log" >&2 2>/dev/null
test_fail "proxy listener never published a port"
fi
port=`cat "$port_file"`
case "$port" in
*[!0-9]*|"") kill "$proxy_pid" 2>/dev/null; test_fail "bogus port from listener: '$port'" ;;
esac
# Run rsync through the malicious proxy. Any rsync:// URL works:
# the proxy intercepts the CONNECT and never forwards anywhere.
rsync_err="$workdir/rsync.err"
# rsync MUST exit non-zero here (the proxy is misbehaving).
# Use `|| status=$?` so we capture the real exit code under `sh -e`;
# `if ! cmd; then status=$?` would only ever see 0 because the `!`
# is the last command before `$?`.
status=0
RSYNC_PROXY="127.0.0.1:$port" \
$RSYNC rsync://example.invalid:873/whatever/ "$workdir/out/" \
>/dev/null 2>"$rsync_err" || status=$?
# Reap the listener.
wait "$proxy_pid" 2>/dev/null || true
# 1. rsync must not have crashed (SIGSEGV/SIGABRT report >= 128).
if [ "$status" -ge 128 ]; then
cat "$rsync_err" >&2
test_fail "rsync killed by signal (status=$status) -- possible stack OOB regression"
fi
# 2. rsync must have actually exited non-zero (i.e. saw the bad proxy).
if [ "$status" -eq 0 ]; then
cat "$rsync_err" >&2
test_fail "rsync returned success despite malformed proxy response"
fi
# 3. The new error message must appear.
if ! grep -q "proxy response line too long" "$rsync_err"; then
cat "$rsync_err" >&2
test_fail "expected 'proxy response line too long' in rsync stderr"
fi
echo "OK: over-long proxy response line rejected cleanly without crashing"
exit 0

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/proxy-response-line-too-long.test.
#
# Regression test for the off-by-one stack OOB write in
# establish_proxy_connection() when a malicious or man-in-the-middle
# HTTP proxy returned a first response line of 1023+ bytes without a
# '\n' terminator. Post-fix, rsync must reject this with "proxy
# response line too long" and exit non-zero without dying from a signal.
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
from rsyncfns import SCRATCHDIR, rsync_argv, test_fail, test_skipped
if shutil.which('python3') is None:
test_skipped("python3 not available")
workdir = SCRATCHDIR / 'workdir'
workdir.mkdir(parents=True, exist_ok=True)
os.chdir(workdir)
# In-process listener: bind a TCP socket, capture the chosen port,
# accept one client, read up to end-of-headers (or 64 KiB), reply
# with exactly 1023 'X' bytes and no '\n', then close. We use a
# thread rather than spawning python3 again -- simpler synchronisation,
# same effect on the rsync side.
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(('127.0.0.1', 0))
port = listener.getsockname()[1]
listener.listen(1)
def _serve_one():
conn, _ = listener.accept()
conn.settimeout(5)
try:
data = b""
while b"\r\n\r\n" not in data and len(data) < 65536:
chunk = conn.recv(8192)
if not chunk:
break
data += chunk
except socket.timeout:
pass
conn.sendall(b"X" * 1023)
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
conn.close()
t = threading.Thread(target=_serve_one)
t.daemon = True
t.start()
# Run rsync against the malicious proxy. The proxy intercepts CONNECT
# and never forwards, so the upstream URL is irrelevant.
env = os.environ.copy()
env['RSYNC_PROXY'] = f'127.0.0.1:{port}'
proc = subprocess.run(
rsync_argv('rsync://example.invalid:873/whatever/', f'{workdir}/out/'),
capture_output=True, text=True, env=env,
)
t.join(timeout=15)
listener.close()
status = proc.returncode
err = proc.stderr
if status >= 128:
sys.stderr.write(err)
test_fail(f"rsync killed by signal (status={status}) -- possible stack OOB regression")
if status == 0:
sys.stderr.write(err)
test_fail("rsync returned success despite malformed proxy response")
if 'proxy response line too long' not in err:
sys.stderr.write(err)
test_fail("expected 'proxy response line too long' in rsync stderr")
print("OK: over-long proxy response line rejected cleanly without crashing")

View File

@@ -1,60 +0,0 @@
#!/bin/sh
# Copyright (C) 2005-2020 Wayne Davison
#
# This program is distributable under the terms of the GNU GPL (see COPYING)
. "$suitedir/rsync.fns"
deepstr='down/3/deep'
deepdir="$fromdir/$deepstr"
extradir="$fromdir/extra"
makepath "$deepdir" "$extradir/$deepstr" "$chkdir"
fromdir="$deepdir"
hands_setup
fromdir="$tmpdir/from"
extrafile="$extradir/./$deepstr/extra.added.value"
echo wowza >"$extrafile"
$RSYNC -av --existing --include='*/' --exclude='*' "$fromdir/" "$extradir/"
cd "$fromdir"
# Main script starts here
$RSYNC -ai --include=/down/ --exclude='/*' "$fromdir/" "$chkdir/"
sleep 1
runtest "basic relative" 'checkit "$RSYNC -avR ./$deepstr \"$todir\"" "$chkdir" "$todir"'
ln $deepstr/filelist $deepstr/dir
ln ../chk/$deepstr/filelist ../chk/$deepstr/dir
# Work around time rounding/truncating issue by touching both dirs.
touch -r $deepstr/dir $deepstr/dir ../chk/$deepstr/dir
runtest "hard links" 'checkit "$RSYNC -avHR ./$deepstr/ \"$todir\"" "$chkdir" "$todir"'
cp "$deepdir/text" "$todir/$deepstr/ThisShouldGo"
cp "$deepdir/text" "$todir/$deepstr/dir/ThisShouldGoToo"
runtest "deletion" 'checkit "$RSYNC -avHR --del ./$deepstr/ \"$todir\"" "$chkdir" "$todir"'
runtest "non-deletion" 'checkit "$RSYNC -aiHR --del ./$deepstr/ \"$todir\"" "$chkdir" "$todir"' \
| tee "$outfile"
# Make sure no files were deleted
grep 'deleting ' "$outfile" && test_fail "Erroneous deletions occurred!"
# Relative with merging.
$RSYNC -ai "$extradir/down" "$chkdir/"
checkit "$RSYNC -aiR $deepstr '$extrafile' '$todir'" "$chkdir" "$todir"
checkit "$RSYNC -aiR --del $deepstr '$extrafile' '$todir'" "$chkdir" "$todir" \
| tee "$outfile"
# Make sure no files were deleted
grep 'deleting ' "$outfile" && test_fail "Erroneous deletions occurred! (2)"
# The script would have aborted on error, so getting here means we've won.
exit 0

111
testsuite/relative_test.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/relative.test.
#
# Exercise rsync --relative (-R) behaviour: paths anchored at a "./" cut
# point should reproduce that subtree at the destination. We pile on
# hard-link preservation, --del / --del-on-extras and the side-by-side
# combination of an -R-anchored arg with an absolute "extra" path.
import os
import subprocess
import time
from rsyncfns import (
CHKDIR, FROMDIR, OUTFILE, TMPDIR, TODIR,
checkit, hands_setup, makepath, rsync_argv,
run_rsync, test_fail,
)
deepstr = 'down/3/deep'
deepdir = FROMDIR / deepstr
extradir = TMPDIR / 'extra'
makepath(deepdir, extradir / deepstr, CHKDIR)
# Generate the rich source tree underneath the deep nested dir, not under
# fromdir directly. hands_setup reads FROMDIR from the module, so override
# briefly via the rsyncfns module attribute.
import rsyncfns
real_fromdir = rsyncfns.FROMDIR
try:
rsyncfns.FROMDIR = deepdir
hands_setup()
finally:
rsyncfns.FROMDIR = real_fromdir
extrafile = extradir / deepstr / 'extra.added.value'
extrafile.write_text("wowza\n")
# rsync's -R uses "./" as the anchor cut point: anything after it is the
# subtree path to recreate at the destination. Preserve the literal "./"
# in the string we pass to rsync, separately from the Path we use for
# filesystem operations.
extrafile_for_rsync = f"{extradir}/./{deepstr}/extra.added.value"
# Seed extradir with just the directory skeleton of fromdir.
run_rsync('-av', '--existing', '--include=*/', '--exclude=*',
f'{FROMDIR}/', f'{extradir}/')
os.chdir(FROMDIR)
# chkdir: same shape as a --include=/down/ --exclude=/* sync of fromdir.
run_rsync('-ai', '--include=/down/', '--exclude=/*',
f'{FROMDIR}/', f'{CHKDIR}/')
time.sleep(1)
print("Test basic relative:")
checkit(['-avR', f'./{deepstr}', str(TODIR)], CHKDIR, TODIR)
# Add a hard link inside the source and the chk dir; mirror it on both
# sides so the --delete pass below doesn't see it as new on either tree.
os.link(deepdir / 'filelist', deepdir / 'dir' / 'filelist')
os.link(CHKDIR / deepstr / 'filelist', CHKDIR / deepstr / 'dir' / 'filelist')
# Re-touch both dirs so the inner-dir time matches.
src_t = (deepdir / 'dir').stat().st_mtime
os.utime(deepdir / 'dir', (src_t, src_t))
os.utime(CHKDIR / deepstr / 'dir', (src_t, src_t))
print("Test hard links:")
checkit(['-avHR', f'./{deepstr}/', str(TODIR)], CHKDIR, TODIR)
# Drop some stray files at the dest then re-sync with --del to confirm
# they're removed.
import shutil
shutil.copy(deepdir / 'text', TODIR / deepstr / 'ThisShouldGo')
shutil.copy(deepdir / 'text', TODIR / deepstr / 'dir' / 'ThisShouldGoToo')
print("Test deletion:")
checkit(['-avHR', '--del', f'./{deepstr}/', str(TODIR)], CHKDIR, TODIR)
print("Test non-deletion:")
# Same as the previous pass but capture output to grep for spurious
# 'deleting ' lines.
proc = subprocess.run(
rsync_argv('-aiHR', '--del', f'./{deepstr}/', str(TODIR)),
capture_output=True, text=True,
)
OUTFILE.write_text(proc.stdout)
print(proc.stdout, end='')
if proc.returncode != 0:
test_fail(f"non-deletion run exited {proc.returncode}")
if 'deleting ' in proc.stdout:
test_fail("Erroneous deletions occurred!")
# Relative with merging.
run_rsync('-ai', str(extradir / 'down'), f'{CHKDIR}/')
print("Test merge:")
checkit(['-aiR', deepstr, extrafile_for_rsync, str(TODIR)], CHKDIR, TODIR)
print("Test merge with --del:")
proc = subprocess.run(
rsync_argv('-aiR', '--del', deepstr, extrafile_for_rsync, str(TODIR)),
capture_output=True, text=True,
)
OUTFILE.write_text(proc.stdout)
print(proc.stdout, end='')
if proc.returncode != 0:
test_fail(f"merge --del run exited {proc.returncode}")
if 'deleting ' in proc.stdout:
test_fail("Erroneous deletions occurred! (2)")

View File

@@ -1,542 +0,0 @@
#!/bin/sh
# Copyright (C) 2001 by Martin Pool <mbp@samba.org>
# General-purpose test functions for rsync.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version
# 2 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program; if not, write to the Free Software
# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
tmpdir="$scratchdir"
fromdir="$tmpdir/from"
todir="$tmpdir/to"
chkdir="$tmpdir/chk"
chkfile="$scratchdir/rsync.chk"
outfile="$scratchdir/rsync.out"
# For itemized output:
all_plus='+++++++++'
allspace=' '
dots='.....' # trailing dots after changes
tab_ch=' ' # a single tab character
# Berkley's nice.
PATH="$PATH:/usr/ucb"
if diff -u "$suitedir/rsync.fns" "$suitedir/rsync.fns" >/dev/null 2>&1; then
diffopt="-u"
else
diffopt="-c"
fi
HOME="$scratchdir"
export HOME
runtest() {
echo $ECHO_N "Test $1: $ECHO_C"
if eval "$2"; then
echo "$ECHO_T done."
return 0
else
echo "$ECHO_T failed!"
return 1
fi
}
set_cp_destdir() {
while test $# -gt 1; do
shift
done
destdir="$1"
}
# Perform a "cp -p", making sure that timestamps are really the same,
# even if the copy rounded microsecond times on the destination file.
cp_touch() {
cp_p "${@}"
if test $# -gt 2 || test -d "$2"; then
set_cp_destdir "${@}" # sets destdir var
while test $# -gt 1; do
destname="$destdir/`basename $1`"
touch -r "$destname" "$1" "$destname"
shift
done
else
touch -r "$2" "$1" "$2"
fi
}
# Call this if you want to filter (stdin -> stdout) verbose messages (-v or
# -vv) from an rsync run (whittling the output down to just the file messages).
# This isn't needed if you use -i without -v.
v_filt() {
sed -e '/^building file list /d' \
-e '/^sending incremental file list/d' \
-e '/^created directory /d' \
-e '/^done$/d' \
-e '/ --whole-file$/d' \
-e '/^total: /d' \
-e '/^client charset: /d' \
-e '/^server charset: /d' \
-e '/^$/,$d'
}
printmsg() {
echo "$1"
}
rsync_ls_lR() {
find "$@" -name .git -prune -o -name auto-build-save -prune -o -name testtmp -prune -o -print | \
sort | sed 's/ /\\ /g' | xargs "$TOOLDIR/tls" $TLS_ARGS
}
get_testuid() {
uid=`id -u 2>/dev/null || true`
case "$uid" in
[0-9]*) echo "$uid" ;;
*) id 2>/dev/null | sed 's/^[^0-9]*\([0-9][0-9]*\).*/\1/' ;;
esac
}
get_rootuid() {
uid=`id -u root 2>/dev/null || true`
case "$uid" in
[0-9]*) echo "$uid" ;;
*) echo 0 ;;
esac
}
get_rootgid() {
gid=`id -g root 2>/dev/null || true`
case "$gid" in
[0-9]*) echo "$gid" ;;
*) echo 0 ;;
esac
}
# When copying via "cp -p", we want to ensure that a non-root user does not
# preserve ownership (we want our files to be created as the testing user).
# For instance, a Cygwin CI run might have git files owned by a different
# user than the (admin) user running the tests.
cp_cmd="cp -p"
if test x`get_testuid` != x0; then
case `cp --help 2>/dev/null` in
*--no-preserve=*) cp_cmd="cp -p --no-preserve=ownership" ;;
esac
fi
cp_p() {
$cp_cmd "${@}" || test_fail "$cp_cmd failed"
}
check_perms() {
perms=`"$TOOLDIR/tls" "$1" | sed 's/^[-d]\(.........\).*/\1/'`
if test $perms = $2; then
return 0
fi
echo "permissions: $perms on $1"
echo "should be: $2"
test_fail "failed test $3"
}
rsync_getgroups() {
"$TOOLDIR/getgroups"
}
####################
# Build test directories $todir and $fromdir, with $fromdir full of files.
hands_setup() {
# Clean before creation
rm -rf "$fromdir"
rm -rf "$todir"
[ -d "$tmpdir" ] || mkdir "$tmpdir"
[ -d "$fromdir" ] || mkdir "$fromdir"
[ -d "$todir" ] || mkdir "$todir"
# On some BSD systems, the umask affects the mode of created
# symlinks, even though the mode apparently has no effect on how
# the links behave in the future, and it cannot be changed using
# chmod! rsync always sets its umask to 000 so that it can
# accurately recreate permissions, but this script is probably run
# with a different umask.
# This causes a little problem that "ls -l" of the two will not be
# the same. So, we need to set our umask before doing any creations.
# set up test data
touch "$fromdir/empty"
mkdir "$fromdir/emptydir"
# a hundred lines of text or so
rsync_ls_lR "$srcdir" > "$fromdir/filelist"
echo $ECHO_N "This file has no trailing lf$ECHO_C" > "$fromdir/nolf"
umask 0
ln -s nolf "$fromdir/nolf-symlink"
umask 022
cat "$srcdir"/*.c > "$fromdir/text"
mkdir "$fromdir/dir"
cp "$fromdir/text" "$fromdir/dir"
mkdir "$fromdir/dir/subdir"
echo some data > "$fromdir/dir/subdir/foobar.baz"
mkdir "$fromdir/dir/subdir/subsubdir"
if [ -r /etc ]; then
ls -ltr /etc > "$fromdir/dir/subdir/subsubdir/etc-ltr-list" || [ $? -eq 1 ]
else
ls -ltr / > "$fromdir/dir/subdir/subsubdir/etc-ltr-list" || [ $? -eq 1 ]
fi
mkdir "$fromdir/dir/subdir/subsubdir2"
if [ -r /bin ]; then
ls -lt /bin > "$fromdir/dir/subdir/subsubdir2/bin-lt-list" || [ $? -eq 1 ]
else
ls -lt / > "$fromdir/dir/subdir/subsubdir2/bin-lt-list" || [ $? -eq 1 ]
fi
# echo testing head:
# ls -lR "$srcdir" | head -10 || echo failed
}
####################
# Many machines do not have "mkdir -p", so we have to build up long paths.
# How boring.
makepath() {
for p in "${@}"; do
(echo " makepath $p"
# Absolute Unix path.
if echo $p | grep '^/' >/dev/null; then
cd /
fi
# This will break if $p contains a space.
for c in `echo $p | tr '/' ' '`; do
if [ -d "$c" ] || mkdir "$c"; then
cd "$c" || return $?
else
echo "failed to create $c" >&2; return $?
fi
done)
done
}
###########################
# Create a file at $1 of $2 bytes containing non-trivial content
# suitable for rsync's delta algorithm to chew on. Prefers
# /dev/urandom for speed and entropy, falling back to a
# deterministic awk pseudo-random generator on platforms that
# lack /dev/urandom (e.g. HPE NonStop). The tests using this
# helper don't need cryptographic randomness -- they only need
# bytes that compress and delta-match like normal file content.
make_data_file() {
if [ $# -ne 2 ]; then
echo "usage: make_data_file PATH SIZE" >&2
return 2
fi
if [ -r /dev/urandom ] && \
dd if=/dev/urandom of="$1" bs="$2" count=1 2>/dev/null && \
[ -s "$1" ]; then
return 0
fi
# Fallback: a 32-bit linear congruential generator with BSD/glibc
# parameters. Seeded from PID and a POSIX cksum of the destination
# path so successive calls with different paths produce distinct
# content. Output is constrained to the printable-ASCII range
# (33..126, i.e. '!' through '~') for two portability reasons:
# - awk implementations vary on whether printf "%c", 0 emits a
# NUL byte or terminates the string;
# - gawk in UTF-8 locales encodes printf "%c", N for N > 127
# as a 2-byte UTF-8 sequence, which would make the output
# larger than the requested sz.
# The tests using this helper don't need 8-bit binary data, only
# non-trivial content for the rsync delta algorithm.
_path_seed=$(printf '%s' "$1" | cksum 2>/dev/null | awk '{print $1}')
awk -v sz="$2" -v seed_a="$$" -v seed_b="${_path_seed:-0}" 'BEGIN {
s = (seed_a + seed_b) % 2147483648
if (s < 0) s = -s
for (i = 0; i < sz; i++) {
s = (s * 1103515245 + 12345) % 2147483648
b = (int(s / 65536) % 94) + 33 # 33..126
printf "%c", b
}
}' > "$1"
}
###########################
# Run a test (in '$1') then compare directories $2 and $3 to see if
# there are any difference. If there are, explain them.
# So normally basically $1 should be an rsync command, and $2 and $3
# the source and destination directories. This is only good when you
# expect to transfer the whole directory exactly as is. If some files
# should be excluded, you might need to use something else.
checkit() {
failed=
# We can just write everything to stdout/stderr, because the
# wrapper hides it unless there is a problem.
case "x$TLS_ARGS" in
*--atimes*)
( cd "$2" && rsync_ls_lR . ) > "$tmpdir/ls-from"
;;
*)
;;
esac
echo "Running: \"$1\""
eval "$1"
status=$?
if [ $status != 0 ]; then
failed="$failed status=$status"
fi
case "x$TLS_ARGS" in
*--atimes*)
;;
*)
( cd "$2" && rsync_ls_lR . ) > "$tmpdir/ls-from"
;;
esac
echo "-------------"
echo "check how the directory listings compare with diff:"
echo ""
( cd "$3" && rsync_ls_lR . ) > "$tmpdir/ls-to"
diff $diffopt "$tmpdir/ls-from" "$tmpdir/ls-to" || failed="$failed dir-diff"
echo "-------------"
echo "check how the files compare with diff:"
echo ""
if [ "x$4" != x ]; then
echo " === Skipping (as directed) ==="
else
diff -r $diffopt "$2" "$3" || failed="$failed file-diff"
fi
echo "-------------"
if [ -z "$failed" ]; then
return 0
fi
echo "Failed: $failed"
return 1
}
# Run a test in $1 and make sure it has a zero exit status. Capture the
# output into $outfile and echo it to stdout.
checktee() {
echo "Running: \"$1\""
eval "$1" >"$outfile"
status=$?
cat "$outfile"
if [ $status != 0 ]; then
echo "Failed: status=$status"
return 1
fi
return 0
}
# Slurp stdin into $chkfile and then call checkdiff2().
checkdiff() {
cat >"$chkfile" # Save off stdin
checkdiff2 "${@}"
}
# Run a test in $1 and make sure it has a zero exit status. Capture the output
# into $outfile. If $2 is set, use it to filter the outfile. If resulting
# outfile differs from the chkfile data, fail with an error.
checkdiff2() {
failed=
echo "Running: \"$1\""
eval "$1" >"$outfile"
status=$?
cat "$outfile"
if [ $status != 0 ]; then
failed="$failed status=$status"
fi
if [ -n "$2" ]; then
eval "cat '$outfile' | $2 >'$outfile.new'"
mv "$outfile.new" "$outfile"
fi
diff $diffopt "$chkfile" "$outfile" || failed="$failed output differs"
if [ -n "$failed" ]; then
echo "Failed:$failed"
return 1
fi
return 0
}
build_rsyncd_conf() {
# Build an appropriate configuration file
conf="$scratchdir/test-rsyncd.conf"
echo "building configuration $conf"
port=2612
pidfile="$scratchdir/rsyncd.pid"
logfile="$scratchdir/rsyncd.log"
hostname=`uname -n`
my_uid=`get_testuid`
root_uid=`get_rootuid`
root_gid=`get_rootgid`
uid_setting="uid = $root_uid"
gid_setting="gid = $root_gid"
if test x"$my_uid" != x"$root_uid"; then
# Non-root cannot specify uid & gid settings
uid_setting="#$uid_setting"
gid_setting="#$gid_setting"
fi
cat >"$conf" <<EOF
# rsyncd configuration file autogenerated by $0
pid file = $pidfile
use chroot = no
munge symlinks = no
hosts allow = localhost 127.0.0.0/24 192.168.0.0/16 10.0.0.0/8 $hostname
log file = $logfile
transfer logging = yes
# We don't define log format here so that the test-hidden module will default
# to the internal static string (since we had a crash trying to tweak it).
exclude = ? foobar.baz
max verbosity = 4
$uid_setting
$gid_setting
[test-from]
path = $fromdir
log format = %i %h [%a] %m (%u) %l %f%L
read only = yes
comment = r/o
[test-to]
path = $todir
log format = %i %h [%a] %m (%u) %l %f%L
read only = no
comment = r/w
[test-scratch]
path = $scratchdir
log format = %i %h [%a] %m (%u) %l %f%L
read only = no
[test-hidden]
path = $fromdir
list = no
EOF
# Build a helper script to ignore exit code 23
ignore23="$scratchdir/ignore23"
echo "building help script $ignore23"
cat >"$ignore23" <<'EOT'
if "${@}"; then
exit
fi
ret=$?
if test $ret = 23; then
exit
fi
exit $ret
EOT
chmod +x "$ignore23"
}
build_symlinks() {
mkdir "$fromdir"
date >"$fromdir/referent"
ln -s referent "$fromdir/relative"
ln -s "$fromdir/referent" "$fromdir/absolute"
ln -s nonexistent "$fromdir/dangling"
ln -s "$srcdir/rsync.c" "$fromdir/unsafe"
}
test_fail() {
echo "$@" >&2
exit 1
}
test_skipped() {
echo "$@" >&2
echo "$@" > "$tmpdir/whyskipped"
exit 77
}
# It failed, but we expected that. Don't dump out error logs,
# because most users won't want to see them. But do leave
# the working directory around.
test_xfail() {
echo "$@" >&2
exit 78
}
# Determine what shell command will appropriately test for links.
ln -s foo "$scratchdir/testlink"
for cmd in test /bin/test /usr/bin/test /usr/ucb/bin/test /usr/ucb/test; do
for switch in -h -L; do
if $cmd $switch "$scratchdir/testlink" 2>/dev/null; then
# how nice
TEST_SYMLINK_CMD="$cmd $switch"
# i wonder if break 2 is portable?
break 2
fi
done
done
# ok, now get rid of it
rm "$scratchdir/testlink"
if [ "x$TEST_SYMLINK_CMD" = 'x' ]; then
test_fail "Couldn't determine how to test for symlinks"
else
echo "Testing for symlinks using '$TEST_SYMLINK_CMD'"
fi
# Test whether something is a link, allowing for shell peculiarities
is_a_link() {
# note the variable contains the first option and therefore is not quoted
$TEST_SYMLINK_CMD "$1"
}
# We need to set the umask to be reproducible. Note also that when we
# do some daemon tests as root, we will setuid() and therefore the
# directory has to be writable by the nobody user in some cases. The
# best thing is probably to explicitly chmod those directories after
# creation.
umask 022

592
testsuite/rsyncfns.py Normal file
View File

@@ -0,0 +1,592 @@
"""Shared helpers for rsync's Python test scripts.
This is the Python counterpart of testsuite/rsync.fns. It exposes only what
the Python-rewritten tests actually need; grow it as more shell tests are
ported.
Conventions matching the shell harness:
* Exit 0 = pass, 1 = fail, 77 = skip, 78 = xfail.
* The runner sets these environment variables before invoking each test:
scratchdir per-test scratch directory
srcdir rsync source directory
TOOLDIR build directory (holds the rsync binary and helpers)
RSYNC the rsync command line (may include valgrind / --protocol=N)
TLS_ARGS extra arguments to pass to the 'tls' helper
suitedir this directory (testsuite/)
"""
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
# --- environment -----------------------------------------------------------
def _required(name: str) -> str:
v = os.environ.get(name)
if not v:
sys.stderr.write(
f"rsyncfns: required environment variable {name} is not set; "
"run this test via runtests.py rather than directly.\n"
)
sys.exit(2)
return v
SCRATCHDIR = Path(_required('scratchdir'))
SRCDIR = Path(_required('srcdir'))
TOOLDIR = Path(_required('TOOLDIR'))
SUITEDIR = Path(os.environ.get('suitedir', SRCDIR / 'testsuite'))
# rsync.fns overrides HOME to $scratchdir; tests that exercise ssh-style
# transfers with no path component (e.g. localhost: at end of args) rely on
# HOME pointing at the per-test scratch dir.
os.environ['HOME'] = str(SCRATCHDIR)
RSYNC = _required('RSYNC') # full command line, possibly with valgrind/protocol
# TLS_ARGS controls how the 'tls' helper formats listings (e.g. --atimes,
# -l, -L). Tests that exercise non-default rsync features (atimes, etc.)
# assign to rsyncfns.TLS_ARGS before calling checkit / rsync_ls_lR.
TLS_ARGS = os.environ.get('TLS_ARGS', '')
# Mnemonics for rsync's itemize-changes (-i / -ii) format:
# all_plus -> +++++++++ every attribute changed (an additive create)
# allspace -> every attribute unchanged
# dots -> ..... trailing dots after the change columns
all_plus = '+++++++++'
allspace = ' '
dots = '.....'
# The "$tmpdir/from", "$tmpdir/to", "$tmpdir/chk" layout from rsync.fns.
TMPDIR = SCRATCHDIR
FROMDIR = SCRATCHDIR / 'from'
TODIR = SCRATCHDIR / 'to'
CHKDIR = SCRATCHDIR / 'chk'
CHKFILE = SCRATCHDIR / 'rsync.chk'
OUTFILE = SCRATCHDIR / 'rsync.out'
# --- result reporting ------------------------------------------------------
def test_fail(msg: str) -> 'None':
sys.stderr.write(msg.rstrip() + '\n')
sys.exit(1)
def test_skipped(msg: str) -> 'None':
sys.stderr.write(msg.rstrip() + '\n')
(TMPDIR / 'whyskipped').write_text(msg.rstrip() + '\n')
sys.exit(77)
def test_xfail(msg: str) -> 'None':
sys.stderr.write(msg.rstrip() + '\n')
sys.exit(78)
# --- rsync invocation ------------------------------------------------------
def rsync_argv(*args: str) -> list:
"""Return the argv for invoking rsync with the given extra arguments.
RSYNC may be a multi-word command (e.g. 'valgrind ... /build/rsync'); we
shlex-split it so subprocess sees a proper argv list. Each *args entry
is appended verbatim, so callers should pass tokens already split (no
embedded option/value joined by spaces).
"""
return shlex.split(RSYNC) + list(args)
def run_rsync(*args: str, check: bool = True,
capture_output: bool = False) -> subprocess.CompletedProcess:
"""Run rsync with the given arguments.
By default, stdout/stderr inherit (so the runner captures them in the
per-test log). Set capture_output=True if the test needs to inspect the
output. If check is True (the default), a non-zero exit calls
test_fail() with the rsync command line.
"""
argv = rsync_argv(*args)
if capture_output:
proc = subprocess.run(argv, capture_output=True, text=True)
else:
proc = subprocess.run(argv)
if check and proc.returncode != 0:
test_fail(f"rsync exited {proc.returncode}: {' '.join(argv)}")
return proc
# --- filesystem helpers ----------------------------------------------------
def makepath(*paths) -> 'None':
"""Equivalent of rsync.fns makepath: mkdir -p, but for multiple paths."""
for p in paths:
os.makedirs(p, exist_ok=True)
def rmtree(path) -> 'None':
"""Remove a tree if it exists, ignoring missing entries."""
p = Path(path)
if p.exists() or p.is_symlink():
shutil.rmtree(p, ignore_errors=True)
def is_a_link(path) -> bool:
"""True if 'path' is a symbolic link (dangling or not)."""
return os.path.islink(path)
def cp_p(src, dst) -> 'None':
"""Equivalent of rsync.fns cp_p: copy preserving mode + timestamps."""
shutil.copy2(src, dst)
def make_data_file(path, size: int) -> 'None':
"""Equivalent of rsync.fns make_data_file: create `path` with `size`
bytes of non-trivial content suitable for rsync's delta algorithm.
Prefers /dev/urandom for speed. Falls back to a deterministic LCG
seeded from PID and the destination path so successive calls produce
distinct content -- matching the shell helper.
"""
path = str(path)
if os.path.exists('/dev/urandom'):
try:
with open('/dev/urandom', 'rb') as src, open(path, 'wb') as dst:
remaining = size
while remaining:
chunk = src.read(min(remaining, 1 << 16))
if not chunk:
break
dst.write(chunk)
remaining -= len(chunk)
if remaining == 0:
return
except OSError:
pass
# Fallback: BSD-LCG to printable-ASCII (33..126), so output stays
# exactly `size` bytes regardless of awk/utf8 quirks the shell
# version worked around.
path_seed = int.from_bytes(path.encode(), 'big') & 0xFFFFFFFF
state = (os.getpid() + path_seed) % 2147483648
with open(path, 'wb') as f:
out = bytearray(size)
for i in range(size):
state = (state * 1103515245 + 12345) % 2147483648
out[i] = ((state >> 16) % 94) + 33
f.write(bytes(out))
def get_testuid() -> int:
return os.getuid()
def get_rootuid() -> int:
return 0
def get_rootgid() -> int:
return 0
def build_rsyncd_conf() -> 'Path':
"""Equivalent of rsync.fns build_rsyncd_conf.
Writes $scratchdir/test-rsyncd.conf with the four standard modules
(test-from, test-to, test-scratch, test-hidden) and a $scratchdir/
ignore23 wrapper that propagates rsync's exit status except for
code 23 (vanished/missing source files), which it eats so that the
surrounding test can tolerate the partial-transfer case.
Returns the path to the config file. Tests typically follow up by
setting RSYNC_CONNECT_PROG so rsync forks an in-tree daemon instead
of contacting one over the network.
"""
conf = SCRATCHDIR / 'test-rsyncd.conf'
pidfile = SCRATCHDIR / 'rsyncd.pid'
logfile = SCRATCHDIR / 'rsyncd.log'
hostname = subprocess.check_output(['uname', '-n'], text=True).strip()
my_uid = get_testuid()
root_uid = get_rootuid()
root_gid = get_rootgid()
if my_uid != root_uid:
# Non-root cannot specify uid/gid in rsyncd.conf.
uid_line = f"#uid = {root_uid}"
gid_line = f"#gid = {root_gid}"
else:
uid_line = f"uid = {root_uid}"
gid_line = f"gid = {root_gid}"
conf.write_text(f"""\
# rsyncd configuration file autogenerated by rsyncfns.build_rsyncd_conf
pid file = {pidfile}
use chroot = no
munge symlinks = no
hosts allow = localhost 127.0.0.0/24 192.168.0.0/16 10.0.0.0/8 {hostname}
log file = {logfile}
transfer logging = yes
# We don't define log format here so the test-hidden module defaults
# to the internal static string (since we had a crash trying to tweak it).
exclude = ? foobar.baz
max verbosity = 4
{uid_line}
{gid_line}
[test-from]
\tpath = {FROMDIR}
\tlog format = %i %h [%a] %m (%u) %l %f%L
\tread only = yes
\tcomment = r/o
[test-to]
\tpath = {TODIR}
\tlog format = %i %h [%a] %m (%u) %l %f%L
\tread only = no
\tcomment = r/w
[test-scratch]
\tpath = {SCRATCHDIR}
\tlog format = %i %h [%a] %m (%u) %l %f%L
\tread only = no
[test-hidden]
\tpath = {FROMDIR}
\tlist = no
""")
ignore23 = SCRATCHDIR / 'ignore23'
ignore23.write_text(
'#!/bin/sh\n'
'if "${@}"; then exit; fi\n'
'ret=$?\n'
'if test $ret = 23; then exit; fi\n'
'exit $ret\n'
)
ignore23.chmod(0o755)
return conf
def rsync_getgroups() -> list:
"""List of group ids the test user is a member of, via the getgroups
test helper binary. Mirrors rsync.fns rsync_getgroups."""
out = subprocess.check_output([str(TOOLDIR / 'getgroups')], text=True)
return out.split()
def runtest(label: str, fn, *args, **kwargs):
"""Run a sub-test step with an echoed label, like rsync.fns runtest.
The shell helper does `Test $1: $2 ... done.` -- this prints a similar
banner and propagates exceptions (which surface as a failing test).
"""
print(f"Test {label}: ", end="", flush=True)
fn(*args, **kwargs)
print("done.")
def cp_touch(src, dst) -> 'None':
"""Equivalent of rsync.fns cp_touch: copy preserving timestamps, then
forcibly re-touch both source and destination to identical times.
On some filesystems cp rounds microsecond timestamps on the destination;
rsync.fns works around this by then `touch -r dst src dst`. Here we set
both src and dst to dst's mtime/atime after the copy, so a diff of the
tls output (which prints times) sees identical entries on both sides.
"""
shutil.copy2(src, dst)
if os.path.isdir(dst):
dst = os.path.join(dst, os.path.basename(src))
st = os.stat(dst, follow_symlinks=False)
os.utime(src, ns=(st.st_atime_ns, st.st_mtime_ns), follow_symlinks=False)
os.utime(dst, ns=(st.st_atime_ns, st.st_mtime_ns), follow_symlinks=False)
def build_symlinks() -> 'None':
"""Equivalent of rsync.fns build_symlinks: a set of canonical relative,
absolute, dangling and unsafe symlinks under FROMDIR for symlink tests.
"""
FROMDIR.mkdir(parents=True, exist_ok=True)
(FROMDIR / 'referent').write_text(
subprocess.check_output(['date'], text=True)
)
os.symlink('referent', FROMDIR / 'relative')
os.symlink(str(FROMDIR / 'referent'), FROMDIR / 'absolute')
os.symlink('nonexistent', FROMDIR / 'dangling')
os.symlink(str(SRCDIR / 'rsync.c'), FROMDIR / 'unsafe')
def hands_setup() -> 'None':
"""Equivalent of rsync.fns hands_setup: populate FROMDIR with a varied
tree of files and directories for the canonical 'hands' transfer test.
Recreates the shell behavior bit-for-bit so the tls listings match
across the shell and Python halves of the suite during the transition.
"""
rmtree(FROMDIR)
rmtree(TODIR)
TMPDIR.mkdir(parents=True, exist_ok=True)
FROMDIR.mkdir(parents=True, exist_ok=True)
TODIR.mkdir(parents=True, exist_ok=True)
(FROMDIR / 'empty').touch()
(FROMDIR / 'emptydir').mkdir(exist_ok=True)
# File list of srcdir contents, generated through the tls helper so it
# matches the format the rest of the suite uses.
(FROMDIR / 'filelist').write_text(rsync_ls_lR(SRCDIR))
# The shell test uses `echo -n` semantics; write_text without a trailing
# newline is the cleanest equivalent.
(FROMDIR / 'nolf').write_text("This file has no trailing lf")
old_umask = os.umask(0)
try:
os.symlink('nolf', FROMDIR / 'nolf-symlink')
finally:
os.umask(old_umask)
# Concatenate all *.c files in srcdir into a single 'text' file.
text = bytearray()
for c in sorted(SRCDIR.glob('*.c')):
text.extend(c.read_bytes())
(FROMDIR / 'text').write_bytes(bytes(text))
(FROMDIR / 'dir').mkdir(exist_ok=True)
shutil.copy(FROMDIR / 'text', FROMDIR / 'dir')
(FROMDIR / 'dir' / 'subdir').mkdir(exist_ok=True)
(FROMDIR / 'dir' / 'subdir' / 'foobar.baz').write_text("some data\n")
(FROMDIR / 'dir' / 'subdir' / 'subsubdir').mkdir(exist_ok=True)
src_listdir = '/etc' if os.access('/etc', os.R_OK) else '/'
out = subprocess.run(['ls', '-ltr', src_listdir], capture_output=True, text=True)
(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list').write_text(out.stdout)
(FROMDIR / 'dir' / 'subdir' / 'subsubdir2').mkdir(exist_ok=True)
src_listdir = '/bin' if os.access('/bin', os.R_OK) else '/'
out = subprocess.run(['ls', '-lt', src_listdir], capture_output=True, text=True)
(FROMDIR / 'dir' / 'subdir' / 'subsubdir2' / 'bin-lt-list').write_text(out.stdout)
# --- listing / verification ------------------------------------------------
def rsync_ls_lR(directory) -> str:
"""Equivalent of rsync.fns rsync_ls_lR: print a sorted ls-style listing
of `directory`, pruning .git / auto-build-save / testtmp subtrees, using
the project's `tls` helper so the output format matches the rest of the
suite.
"""
cmd = (
"find . -name .git -prune -o -name auto-build-save -prune "
"-o -name testtmp -prune -o -print | sort | sed 's/ /\\\\ /g' | "
f"xargs '{TOOLDIR}/tls' {TLS_ARGS}"
)
proc = subprocess.run(['sh', '-c', cmd], capture_output=True,
text=True, cwd=str(directory))
return proc.stdout
def checkit(args, expected_dir, actual_dir, skip_file_diff: bool = False,
allowed_codes=(0,)) -> 'None':
"""Run rsync with `args` (a list of extra rsync arguments) and then
verify two things:
1. The tls-formatted listings of `expected_dir` and `actual_dir`
are identical.
2. (Unless skip_file_diff) diff -r against the two trees reports
no differences.
`allowed_codes` is the tuple of exit codes treated as success.
Pass (0, 23) for daemon-mode transfers that may report partial-
transfer codes even when the listings still match.
Calls test_fail() on any mismatch. Mirrors the rsync.fns checkit shell
helper; callers pass rsync arguments as a Python list rather than as a
pre-quoted command string, which avoids the shell-quoting gymnastics
that the shell version needed.
"""
expected_dir = str(expected_dir)
actual_dir = str(actual_dir)
failed = []
# If TLS_ARGS asks for atimes, the listing must be captured BEFORE the
# rsync run because diff'ing files afterwards updates their atimes.
ls_from = None
if '--atimes' in TLS_ARGS:
ls_from = rsync_ls_lR(expected_dir)
print(f"Running: rsync {' '.join(args)}")
proc = subprocess.run(rsync_argv(*args))
if proc.returncode not in allowed_codes:
failed.append(f"status={proc.returncode}")
if ls_from is None:
ls_from = rsync_ls_lR(expected_dir)
ls_to = rsync_ls_lR(actual_dir)
print("-------------")
print("check how the directory listings compare with diff:")
print()
if ls_from != ls_to:
ls_from_path = TMPDIR / 'ls-from'
ls_to_path = TMPDIR / 'ls-to'
ls_from_path.write_text(ls_from)
ls_to_path.write_text(ls_to)
diff = subprocess.run(
['diff', '-u', str(ls_from_path), str(ls_to_path)],
capture_output=True, text=True,
)
sys.stdout.write(diff.stdout)
failed.append("dir-diff")
print("-------------")
print("check how the files compare with diff:")
print()
if skip_file_diff:
print(" === Skipping (as directed) ===")
else:
diff = subprocess.run(['diff', '-r', '-u', expected_dir, actual_dir])
if diff.returncode != 0:
failed.append("file-diff")
print("-------------")
if failed:
test_fail("Failed: " + " ".join(failed))
def verify_dirs(expected_dir, actual_dir, skip_file_diff: bool = False,
label: str = '') -> 'None':
"""Verify two directory trees match: identical tls listings and
(unless skip_file_diff) identical file contents. Same comparison
logic as checkit() but with no rsync invocation -- useful when the
rsync that produced `actual_dir` had to be driven manually so that
its output could be captured for inspection."""
expected_dir = str(expected_dir)
actual_dir = str(actual_dir)
tag = f"{label}: " if label else ""
ls_expected = rsync_ls_lR(expected_dir)
ls_actual = rsync_ls_lR(actual_dir)
if ls_expected != ls_actual:
ls_expected_path = TMPDIR / 'ls-from'
ls_actual_path = TMPDIR / 'ls-to'
ls_expected_path.write_text(ls_expected)
ls_actual_path.write_text(ls_actual)
diff = subprocess.run(
['diff', '-u', str(ls_expected_path), str(ls_actual_path)],
capture_output=True, text=True,
)
sys.stdout.write(diff.stdout)
test_fail(f"{tag}directory listings differ between "
f"{expected_dir} and {actual_dir}")
if not skip_file_diff:
diff = subprocess.run(['diff', '-r', '-u', expected_dir, actual_dir])
if diff.returncode != 0:
test_fail(f"{tag}file content differs between "
f"{expected_dir} and {actual_dir}")
def v_filt(text: str) -> str:
"""Strip the boilerplate lines rsync emits at -v / -vv so callers can
diff only the file/directory change lines. Mirrors rsync.fns v_filt:
delete the build/progress banners, then everything from the first
blank line to end-of-text."""
out = []
skip_prefix = (
'building file list ',
'sending incremental file list',
'created directory ',
'total: ',
'client charset: ',
'server charset: ',
)
for line in text.splitlines():
if line == '':
break
if line.startswith(skip_prefix):
continue
if line == 'done':
continue
if line.endswith(' --whole-file'):
continue
out.append(line)
return '\n'.join(out) + ('\n' if out else '')
def checkdiff(args, expected: str, *, filter=None, allowed_codes=(0,),
direct: bool = False) -> 'None':
"""Run a command, capture its stdout, optionally pipe through `filter`,
then compare to `expected`. Mirrors rsync.fns checkdiff/checkdiff2.
args is normally a list of rsync arguments -- the rsync binary is
prepended via rsync_argv. Pass direct=True to run `args` as a literal
command (used by tests that drive a wrapper such as BATCH.sh).
"""
if direct:
argv = list(args)
label = ' '.join(argv)
else:
argv = rsync_argv(*args)
label = 'rsync ' + ' '.join(args)
print(f"Running: {label}")
proc = subprocess.run(argv, capture_output=True, text=True)
stdout = proc.stdout
if proc.stderr:
sys.stderr.write(proc.stderr)
sys.stdout.write(stdout)
failed = []
if proc.returncode not in allowed_codes:
failed.append(f"status={proc.returncode}")
if filter is not None:
stdout = filter(stdout)
if stdout != expected:
from difflib import unified_diff
diff = unified_diff(
expected.splitlines(keepends=True),
stdout.splitlines(keepends=True),
fromfile='expected', tofile='got',
)
sys.stdout.write(''.join(diff))
failed.append("output differs")
if failed:
test_fail("Failed: " + " ".join(failed))
def check_perms(path, expected: str) -> 'None':
"""Verify that the 9-char rwx permission string of `path` matches
`expected` (e.g. 'rwx------'). Calls test_fail() on mismatch."""
mode = os.stat(path, follow_symlinks=False).st_mode
bits = [
(0o400, 'r'), (0o200, 'w'), (0o100, 'x'),
(0o040, 'r'), (0o020, 'w'), (0o010, 'x'),
(0o004, 'r'), (0o002, 'w'), (0o001, 'x'),
]
chars = [c if mode & bit else '-' for bit, c in bits]
# Layer the setuid/setgid/sticky bits over x as the long-listing format does.
if mode & 0o4000:
chars[2] = 's' if mode & 0o100 else 'S'
if mode & 0o2000:
chars[5] = 's' if mode & 0o010 else 'S'
if mode & 0o1000:
chars[8] = 't' if mode & 0o001 else 'T'
perms = ''.join(chars)
if perms != expected:
print(f"permissions: {perms} on {path}")
print(f"should be: {expected}")
test_fail(f"check_perms failed for {path}")

View File

@@ -1,55 +0,0 @@
#!/bin/sh
. "$suitedir/rsync.fns"
test_symlink() {
is_a_link "$1" || test_fail "File $1 is not a symlink"
}
test_regular() {
if [ ! -f "$1" ]; then
test_fail "File $1 is not regular file or not exists"
fi
}
test_notexist() {
if [ -e "$1" ]; then
test_fail "File $1 exists"
fi
if [ -h "$1" ]; then
test_fail "File $1 exists as a symlink"
fi
}
cd "$tmpdir"
mkdir from
mkdir "from/safe"
mkdir "from/unsafe"
mkdir "from/safe/files"
mkdir "from/safe/links"
touch "from/safe/files/file1"
touch "from/safe/files/file2"
touch "from/unsafe/unsafefile"
ln -s ../files/file1 "from/safe/links/"
ln -s ../files/file2 "from/safe/links/"
ln -s ../../unsafe/unsafefile "from/safe/links/"
ln -s a/a/a/../../../unsafe2 "from/safe/links/"
#echo "LISTING FROM"
#ls -lR from
echo "rsync with relative path and just -a"
$RSYNC -avv --safe-links from/safe/ to
#echo "LISTING TO"
#ls -lR to
test_symlink to/links/file1
test_symlink to/links/file2
test_notexist to/links/unsafefile
test_notexist to/links/unsafe2

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/safe-links.test.
#
# --safe-links must drop symlinks whose target escapes the transfer root.
# In-tree symlinks survive; escape-attempt symlinks (../../..., a/a/../../...)
# must NOT appear at the destination at all.
import os
from rsyncfns import TMPDIR, is_a_link, run_rsync, test_fail
def assert_symlink(path):
if not is_a_link(path):
test_fail(f"File {path} is not a symlink")
def assert_notexist(path):
if os.path.exists(path):
test_fail(f"File {path} exists")
if os.path.islink(path):
test_fail(f"File {path} exists as a symlink")
os.chdir(TMPDIR)
os.mkdir("from")
os.mkdir("from/safe")
os.mkdir("from/unsafe")
os.mkdir("from/safe/files")
os.mkdir("from/safe/links")
open("from/safe/files/file1", "w").close()
open("from/safe/files/file2", "w").close()
open("from/unsafe/unsafefile", "w").close()
os.symlink("../files/file1", "from/safe/links/file1")
os.symlink("../files/file2", "from/safe/links/file2")
os.symlink("../../unsafe/unsafefile", "from/safe/links/unsafefile")
os.symlink("a/a/a/../../../unsafe2", "from/safe/links/unsafe2")
print("rsync with relative path and --safe-links")
run_rsync('-avv', '--safe-links', 'from/safe/', 'to')
assert_symlink("to/links/file1")
assert_symlink("to/links/file2")
assert_notexist("to/links/unsafefile")
assert_notexist("to/links/unsafe2")

View File

@@ -1,34 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for codex audit Finding 5: secure_relative_open()'s
# front-door input check rejects "../foo" and "foo/../bar" but
# misses bare "..", "subdir/..", and other variants whose "/"-split
# components contain a literal "..". The kernel-enforced
# RESOLVE_BENEATH (Linux 5.6+) and O_RESOLVE_BENEATH
# (FreeBSD 13+, macOS 15+) reject these in-kernel; the per-component
# walk fallback used on NetBSD, OpenBSD, Solaris, Cygwin and pre-5.6
# Linux does not -- so the validation must happen at the front door.
#
# This test invokes the t_secure_relpath helper, which calls
# secure_relative_open() with each suspect input and verifies the
# return value is -1 with errno == EINVAL. EINVAL is the marker
# that the front-door rejected the input, not the kernel; pre-fix
# the kernel returns -1 with EXDEV (or, on the per-component
# fallback, may return a valid fd at all -- "escape").
. "$suitedir/rsync.fns"
testdir="$scratchdir/relpath-test"
rm -rf "$testdir"
mkdir -p "$testdir"
if ! "$TOOLDIR/t_secure_relpath" "$testdir"; then
test_fail "t_secure_relpath rejected one or more inputs incorrectly (see stderr above for the specific case)"
fi
exit 0

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/secure-relpath-validation.test.
#
# Regression test for codex audit Finding 5: secure_relative_open()'s
# front-door input check rejects "../foo" and "foo/../bar" but missed
# bare "..", "subdir/..", and other variants whose "/"-split components
# contain a literal "..". RESOLVE_BENEATH equivalents catch these in
# the kernel, but the per-component O_NOFOLLOW fallback (on NetBSD,
# OpenBSD, Solaris, Cygwin, pre-5.6 Linux) does not -- so the
# validation must happen at the front door.
#
# The t_secure_relpath helper runs each suspect input through
# secure_relative_open() and confirms it gets back -1/EINVAL (the
# marker that the front-door check kicked in, not the kernel).
import subprocess
from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail
testdir = SCRATCHDIR / 'relpath-test'
rmtree(testdir)
testdir.mkdir(parents=True)
proc = subprocess.run([str(TOOLDIR / 't_secure_relpath'), str(testdir)])
if proc.returncode != 0:
test_fail(
"t_secure_relpath rejected one or more inputs incorrectly "
"(see stderr above for the specific case)"
)

View File

@@ -1,90 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 by Andrew Tridgell
# This program is distributable under the terms of the GNU GPL (see
# COPYING).
# Regression test for codex re-check finding: the sender-side file-
# list generator can still follow an attacker-planted symlink out of
# the module via change_pathname() -> change_dir(...,CD_SKIP_CHDIR)
# followed by change_dir(...,CD_NORMAL). The CD_SKIP_CHDIR sets
# skipped_chdir=1, and the next CD_NORMAL call's secure-branch in
# util1.c is gated on `!skipped_chdir`, so the secure path is
# bypassed and a raw chdir(curr_dir) follows attacker-controlled
# symlinks during flist generation.
#
# Reach: rsync daemon module with `use chroot = no`. A local
# attacker plants module/cd -> /outside. A client (innocent or
# malicious) pulls rsync://<daemon>/<module>/cd/. The daemon, as
# sender, enumerates files in /outside and ships their metadata
# (names, sizes, modes, mtimes) to the client. The actual content
# transfer fails later at the secure_relative_open step with EXDEV,
# but by then the metadata has already leaked.
#
# We detect by running a dry-run pull of the symlinked subdir and
# checking whether the client's --list-only output mentions any
# file from /outside. With the bug, /outside/secret.txt appears in
# the list with its size; with the fix, the daemon's chdir into
# the symlinked subdir is rejected and no /outside file is listed.
. "$suitedir/rsync.fns"
case "$(uname -s)" in
SunOS|OpenBSD|NetBSD|CYGWIN*)
test_skipped "secure change_dir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
;;
esac
mod="$scratchdir/module"
outside="$scratchdir/outside"
listfile="$scratchdir/listed.txt"
conf="$scratchdir/test-rsyncd.conf"
rm -rf "$mod" "$outside"
mkdir -p "$mod" "$outside"
# Outside-the-module file the daemon should NOT enumerate to clients.
# A distinctive name + non-trivial size makes the leak easy to spot.
echo "OUTSIDE_PROTECTED_FILE_USED_AS_LEAK_DETECTOR" > "$outside/leak_marker.txt"
chmod 0644 "$outside/leak_marker.txt"
# The symlink trap planted by the local attacker.
ln -s "$outside" "$mod/cd"
my_uid=`get_testuid`
root_uid=`get_rootuid`
root_gid=`get_rootgid`
uid_setting="uid = $root_uid"
gid_setting="gid = $root_gid"
if test x"$my_uid" != x"$root_uid"; then
uid_setting="#$uid_setting"
gid_setting="#$gid_setting"
fi
cat > "$conf" <<EOF
use chroot = no
$uid_setting
$gid_setting
log file = $scratchdir/rsyncd.log
[upload]
path = $mod
use chroot = no
read only = no
EOF
# Pull recursively into the symlinked subdir with dry-run + verbose,
# capturing the daemon's flist (file list) on stdout. If the daemon
# enumerates /outside, leak_marker.txt will appear in the listing.
RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
$RSYNC -nrv rsync://localhost/upload/cd/ "$scratchdir/dst/" \
> "$listfile" 2>&1 || true
if grep -q "leak_marker\.txt" "$listfile"; then
echo "----- leaked listing follows" >&2
sed 's/^/ /' "$listfile" >&2
echo "----- leaked listing ends" >&2
test_fail "sender flist leak: outside/leak_marker.txt was enumerated to the client (daemon's chdir followed the cd symlink during flist generation)"
fi
exit 0

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/sender-flist-symlink-leak.test.
#
# Regression test for codex re-check finding: the sender-side file-list
# generator could still follow an attacker-planted symlink out of the
# module via change_pathname() -> change_dir(...,CD_SKIP_CHDIR) ->
# change_dir(...,CD_NORMAL). Reach: a daemon module with use chroot =
# no, attacker plants module/cd -> /outside, client pulls
# rsync://daemon/module/cd/; the daemon would enumerate /outside in
# the file list (metadata leak) before the actual content transfer
# failed at secure_relative_open.
import os
import platform
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
rsync_argv, get_testuid, get_rootuid, get_rootgid,
rmtree, test_fail, test_skipped,
)
# Platforms without RESOLVE_BENEATH equivalents fall back to a per-
# component walk that this test is not in scope for.
if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
test_skipped(
f"secure change_dir relies on RESOLVE_BENEATH-equivalent kernel "
f"support not available on {platform.system()}"
)
mod = SCRATCHDIR / 'module'
outside = SCRATCHDIR / 'outside'
listfile = SCRATCHDIR / 'listed.txt'
conf = SCRATCHDIR / 'test-rsyncd.conf'
rmtree(mod)
rmtree(outside)
mod.mkdir(parents=True)
outside.mkdir(parents=True)
(outside / 'leak_marker.txt').write_text(
"OUTSIDE_PROTECTED_FILE_USED_AS_LEAK_DETECTOR\n"
)
os.chmod(outside / 'leak_marker.txt', 0o644)
os.symlink(str(outside), mod / 'cd')
my_uid = get_testuid()
root_uid = get_rootuid()
root_gid = get_rootgid()
uid_line = f"uid = {root_uid}"
gid_line = f"gid = {root_gid}"
if my_uid != root_uid:
uid_line = '#' + uid_line
gid_line = '#' + gid_line
conf.write_text(f"""\
use chroot = no
{uid_line}
{gid_line}
log file = {SCRATCHDIR}/rsyncd.log
[upload]
path = {mod}
use chroot = no
read only = no
""")
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
proc = subprocess.run(
rsync_argv('-nrv', 'rsync://localhost/upload/cd/', f'{SCRATCHDIR}/dst/'),
capture_output=True, text=True, env=env,
)
listfile.write_text(proc.stdout + proc.stderr)
if 'leak_marker.txt' in listfile.read_text():
import sys
sys.stderr.write("----- leaked listing follows\n")
for line in listfile.read_text().splitlines():
sys.stderr.write(f" {line}\n")
sys.stderr.write("----- leaked listing ends\n")
test_fail(
"sender flist leak: outside/leak_marker.txt was enumerated to "
"the client (daemon's chdir followed the cd symlink during flist "
"generation)"
)

View File

@@ -1,11 +0,0 @@
#!/bin/sh
# Test SIMD checksum implementations against the C reference
. "$suitedir/rsync.fns"
if ! test -x "$TOOLDIR/simdtest"; then
test_skipped "simdtest not built (SIMD not available)"
fi
"$TOOLDIR/simdtest"

Some files were not shown because too many files have changed in this diff Show More