mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-05-24 23:05:52 -04:00
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:
18
Makefile.in
18
Makefile.in
@@ -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,
|
||||
|
||||
63
runtests.py
63
runtests.py
@@ -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(
|
||||
|
||||
@@ -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
|
||||
99
testsuite/00-hello_test.py
Normal file
99
testsuite/00-hello_test.py
Normal 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)
|
||||
@@ -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
|
||||
95
testsuite/acls-default_test.py
Normal file
95
testsuite/acls-default_test.py
Normal 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')
|
||||
@@ -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
93
testsuite/acls_test.py
Normal 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")
|
||||
@@ -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
|
||||
97
testsuite/alt-dest-symlink-race_test.py
Normal file
97
testsuite/alt-dest-symlink-race_test.py
Normal 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"
|
||||
)
|
||||
@@ -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
|
||||
72
testsuite/alt-dest_test.py
Normal file
72
testsuite/alt-dest_test.py
Normal 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)
|
||||
@@ -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
33
testsuite/atimes_test.py
Normal 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)
|
||||
@@ -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
133
testsuite/backup_test.py
Normal 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)
|
||||
@@ -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
|
||||
131
testsuite/bare-do-open-symlink-race_test.py
Normal file
131
testsuite/bare-do-open-symlink-race_test.py
Normal 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")
|
||||
@@ -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
|
||||
91
testsuite/batch-mode_test.py
Normal file
91
testsuite/batch-mode_test.py
Normal 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")
|
||||
@@ -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
|
||||
119
testsuite/chdir-symlink-race_test.py
Normal file
119
testsuite/chdir-symlink-race_test.py
Normal 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/')
|
||||
@@ -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
38
testsuite/chgrp_test.py
Normal 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)
|
||||
@@ -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
|
||||
87
testsuite/chmod-option_test.py
Normal file
87
testsuite/chmod-option_test.py
Normal 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))
|
||||
@@ -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
|
||||
51
testsuite/chmod-symlink-race_test.py
Normal file
51
testsuite/chmod-symlink-race_test.py
Normal 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"
|
||||
)
|
||||
@@ -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
|
||||
65
testsuite/chmod-temp-dir_test.py
Normal file
65
testsuite/chmod-temp-dir_test.py
Normal 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)
|
||||
@@ -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
40
testsuite/chmod_test.py
Normal 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)
|
||||
@@ -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
84
testsuite/chown_test.py
Normal 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)
|
||||
@@ -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
|
||||
33
testsuite/clean-fname-underflow_test.py
Normal file
33
testsuite/clean-fname-underflow_test.py
Normal 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")
|
||||
@@ -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
|
||||
94
testsuite/copy-dest-source-symlink_test.py
Normal file
94
testsuite/copy-dest-source-symlink_test.py
Normal 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"
|
||||
)
|
||||
@@ -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
38
testsuite/crtimes_test.py
Normal 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)
|
||||
@@ -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
|
||||
131
testsuite/daemon-chroot-acl_test.py
Normal file
131
testsuite/daemon-chroot-acl_test.py
Normal 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)"
|
||||
)
|
||||
@@ -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
|
||||
28
testsuite/daemon-gzip-download_test.py
Normal file
28
testsuite/daemon-gzip-download_test.py
Normal 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),
|
||||
)
|
||||
@@ -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
|
||||
29
testsuite/daemon-gzip-upload_test.py
Normal file
29
testsuite/daemon-gzip-upload_test.py
Normal 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),
|
||||
)
|
||||
@@ -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
|
||||
51
testsuite/daemon-refuse-compress_test.py
Normal file
51
testsuite/daemon-refuse-compress_test.py
Normal 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))
|
||||
@@ -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
133
testsuite/daemon_test.py
Normal 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}")
|
||||
@@ -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
|
||||
28
testsuite/delay-updates_test.py
Normal file
28
testsuite/delay-updates_test.py
Normal 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)
|
||||
@@ -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
105
testsuite/delete_test.py
Normal 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'}")
|
||||
@@ -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
173
testsuite/devices_test.py
Normal 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)
|
||||
@@ -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
|
||||
72
testsuite/dir-sgid_test.py
Normal file
72
testsuite/dir-sgid_test.py
Normal 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)
|
||||
@@ -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
|
||||
49
testsuite/duplicates_test.py
Normal file
49
testsuite/duplicates_test.py
Normal 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")
|
||||
@@ -1 +0,0 @@
|
||||
exclude.test
|
||||
@@ -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
321
testsuite/exclude_test.py
Normal 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 ''),
|
||||
)
|
||||
@@ -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
|
||||
45
testsuite/executability_test.py
Normal file
45
testsuite/executability_test.py
Normal 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')
|
||||
@@ -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
|
||||
51
testsuite/files-from_test.py
Normal file
51
testsuite/files-from_test.py
Normal 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,
|
||||
)
|
||||
@@ -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
22
testsuite/fuzzy_test.py
Normal 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)
|
||||
@@ -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
63
testsuite/hands_test.py
Normal 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)
|
||||
@@ -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
113
testsuite/hardlinks_test.py
Normal 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")
|
||||
@@ -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
256
testsuite/itemize_test.py
Normal 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)
|
||||
@@ -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
41
testsuite/longdir_test.py
Normal 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)
|
||||
@@ -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
89
testsuite/merge_test.py
Normal 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)
|
||||
@@ -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
56
testsuite/missing_test.py
Normal 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")
|
||||
@@ -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
65
testsuite/mkpath_test.py
Normal 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")
|
||||
@@ -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
|
||||
62
testsuite/open-noatime_test.py
Normal file
62
testsuite/open-noatime_test.py
Normal 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")
|
||||
@@ -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
|
||||
73
testsuite/protected-regular_test.py
Normal file
73
testsuite/protected-regular_test.py
Normal 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'))
|
||||
@@ -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
|
||||
91
testsuite/proxy-response-line-too-long_test.py
Normal file
91
testsuite/proxy-response-line-too-long_test.py
Normal 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")
|
||||
@@ -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
111
testsuite/relative_test.py
Normal 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)")
|
||||
@@ -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
592
testsuite/rsyncfns.py
Normal 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}")
|
||||
@@ -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
|
||||
48
testsuite/safe-links_test.py
Normal file
48
testsuite/safe-links_test.py
Normal 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")
|
||||
@@ -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
|
||||
30
testsuite/secure-relpath-validation_test.py
Normal file
30
testsuite/secure-relpath-validation_test.py
Normal 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)"
|
||||
)
|
||||
@@ -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
|
||||
88
testsuite/sender-flist-symlink-leak_test.py
Normal file
88
testsuite/sender-flist-symlink-leak_test.py
Normal 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)"
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user