testsuite: secure stdio-pipe daemon transport by default, opt-in TCP

Daemon-mode tests default to the stdio-pipe transport (RSYNC_CONNECT_PROG),
which opens no listening socket -- so `make check` never exposes a network
service. Real TCP is opt-in via `runtests.py --use-tcp`, with the daemon
bound to loopback (127.0.0.1) on a claim_ports()-reserved port; CI runs the
suite both ways.

start_test_daemon() is the single seam every daemon test uses: the secure
pipe by default, a real rsyncd on a claimed loopback port under --use-tcp.
Tests with no pipe equivalent (the fake-proxy listener and the reverse-DNS
hostname-ACL daemon test) are gated behind require_tcp().

`make check` also now runs the suite in parallel by default (CHECK_J=8);
the claim_ports() byte-range locks make that safe across concurrent runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Tridgell
2026-05-21 14:14:13 +10:00
parent bf8aab51e8
commit bea8a3a16f
25 changed files with 331 additions and 113 deletions

View File

@@ -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

View File

@@ -39,7 +39,11 @@ 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'
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

View File

@@ -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

View File

@@ -41,7 +41,11 @@ 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
run: sudo RSYNC_EXPECT_SKIPPED=acls-default,chmod-temp-dir,chown-fake,daemon-chroot-acl,devices-fake,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,simd-checksum,xattrs-hlink,xattrs 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -320,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@
@@ -362,7 +366,7 @@ testsuite/exclude-lsh_test.py:
.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

View File

@@ -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()
@@ -366,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
@@ -393,6 +400,10 @@ def main():
'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

View File

