]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: secure stdio-pipe daemon transport by default, opt-in TCP
authorAndrew Tridgell <andrew@tridgell.net>
Thu, 21 May 2026 04:14:13 +0000 (14:14 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Fri, 22 May 2026 04:34:52 +0000 (14:34 +1000)
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>
25 files changed:
.github/workflows/almalinux-8-build.yml
.github/workflows/cygwin-build.yml
.github/workflows/freebsd-build.yml
.github/workflows/macos-build.yml
.github/workflows/netbsd-build.yml
.github/workflows/openbsd-build.yml
.github/workflows/solaris-build.yml
.github/workflows/ubuntu-22.04-build.yml
.github/workflows/ubuntu-build.yml
Makefile.in
runtests.py
testsuite/alt-dest-symlink-race_test.py
testsuite/bare-do-open-symlink-race_test.py
testsuite/batch-mode_test.py
testsuite/chdir-symlink-race_test.py
testsuite/chmod-option_test.py
testsuite/copy-dest-source-symlink_test.py
testsuite/daemon-chroot-acl_test.py
testsuite/daemon-gzip-download_test.py
testsuite/daemon-gzip-upload_test.py
testsuite/daemon-refuse-compress_test.py
testsuite/daemon_test.py
testsuite/proxy-response-line-too-long_test.py
testsuite/rsyncfns.py
testsuite/sender-flist-symlink-leak_test.py

index 9d7ea782e75779c15db7ce4c9149852f81e6dbd8..e269a3e7ade2b81f35145957300a3eadd9f50b70 100644 (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
index fe5a5c422d37680d7dbaadaea2d8a7a86268eff5..7f766f07fb7f77d05443c170b959c092e2728d47 100644 (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
index 79633ad16a2a580194f9ecb230195751234c5f28..0d2b362790d17fdb60122b6f1bf67c6acca2dda5 100644 (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
index a127526e6b10c807ec2322872f897d82255a61fa..19ef8a7524d1f60ed49afc59ed6732d5357ae520 100644 (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
index 770d7124c0dcc6d64f8190e5661dedb418572e4e..3acc23408916b87102bb8b07654a5a54637911d3 100644 (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
index 749724cd6992a7cd65a2cafb808842b67306c833..3d83ab1a46e640cf4f5d8965f45cfa58a00cd83b 100644 (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
index e41e002dc6216111917d55e8070efe010557fa75..c7867f90cee53e20bc2226af694d9efa02ff1c6d 100644 (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
index 0e608279ef514b3ad6f3fa6c3a809478865fb4d6..154b1b90a5e96063db741a371966fd02b3b4a513 100644 (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
index 5efadce5b7c694195eee9e8ff308b223799b3fa4..5fe6cca4444dcc7f12e0b985e5d12ab6597b07ee 100644 (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
index 451ff5c6cc659f5f170b5339f468abea70c73528..af9fbfb28e26f5ed6205d11127713172789e249c 100644 (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
 
index feb05d627f5cfda166c8e152c90598f81ee33bbd..2259aee15e2aaf4aa79244ccc8747f1eecb849e1 100755 (executable)
@@ -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
index 9045fa6ee7178e4f456af3b5adbd631ce07fa57e..fc83c5762f97c88e4cf6aac5ddfda8507247526a 100644 (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'
index d232204b91614fc099340a20ee4750fd2dd52f4b..deff07314e1da93b66f79eb097c0f9e927c9ebb8 100644 (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
+
+[upload_fake]
     path = {mod}
     use chroot = no
     read only = no
-{extra}""")
+    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")
index 7cd9e79d5b20667901a35da43086d3b799380ac9..3c1a682714839bd4cd631fa78384162718087fdd 100644 (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}")
index f2495f7e34c6e76bf4145aada9e4a8b2e4575ecb..80b314c04ceaa72e0ac8686676cb9d716c06ef8a 100644 (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/')
index 7ca2e2920aa319a893a8023b12d3b4deeb2e81cf..b16e7816a0a723dcbb842a38994b50b44ed3ca07 100644 (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))
index f3c6b09f3bdaf83f332c0a149f3684a5ed3b7be3..d1f445423c4cc57bd4fbcd515fca7f29927cec43 100644 (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'
index 3fa5e2c3983f18c5e380244593eef36518140dff..f477145e4d50d0c3f75b5a13f9c18c491e3b8fae 100644 (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")
 
index adf21ae036cacf977dfe338b9b714cf8b3d2e7f5..4550863aaac7a8f721f4e50309a05d2c371aca82 100644 (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),
 )
index f5fd85f7b6236124d37e77aba73c481b74b09a15..64922a14317bc6b46a0f889dd5dbd37bae3e81d3 100644 (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),
 )
index 29b28bb77cb329a51012c54629124175406deac4..83af17294b1b0ad15f8b38052e75ab9b537e055b 100644 (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))
index 41de280a6f8f65117b75e016888d1e5662e83694..2dcee1a10be5bd76a13b7bd15392836d4ae92c53 100644 (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:
index af4a79f312cf5c7b0f1fc80d79e9258cca59c3fc..a1e7aa66cc3eb5c946ac089b9397f3aa14e9b656 100644 (file)
@@ -15,8 +15,15 @@ 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")
index 224f638827a628c77c2162ef2689293ee960dfca..95029ff1bc0efd25d1d22a74bba3684202e02b9f 100644 (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
index afa2ead62c23cdcda1179a4c9bee2569daed1c80..67fc2f11bed098f4229ba4169212d1e54af91d32 100644 (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)