diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index 9d7ea782e..e269a3e7a 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -59,8 +59,13 @@ jobs: run: ./rsync --version - name: check # In the container we already run as root, so no sudo. The - # crtimes-not-supported skip matches the other Linux jobs. - run: RSYNC_EXPECT_SKIPPED=crtimes make check + # crtimes-not-supported skip matches the other Linux jobs; + # daemon-chroot-acl and proxy-response-line-too-long skip because + # the default (secure) transport opens no listening socket. + run: RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check + - name: check (TCP daemon transport) + # Second run exercising the real loopback-TCP daemon path. + run: ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 - name: ssl file list run: ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index fe5a5c422..946166fa7 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -39,7 +39,16 @@ jobs: - name: info run: bash -c '/usr/local/bin/rsync --version' - name: check - run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check' + # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on Cygwin + # (rsyncfns.py drives xattrs via getfattr/setfattr from the `attr` + # package installed above), verified on a real Cygwin host. The real + # chown/devices tests still skip (need root/mknod), as do the + # RESOLVE_BENEATH symlink-race tests. + run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check' + - name: check (TCP daemon transport) + # Second run with daemon tests over a real loopback rsyncd; the default + # 'make check' above uses the secure stdio-pipe transport. + run: bash -c './runtests.py --rsync-bin=`pwd`/rsync.exe --use-tcp -j 8' - name: ssl file list run: bash -c 'PATH="/usr/local/bin:$PATH" rsync-ssl --no-motd download.samba.org::rsyncftp/ || true' - name: save artifact diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml index 79633ad16..0d2b36279 100644 --- a/.github/workflows/freebsd-build.yml +++ b/.github/workflows/freebsd-build.yml @@ -35,6 +35,7 @@ jobs: make ./rsync --version make check + ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index a127526e6..f019e6c45 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -41,7 +41,14 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=acls-default,chmod-temp-dir,chown-fake,daemon-chroot-acl,devices-fake,dir-sgid,open-noatime,protected-regular,simd-checksum,xattrs-hlink,xattrs make check + # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS + # (rsyncfns.py drives xattrs via the `xattr` command), verified on a + # real macOS host, so they're no longer in the skip set. + run: sudo RSYNC_EXPECT_SKIPPED=acls-default,chmod-temp-dir,daemon-chroot-acl,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,simd-checksum make check + - name: check (TCP daemon transport) + # Second run with daemon tests over a real loopback rsyncd; the default + # 'make check' above uses the secure stdio-pipe transport. + run: sudo ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml index 770d7124c..3acc23408 100644 --- a/.github/workflows/netbsd-build.yml +++ b/.github/workflows/netbsd-build.yml @@ -36,6 +36,7 @@ jobs: make ./rsync --version make check + ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml index 749724cd6..3d83ab1a4 100644 --- a/.github/workflows/openbsd-build.yml +++ b/.github/workflows/openbsd-build.yml @@ -37,6 +37,7 @@ jobs: make ./rsync --version make check + ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml index e41e002dc..c7867f90c 100644 --- a/.github/workflows/solaris-build.yml +++ b/.github/workflows/solaris-build.yml @@ -35,6 +35,7 @@ jobs: make ./rsync --version make check + ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ubuntu-22.04-build.yml b/.github/workflows/ubuntu-22.04-build.yml index 0e608279e..154b1b90a 100644 --- a/.github/workflows/ubuntu-22.04-build.yml +++ b/.github/workflows/ubuntu-22.04-build.yml @@ -39,11 +39,15 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check + run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check30 + run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check30 - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check29 + run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check29 + - name: check (TCP daemon transport) + # Second run with daemon tests over a real loopback rsyncd; the default + # 'make check' above uses the secure stdio-pipe transport. + run: sudo ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 5efadce5b..5fe6cca44 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -35,11 +35,17 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check + run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check30 + run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check30 - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check29 + run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-chroot-acl,proxy-response-line-too-long make check29 + - name: check (TCP daemon transport) + # Second run with daemon tests over a real loopback rsyncd. The default + # 'make check' above uses the secure stdio-pipe transport (no listening + # sockets); this run exercises the real TCP accept/auth path. Skip-set + # is env-dependent here (chroot-acl), so leave RSYNC_EXPECT_SKIPPED unset. + run: sudo ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact diff --git a/Makefile.in b/Makefile.in index 699d99562..af9fbfb28 100644 --- a/Makefile.in +++ b/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 @@ -319,17 +320,21 @@ test: check # catch Bash-isms earlier even if we're running on GNU. Of course, we # might lose in the future where POSIX diverges from old sh. +# `make check` runs tests in parallel by default. Override with +# `make check CHECK_J=1` (serial) or any other value. +CHECK_J = 8 + .PHONY: check check: all $(CHECK_PROGS) $(CHECK_SYMLINKS) - $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) + $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) .PHONY: check29 check29: all $(CHECK_PROGS) $(CHECK_SYMLINKS) - $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) --protocol=29 + $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) --protocol=29 .PHONY: check30 check30: all $(CHECK_PROGS) $(CHECK_SYMLINKS) - $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) --protocol=30 + $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) --protocol=30 wildtest.o: wildtest.c t_stub.o lib/wildmatch.c rsync.h config.h wildtest$(EXEEXT): wildtest.o lib/compat.o lib/snprintf.o @BUILD_POPT@ @@ -343,14 +348,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.py: + ln -s devices_test.py $(srcdir)/testsuite/devices-fake_test.py -testsuite/devices-fake.test: - ln -s devices.test $(srcdir)/testsuite/devices-fake.test +testsuite/xattrs-hlink_test.py: + ln -s xattrs_test.py $(srcdir)/testsuite/xattrs-hlink_test.py -testsuite/xattrs-hlink.test: - ln -s xattrs.test $(srcdir)/testsuite/xattrs-hlink.test +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, @@ -358,7 +366,7 @@ testsuite/xattrs-hlink.test: .PHONY: installcheck installcheck: $(CHECK_PROGS) $(CHECK_SYMLINKS) - $(srcdir)/runtests.py --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir=`pwd` + $(srcdir)/runtests.py --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir=`pwd` -j $(CHECK_J) # TODO: Add 'dist' target; need to know which files will be included diff --git a/runtests.py b/runtests.py index 08f67bb52..2259aee15 100755 --- a/runtests.py +++ b/runtests.py @@ -61,6 +61,12 @@ def parse_args(): help='Force protocol version (adds --protocol=VER to rsync)') p.add_argument('--expect-skipped', default=None, metavar='LIST', help='Comma-separated list of expected-skipped tests') + p.add_argument('--use-tcp', action='store_true', + help='Run daemon tests against a real rsyncd bound to ' + '127.0.0.1 (non-default). The default is the secure ' + 'stdio-pipe transport, which opens no listening ' + 'socket; --use-tcp exposes a loopback port for the ' + 'duration of each daemon test.') return p.parse_args() @@ -151,17 +157,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 +239,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', '.') @@ -329,6 +372,7 @@ def main(): print(f' valgrind=enabled (logs in valgrind.*.log)') if args.parallel > 1: print(f' parallel={args.parallel}') + print(f' daemon_transport={"tcp (loopback)" if args.use_tcp else "pipe (secure default)"}') print(f' scratchbase={scratchbase}') # Build base environment for test scripts @@ -336,6 +380,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,7 +398,12 @@ def main(): 'suitedir': suitedir, 'TESTRUN_TIMEOUT': str(args.timeout), 'HOME': scratchbase, + 'PYTHONPATH': pythonpath, }) + if args.use_tcp: + # Opt-in: daemon tests start a real rsyncd on a claimed loopback port. + # Default (unset) keeps the secure stdio-pipe transport. + base_env['RSYNC_TEST_USE_TCP'] = '1' for k, v in shconfig.items(): if v: base_env[k] = v @@ -365,7 +419,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 +456,7 @@ def process_result(tr): 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 +477,7 @@ def process_result(tr): else: # Sequential execution for testscript in tests: - testbase = os.path.basename(testscript).replace('.test', '') + testbase = _testbase(testscript) scratchdir = os.path.join(scratchbase, testbase) timeout = 600 if 'hardlinks' in testbase else args.timeout tr = run_one_test( diff --git a/socket.c b/socket.c index 6a8f6f4ae..d5aa0cb71 100644 --- a/socket.c +++ b/socket.c @@ -739,8 +739,12 @@ void set_socket_options(int fd, char *options) /* This is like socketpair but uses tcp. The function guarantees that nobody * else can attach to the socket, or if they do that this function fails and - * the socket gets closed. Returns 0 on success, -1 on failure. The resulting - * file descriptors are symmetrical. Currently only for RSYNC_CONNECT_PROG. */ + * the socket gets closed. The anti-hijack guarantee is enforced after the + * accept() below: a local attacker who races a connection in on the loopback + * listener before our own connect() lands would be detected by the peer-vs- + * local address comparison and the function fails. Returns 0 on success, -1 + * on failure. The resulting file descriptors are symmetrical. Currently + * only for RSYNC_CONNECT_PROG. */ static int socketpair_tcp(int fd[2]) { int listener; @@ -792,6 +796,28 @@ static int socketpair_tcp(int fd[2]) goto failed; } + /* Confirm that the connection we accepted is the one we just made, and + * not one a local attacker raced in on the loopback listener before our + * own connect() completed. The peer of the accepted end (fd[0]) must be + * the local address of our connecting end (fd[1]), and both must be + * loopback. If they differ, someone else connected first; fail closed. */ + { + struct sockaddr_in accepted_peer, our_local; + socklen_t plen = sizeof accepted_peer; + socklen_t llen = sizeof our_local; + + if (getpeername(fd[0], (struct sockaddr *)&accepted_peer, &plen) != 0 + || getsockname(fd[1], (struct sockaddr *)&our_local, &llen) != 0 + || accepted_peer.sin_family != AF_INET + || our_local.sin_family != AF_INET + || accepted_peer.sin_addr.s_addr != htonl(INADDR_LOOPBACK) + || our_local.sin_addr.s_addr != htonl(INADDR_LOOPBACK) + || accepted_peer.sin_port != our_local.sin_port) { + errno = EPERM; + goto failed; + } + } + /* all OK! */ return 0; diff --git a/testsuite/00-hello.test b/testsuite/00-hello.test deleted file mode 100644 index ebd068365..000000000 --- a/testsuite/00-hello.test +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/sh - -# Test some foundational things. - -. "$suitedir/rsync.fns" - -RSYNC_RSH="$scratchdir/src/support/lsh.sh" -export RSYNC_RSH - -echo $0 running - -$RSYNC --version || test_fail '--version output failed' - -$RSYNC --info=help || test_fail '--info=help output failed' - -$RSYNC --debug=help || test_fail '--debug=help output failed' - -weird_name="A weird)name" - -mkdir "$fromdir" -mkdir "$fromdir/$weird_name" - -append_line() { - echo "$1" - echo "$1" >>"$fromdir/$weird_name/file" -} - -append_line test1 -checkit "$RSYNC -ai '$fromdir/' '$todir/'" "$fromdir" "$todir" - -copy_weird() { - checkit "$RSYNC $1 --rsync-path='$RSYNC' '$2$fromdir/$weird_name/' '$3$todir/$weird_name'" "$fromdir" "$todir" -} - -append_line test2 -copy_weird '-ai' 'lh:' '' - -append_line test3 -copy_weird '-ai' '' 'lh:' - -append_line test4 -copy_weird '-ais' 'lh:' '' - -append_line test5 -copy_weird '-ais' '' 'lh:' - -echo test6 - -touch "$fromdir/one" "$fromdir/two" -(cd "$fromdir" && $RSYNC -ai --old-args --rsync-path="$RSYNC" lh:'one two' "$todir/") -if [ ! -f "$todir/one" ] || [ ! -f "$todir/two" ]; then - test_fail "old-args copy of 'one two' failed" -fi - -echo test7 - -rm "$todir/one" "$todir/two" -(cd "$fromdir" && RSYNC_OLD_ARGS=1 $RSYNC -ai --rsync-path="$RSYNC" lh:'one two' "$todir/") - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/00-hello_test.py b/testsuite/00-hello_test.py new file mode 100644 index 000000000..22b2e0e68 --- /dev/null +++ b/testsuite/00-hello_test.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/00-hello.test. +# +# Foundational smoke test: --version / --info=help / --debug=help all +# work, plus a round-trip transfer of a directory whose name contains +# shell-special characters via the lsh.sh remote-shell stand-in. + +import os + +from rsyncfns import ( + FROMDIR, RSYNC, SRCDIR, TODIR, + checkit, run_rsync, test_fail, +) + + +# Set RSYNC_RSH so rsync picks up lsh.sh for the "lh:" hosts below. +os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh') + +# Basic help dumps must not crash. +if run_rsync('--version', check=False).returncode != 0: + test_fail('--version output failed') +if run_rsync('--info=help', check=False).returncode != 0: + test_fail('--info=help output failed') +if run_rsync('--debug=help', check=False).returncode != 0: + test_fail('--debug=help output failed') + +weird_name = "A weird)name" + +FROMDIR.mkdir(parents=True, exist_ok=True) +weird_dir = FROMDIR / weird_name +weird_dir.mkdir() + + +def append_line(line: str) -> None: + print(line) + with open(weird_dir / 'file', 'a') as f: + f.write(line + '\n') + + +def copy_weird(args: list, src_host: str, dst_host: str) -> None: + checkit( + [*args, f'--rsync-path={RSYNC}', + f'{src_host}{weird_dir}/', + f'{dst_host}{TODIR / weird_name}'], + FROMDIR, TODIR, + ) + + +append_line('test1') +checkit(['-ai', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) + +append_line('test2') +copy_weird(['-ai'], 'lh:', '') + +append_line('test3') +copy_weird(['-ai'], '', 'lh:') + +append_line('test4') +copy_weird(['-ais'], 'lh:', '') + +append_line('test5') +copy_weird(['-ais'], '', 'lh:') + +# test6: --old-args lets two whitespace-separated names go through as a +# single "one two" remote argument to be re-split by the remote shell. +print('test6') +(FROMDIR / 'one').touch() +(FROMDIR / 'two').touch() + +saved = os.getcwd() +os.chdir(FROMDIR) +try: + run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC}', + 'lh:one two', f'{TODIR}/') +finally: + os.chdir(saved) + +if not (TODIR / 'one').is_file() or not (TODIR / 'two').is_file(): + test_fail("old-args copy of 'one two' failed") + +# test7: the RSYNC_OLD_ARGS=1 env var should be equivalent to --old-args. +print('test7') +(TODIR / 'one').unlink() +(TODIR / 'two').unlink() + +env = os.environ.copy() +env['RSYNC_OLD_ARGS'] = '1' +import subprocess +from rsyncfns import rsync_argv + +os.chdir(FROMDIR) +try: + subprocess.run( + rsync_argv('-ai', f'--rsync-path={RSYNC}', + 'lh:one two', f'{TODIR}/'), + env=env, check=True, + ) +finally: + os.chdir(saved) diff --git a/testsuite/acls-default.test b/testsuite/acls-default.test deleted file mode 100644 index d8fba7fee..000000000 --- a/testsuite/acls-default.test +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/sh - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test that rsync obeys default ACLs. -- Matt McCutchen - -. $suitedir/rsync.fns - -$RSYNC -VV | grep '"ACLs": true' >/dev/null || test_skipped "Rsync is configured without ACL support" - -case "$setfacl_nodef" in -true) test_skipped "I don't know how to use your setfacl command" ;; -*-k*) opts='-dm u::7,g::5,o:5' ;; -*) opts='-m d:u::7,d:g::5,d:o:5' ;; -esac -setfacl $opts "$scratchdir" || test_skipped "Your filesystem has ACLs disabled" - -# Call as: testit -testit() { - todir="$scratchdir/$1" - mkdir "$todir" - $setfacl_nodef "$todir" - if [ -n "$2" ]; then - case "$setfacl_nodef" in - *-k*) opts="-dm $2" ;; - *) opts="-m `echo $2 | sed 's/\([ugom]:\)/d:\1/g'`" - esac - setfacl $opts "$todir" - fi - # Make sure we obey ACLs when creating a directory to hold multiple transferred files, - # even though the directory itself is outside the transfer - $RSYNC -rvv "$scratchdir/dir" "$scratchdir/file" "$scratchdir/program" "$todir/to/" - check_perms "$todir/to" $4 "Target $1" - check_perms "$todir/to/dir" $4 "Target $1" - check_perms "$todir/to/file" $3 "Target $1" - check_perms "$todir/to/program" $4 "Target $1" - # Make sure get_local_name doesn't mess us up when transferring only one file - $RSYNC -rvv "$scratchdir/file" "$todir/to/anotherfile" - check_perms "$todir/to/anotherfile" $3 "Target $1" - # Make sure we obey default ACLs when not transferring a regular file - $RSYNC -rvv "$scratchdir/dir/" "$todir/to/anotherdir/" - check_perms "$todir/to/anotherdir" $4 "Target $1" -} - -mkdir "$scratchdir/dir" -echo "File!" >"$scratchdir/file" -echo "#!/bin/sh" >"$scratchdir/program" -chmod 777 "$scratchdir/dir" -chmod 666 "$scratchdir/file" -chmod 777 "$scratchdir/program" - -# Test some target directories -umask 0077 -testit da777 u::7,g::7,o:7 rw-rw-rw- rwxrwxrwx -testit da775 u::7,g::7,o:5 rw-rw-r-- rwxrwxr-x -testit da750 u::7,g::5,o:0 rw-r----- rwxr-x--- -testit da750mask u::7,u:0:7,g::7,m:5,o:0 rw-r----- rwxr-x--- -testit noda1 '' rw------- rwx------ -umask 0000 -testit noda2 '' rw-rw-rw- rwxrwxrwx -umask 0022 -testit noda3 '' rw-r--r-- rwxr-xr-x - -# Hooray -exit 0 diff --git a/testsuite/acls-default_test.py b/testsuite/acls-default_test.py new file mode 100644 index 000000000..66c8cef41 --- /dev/null +++ b/testsuite/acls-default_test.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/acls-default.test. +# +# Test that rsync obeys POSIX default ACLs on the destination's parent +# directory when creating the transfer's container directory, even +# though that parent is outside the transfer itself. + +import os +import re +import shlex +import subprocess + +from rsyncfns import ( + SCRATCHDIR, + check_perms, run_rsync, test_skipped, +) + + +vv = run_rsync('-VV', check=True, capture_output=True) +if '"ACLs": true' not in vv.stdout: + test_skipped("Rsync is configured without ACL support") + +setfacl_nodef = os.environ.get('setfacl_nodef', 'true') +if setfacl_nodef == 'true': + test_skipped("I don't know how to use your setfacl command") + +if '-k' in setfacl_nodef.split(): + seed_opts = ['-dm', 'u::7,g::5,o:5'] +else: + seed_opts = ['-m', 'd:u::7,d:g::5,d:o:5'] + +# Seed the scratch dir with a default ACL so the upcoming testit() runs +# inherit a known-base; if setfacl rejects this the FS doesn't have ACLs. +proc = subprocess.run(['setfacl', *seed_opts, str(SCRATCHDIR)]) +if proc.returncode != 0: + test_skipped("Your filesystem has ACLs disabled") + + +def testit(dirname, default_acl, file_expected, prog_expected): + """Set a default ACL on a destination parent dir, then verify that + a transfer into a fresh subdir picks up the inherited perms.""" + todir = SCRATCHDIR / dirname + todir.mkdir() + # Clear any inherited default ACL first. + subprocess.run(shlex.split(setfacl_nodef) + [str(todir)]) + if default_acl: + if '-k' in setfacl_nodef.split(): + opts = ['-dm', default_acl] + else: + # Each "u:/g:/o:/m:" prefix becomes "d:u:/d:g:/d:o:/d:m:". + translated = re.sub(r'([ugom]:)', r'd:\1', default_acl) + opts = ['-m', translated] + subprocess.run(['setfacl', *opts, str(todir)], check=True) + + run_rsync('-rvv', + str(SCRATCHDIR / 'dir'), + str(SCRATCHDIR / 'file'), + str(SCRATCHDIR / 'program'), + f'{todir}/to/') + + check_perms(todir / 'to', prog_expected) + check_perms(todir / 'to' / 'dir', prog_expected) + check_perms(todir / 'to' / 'file', file_expected) + check_perms(todir / 'to' / 'program', prog_expected) + + # get_local_name shouldn't mess up a single-file transfer. + run_rsync('-rvv', + str(SCRATCHDIR / 'file'), + f'{todir}/to/anotherfile') + check_perms(todir / 'to' / 'anotherfile', file_expected) + + # And the no-regular-file case (sole-dir transfer). + run_rsync('-rvv', + f'{SCRATCHDIR / "dir"}/', + f'{todir}/to/anotherdir/') + check_perms(todir / 'to' / 'anotherdir', prog_expected) + + +(SCRATCHDIR / 'dir').mkdir() +(SCRATCHDIR / 'file').write_text("File!\n") +(SCRATCHDIR / 'program').write_text("#!/bin/sh\n") +os.chmod(SCRATCHDIR / 'dir', 0o777) +os.chmod(SCRATCHDIR / 'file', 0o666) +os.chmod(SCRATCHDIR / 'program', 0o777) + +os.umask(0o077) +testit('da777', 'u::7,g::7,o:7', 'rw-rw-rw-', 'rwxrwxrwx') +testit('da775', 'u::7,g::7,o:5', 'rw-rw-r--', 'rwxrwxr-x') +testit('da750', 'u::7,g::5,o:0', 'rw-r-----', 'rwxr-x---') +testit('da750mask', 'u::7,u:0:7,g::7,m:5,o:0', 'rw-r-----', 'rwxr-x---') +testit('noda1', '', 'rw-------', 'rwx------') +os.umask(0o000) +testit('noda2', '', 'rw-rw-rw-', 'rwxrwxrwx') +os.umask(0o022) +testit('noda3', '', 'rw-r--r--', 'rwxr-xr-x') diff --git a/testsuite/acls.test b/testsuite/acls.test deleted file mode 100644 index 693da6677..000000000 --- a/testsuite/acls.test +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/sh - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test that rsync handles basic ACL preservation. - -. $suitedir/rsync.fns - -$RSYNC -VV | grep '"ACLs": true' >/dev/null || test_skipped "Rsync is configured without ACL support" - -makepath "$fromdir/foo" -echo something >"$fromdir/file1" -echo else >"$fromdir/file2" - -files='foo file1 file2' - -case "$setfacl_nodef" in -true) - if ! chmod --help 2>&1 | grep -F +a >/dev/null; then - test_skipped "I don't know how to use setfacl or chmod for ACLs" - fi - chmod +a "root allow read,write,execute" "$fromdir/foo" || test_skipped "Your filesystem has ACLs disabled" - chmod +a "root allow read,execute" "$fromdir/file1" - chmod +a "admin allow read" "$fromdir/file1" - chmod +a "daemon allow read,write" "$fromdir/file1" - chmod +a "root allow read,execute" "$fromdir/file2" - - see_acls() { - ls -le "${@}" - } - ;; -*) - setfacl -m u:0:7 "$fromdir/foo" || test_skipped "Your filesystem has ACLs disabled" - setfacl -m g:1:5 "$fromdir/foo" - setfacl -m g:2:1 "$fromdir/foo" - setfacl -m g:0:7 "$fromdir/foo" - setfacl -m u:2:1 "$fromdir/foo" - setfacl -m u:1:5 "$fromdir/foo" - - setfacl -m u:0:5 "$fromdir/file1" - setfacl -m g:0:4 "$fromdir/file1" - setfacl -m u:1:6 "$fromdir/file1" - - setfacl -m u:0:5 "$fromdir/file2" - - see_acls() { - getfacl "${@}" - } - ;; -esac - -cd "$fromdir" -$RSYNC -avvA $files "$todir/" - -see_acls $files >"$scratchdir/acls.txt" - -cd "$todir" -see_acls $files | diff $diffopt "$scratchdir/acls.txt" - - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/acls_test.py b/testsuite/acls_test.py new file mode 100644 index 000000000..c24d7b00f --- /dev/null +++ b/testsuite/acls_test.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/acls.test. +# +# Test that rsync -A preserves POSIX ACLs across a transfer. Skips on +# binaries built without ACL support, on filesystems with ACLs disabled, +# and on hosts that lack both setfacl(1) and a chmod that understands "+a". + +import os +import platform +import subprocess + +from rsyncfns import FROMDIR, SCRATCHDIR, TODIR, makepath, run_rsync, test_fail, test_skipped + + +vv = run_rsync('-VV', check=True, capture_output=True) +if '"ACLs": true' not in vv.stdout: + test_skipped("Rsync is configured without ACL support") + +makepath(FROMDIR / 'foo') +(FROMDIR / 'file1').write_text("something\n") +(FROMDIR / 'file2').write_text("else\n") + +files = ['foo', 'file1', 'file2'] + +# Decide which ACL command surface to use. Mirrors the shell test's +# branching on $setfacl_nodef (set by runtests.py). +setfacl_nodef = os.environ.get('setfacl_nodef', 'true') + + +def _chmod_plus_a_supported() -> bool: + """macOS-style: chmod +a 'user allow ...'.""" + out = subprocess.run(['chmod', '--help'], capture_output=True, text=True) + return '+a' in (out.stdout + out.stderr) + + +use_chmod_plus_a = setfacl_nodef == 'true' and _chmod_plus_a_supported() + +if setfacl_nodef == 'true' and not use_chmod_plus_a: + test_skipped("I don't know how to use setfacl or chmod for ACLs") + + +def _setfacl(*args) -> int: + return subprocess.run(['setfacl', *args]).returncode + + +def _chmod_acl(*args) -> int: + return subprocess.run(['chmod', *args]).returncode + + +if use_chmod_plus_a: + if _chmod_acl('+a', 'root allow read,write,execute', + str(FROMDIR / 'foo')) != 0: + test_skipped("Your filesystem has ACLs disabled") + _chmod_acl('+a', 'root allow read,execute', str(FROMDIR / 'file1')) + _chmod_acl('+a', 'admin allow read', str(FROMDIR / 'file1')) + _chmod_acl('+a', 'daemon allow read,write', str(FROMDIR / 'file1')) + _chmod_acl('+a', 'root allow read,execute', str(FROMDIR / 'file2')) + + def see_acls(paths): + return subprocess.check_output(['ls', '-le', *paths], text=True) +else: + if _setfacl('-m', 'u:0:7', str(FROMDIR / 'foo')) != 0: + test_skipped("Your filesystem has ACLs disabled") + _setfacl('-m', 'g:1:5', str(FROMDIR / 'foo')) + _setfacl('-m', 'g:2:1', str(FROMDIR / 'foo')) + _setfacl('-m', 'g:0:7', str(FROMDIR / 'foo')) + _setfacl('-m', 'u:2:1', str(FROMDIR / 'foo')) + _setfacl('-m', 'u:1:5', str(FROMDIR / 'foo')) + + _setfacl('-m', 'u:0:5', str(FROMDIR / 'file1')) + _setfacl('-m', 'g:0:4', str(FROMDIR / 'file1')) + _setfacl('-m', 'u:1:6', str(FROMDIR / 'file1')) + + _setfacl('-m', 'u:0:5', str(FROMDIR / 'file2')) + + def see_acls(paths): + return subprocess.check_output(['getfacl', *paths], text=True) + + +os.chdir(FROMDIR) +run_rsync('-avvA', *files, f'{TODIR}/') + +before = see_acls(files) +(SCRATCHDIR / 'acls.txt').write_text(before) + +os.chdir(TODIR) +after = see_acls(files) +if before != after: + print("--- expected (from) ---") + print(before) + print("--- got (to) ---") + print(after) + test_fail("ACL listing differs between source and destination") diff --git a/testsuite/alt-dest-symlink-race.test b/testsuite/alt-dest-symlink-race.test deleted file mode 100755 index fd36c6e6c..000000000 --- a/testsuite/alt-dest-symlink-race.test +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2026 by Andrew Tridgell - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Regression test for the basedir-confinement gap in -# secure_relative_open(). The function opens basedir with a plain -# openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY), without -# RESOLVE_BENEATH or a per-component O_NOFOLLOW walk, so a parent -# symlink ON basedir is followed unrestrictedly. RESOLVE_BENEATH is -# then applied only to relpath, anchored at the wrong directory. -# -# The receiver's basis-file lookup at receiver.c passes -# basis_dir[fnamecmp_type] (from --copy-dest / --link-dest / -# --compare-dest -- all sender-controllable in daemon mode) as -# basedir. A daemon-module attacker with write access can plant a -# symlink at module/cd -> /outside, then run --link-dest=cd to -# make the daemon's basis-file lookup resolve into /outside, -# leaking the contents of daemon-readable files via the rsync -# delta-rolling read-disclosure primitive. -# -# We detect the escape by leveraging --link-dest: when basis -# matches source exactly (content + mtime + mode), --link-dest -# hard-links the destination to the basis file. With the bug, the -# destination ends up as a hard link to the outside-the-module -# file (same inode). With the fix, no basis is found and the -# destination is a fresh copy (different inode). -# -# The vulnerable code path is the same on every platform -# (including the per-component fallback on systems without -# RESOLVE_BENEATH), so this test is not platform-gated. - -. "$suitedir/rsync.fns" - -mod="$scratchdir/module" -outside="$scratchdir/outside" -src="$scratchdir/src" -conf="$scratchdir/test-rsyncd.conf" - -rm -rf "$mod" "$outside" "$src" -mkdir -p "$mod" "$outside" "$src" - -# Portable inode-number helper (GNU coreutils stat -c, BSD stat -f). -file_inode() { - stat -c %i "$1" 2>/dev/null || stat -f %i "$1" -} - -# Outside-the-module file an attacker would like the daemon to -# treat as a basis. -echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt" -chmod 0644 "$outside/target.txt" - -# The symlink trap planted in the module by the local attacker. -ln -s "$outside" "$mod/cd" - -# Source file matches outside/target.txt exactly (content + mtime -# + mode) so --link-dest will hard-link the destination to the -# basis file iff the daemon's basedir lookup reaches outside/. -echo "OUTSIDE_SECRET_DATA" > "$src/target.txt" -touch -r "$outside/target.txt" "$src/target.txt" -chmod 0644 "$src/target.txt" - -# When running as root the daemon would drop to "nobody" by -# default, which can't write into the test scratch dir. Force the -# daemon to keep our uid/gid in that case so the basis-link -# transfer can actually create the destination file. (Non-root -# can't specify uid/gid in rsyncd.conf -- comment them out then.) -my_uid=`get_testuid` -root_uid=`get_rootuid` -root_gid=`get_rootgid` -uid_setting="uid = $root_uid" -gid_setting="gid = $root_gid" -if test x"$my_uid" != x"$root_uid"; then - uid_setting="#$uid_setting" - gid_setting="#$gid_setting" -fi - -cat > "$conf" </dev/null 2>&1 || true - -if [ ! -f "$mod/target.txt" ]; then - test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour" -fi - -outside_inode=$(file_inode "$outside/target.txt") -dst_inode=$(file_inode "$mod/target.txt") - -if [ "$outside_inode" = "$dst_inode" ]; then - test_fail "basedir-escape: --link-dest hard-linked module/target.txt to outside/target.txt (inode $outside_inode); daemon's basis-file lookup followed the parent symlink on the basedir" -fi - -exit 0 diff --git a/testsuite/alt-dest-symlink-race_test.py b/testsuite/alt-dest-symlink-race_test.py new file mode 100644 index 000000000..fc83c5762 --- /dev/null +++ b/testsuite/alt-dest-symlink-race_test.py @@ -0,0 +1,98 @@ +#!/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 ( + SCRATCHDIR, + rsync_argv, get_testuid, get_rootuid, get_rootgid, + rmtree, start_test_daemon, test_fail, +) + + +DAEMON_PORT = 12882 + + +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 +""") + +url = start_test_daemon(conf, DAEMON_PORT) + +# 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}/', f'{url}upload/'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, +) + +target = mod / 'target.txt' +if not target.is_file(): + test_fail( + "destination file was not created -- daemon transfer failed " + "before the test could observe the basedir behaviour" + ) + +if target.stat().st_ino == (outside / 'target.txt').stat().st_ino: + test_fail( + f"basedir-escape: --link-dest hard-linked module/target.txt to " + f"outside/target.txt (inode {target.stat().st_ino}); daemon's " + "basis-file lookup followed the parent symlink on the basedir" + ) diff --git a/testsuite/alt-dest.test b/testsuite/alt-dest.test deleted file mode 100644 index d2fb5a1bb..000000000 --- a/testsuite/alt-dest.test +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2004-2022 Wayne Davison - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test rsync handling of --compare-dest and similar options. - -. "$suitedir/rsync.fns" - -alt1dir="$tmpdir/alt1" -alt2dir="$tmpdir/alt2" -alt3dir="$tmpdir/alt3" - -SSH="$scratchdir/src/support/lsh.sh" - -# Build some files/dirs/links to copy - -hands_setup - -# Setup the alt and chk dirs -$RSYNC -av --include=text --include='*/' --exclude='*' "$fromdir/" "$alt1dir/" -$RSYNC -av --include=etc-ltr-list --include='*/' --exclude='*' "$fromdir/" "$alt2dir/" - -# Create a side dir where there is a candidate destfile of the same name as a sourcefile -echo "This is a test file" >"$fromdir/likely" - -mkdir "$alt3dir" -echo "This is a test file" >"$alt3dir/likely" - -sleep 1 -touch "$fromdir/dir/text" "$fromdir/likely" - -$RSYNC -av --exclude=/text --exclude=etc-ltr-list "$fromdir/" "$chkdir/" - -# Let's do it! -checkit "$RSYNC -avv --no-whole-file \ - --compare-dest='$alt1dir' --compare-dest='$alt2dir' \ - '$fromdir/' '$todir/'" "$chkdir" "$todir" - -rm -rf "$todir" -checkit "$RSYNC -avv --no-whole-file \ - --copy-dest='$alt1dir' --copy-dest='$alt2dir' \ - '$fromdir/' '$todir/'" "$fromdir" "$todir" - -# Test that copy_file() works correctly with tmpfiles -for maybe_inplace in '' --inplace; do - rm -rf "$todir" - checkit "$RSYNC -av $maybe_inplace --copy-dest='$alt3dir' \ - '$fromdir/' '$todir/'" "$fromdir" "$todir" - - for srchost in '' 'localhost:'; do - if [ -z "$srchost" ]; then - desthost='localhost:' - else - desthost='' - fi - - rm -rf "$todir" - checkit "$RSYNC -ave '$SSH' --rsync-path='$RSYNC' $maybe_inplace \ - --copy-dest='$alt3dir' '$srchost$fromdir/' '$desthost$todir/'" \ - "$fromdir" "$todir" - done -done - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/alt-dest_test.py b/testsuite/alt-dest_test.py new file mode 100644 index 000000000..7530bd5a2 --- /dev/null +++ b/testsuite/alt-dest_test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/alt-dest.test. +# +# Exercise rsync's --compare-dest, --copy-dest and --link-dest +# alternative-destination options, both locally and across the lsh.sh +# remote-shell stand-in. Also covers the tmpfile path in copy_file() by +# pointing --copy-dest at a directory holding a same-name candidate. + +import os +import shutil +import time + +from rsyncfns import ( + CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, + checkit, hands_setup, rmtree, run_rsync, +) + + +alt1dir = TMPDIR / 'alt1' +alt2dir = TMPDIR / 'alt2' +alt3dir = TMPDIR / 'alt3' + +SSH = str(SRCDIR / 'support' / 'lsh.sh') + +hands_setup() + +# Seed alt1 and alt2 with disjoint single-file subtrees of fromdir. +run_rsync('-av', '--include=text', '--include=*/', '--exclude=*', + f'{FROMDIR}/', f'{alt1dir}/') +run_rsync('-av', '--include=etc-ltr-list', '--include=*/', '--exclude=*', + f'{FROMDIR}/', f'{alt2dir}/') + +# Create a side dir with one identically-named candidate so copy_file()'s +# tmpfile path gets exercised. +(FROMDIR / 'likely').write_text("This is a test file\n") +alt3dir.mkdir() +(alt3dir / 'likely').write_text("This is a test file\n") + +time.sleep(1) +os.utime(FROMDIR / 'dir' / 'text') +os.utime(FROMDIR / 'likely') + +# chkdir: what a vanilla copy would produce, minus /text and etc-ltr-list. +run_rsync('-av', '--exclude=/text', '--exclude=etc-ltr-list', + f'{FROMDIR}/', f'{CHKDIR}/') + +# Stacked --compare-dest: dest grows just the deltas alt1+alt2 don't have. +checkit(['-avv', '--no-whole-file', + f'--compare-dest={alt1dir}', f'--compare-dest={alt2dir}', + f'{FROMDIR}/', f'{TODIR}/'], CHKDIR, TODIR) + +rmtree(TODIR) +# Stacked --copy-dest: dest gets full copy because content can be hardlinked +# from the alt dirs where available. +checkit(['-avv', '--no-whole-file', + f'--copy-dest={alt1dir}', f'--copy-dest={alt2dir}', + f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) + +# Test that copy_file() works correctly with tmpfiles. Combine each of +# {direct, --inplace} with each of {local, remote-source, remote-dest}. +for maybe_inplace in ([], ['--inplace']): + rmtree(TODIR) + checkit(['-av', *maybe_inplace, f'--copy-dest={alt3dir}', + f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) + + for srchost in ('', 'localhost:'): + desthost = 'localhost:' if not srchost else '' + rmtree(TODIR) + checkit(['-ave', SSH, f'--rsync-path={RSYNC}', *maybe_inplace, + f'--copy-dest={alt3dir}', + f'{srchost}{FROMDIR}/', f'{desthost}{TODIR}/'], + FROMDIR, TODIR) diff --git a/testsuite/atimes.test b/testsuite/atimes.test deleted file mode 100644 index 4d46eb057..000000000 --- a/testsuite/atimes.test +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh - -# Test rsync copying atimes - -. "$suitedir/rsync.fns" - -$RSYNC -VV | grep '"atimes": true' >/dev/null || test_skipped "Rsync is configured without atimes support" - -mkdir "$fromdir" - -touch "$fromdir/foo" -touch -a -t 200102031717.42 "$fromdir/foo" - -TLS_ARGS=--atimes - -checkit "$RSYNC -rtUgvvv \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/atimes_test.py b/testsuite/atimes_test.py new file mode 100644 index 000000000..bb8d3268b --- /dev/null +++ b/testsuite/atimes_test.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/atimes.test. +# +# Test that rsync preserves source atimes when the binary was built with +# atimes support. We pin the source file's atime to a known historical +# value then sync with -U; the listing (with --atimes in tls) must match +# between source and destination. + +import os + +import rsyncfns +from rsyncfns import FROMDIR, TODIR, checkit, run_rsync, test_skipped + + +vv = run_rsync('-VV', check=True, capture_output=True) +if '"atimes": true' not in vv.stdout: + test_skipped("Rsync is configured without atimes support") + +FROMDIR.mkdir(parents=True, exist_ok=True) +foo = FROMDIR / 'foo' +foo.touch() + +# `touch -a -t 200102031717.42` -> set atime to 2001-02-03 17:17:42, mtime unchanged. +import datetime +atime = datetime.datetime(2001, 2, 3, 17, 17, 42).timestamp() +mtime = foo.stat().st_mtime +os.utime(foo, (atime, mtime)) + +# Make the listing include atimes so checkit's tls compare picks up the +# transferred atime. +rsyncfns.TLS_ARGS = '--atimes' + +checkit(['-rtUgvvv', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) diff --git a/testsuite/backup.test b/testsuite/backup.test deleted file mode 100644 index 4de38674d..000000000 --- a/testsuite/backup.test +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2004-2022 Wayne Davison - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test that the --backup option works right. - -. "$suitedir/rsync.fns" - -bakdir="$tmpdir/bak" - -makepath "$fromdir/deep" "$bakdir/dname" -name1="$fromdir/deep/name1" -name2="$fromdir/deep/name2" - -cat "$srcdir"/[gr]*.[ch] > "$name1" -cat "$srcdir"/[et]*.[ch] > "$name2" - -checkit "$RSYNC -ai --info=backup '$fromdir/' '$todir/'" "$fromdir" "$todir" - -checkit "$RSYNC -ai --info=backup '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir" -cat "$srcdir"/[fgpr]*.[ch] > "$name1" -cat "$srcdir"/[etw]*.[ch] > "$name2" - -checktee "$RSYNC -ai --info=backup --no-whole-file --backup '$fromdir/' '$todir/'" -for fn in deep/name1 deep/name2; do - grep "backed up $fn to $fn~" "$outfile" >/dev/null || test_fail "no backup message output for $fn" - diff $diffopt "$fromdir/$fn" "$todir/$fn" || test_fail "copy of $fn failed" - diff $diffopt "$chkdir/$fn" "$todir/$fn~" || test_fail "backup of $fn to $fn~ failed" - mv "$todir/$fn~" "$todir/$fn" -done - -echo deleted-file >"$todir/dname" -cp_touch "$todir/dname" "$chkdir" - -checkit "$RSYNC -ai --info=backup --no-whole-file --delete-delay \ - --backup --backup-dir='$bakdir' '$fromdir/' '$todir/'" "$fromdir" "$todir" \ - | tee "$outfile" - -for fn in deep/name1 deep/name2; do - grep "backed up $fn to .*/$fn$" "$outfile" >/dev/null || test_fail "no backup message output for $fn" -done -diff -r $diffopt "$chkdir" "$bakdir" || test_fail "backup dir contents are bogus" -rm "$bakdir/dname" - -checkit "$RSYNC -ai --info=backup --del '$fromdir/' '$chkdir/'" "$fromdir" "$chkdir" -cat "$srcdir"/[efgr]*.[ch] > "$name1" -cat "$srcdir"/[ew]*.[ch] > "$name2" - -checkit "$RSYNC -ai --info=backup --inplace --no-whole-file --backup --backup-dir='$bakdir' '$fromdir/' '$todir/'" "$fromdir" "$todir" \ - | tee "$outfile" - -for fn in deep/name1 deep/name2; do - grep "backed up $fn to .*/$fn$" "$outfile" >/dev/null || test_fail "no backup message output for $fn" -done -diff -r $diffopt "$chkdir" "$bakdir" || test_fail "backup dir contents are bogus" - -checkit "$RSYNC -ai --info=backup --inplace --no-whole-file '$fromdir/' '$bakdir/'" "$fromdir" "$bakdir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/backup_test.py b/testsuite/backup_test.py new file mode 100644 index 000000000..eb2e3c58f --- /dev/null +++ b/testsuite/backup_test.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/backup.test. +# +# Walk through --backup behaviour: +# * a plain backup leaves the old file at name~ alongside the new one, +# * --backup-dir relocates the old file into a parallel tree and also +# captures deletions when used with --delete-delay, +# * --backup --inplace --backup-dir handles delta-overwrites too. +# Each phase also confirms the destination ends up matching the source via +# the usual checkit listing+content diff. + +import os +import shutil +import subprocess + +from rsyncfns import ( + CHKDIR, FROMDIR, OUTFILE, SRCDIR, TMPDIR, TODIR, + checkit, cp_touch, makepath, rsync_argv, test_fail, verify_dirs, +) + + +bakdir = TMPDIR / 'bak' + +makepath(FROMDIR / 'deep', bakdir / 'dname') +name1 = FROMDIR / 'deep' / 'name1' +name2 = FROMDIR / 'deep' / 'name2' + + +def _cat_glob(pattern: str, dest): + """Concatenate every srcdir file matching `pattern` into `dest`. + + Mirrors `cat "$srcdir"/[abc]*.[ch] > "$dest"`. + """ + chunks = bytearray() + for f in sorted(SRCDIR.glob(pattern)): + chunks.extend(f.read_bytes()) + dest.write_bytes(bytes(chunks)) + + +_cat_glob('[gr]*.[ch]', name1) +_cat_glob('[et]*.[ch]', name2) + +# Establish baseline destination and chk copies of the source. +checkit(['-ai', '--info=backup', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) +checkit(['-ai', '--info=backup', f'{FROMDIR}/', f'{CHKDIR}/'], FROMDIR, CHKDIR) + +# Mutate the source files; delta-transfer will need to back up the old +# contents at $todir/$fn~ before overwriting in place. +_cat_glob('[fgpr]*.[ch]', name1) +_cat_glob('[etw]*.[ch]', name2) + + +def _run_and_capture(args, outfile): + proc = subprocess.run(rsync_argv(*args), capture_output=True, text=True) + outfile.write_text(proc.stdout) + print(proc.stdout, end='') + if proc.returncode != 0: + test_fail(f"rsync exited {proc.returncode}") + return proc + + +_run_and_capture( + ['-ai', '--info=backup', '--no-whole-file', '--backup', + f'{FROMDIR}/', f'{TODIR}/'], + OUTFILE, +) +text = OUTFILE.read_text() +for fn in ('deep/name1', 'deep/name2'): + if f"backed up {fn} to {fn}~" not in text: + test_fail(f"no backup message output for {fn}") + diff = subprocess.run(['diff', '-u', str(FROMDIR / fn), str(TODIR / fn)]) + if diff.returncode != 0: + test_fail(f"copy of {fn} failed") + diff = subprocess.run(['diff', '-u', str(CHKDIR / fn), str(TODIR / f'{fn}~')]) + if diff.returncode != 0: + test_fail(f"backup of {fn} to {fn}~ failed") + shutil.move(str(TODIR / f'{fn}~'), str(TODIR / fn)) + + +# --backup-dir + --delete-delay: a deletion at the dest gets routed into +# the backup dir rather than being lost. +(TODIR / 'dname').write_text("deleted-file\n") +cp_touch(TODIR / 'dname', CHKDIR) + +_run_and_capture( + ['-ai', '--info=backup', '--no-whole-file', '--delete-delay', + '--backup', f'--backup-dir={bakdir}', + f'{FROMDIR}/', f'{TODIR}/'], + OUTFILE, +) +# After the run, FROMDIR and TODIR should match (the backup ran into +# bakdir, not into chkdir, so chkdir must NOT be touched -- it still +# holds the pre-rsync contents that we'll compare against bakdir below). +verify_dirs(FROMDIR, TODIR, label="post --backup-dir run") + +text = OUTFILE.read_text() +import re +for fn in ('deep/name1', 'deep/name2'): + if not re.search(rf"backed up {re.escape(fn)} to .*/{re.escape(fn)}$", + text, flags=re.MULTILINE): + test_fail(f"no backup message output for {fn}") +diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR), str(bakdir)]) +if diff.returncode != 0: + test_fail("backup dir contents are bogus") +(bakdir / 'dname').unlink() + + +# Re-establish chk and mutate source again for the --inplace pass. +checkit(['-ai', '--info=backup', '--del', f'{FROMDIR}/', f'{CHKDIR}/'], + FROMDIR, CHKDIR) +_cat_glob('[efgr]*.[ch]', name1) +_cat_glob('[ew]*.[ch]', name2) + +_run_and_capture( + ['-ai', '--info=backup', '--inplace', '--no-whole-file', + '--backup', f'--backup-dir={bakdir}', + f'{FROMDIR}/', f'{TODIR}/'], + OUTFILE, +) +verify_dirs(FROMDIR, TODIR, label="post --inplace --backup-dir run") + +text = OUTFILE.read_text() +for fn in ('deep/name1', 'deep/name2'): + if not re.search(rf"backed up {re.escape(fn)} to .*/{re.escape(fn)}$", + text, flags=re.MULTILINE): + test_fail(f"no backup message output for {fn}") +diff = subprocess.run(['diff', '-r', '-u', str(CHKDIR), str(bakdir)]) +if diff.returncode != 0: + test_fail("backup dir contents are bogus") + +# Final clean inplace sync to the bakdir so it ends up matching fromdir. +checkit(['-ai', '--info=backup', '--inplace', '--no-whole-file', + f'{FROMDIR}/', f'{bakdir}/'], FROMDIR, bakdir) diff --git a/testsuite/bare-do-open-symlink-race.test b/testsuite/bare-do-open-symlink-race.test deleted file mode 100755 index e29522396..000000000 --- a/testsuite/bare-do-open-symlink-race.test +++ /dev/null @@ -1,206 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2026 by Andrew Tridgell - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Regression test for codex audit Findings 3b and 3c: -# -# 3b: generator.c:1905 -- the in-place backup creation opens -# backupptr via bare do_open(O_WRONLY|O_CREAT|O_TRUNC|O_EXCL). -# With --backup-dir set to an attacker-planted parent symlink, -# the backup file is written outside the module under the -# daemon's authority. -# -# 3c-symlink: syscall.c:207 -- do_symlink_at falls through to bare -# do_symlink for am_root < 0 (fake-super), which then opens -# the destination path with bare open() (final-component -# fake-super file). A parent symlink on the destination path -# redirects the file creation outside the module. -# -# 3c-mknod: syscall.c:506 -- do_mknod_at falls through to bare -# do_mknod for am_root < 0, same path-based open(). For -# FIFOs/sockets/devices the bare path is also used. -# -# Each scenario plants a "secret" file outside the module at a -# location the symlink trap points to. The check is that the -# outside file's content and mode are unchanged after the attack -# attempt. - -. "$suitedir/rsync.fns" - -# All three scenarios depend on receiver-side daemon code paths -# that are only secured on platforms with a working -# secure_relative_open. The chdir/chmod tests already skip the -# same set; mirror that. -case "$(uname -s)" in - SunOS|OpenBSD|NetBSD|CYGWIN*) - test_skipped "secure_relative_open relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" - ;; -esac - -mod="$scratchdir/module" -outside="$scratchdir/outside" -src="$scratchdir/src" -conf="$scratchdir/test-rsyncd.conf" - -# Portable inode-and-mode helpers. -file_mode() { - stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" -} - -setup() { - rm -rf "$mod" "$outside" "$src" - mkdir -p "$mod" "$outside" "$src" - - echo "OUTSIDE_PROTECTED_DATA" > "$outside/target.txt" - chmod 0644 "$outside/target.txt" - outside_pristine="$scratchdir/outside-pristine.txt" - cp -p "$outside/target.txt" "$outside_pristine" - - ln -s "$outside" "$mod/cd" -} - -verify_outside_unchanged() { - label="$1" - mode=$(file_mode "$outside/target.txt") - case "$mode" in - 644|0644) ;; - *) test_fail "$label: outside/target.txt mode changed from 644 to $mode" ;; - esac - if ! cmp -s "$outside/target.txt" "$outside_pristine"; then - test_fail "$label: outside/target.txt content changed -- daemon followed the cd symlink" - fi -} - -verify_outside_unchanged_or_absent() { - label="$1" - target="$2" # specific file under outside/ to check absence of - if [ -e "$outside/$target" ]; then - test_fail "$label: outside/$target was created -- daemon followed the cd symlink" - fi -} - -# When running as root the daemon would drop to "nobody" by default -# and fail to write into the test scratch dir. Force it to keep our -# uid/gid in that case so the receiver actually runs the code paths -# we want to test. -my_uid=`get_testuid` -root_uid=`get_rootuid` -root_gid=`get_rootgid` -uid_setting="uid = $root_uid" -gid_setting="gid = $root_gid" -if test x"$my_uid" != x"$root_uid"; then - uid_setting="#$uid_setting" - gid_setting="#$gid_setting" -fi - - -############################################################ -# Scenario 3b: --inplace --backup --backup-dir=cd -# -# Pre-create module/target.txt so the receiver enters the in-place -# update path; a backup of the existing content must be made -# before the update. With --backup-dir=cd, backupptr resolves to -# "cd/target.txt"; with the bug, robust_unlink and the bare -# do_open at generator.c:1905 both follow the cd symlink, the -# unlink deletes outside/target.txt and the create writes the -# pre-existing module/target.txt content there. -############################################################ - -setup -echo "EXISTING_MODULE_DATA" > "$mod/target.txt" -chmod 0666 "$mod/target.txt" -echo "NEW_DATA_FROM_SENDER" > "$src/target.txt" -chmod 0644 "$src/target.txt" - -cat > "$conf" </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" </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" </dev/null 2>&1 || true - -verify_outside_unchanged_or_absent "3c-mknod fake-super FIFO push" "fifo" - -exit 0 diff --git a/testsuite/bare-do-open-symlink-race_test.py b/testsuite/bare-do-open-symlink-race_test.py new file mode 100644 index 000000000..deff07314 --- /dev/null +++ b/testsuite/bare-do-open-symlink-race_test.py @@ -0,0 +1,136 @@ +#!/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 ( + SCRATCHDIR, + get_rootgid, get_rootuid, get_testuid, + rmtree, rsync_argv, start_test_daemon, test_fail, test_skipped, +) + + +DAEMON_PORT = 12884 + + +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 + + +# All three scenarios use the same daemon -- they just target a different +# module. Write both modules up-front so the daemon doesn't need to be +# restarted between scenarios. +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 + +[upload_fake] + path = {mod} + use chroot = no + read only = no + fake super = yes +""") +daemon_url = start_test_daemon(conf, DAEMON_PORT).rstrip('/') + + +def run_attack(args): + subprocess.run( + rsync_argv(*args), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +# 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) + +run_attack([ + '--inplace', '--backup', '--backup-dir=cd', + f'{src}/target.txt', f'{daemon_url}/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') + +run_attack(['-rl', f'{src}/', f'{daemon_url}/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") + +run_attack(['-rD', f'{src}/', f'{daemon_url}/upload_fake/']) +verify_outside_unchanged_or_absent("3c-mknod fake-super FIFO push", "fifo") diff --git a/testsuite/batch-mode.test b/testsuite/batch-mode.test deleted file mode 100644 index cf4e94d37..000000000 --- a/testsuite/batch-mode.test +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2004 by Chris Shoemaker - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test rsync's --write-batch and --read-batch options - -. "$suitedir/rsync.fns" - -hands_setup - -cd "$tmpdir" - -# Build chkdir for the daemon tests using a normal rsync and an --exclude. -$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/" - -$RSYNC -av --only-write-batch=BATCH --exclude=foobar.baz "$fromdir/" "$todir/missing/" -test -d "$todir/missing" && test_fail "--only-write-batch should not have created destination dir" - -runtest "--read-batch (only)" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$chkdir" "$todir"' - -rm -rf "$todir" BATCH* -runtest "local --write-batch" 'checkit "$RSYNC -av --write-batch=BATCH \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"' - -rm -rf "$todir" -runtest "--read-batch" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$fromdir" "$todir"' - -build_rsyncd_conf - -RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" -export RSYNC_CONNECT_PROG - -rm -rf "$todir" -runtest "daemon sender --write-batch" 'checkit "$RSYNC -av --write-batch=BATCH rsync://localhost/test-from/ \"$todir\"" "$chkdir" "$todir"' - -rm -rf "$todir" -runtest "--read-batch from daemon" 'checkit "$RSYNC -av --read-batch=BATCH \"$todir\"" "$chkdir" "$todir"' - -rm -rf "$todir" -runtest "BATCH.sh use of --read-batch" 'checkit "./BATCH.sh" "$chkdir" "$todir"' - -runtest "do-nothing re-run of batch" 'checkit "./BATCH.sh" "$chkdir" "$todir"' - -rm -rf "$todir" -mkdir "$todir" || test_fail "failed to restore empty destination directory" -runtest "daemon recv --write-batch" 'checkit "\"$ignore23\" $RSYNC -av --write-batch=BATCH \"$fromdir/\" rsync://localhost/test-to" "$chkdir" "$todir"' - -# The script would have aborted on error, so getting here means we pass. -exit 0 diff --git a/testsuite/batch-mode_test.py b/testsuite/batch-mode_test.py new file mode 100644 index 000000000..3c1a68271 --- /dev/null +++ b/testsuite/batch-mode_test.py @@ -0,0 +1,95 @@ +#!/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, SCRATCHDIR, TMPDIR, TODIR, + build_rsyncd_conf, checkit, hands_setup, rmtree, + run_rsync, start_test_daemon, test_fail, +) + + +DAEMON_PORT = 12874 + +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: pipe transport by default, real loopback rsyncd under +# --use-tcp. +url = start_test_daemon(conf, DAEMON_PORT) + +rmtree(TODIR) +print("Test daemon sender --write-batch:") +checkit(['-av', '--write-batch=BATCH', + f'{url}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}/', f'{url}test-to')], +) +if proc.returncode != 0: + test_fail(f"daemon recv --write-batch exited {proc.returncode}") +verify_dirs(CHKDIR, TODIR, label="daemon recv --write-batch") diff --git a/testsuite/chdir-symlink-race.test b/testsuite/chdir-symlink-race.test deleted file mode 100755 index c464101f2..000000000 --- a/testsuite/chdir-symlink-race.test +++ /dev/null @@ -1,137 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2026 by Andrew Tridgell - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Regression test for the symlink-TOCTOU class of bug at the receiver's -# chdir(). After the CVE-2026-29518 fix to secure_relative_open(), an -# attack remained where the receiver's chdir() into a destination -# subdirectory followed an attacker-planted symlink, escaping the -# module. Every subsequent path-relative syscall (open, chmod, lchown, -# utimes, etc.) inherited the escape -- secure_relative_open's -# RESOLVE_BENEATH anchor itself was outside the module by then, so it -# stopped protecting against anything. -# -# This test runs an actual rsync daemon (via RSYNC_CONNECT_PROG to -# avoid the network) configured with "use chroot = no", plants a -# symlink at module/subdir -> ../outside, and runs four flavours of -# rsync transfer that previously all reached files in ../outside: -# -# 1. single-file dest = subdir/target.txt (the original poc_chmod) -# 2. -r src/subdir/ to upload/subdir/ (the chdir-escape case) -# 3. -r src/subdir/ to upload/subdir/ (no --size-only: forces basis read+write) -# 4. -r src/ to upload/ (was already protected by the -# original CVE-2026-29518 fix; -# regression-checked here) -# -# All four must leave the outside-the-module sentinel file's mode AND -# content unchanged. - -. "$suitedir/rsync.fns" - -case "$(uname -s)" in - SunOS|OpenBSD|NetBSD|CYGWIN*) - test_skipped "secure chdir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" - ;; -esac - -mod="$scratchdir/module" -outside="$scratchdir/outside" -src="$scratchdir/src" -conf="$scratchdir/test-rsyncd.conf" - -rm -rf "$mod" "$outside" "$src" -mkdir -p "$mod" "$outside" "$src" "$src/subdir" - -# Portable octal-mode helper -- macOS and FreeBSD's stat use -f, GNU -# coreutils stat uses -c. -file_mode() { - stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" -} - -# The "secret" file outside the module the attacker is trying to alter. -# Save a pristine copy alongside it so we can compare with cmp(1) rather -# than depending on sha1sum/shasum/sha1, which differ across platforms. -echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt" -chmod 0600 "$outside/target.txt" -outside_pristine="$scratchdir/outside-pristine.txt" -cp -p "$outside/target.txt" "$outside_pristine" - -# Symlink trap planted in the module by the local attacker. -ln -s "$outside" "$mod/subdir" - -# Source files the sender will push: same size as the outside target, -# different content, mode 0666 (the perms the attacker tries to push). -SIZE=$(stat -c %s "$outside/target.txt" 2>/dev/null \ - || stat -f %z "$outside/target.txt") -make_data_file "$src/target.txt" "$SIZE" \ - || test_fail "failed to create source file" -make_data_file "$src/subdir/target.txt" "$SIZE" \ - || test_fail "failed to create source file" -chmod 0666 "$src/target.txt" "$src/subdir/target.txt" - -cat > "$conf" < "$outside/target.txt" -} - -verify_unchanged() { - label="$1" - mode=$(file_mode "$outside/target.txt") - case "$mode" in - 600|0600) ;; - *) test_fail "$label: outside file mode changed from 600 to $mode (chmod escape)" ;; - esac - if ! cmp -s "$outside/target.txt" "$outside_pristine"; then - test_fail "$label: outside file content changed (write escape)" - fi -} - -run_attack() { - label="$1"; shift - reset_outside - RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \ - $RSYNC "$@" >/dev/null 2>&1 || true - verify_unchanged "$label" -} - -# 1. The original poc_chmod scenario: single file, dest path with -# the symlinked subdir as a path component. With --size-only the -# receiver normally skips the basis open and goes straight to chmod -# -- only the chdir-escape blocks the chmod from reaching outside. -run_attack "single-file --size-only" \ - -tp --size-only \ - "$src/target.txt" rsync://localhost/upload/subdir/target.txt - -# 2. -r push into the symlinked subdir: receiver chdir's into "subdir", -# follows the symlink, ends up in outside. -run_attack "-r --size-only into subdir/" \ - -rtp --size-only \ - "$src/subdir/" rsync://localhost/upload/subdir/ - -# 3. Same but no --size-only -- forces the basis-file open and a real -# rename, so this exercises the read-disclosure and write-escape -# paths together. -run_attack "-r without --size-only into subdir/" \ - -rtp \ - "$src/subdir/" rsync://localhost/upload/subdir/ - -# 4. -r src/ to upload/ -- this case was already covered by the -# original CVE-2026-29518 fix because the receiver stays at module -# root and operates on slashed paths. Regression check. -run_attack "-r --size-only into upload/ root" \ - -rtp --size-only \ - "$src/" rsync://localhost/upload/ - -exit 0 diff --git a/testsuite/chdir-symlink-race_test.py b/testsuite/chdir-symlink-race_test.py new file mode 100644 index 000000000..80b314c04 --- /dev/null +++ b/testsuite/chdir-symlink-race_test.py @@ -0,0 +1,122 @@ +#!/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 ( + SCRATCHDIR, + get_rootgid, get_rootuid, get_testuid, + make_data_file, rmtree, rsync_argv, start_test_daemon, + test_fail, test_skipped, +) + + +DAEMON_PORT = 12885 + +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)") + + +url = start_test_daemon(conf, DAEMON_PORT) + + +def run_attack(label: str, *args) -> None: + reset_outside() + subprocess.run( + rsync_argv(*args), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + 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', + f'{url}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/', + f'{url}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/', + f'{url}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}/', + f'{url}upload/') diff --git a/testsuite/chgrp.test b/testsuite/chgrp.test deleted file mode 100644 index 467d4029f..000000000 --- a/testsuite/chgrp.test +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2002 by Martin Pool - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test that rsync with -gr will preserve groups when the user running -# the test is a member of them. Hopefully they're in at least one -# test. - -. "$suitedir/rsync.fns" - -# Build some hardlinks - -mygrps="`rsync_getgroups`" || test_fail "Can't get groups" -mkdir "$fromdir" - -for g in $mygrps; do - name="$fromdir/foo-$g" - date > "$name" - chgrp "$g" "$name" || test_fail "Can't chgrp" -done -sleep 2 - -checkit "$RSYNC -rtgpvvv '$fromdir/' '$todir/'" "$fromdir" "$todir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/chgrp_test.py b/testsuite/chgrp_test.py new file mode 100644 index 000000000..ad20fc0e7 --- /dev/null +++ b/testsuite/chgrp_test.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/chgrp.test. +# +# Test that -g preserves group ownership when the user is a member of the +# target group. Creates one file per supplementary group, chgrps each, +# then verifies the destination listing matches. + +import os +import shutil +import time + +from rsyncfns import FROMDIR, TODIR, checkit, rsync_getgroups, test_fail + + +groups = rsync_getgroups() +if not groups: + test_fail("Can't get groups") + +FROMDIR.mkdir(parents=True, exist_ok=True) +for g in groups: + fname = FROMDIR / f'foo-{g}' + fname.write_text(time.ctime() + '\n') + chgrp = shutil.which('chgrp') + if chgrp is None: + test_fail("chgrp not found in PATH") + # The shell test treats chgrp failure as fatal. + try: + os.chown(fname, -1, int(g)) + except (ValueError, PermissionError): + # If g isn't numeric or we lack permission, fall back to chgrp(1). + import subprocess + proc = subprocess.run([chgrp, g, str(fname)]) + if proc.returncode != 0: + test_fail("Can't chgrp") + +time.sleep(2) + +checkit(['-rtgpvvv', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) diff --git a/testsuite/chmod-option.test b/testsuite/chmod-option.test deleted file mode 100644 index ddf764cf9..000000000 --- a/testsuite/chmod-option.test +++ /dev/null @@ -1,71 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2002 by Martin Pool - -# 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" < "$mod/realdir/sentinel" -chmod 0600 "$mod/realdir/sentinel" -echo target > "$trap_outside/sentinel" -chmod 0600 "$trap_outside/sentinel" -ln -s realdir "$mod/inside_link" -ln -s ../trap "$mod/escape_link" -echo top > "$mod/topfile" -chmod 0600 "$mod/topfile" - -"$TOOLDIR/t_chmod_secure" "$mod" || \ - test_fail "t_chmod_secure reported failures (see stderr above)" - -# Sanity-check from the shell side too: the outside file's mode must -# still be 0600 -- the helper checked this, but a second look from -# the shell guards against a helper-internal stat() bug. -mode=$(stat -c '%a' "$trap_outside/sentinel" 2>/dev/null \ - || stat -f '%Lp' "$trap_outside/sentinel" 2>/dev/null) -if [ "$mode" != "600" ]; then - test_fail "outside sentinel mode changed from 600 to $mode -- chmod escaped the module" -fi - -exit 0 diff --git a/testsuite/chmod-symlink-race_test.py b/testsuite/chmod-symlink-race_test.py new file mode 100644 index 000000000..000691f2e --- /dev/null +++ b/testsuite/chmod-symlink-race_test.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/chmod-symlink-race.test. +# +# Regression test for the symlink-TOCTOU class of bug applied to chmod() +# on the receiver side. The CVE-2026-29518 fix used +# secure_relative_open() for the basis-file open, but every other +# path-based syscall the receiver runs on sender-controllable paths is +# vulnerable to the same primitive: a local attacker swaps a symlink +# into one of the parent directory components between the receiver's +# check and its act, and the syscall escapes the module. +# +# The helper t_chmod_secure exercises the new do_chmod_at() wrapper +# across four scenarios; see the shell version for the full enumeration. +# After the helper runs we sanity-check the outside sentinel's mode +# from Python too, in case the helper's internal stat() ever drifts. + +import os +import subprocess + +from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail + + +mod = SCRATCHDIR / 'module' +trap_outside = SCRATCHDIR / 'trap' +rmtree(mod) +rmtree(trap_outside) +mod.mkdir(parents=True) +(mod / 'realdir').mkdir(parents=True) +trap_outside.mkdir(parents=True) + +# File-system objects the helper expects. +(mod / 'realdir' / 'sentinel').write_text("bystander\n") +os.chmod(mod / 'realdir' / 'sentinel', 0o600) +(trap_outside / 'sentinel').write_text("target\n") +os.chmod(trap_outside / 'sentinel', 0o600) +os.symlink('realdir', mod / 'inside_link') +os.symlink('../trap', mod / 'escape_link') +(mod / 'topfile').write_text("top\n") +os.chmod(mod / 'topfile', 0o600) + +proc = subprocess.run([str(TOOLDIR / 't_chmod_secure'), str(mod)]) +if proc.returncode != 0: + test_fail("t_chmod_secure reported failures (see stderr above)") + +# Second-look sanity check from Python. +sentinel_mode = (trap_outside / 'sentinel').stat().st_mode & 0o777 +if sentinel_mode != 0o600: + test_fail( + f"outside sentinel mode changed from 600 to {oct(sentinel_mode)[2:]} " + "-- chmod escaped the module" + ) diff --git a/testsuite/chmod-temp-dir.test b/testsuite/chmod-temp-dir.test deleted file mode 100644 index 362d9d993..000000000 --- a/testsuite/chmod-temp-dir.test +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2004-2022 Wayne Davison - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test that various read-only and set[ug]id permissions work properly, -# even when using a --temp-dir option (which we try to point at a -# different filesystem than the destination dir). - -. "$suitedir/rsync.fns" - -hands_setup - -sdev=`$TOOLDIR/getfsdev $scratchdir` -tdev=$sdev - -for tmpdir2 in "${RSYNC_TEST_TMP:-/override-tmp-not-specified}" /run/shm /var/tmp /tmp; do - [ -d "$tmpdir2" ] && [ -w "$tmpdir2" ] || continue - tdev=`$TOOLDIR/getfsdev "$tmpdir2"` - [ x$sdev != x$tdev ] && break -done - -[ x$sdev = x$tdev ] && test_skipped "Can't find a tmp dir on a different file system" - -chmod 440 "$fromdir/text" -chmod 500 "$fromdir/dir/text" -e="$fromdir/dir/subdir/foobar.baz" -chmod 6450 "$e" || chmod 2450 "$e" || chmod 1450 "$e" || chmod 450 "$e" -e="$fromdir/dir/subdir/subsubdir/etc-ltr-list" -chmod 2670 "$e" || chmod 1670 "$e" || chmod 670 "$e" - -# First a normal copy. -runtest "normal copy" 'checkit "$RSYNC -avv --temp-dir=\"$tmpdir2\" \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"' - -# Then we update all the files. -runtest "update copy" 'checkit "$RSYNC -avvI --no-whole-file --temp-dir=\"$tmpdir2\" \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"' - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/chmod-temp-dir_test.py b/testsuite/chmod-temp-dir_test.py new file mode 100644 index 000000000..30d9fe20a --- /dev/null +++ b/testsuite/chmod-temp-dir_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/chmod-temp-dir.test. +# +# Like chmod_test.py, but uses --temp-dir pointing at a different +# filesystem so rsync must rename(2) across filesystems (i.e. fall back +# to copy+unlink) instead of the in-place rename it does when temp and +# destination are on the same fs. We probe candidate tmp paths to find +# one whose filesystem differs from the scratch dir. + +import os +import subprocess + +from rsyncfns import FROMDIR, SCRATCHDIR, TODIR, TOOLDIR, checkit, hands_setup, test_skipped + + +def _fsdev(path: str) -> str: + return subprocess.check_output( + [str(TOOLDIR / 'getfsdev'), path], text=True, + ).strip() + + +hands_setup() + +scratch_dev = _fsdev(str(SCRATCHDIR)) +tmpdir2 = None +candidates = [ + os.environ.get('RSYNC_TEST_TMP', '/override-tmp-not-specified'), + '/run/shm', '/var/tmp', '/tmp', +] +for cand in candidates: + if not (os.path.isdir(cand) and os.access(cand, os.W_OK)): + continue + if _fsdev(cand) != scratch_dev: + tmpdir2 = cand + break + +if tmpdir2 is None: + test_skipped("Can't find a tmp dir on a different file system") + + +# Mirror chmod_test.py: set up a varied permission tree on the source. +def _try_chmods(path, modes): + for m in modes: + try: + os.chmod(path, m) + return + except PermissionError: + continue + os.chmod(path, modes[-1]) + + +os.chmod(FROMDIR / 'text', 0o440) +os.chmod(FROMDIR / 'dir' / 'text', 0o500) +_try_chmods(FROMDIR / 'dir' / 'subdir' / 'foobar.baz', + [0o6450, 0o2450, 0o1450, 0o450]) +_try_chmods(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list', + [0o2670, 0o1670, 0o670]) + +# First a normal copy (whole-file) but through a cross-fs --temp-dir. +checkit(['-avv', f'--temp-dir={tmpdir2}', f'{FROMDIR}/', str(TODIR)], + FROMDIR, TODIR) + +# Then an update through delta, still routing partial transfers across fs. +checkit(['-avvI', '--no-whole-file', f'--temp-dir={tmpdir2}', + f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR) diff --git a/testsuite/chmod.test b/testsuite/chmod.test deleted file mode 100644 index 1646a9c81..000000000 --- a/testsuite/chmod.test +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2004-2022 Wayne Davison - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test that various read-only and set[ug]id permissions work properly, -# even when using a --temp-dir option (which we try to point at a -# different filesystem than the destination dir). - -. "$suitedir/rsync.fns" - -hands_setup - -chmod 440 "$fromdir/text" -chmod 500 "$fromdir/dir/text" -e="$fromdir/dir/subdir/foobar.baz" -chmod 6450 "$e" || chmod 2450 "$e" || chmod 1450 "$e" || chmod 450 "$e" -e="$fromdir/dir/subdir/subsubdir/etc-ltr-list" -chmod 2670 "$e" || chmod 1670 "$e" || chmod 670 "$e" - -# First a normal copy. -runtest "normal copy" 'checkit "$RSYNC -avv \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"' - -# Then we update all the files. -runtest "update copy" 'checkit "$RSYNC -avvI --no-whole-file \"$fromdir/\" \"$todir\"" "$fromdir" "$todir"' - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/chmod_test.py b/testsuite/chmod_test.py new file mode 100644 index 000000000..ea6934b58 --- /dev/null +++ b/testsuite/chmod_test.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/chmod.test. +# +# Test that varied read-only and set[ug]id permissions transfer correctly +# both on first copy (whole-file) and on subsequent updates (delta). + +import os + +from rsyncfns import FROMDIR, TODIR, checkit, hands_setup + + +hands_setup() + +# Three of these chmod modes use the sticky/setuid/setgid bits which some +# platforms refuse for non-root. The shell test tries them in descending +# order, falling back to plain mode on rejection. +def _try_chmods(path, modes): + for m in modes: + try: + os.chmod(path, m) + return + except PermissionError: + continue + # Final mode in the list is the no-special-bits fallback. + os.chmod(path, modes[-1]) + + +os.chmod(FROMDIR / 'text', 0o440) +os.chmod(FROMDIR / 'dir' / 'text', 0o500) +_try_chmods(FROMDIR / 'dir' / 'subdir' / 'foobar.baz', + [0o6450, 0o2450, 0o1450, 0o450]) +_try_chmods(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list', + [0o2670, 0o1670, 0o670]) + +# First a normal whole-file copy. +checkit(['-avv', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR) + +# Then update through delta with -I (ignore times) so every file is +# touched again. +checkit(['-avvI', '--no-whole-file', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR) diff --git a/testsuite/chown.test b/testsuite/chown.test deleted file mode 100644 index b53413e1e..000000000 --- a/testsuite/chown.test +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2002 by Martin Pool - -# 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" < rsync.%stat -EOF - done - } - ;; - freebsd*) - chown() { - own=$1 - shift - setextattr -h user "rsync.%stat" "100644 0,0 $own" "${@}" - } - ;; - *) - chown() { - own=$1 - shift - setfattr -n 'user.rsync.%stat' -v "100644 0,0 $own" "${@}" - } - ;; - esac - ;; -*) - RSYNC="$RSYNC --super" - my_uid=`get_testuid` - root_uid=`get_rootuid` - if test x"$my_uid" = x; then - : # If "id" failed, try to continue... - elif test x"$my_uid" != x"$root_uid"; then - if [ -e "$FAKEROOT_PATH" ]; then - echo "Let's try re-running the script under fakeroot..." - exec "$FAKEROOT_PATH" "$SHELL_PATH" "$0" - fi - fi - ;; -esac - -# Build some hardlinks - -mkdir "$fromdir" -name1="$fromdir/name1" -name2="$fromdir/name2" -echo "This is the file" > "$name1" -echo "This is the other file" > "$name2" - -chown 5000:5002 "$name1" || test_skipped "Can't chown (probably need root)" -chown 5001:5003 "$name2" || test_skipped "Can't chown (probably need root)" - -cd "$fromdir/.." -checkit "$RSYNC -aHvv from/ to/" "$fromdir" "$todir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/chown_test.py b/testsuite/chown_test.py new file mode 100644 index 000000000..116b97ef0 --- /dev/null +++ b/testsuite/chown_test.py @@ -0,0 +1,73 @@ +#!/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 sys + +import rsyncfns +from rsyncfns import ( + FROMDIR, RSYNC_PREFIX, TODIR, + checkit, test_skipped, xattr_set, xattrs_supported, +) + + +# 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 (and a way to set them here). + if not xattrs_supported(): + 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() + + def chown_or_fake(path, uid, gid): + # Store ownership in rsync's fake-super "%stat" xattr -- the name + # (RSYNC_PREFIX) and namespace vary by OS; xattr_set handles that. + # %stat format: "MODE DEV_MAJOR,DEV_MINOR UID:GID" + mode = os.stat(path).st_mode + xattr_set(f'{RSYNC_PREFIX}.%stat', f"{mode:o} 0,0 {uid}:{gid}", path) + return True +else: + rsyncfns.RSYNC = rsyncfns.RSYNC + ' --super' + + my_uid = os.getuid() + if my_uid != 0: + # If a fakeroot binary is in the environment, re-exec ourselves + # under it -- same trick the shell test used. + fakeroot_path = os.environ.get('FAKEROOT_PATH') + if fakeroot_path and os.access(fakeroot_path, os.X_OK): + print("Let's try re-running the script under fakeroot...") + os.execv(fakeroot_path, [fakeroot_path, sys.executable, __file__]) + + def chown_or_fake(path, uid, gid): + try: + os.chown(path, uid, gid) + return True + except (PermissionError, OSError): + return False + + +FROMDIR.mkdir(parents=True, exist_ok=True) +name1 = FROMDIR / 'name1' +name2 = FROMDIR / 'name2' +name1.write_text("This is the file\n") +name2.write_text("This is the other file\n") + +if not chown_or_fake(name1, 5000, 5002): + test_skipped("Can't chown (probably need root)") +if not chown_or_fake(name2, 5001, 5003): + test_skipped("Can't chown (probably need root)") + +os.chdir(FROMDIR.parent) +checkit(['-aHvv', 'from/', 'to/'], FROMDIR, TODIR) diff --git a/testsuite/clean-fname-underflow.test b/testsuite/clean-fname-underflow.test deleted file mode 100644 index 24625a847..000000000 --- a/testsuite/clean-fname-underflow.test +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh -# clean-fname-underflow.test -# Ensure clean_fname() does not read-before-buffer when collapsing "..". -# This exercises the --server path where a crafted merge filename hits clean_fname(). - -. "$suitedir/rsync.fns" - -workdir="$scratchdir/workdir" -mkdir -p "$workdir/mod" -cd "$workdir" - -rsync_bin=`echo $RSYNC | sed 's/ .*//'` - -# Invoke the server-side path. We don't need a real transfer; we just want to -# ensure clean_fname() doesn't crash when given "a/../test" via --filter=merge. -if $rsync_bin --server --sender -vlr --filter='merge a/../test' . mod/ >/dev/null 2>&1; then - : # success -else - status=$? - # Non-zero exit is expected for bogus input; ensure it wasn't a signal/crash. - if [ $status -ge 128 ]; then - test_fail "rsync exited due to a signal (status=$status)" - fi -fi - -echo "OK: clean_fname() handled 'a/../test' without crashing" -exit 0 diff --git a/testsuite/clean-fname-underflow_test.py b/testsuite/clean-fname-underflow_test.py new file mode 100644 index 000000000..4b30155ce --- /dev/null +++ b/testsuite/clean-fname-underflow_test.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/clean-fname-underflow.test. +# +# Ensure clean_fname() does not read-before-buffer when collapsing "..". +# Exercises the --server path where a crafted merge filename hits +# clean_fname(); a non-zero exit is expected (the input is bogus), but +# the test fails if rsync dies from a signal (status >= 128). + +import os +import shlex +import subprocess + +from rsyncfns import RSYNC, TMPDIR, test_fail + + +workdir = TMPDIR / 'workdir' +(workdir / 'mod').mkdir(parents=True, exist_ok=True) +os.chdir(workdir) + +# RSYNC may be a multi-word command (e.g. valgrind + rsync); take just the +# binary path, matching the shell test's `echo $RSYNC | sed 's/ .*//'`. +rsync_bin = shlex.split(RSYNC)[0] + +proc = subprocess.run( + [rsync_bin, '--server', '--sender', '-vlr', + '--filter=merge a/../test', '.', 'mod/'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, +) + +if proc.returncode >= 128: + test_fail(f"rsync exited due to a signal (status={proc.returncode})") + +print("OK: clean_fname() handled 'a/../test' without crashing") diff --git a/testsuite/copy-dest-source-symlink.test b/testsuite/copy-dest-source-symlink.test deleted file mode 100755 index f91ee986f..000000000 --- a/testsuite/copy-dest-source-symlink.test +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2026 by Andrew Tridgell - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Regression test for codex audit Finding 3a: copy_file()'s source -# open in copy_altdest_file() is via do_open_nofollow(), which only -# refuses a final-component symlink. Parent components are still -# resolved with normal symlink-following. A daemon module attacker -# who plants a parent symlink at module/cd -> /outside, then runs -# --copy-dest=cd against a source file matching the size+mtime of -# /outside/target.txt, drives the receiver to: -# -# 1. Find a match-level >= 2 basis at "cd/target.txt" -# 2. Call copy_altdest_file -> copy_file(src="cd/target.txt", ...) -# 3. do_open_nofollow follows the "cd" parent symlink and reads -# the contents of /outside/target.txt under the daemon's -# authority -# 4. Copy that content into the module destination -# -# Result: outside/target.txt content lands at module/target.txt, -# accessible to the attacker on a subsequent pull. -# -# We detect by content: src/target.txt and outside/target.txt have -# identical metadata (size + mtime + mode) but different content. -# After the transfer, module/target.txt should match src (no -# basedir escape) -- if it matches outside, the bug copied across -# the symlink boundary. - -. "$suitedir/rsync.fns" - -mod="$scratchdir/module" -outside="$scratchdir/outside" -src="$scratchdir/src" -conf="$scratchdir/test-rsyncd.conf" - -rm -rf "$mod" "$outside" "$src" -mkdir -p "$mod" "$outside" "$src" - -# Outside-the-module file the daemon should not read on the -# attacker's behalf. -echo "OUTSIDE_LEAKED_DATA!" > "$outside/target.txt" -chmod 0644 "$outside/target.txt" - -# The symlink trap. -ln -s "$outside" "$mod/cd" - -# Source: same size, same mtime, same mode as outside -- so the -# generator's link_stat + quick_check_ok finds a match-level >= 2 -# basis and calls copy_altdest_file. -echo "ATTACKER_KNOWN_DATA!" > "$src/target.txt" -touch -r "$outside/target.txt" "$src/target.txt" -chmod 0644 "$src/target.txt" - -# When running as root the daemon would drop to "nobody" by -# default and fail to mkstemp in the scratch dir; force it to -# keep our uid/gid in that case. -my_uid=`get_testuid` -root_uid=`get_rootuid` -root_gid=`get_rootgid` -uid_setting="uid = $root_uid" -gid_setting="gid = $root_gid" -if test x"$my_uid" != x"$root_uid"; then - uid_setting="#$uid_setting" - gid_setting="#$gid_setting" -fi - -cat > "$conf" </dev/null 2>&1 || true - -if [ ! -f "$mod/target.txt" ]; then - test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour" -fi - -if cmp -s "$mod/target.txt" "$outside/target.txt"; then - test_fail "basedir-escape via copy_file source: module/target.txt now contains the contents of outside/target.txt -- daemon read /outside via the cd symlink and copied it into the module" -fi - -if ! cmp -s "$mod/target.txt" "$src/target.txt"; then - test_fail "destination doesn't match source content (and isn't outside content either): unexpected state" -fi - -exit 0 diff --git a/testsuite/copy-dest-source-symlink_test.py b/testsuite/copy-dest-source-symlink_test.py new file mode 100644 index 000000000..d1f445423 --- /dev/null +++ b/testsuite/copy-dest-source-symlink_test.py @@ -0,0 +1,95 @@ +#!/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 ( + SCRATCHDIR, + rsync_argv, get_testuid, get_rootuid, get_rootgid, + rmtree, start_test_daemon, test_fail, +) + + +DAEMON_PORT = 12883 + + +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 +""") + +url = start_test_daemon(conf, DAEMON_PORT) + +subprocess.run( + rsync_argv('-rtp', '--copy-dest=cd', + f'{src_dir}/', f'{url}upload/'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, +) + +target = mod / 'target.txt' +if not target.is_file(): + test_fail( + "destination file was not created -- daemon transfer failed " + "before the test could observe the basedir behaviour" + ) + +if filecmp.cmp(target, outside / 'target.txt', shallow=False): + test_fail( + "basedir-escape via copy_file source: module/target.txt now " + "contains the contents of outside/target.txt -- daemon read " + "/outside via the cd symlink and copied it into the module" + ) +if not filecmp.cmp(target, src_dir / 'target.txt', shallow=False): + test_fail( + "destination doesn't match source content (and isn't outside " + "content either): unexpected state" + ) diff --git a/testsuite/crtimes.test b/testsuite/crtimes.test deleted file mode 100644 index 456f0a5f1..000000000 --- a/testsuite/crtimes.test +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh - -# Test rsync copying create times - -. "$suitedir/rsync.fns" - -$RSYNC -VV | grep '"crtimes": true' >/dev/null || test_skipped "Rsync is configured without crtimes support" - -# Setting an older time via touch sets the create time to the mtime. -# Setting it to a newer time affects just the mtime. - -mkdir "$fromdir" -echo hiho >"$fromdir/foo" - -touch -t 200101011111.11 "$fromdir" -touch -t 200202022222.22 "$fromdir" - -touch -t 200111111111.11 "$fromdir/foo" -touch -t 200212122222.22 "$fromdir/foo" - -TLS_ARGS=--crtimes - -checkit "$RSYNC -rtgvvv --crtimes \"$fromdir/\" \"$todir/\"" "$fromdir" "$todir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/crtimes_test.py b/testsuite/crtimes_test.py new file mode 100644 index 000000000..7142b5ea4 --- /dev/null +++ b/testsuite/crtimes_test.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/crtimes.test. +# +# Test that rsync preserves source create times when the binary was built +# with crtimes support. Touch tricks: setting an older time via touch sets +# the create time to the mtime; setting a newer time affects only mtime. + +import datetime +import os + +import rsyncfns +from rsyncfns import FROMDIR, TODIR, checkit, run_rsync, test_skipped + + +vv = run_rsync('-VV', check=True, capture_output=True) +if '"crtimes": true' not in vv.stdout: + test_skipped("Rsync is configured without crtimes support") + +FROMDIR.mkdir(parents=True, exist_ok=True) +(FROMDIR / 'foo').write_text("hiho\n") + + +def _utime(path, when: 'datetime.datetime') -> None: + ts = when.timestamp() + os.utime(path, (ts, ts)) + + +# Touch fromdir to an old time then to a newer time -- in shells with the +# right kernel support this leaves the create time pinned to the older. +_utime(FROMDIR, datetime.datetime(2001, 1, 1, 11, 11, 11)) +_utime(FROMDIR, datetime.datetime(2002, 2, 2, 22, 22, 22)) + +_utime(FROMDIR / 'foo', datetime.datetime(2001, 11, 11, 11, 11, 11)) +_utime(FROMDIR / 'foo', datetime.datetime(2002, 12, 12, 22, 22, 22)) + +rsyncfns.TLS_ARGS = '--crtimes' + +checkit(['-rtgvvv', '--crtimes', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) diff --git a/testsuite/daemon-chroot-acl.test b/testsuite/daemon-chroot-acl.test deleted file mode 100644 index 9d1c1b63b..000000000 --- a/testsuite/daemon-chroot-acl.test +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2026 by Andrew Tridgell - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny" -# rule must still match when the daemon performs a 'daemon chroot' and -# the chroot does not contain the NSS files glibc needs for reverse DNS. -# -# Pre-fix, reverse DNS happened *after* the daemon chroot. With an empty -# chroot the NSS lookup failed, client_name() returned "UNKNOWN", and a -# deny rule referring to the connecting hostname silently failed to -# match. -# -# Two scenarios are exercised so we can distinguish the case the fix -# definitely covers from the per-module path that may still be -# vulnerable: -# A. global "reverse lookup = yes" (covered by b6abdb4c) -# B. only module "reverse lookup = yes" (gap to verify) - -. "$suitedir/rsync.fns" - -case `uname -s` in -Linux*) ;; -*) test_skipped "test is Linux-specific (uses chroot+unshare)" ;; -esac - -# We need CAP_SYS_CHROOT. Re-exec under a user namespace if not root. -if ! chroot / /bin/true 2>/dev/null; then - if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user true 2>/dev/null; then - echo "Re-running under unshare --user --map-root-user..." - RSYNC_UNSHARED=1 exec unshare --user --map-root-user "$SHELL_PATH" $RUNSHFLAGS "$0" - fi - test_skipped "need CAP_SYS_CHROOT (root or unshare --user --map-root-user)" -fi - -# We need 127.0.0.1 to reverse-resolve to a real hostname while NSS is -# still working (i.e. before the daemon's chroot). The daemon will -# look that name up itself as part of its hostname-based ACL check; -# we then deny that name and assert the connection is rejected. -client_hostname=`getent hosts 127.0.0.1 2>/dev/null | awk 'NR==1 {print $2}'` -if [ -z "$client_hostname" ] || [ "$client_hostname" = "127.0.0.1" ]; then - test_skipped "no reverse DNS for 127.0.0.1" -fi - -chrootdir="$scratchdir/chroot" -rm -rf "$chrootdir" -mkdir -p "$chrootdir/modroot" -echo "from chroot" > "$chrootdir/modroot/file1" - -conf="$scratchdir/test-rsyncd.conf" -logfile="$scratchdir/rsyncd.log" - -write_conf() { - cat >"$conf" <"$out" 2>&1 - rc=$? - - echo "----- $label (rsync exit $rc):" - cat "$out" - echo "----- daemon log:" - [ -f "$logfile" ] && cat "$logfile" - echo "-----" - - grep -q '@ERROR.*access denied' "$out" -} - -# Scenario A: global reverse lookup. Covered by b6abdb4c. -write_conf yes yes -if ! run_check "Scenario A (global reverse lookup = yes)"; then - test_fail "Scenario A: hostname deny rule was bypassed" -fi - -# Scenario B: only the per-module reverse-lookup setting is enabled. -# The b6abdb4c fix only pre-warms client_name()'s cache when the -# global setting is on, so the post-chroot lookup in this path may -# still produce "UNKNOWN" and bypass the deny rule. -write_conf no yes -if ! run_check "Scenario B (per-module reverse lookup only)"; then - test_fail "Scenario B: hostname deny rule was bypassed (per-module reverse lookup with daemon chroot still has the bypass)" -fi - -exit 0 diff --git a/testsuite/daemon-chroot-acl_test.py b/testsuite/daemon-chroot-acl_test.py new file mode 100644 index 000000000..f477145e4 --- /dev/null +++ b/testsuite/daemon-chroot-acl_test.py @@ -0,0 +1,144 @@ +#!/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 ( + SCRATCHDIR, TODIR, + require_tcp, rmtree, rsync_argv, start_test_daemon, test_fail, test_skipped, +) + + +DAEMON_PORT = 12878 + +# This test fundamentally needs a real TCP peer address: the daemon reverse- +# resolves the connecting IP for a hostname-based "hosts deny" ACL check. +# The stdio-pipe transport has no peer IP, so only run under --use-tcp. +require_tcp("needs a real TCP peer address for reverse-DNS hostname ACL; " + "run with --use-tcp") + +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() + + # rsyncd re-reads its config file on each accepted connection, so + # rewriting `conf` between scenarios is enough -- we keep the one + # daemon for both. + proc = subprocess.run( + rsync_argv('-av', f'{url}chrootmod/', f'{TODIR}/'), + capture_output=True, text=True, + ) + 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 + + +# Spin up the daemon once; we'll rewrite `conf` between scenarios and rely +# on rsyncd's per-connection re-read of the config file. +write_conf('yes', 'yes') +url = start_test_daemon(conf, DAEMON_PORT) + +# Scenario A: global reverse lookup. Covered by b6abdb4c. +if not run_check("Scenario A (global reverse lookup = yes)"): + test_fail("Scenario A: hostname deny rule was bypassed") + +# Scenario B: only per-module reverse-lookup enabled. +write_conf('no', 'yes') +if not run_check("Scenario B (per-module reverse lookup only)"): + test_fail( + "Scenario B: hostname deny rule was bypassed (per-module reverse " + "lookup with daemon chroot still has the bypass)" + ) diff --git a/testsuite/daemon-gzip-download.test b/testsuite/daemon-gzip-download.test deleted file mode 100644 index 57dd820b8..000000000 --- a/testsuite/daemon-gzip-download.test +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2001, 2002 by Martin Pool - -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - -# This test tries to download a tree over a compressed connection from -# the server. This ought to exercise (exorcise?) a bug in 2.5.3. - -. "$suitedir/rsync.fns" - -build_rsyncd_conf - -RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" -export RSYNC_CONNECT_PROG - -hands_setup - -# Build chkdir with a normal rsync and an --exclude. -$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/" - -checkit "$RSYNC -avvvvzz localhost::test-from/ '$todir/'" "$chkdir" "$todir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/daemon-gzip-download_test.py b/testsuite/daemon-gzip-download_test.py new file mode 100644 index 000000000..4550863aa --- /dev/null +++ b/testsuite/daemon-gzip-download_test.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# Python rewrite of testsuite/daemon-gzip-download.test. +# +# Download a file tree over a compressed connection from a test daemon. +# Exercises (exorcises?) a bug in 2.5.3 that mis-handled doubly-compressed +# transfers. Uses the secure stdio-pipe transport by default; --use-tcp +# runs it against a real loopback rsyncd. + +from rsyncfns import ( + CHKDIR, FROMDIR, TODIR, + build_rsyncd_conf, checkit, hands_setup, run_rsync, start_test_daemon, +) + + +DAEMON_PORT = 12879 + +conf = build_rsyncd_conf() +hands_setup() + +# chkdir: vanilla copy minus the daemon's global "foobar.baz" exclude. +run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/') + +url = start_test_daemon(conf, DAEMON_PORT) + +checkit( + ['-avvvvzz', f'{url}test-from/', f'{TODIR}/'], + CHKDIR, TODIR, + allowed_codes=(0, 23), +) diff --git a/testsuite/daemon-gzip-upload.test b/testsuite/daemon-gzip-upload.test deleted file mode 100644 index b2110ea67..000000000 --- a/testsuite/daemon-gzip-upload.test +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2001, 2002 by Martin Pool - -# This program is distributable under the terms of the GNU GPL (see -# COPYING) - -# We don't really want to start the server listening, because that -# might interfere with the security or operation of the test machine. -# Instead we use the fake-connect feature to dynamically assign a pair -# of ports. - -# This test tries to upload a file over a compressed connection to the -# server. This ought to exercise (exorcise?) a bug in 2.5.3. - -. "$suitedir/rsync.fns" - -build_rsyncd_conf - -RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" -export RSYNC_CONNECT_PROG - -hands_setup - -# Build chkdir with a normal rsync and an --exclude. -$RSYNC -av --exclude=foobar.baz "$fromdir/" "$chkdir/" - -checkit "'$ignore23' $RSYNC -avvvvzz '$fromdir/' localhost::test-to/" "$chkdir" "$todir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/daemon-gzip-upload_test.py b/testsuite/daemon-gzip-upload_test.py new file mode 100644 index 000000000..64922a143 --- /dev/null +++ b/testsuite/daemon-gzip-upload_test.py @@ -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 a test daemon. +# Exercises (exorcises?) a bug in 2.5.3 that mis-handled doubly-compressed +# transfers. Uses the secure stdio-pipe transport by default; --use-tcp +# runs it against a real loopback rsyncd. + +from rsyncfns import ( + CHKDIR, FROMDIR, TODIR, + build_rsyncd_conf, checkit, hands_setup, run_rsync, start_test_daemon, +) + + +DAEMON_PORT = 12880 + +conf = build_rsyncd_conf() +hands_setup() + +# chkdir: vanilla copy minus the daemon's global "foobar.baz" exclude. +run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/') + +url = start_test_daemon(conf, DAEMON_PORT) + +checkit( + ['-avvvvzz', f'{FROMDIR}/', f'{url}test-to/'], + CHKDIR, TODIR, + allowed_codes=(0, 23), +) diff --git a/testsuite/daemon-refuse-compress.test b/testsuite/daemon-refuse-compress.test deleted file mode 100644 index a24e50d12..000000000 --- a/testsuite/daemon-refuse-compress.test +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2026 by Andrew Tridgell - -# This program is distributable under the terms of the GNU GPL (see -# COPYING). - -# Test that a daemon module configured with "refuse options = compress" -# rejects clients that ask for compression and still serves the same -# transfer when the client does not. - -. "$suitedir/rsync.fns" - -build_rsyncd_conf - -# Append a module that refuses --compress (-z). -cat >>"$conf" </dev/null 2>"$errlog"; then - cat "$errlog" >&2 - test_fail "compressed transfer was not refused" -fi - -grep -- '--compress' "$errlog" >/dev/null || { - cat "$errlog" >&2 - test_fail "expected refuse error mentioning --compress" -} - -# The same transfer without -z must succeed. -rm -rf "$todir" -mkdir "$todir" -checkit "$RSYNC -av localhost::no-compress/ '$todir/'" "$chkdir" "$todir" - -# The script would have aborted on error, so getting here means we've won. -exit 0 diff --git a/testsuite/daemon-refuse-compress_test.py b/testsuite/daemon-refuse-compress_test.py new file mode 100644 index 000000000..83af17294 --- /dev/null +++ b/testsuite/daemon-refuse-compress_test.py @@ -0,0 +1,52 @@ +#!/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 subprocess + +from rsyncfns import ( + CHKDIR, FROMDIR, SCRATCHDIR, TODIR, + build_rsyncd_conf, checkit, hands_setup, rmtree, + rsync_argv, run_rsync, start_test_daemon, test_fail, +) + + +DAEMON_PORT = 12876 + +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 +""") + +hands_setup() +run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/') + +url = start_test_daemon(conf, DAEMON_PORT) + 'no-compress/' + +# A compressed transfer must be refused. +errlog = SCRATCHDIR / 'refuse.err' +proc = subprocess.run( + rsync_argv('-avz', url, 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', url, f'{TODIR}/'], CHKDIR, TODIR, + allowed_codes=(0, 23)) diff --git a/testsuite/daemon.test b/testsuite/daemon.test deleted file mode 100644 index 60aa334b7..000000000 --- a/testsuite/daemon.test +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2001 by Martin Pool - -# 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//' <"$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'" </dev/null; then - checkdiff "$RSYNC -rU localhost::test-from/f*" \ - "sed -e '$FILE_REPL' -e '$DIR_REPL' -e '$LS_REPL'" <