@@ -20,12 +20,15 @@ import os
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
SCRATCHDIR,
rsync_argv, get_testuid, get_rootuid, get_rootgid,
rmtree, test_fail,
rmtree, start_test_daemon, test_fail,
)
DAEMON_PORT = 12882
mod = SCRATCHDIR / 'module'
outside = SCRATCHDIR / 'outside'
src_dir = SCRATCHDIR / 'src_files'
@@ -69,17 +72,15 @@ log file = {SCRATCHDIR}/rsyncd.log
read only = no
""")
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
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}/', 'rsync://localhost/upload/'),
f'{src_dir}/', f'{url}upload/'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
target = mod / 'target.txt'

View File

@@ -14,12 +14,15 @@ import stat
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
SCRATCHDIR,
get_rootgid, get_rootuid, get_testuid,
rmtree, rsync_argv, test_fail, test_skipped,
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 "
@@ -66,27 +69,32 @@ if my_uid != root_uid:
gid_line = '#' + gid_line
def write_conf(module_name: str, fake_super: bool = False) -> None:
extra = " fake super = yes\n" if fake_super else ""
conf.write_text(f"""\
# 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
[{module_name}]
[upload]
path = {mod}
use chroot = no
read only = no
{extra}""")
[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):
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
subprocess.run(
rsync_argv(*args),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
@@ -97,10 +105,9 @@ os.chmod(mod / 'target.txt', 0o666)
(src / 'target.txt').write_text("NEW_DATA_FROM_SENDER\n")
os.chmod(src / 'target.txt', 0o644)
write_conf('upload')
run_attack([
'--inplace', '--backup', '--backup-dir=cd',
f'{src}/target.txt', 'rsync://localhost/upload/target.txt',
f'{src}/target.txt', f'{daemon_url}/upload/target.txt',
])
verify_outside_unchanged("3b inplace+backup-dir=cd")
@@ -110,8 +117,7 @@ setup()
(src / 'cd').mkdir()
os.symlink('/etc/passwd', src / 'cd' / 'sym')
write_conf('upload_fake', fake_super=True)
run_attack(['-rl', f'{src}/', 'rsync://localhost/upload_fake/'])
run_attack(['-rl', f'{src}/', f'{daemon_url}/upload_fake/'])
verify_outside_unchanged_or_absent("3c-symlink fake-super symlink push", "sym")
@@ -126,6 +132,5 @@ except OSError:
if not stat.S_ISFIFO((src / 'cd' / 'fifo').stat().st_mode):
test_skipped("mkfifo unavailable; cannot exercise 3c-mknod")
write_conf('upload_fake', fake_super=True)
run_attack(['-rD', f'{src}/', 'rsync://localhost/upload_fake/'])
run_attack(['-rD', f'{src}/', f'{daemon_url}/upload_fake/'])
verify_outside_unchanged_or_absent("3c-mknod fake-super FIFO push", "fifo")

View File

@@ -9,12 +9,14 @@ import shutil
import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TMPDIR, TODIR,
CHKDIR, FROMDIR, SCRATCHDIR, TMPDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, rmtree,
run_rsync, test_fail,
run_rsync, start_test_daemon, test_fail,
)
DAEMON_PORT = 12874
conf = build_rsyncd_conf()
hands_setup()
@@ -44,12 +46,14 @@ rmtree(TODIR)
print("Test --read-batch:")
checkit(['-av', '--read-batch=BATCH', str(TODIR)], FROMDIR, TODIR)
# Daemon variants. RSYNC_CONNECT_PROG plumbs an in-process daemon.
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
# 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', 'rsync://localhost/test-from/', str(TODIR)],
checkit(['-av', '--write-batch=BATCH',
f'{url}test-from/', str(TODIR)],
CHKDIR, TODIR, allowed_codes=(0, 23))
rmtree(TODIR)
@@ -84,7 +88,7 @@ ignore23 = SCRATCHDIR / 'ignore23'
from rsyncfns import rsync_argv
proc = subprocess.run(
[str(ignore23), *rsync_argv('-av', '--write-batch=BATCH',
f'{FROMDIR}/', 'rsync://localhost/test-to')],
f'{FROMDIR}/', f'{url}test-to')],
)
if proc.returncode != 0:
test_fail(f"daemon recv --write-batch exited {proc.returncode}")

View File

@@ -13,12 +13,15 @@ import platform
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
SCRATCHDIR,
get_rootgid, get_rootuid, get_testuid,
make_data_file, rmtree, rsync_argv, test_fail, test_skipped,
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 "
@@ -79,14 +82,14 @@ def verify_unchanged(label: str) -> None:
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()
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
subprocess.run(
rsync_argv(*args),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
verify_unchanged(label)
@@ -96,24 +99,24 @@ def run_attack(label: str, *args) -> None:
run_attack("single-file --size-only",
'-tp', '--size-only',
f'{src}/target.txt',
'rsync://localhost/upload/subdir/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/',
'rsync://localhost/upload/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/',
'rsync://localhost/upload/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}/',
'rsync://localhost/upload/')
f'{url}upload/')

View File

@@ -10,11 +10,15 @@ import os
import shutil
from rsyncfns import (
FROMDIR, RSYNC, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, makepath, rmtree, run_rsync,
FROMDIR, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, makepath, rmtree,
run_rsync, start_test_daemon,
)
DAEMON_PORT = 12875
checkdir = SCRATCHDIR / 'check'
FROMDIR.mkdir(parents=True, exist_ok=True)
@@ -78,10 +82,11 @@ with open(conf, 'a') as f:
\tincoming chmod = Fo-x
""")
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
url = start_test_daemon(conf, DAEMON_PORT)
rmtree(TODIR)
makepath(TODIR)
checkit(['-avv', '--no-perms', f'{FROMDIR}/', 'localhost::test-incoming-chmod/'],
checkit(['-avv', '--no-perms', f'{FROMDIR}/',
f'{url}test-incoming-chmod/'],
checkdir, TODIR, allowed_codes=(0, 23))

View File

@@ -18,12 +18,15 @@ import os
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
SCRATCHDIR,
rsync_argv, get_testuid, get_rootuid, get_rootgid,
rmtree, test_fail,
rmtree, start_test_daemon, test_fail,
)
DAEMON_PORT = 12883
mod = SCRATCHDIR / 'module'
outside = SCRATCHDIR / 'outside'
src_dir = SCRATCHDIR / 'src_files'
@@ -64,14 +67,12 @@ log file = {SCRATCHDIR}/rsyncd.log
read only = no
""")
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
url = start_test_daemon(conf, DAEMON_PORT)
subprocess.run(
rsync_argv('-rtp', '--copy-dest=cd',
f'{src_dir}/', 'rsync://localhost/upload/'),
f'{src_dir}/', f'{url}upload/'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
)
target = mod / 'target.txt'

View File

@@ -15,11 +15,19 @@ import subprocess
import sys
from rsyncfns import (
RSYNC, SCRATCHDIR, TODIR,
rmtree, rsync_argv, test_fail, test_skipped,
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)")
@@ -99,11 +107,12 @@ def run_check(label: str) -> bool:
rmtree(TODIR)
TODIR.mkdir()
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
# 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', 'localhost::chrootmod/', f'{TODIR}/'),
capture_output=True, text=True, env=env,
rsync_argv('-av', f'{url}chrootmod/', f'{TODIR}/'),
capture_output=True, text=True,
)
out = proc.stdout + proc.stderr
@@ -117,8 +126,12 @@ def run_check(label: str) -> bool:
return '@ERROR' in out and 'access denied' in out
# Scenario A: global reverse lookup. Covered by b6abdb4c.
# 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")

View File

@@ -1,28 +1,29 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/daemon-gzip-download.test.
#
# Download a file tree over a compressed connection from an in-process
# rsyncd (via RSYNC_CONNECT_PROG). Exercises (exorcises?) a bug in
# 2.5.3 that mis-handled doubly-compressed transfers.
import os
# 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, RSYNC, TODIR,
build_rsyncd_conf, checkit, hands_setup, run_rsync,
CHKDIR, FROMDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, run_rsync, start_test_daemon,
)
conf = build_rsyncd_conf()
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --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', 'localhost::test-from/', f'{TODIR}/'],
['-avvvvzz', f'{url}test-from/', f'{TODIR}/'],
CHKDIR, TODIR,
allowed_codes=(0, 23),
)

View File

@@ -1,29 +1,29 @@
#!/usr/bin/env python3
# Python rewrite of testsuite/daemon-gzip-upload.test.
#
# Upload a file tree over a compressed connection to an in-process
# rsyncd (via RSYNC_CONNECT_PROG). Exercises (exorcises?) a bug in
# 2.5.3 that mis-handled doubly-compressed transfers.
import os
# 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, RSYNC, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, run_rsync,
CHKDIR, FROMDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, run_rsync, start_test_daemon,
)
conf = build_rsyncd_conf()
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --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}/')
ignore23 = str(SCRATCHDIR / 'ignore23')
url = start_test_daemon(conf, DAEMON_PORT)
checkit(
['-avvvvzz', f'{FROMDIR}/', 'localhost::test-to/'],
['-avvvvzz', f'{FROMDIR}/', f'{url}test-to/'],
CHKDIR, TODIR,
allowed_codes=(0, 23),
)

View File

@@ -5,16 +5,17 @@
# reject clients that ask for compression, and still serve the same
# transfer when the client does not.
import os
import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, TODIR,
CHKDIR, FROMDIR, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, hands_setup, rmtree,
rsync_argv, run_rsync, test_fail,
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:
@@ -25,15 +26,15 @@ with open(conf, 'a') as f:
\trefuse options = compress
""")
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
hands_setup()
run_rsync('-av', '--exclude=foobar.baz', f'{FROMDIR}/', f'{CHKDIR}/')
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', 'localhost::no-compress/', f'{TODIR}/'),
rsync_argv('-avz', url, f'{TODIR}/'),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
)
errlog.write_text(proc.stderr)
@@ -47,5 +48,5 @@ if '--compress' not in proc.stderr:
# The same transfer without -z must succeed.
rmtree(TODIR)
TODIR.mkdir()
checkit(['-av', 'localhost::no-compress/', f'{TODIR}/'], CHKDIR, TODIR,
checkit(['-av', url, f'{TODIR}/'], CHKDIR, TODIR,
allowed_codes=(0, 23))

View File

@@ -13,10 +13,13 @@ import subprocess
from rsyncfns import (
CHKFILE, FROMDIR, OUTFILE, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
build_rsyncd_conf, get_rootuid, get_testuid, makepath,
rsync_argv, run_rsync, test_fail,
rsync_argv, run_rsync, start_test_daemon, test_fail,
)
DAEMON_PORT = 12877
SSH = f"{SRCDIR / 'support' / 'lsh.sh'} --no-cd"
# Replacements that hide the variable parts of `rsync -r` listings: tabs/
@@ -84,16 +87,18 @@ if expected_modules not in out:
test_fail("module list via lsh.sh did not contain the expected modules")
print('====')
# Same module list via RSYNC_CONNECT_PROG -- the same daemon, no remote shell.
os.environ['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
out = run_and_check(['-v', 'localhost::'], expected_modules, "module list via daemon")
# Same module list via the test daemon (pipe transport by default; real
# loopback rsyncd under --use-tcp).
daemon_url = start_test_daemon(conf, DAEMON_PORT).rstrip('/')
out = run_and_check(['-v', f'{daemon_url}/'], expected_modules, "module list via daemon")
if expected_modules not in out:
test_fail("module list via daemon did not contain the expected modules")
print('====')
# test-hidden: a recursive listing of the module, with file/dir/date
# columns normalised so the diff is content-only.
out = run_and_check(['-r', 'localhost::test-hidden'], "", "test-hidden listing")
out = run_and_check(['-r', f'{daemon_url}/test-hidden'], "", "test-hidden listing")
normalised = normalise(out)
expected_hidden = """\
drwxr-xr-x DIR ####/##/## ##:##:## .
@@ -112,7 +117,7 @@ for path in ('bar', 'bar/two', 'bar/baz', 'bar/baz/three', 'foo', 'foo/one'):
test_fail(f"test-hidden listing missing path {path!r}")
# test-from/f* glob: only the foo subtree.
out = run_and_check(['-r', 'localhost::test-from/f*'], "", "test-from glob")
out = run_and_check(['-r', f'{daemon_url}/test-from/f*'], "", "test-from glob")
normalised = normalise(out)
for path in ('foo', 'foo/one'):
if path not in normalised:
@@ -125,7 +130,7 @@ if 'bar' in normalised:
# atimes-format variant -- only if rsync was built with atimes support.
vv = run_rsync('-VV', check=True, capture_output=True)
if '"atimes": true' in vv.stdout:
out = run_and_check(['-rU', 'localhost::test-from/f*'], "", "test-from glob with -U")
out = run_and_check(['-rU', f'{daemon_url}/test-from/f*'], "", "test-from glob with -U")
normalised = normalise(out)
for path in ('foo', 'foo/one'):
if path not in normalised:

View File

@@ -15,9 +15,16 @@ import sys
import threading
import time
from rsyncfns import SCRATCHDIR, claim_ports, rsync_argv, test_fail, test_skipped
from rsyncfns import (
SCRATCHDIR, claim_ports, require_tcp, rsync_argv, test_fail, test_skipped,
)
# This test has no stdio-pipe equivalent: it binds a real loopback socket to
# stand in for a malicious HTTP proxy. Honour the secure default (no listening
# sockets) by only running it under --use-tcp.
require_tcp("fake-proxy listener needs a real TCP socket; run with --use-tcp")
if shutil.which('python3') is None:
test_skipped("python3 not available")

View File

@@ -17,12 +17,15 @@ Conventions matching the shell harness:
from __future__ import annotations
import atexit
import fcntl
import os
import shlex
import shutil
import socket as _socket
import subprocess
import sys
import time
from pathlib import Path
@@ -55,6 +58,12 @@ RSYNC = _required('RSYNC') # full command line, possibly with valgrind/p
# assign to rsyncfns.TLS_ARGS before calling checkit / rsync_ls_lR.
TLS_ARGS = os.environ.get('TLS_ARGS', '')
# Daemon-mode transport. The DEFAULT is the secure stdio-pipe mechanism
# (RSYNC_CONNECT_PROG), which opens no listening socket at all. The runner
# sets RSYNC_TEST_USE_TCP=1 only when invoked with --use-tcp, which switches
# daemon tests to a real rsyncd bound to loopback (see start_test_daemon).
USE_TCP = os.environ.get('RSYNC_TEST_USE_TCP') == '1'
# Mnemonics for rsync's itemize-changes (-i / -ii) format:
# all_plus -> +++++++++ every attribute changed (an additive create)
# allspace -> every attribute unchanged
@@ -148,6 +157,124 @@ def claim_ports(*ports: int) -> 'None':
fcntl.lockf(_port_lock_fd, fcntl.LOCK_EX, 1, port)
# --- standalone rsyncd helpers ---------------------------------------------
def _set_pdeathsig() -> 'None':
"""Linux: ask the kernel to send SIGTERM to us if our parent dies.
A no-op on every other platform. Used as preexec_fn so a kill -9 of
the test process doesn't strand the rsyncd we spawned."""
if not sys.platform.startswith('linux'):
return
try:
import ctypes
libc = ctypes.CDLL('libc.so.6', use_errno=True)
PR_SET_PDEATHSIG = 1
libc.prctl(PR_SET_PDEATHSIG, 15, 0, 0, 0) # 15 == SIGTERM
except OSError:
pass
def _stop_rsyncd(proc) -> 'None':
if proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
try:
proc.kill()
proc.wait(timeout=1)
except (subprocess.TimeoutExpired, OSError):
pass
def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen':
"""Spawn `rsync --daemon --no-detach --address=127.0.0.1 --port=N
--config=conf` and return the Popen handle after the port is accepting
connections.
The daemon is bound to LOOPBACK ONLY (--address=127.0.0.1): without it,
rsync --daemon binds 0.0.0.0 and the test modules would be reachable from
the whole LAN. The daemon is killed automatically when this Python
process exits (atexit). On Linux, the kernel also signals SIGTERM to the
daemon if the parent dies abruptly (PR_SET_PDEATHSIG), so a SIGKILL on
the test process doesn't strand the daemon either. The caller is expected
to have already claim_ports()'d `port`.
This is only ever reached from start_test_daemon() in --use-tcp mode; the
default (pipe) mode never starts a listening daemon.
"""
argv = shlex.split(RSYNC) + [
'--daemon', '--no-detach',
'--address=127.0.0.1',
f'--port={port}',
f'--config={conf_path}',
]
proc = subprocess.Popen(
argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=_set_pdeathsig,
)
atexit.register(_stop_rsyncd, proc)
deadline = time.monotonic() + 10
last_err = None
while time.monotonic() < deadline:
if proc.poll() is not None:
test_fail(
f"rsyncd exited before listening on port {port} "
f"(status={proc.returncode})"
)
try:
with _socket.create_connection(('127.0.0.1', port), timeout=0.5):
return proc
except OSError as e:
last_err = e
time.sleep(0.05)
_stop_rsyncd(proc)
test_fail(f"rsyncd never listened on 127.0.0.1:{port}: {last_err}")
def start_test_daemon(conf_path, port: int) -> str:
"""Bring up the test daemon and return a URL prefix for client commands.
This is the single seam every daemon test uses. The transport depends on
the mode the runner selected:
* DEFAULT (secure) -- no TCP socket at all. Sets RSYNC_CONNECT_PROG so
the rsync client forks the daemon over a private stdio pipe. Returns
'rsync://localhost/'. Another local user can't reach it; nothing is
listening.
* --use-tcp -- starts a real rsyncd bound to 127.0.0.1 on the given
claim_ports()-reserved port. Returns 'rsync://localhost:PORT/'. Bound
to loopback so off-host/LAN access is impossible; a same-host user
could still connect during the test window, which is the documented,
accepted cost of explicitly opting into TCP.
Build URLs as f"{prefix}module/path". `port` is only used (and claimed)
in --use-tcp mode.
"""
if USE_TCP:
claim_ports(port)
start_rsyncd(conf_path, port)
return f'rsync://localhost:{port}/'
os.environ['RSYNC_CONNECT_PROG'] = f'{RSYNC} --config={conf_path} --daemon'
return 'rsync://localhost/'
def require_tcp(reason: str) -> 'None':
"""Skip the test (exit 77) unless we're in --use-tcp mode. For tests that
fundamentally need a real listening socket / TCP peer and have no secure
pipe equivalent (the fake-proxy listener; the reverse-DNS hostname-ACL
daemon test)."""
if not USE_TCP:
test_skipped(reason)
def rsync_argv(*args: str) -> list:
"""Return the argv for invoking rsync with the given extra arguments.
@@ -269,8 +396,6 @@ def build_rsyncd_conf() -> 'Path':
pidfile = SCRATCHDIR / 'rsyncd.pid'
logfile = SCRATCHDIR / 'rsyncd.log'
hostname = subprocess.check_output(['uname', '-n'], text=True).strip()
my_uid = get_testuid()
root_uid = get_rootuid()
root_gid = get_rootgid()
@@ -289,7 +414,10 @@ def build_rsyncd_conf() -> 'Path':
pid file = {pidfile}
use chroot = no
munge symlinks = no
hosts allow = localhost 127.0.0.0/24 192.168.0.0/16 10.0.0.0/8 {hostname}
# Loopback only. In --use-tcp mode the daemon is also bound to 127.0.0.1
# (start_rsyncd passes --address), so this is belt-and-suspenders; in the
# default pipe mode there is no socket to guard at all.
hosts allow = localhost 127.0.0.0/8
log file = {logfile}
transfer logging = yes
# We don't define log format here so the test-hidden module defaults

View File

@@ -15,12 +15,15 @@ import platform
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR,
SCRATCHDIR,
rsync_argv, get_testuid, get_rootuid, get_rootgid,
rmtree, test_fail, test_skipped,
rmtree, start_test_daemon, test_fail, test_skipped,
)
DAEMON_PORT = 12881
# Platforms without RESOLVE_BENEATH equivalents fall back to a per-
# component walk that this test is not in scope for.
if platform.system() in ('SunOS', 'OpenBSD', 'NetBSD') or platform.system().startswith('CYGWIN'):
@@ -66,12 +69,11 @@ log file = {SCRATCHDIR}/rsyncd.log
read only = no
""")
env = os.environ.copy()
env['RSYNC_CONNECT_PROG'] = f"{RSYNC} --config={conf} --daemon"
url = start_test_daemon(conf, DAEMON_PORT)
proc = subprocess.run(
rsync_argv('-nrv', 'rsync://localhost/upload/cd/', f'{SCRATCHDIR}/dst/'),
capture_output=True, text=True, env=env,
rsync_argv('-nrv', f'{url}upload/cd/', f'{SCRATCHDIR}/dst/'),
capture_output=True, text=True,
)
listfile.write_text(proc.stdout + proc.stderr)