From: Andrew Tridgell Date: Sun, 24 May 2026 03:24:03 +0000 (+1000) Subject: testsuite: cover daemon access-control, config includes, --stop-at X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=702a8f61b7fa7f3d06d62785371ddfa81f728b04;p=thirdparty%2Frsync.git testsuite: cover daemon access-control, config includes, --stop-at Target the lowest-coverage rsync files identified from a merged (pipe + proto29/30 + tcp) gcov report: daemon-access-ip hosts allow / hosts deny with exact-IP and CIDR patterns over --use-tcp, exercising access.c make_mask/match_address/ match_binary (19% -> 62% lines), plus client --address (socket.c try_bind_local). require_tcp. daemon-config the &include rsyncd.conf directive (params.c include_config/ parse_directives, 48% -> 60%) and a module with a missing path (clientserver.c path_failure). stop-time --stop-at future/past (options.c parse_time) and --stop-after (options.c 59% -> 64%). Merged scoped coverage: lines 67.3%->68.3%, functions 87.5%->88.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- diff --git a/testsuite/daemon-access-ip_test.py b/testsuite/daemon-access-ip_test.py new file mode 100644 index 00000000..52d61663 --- /dev/null +++ b/testsuite/daemon-access-ip_test.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Daemon coverage: hosts allow / hosts deny IP and CIDR matching (access.c). + +access.c's make_mask / match_address / match_binary only run for a real TCP +peer matched against a numeric hosts allow/deny pattern -- so this needs +--use-tcp (the loopback peer is 127.0.0.1). Verifies that exact-IP and CIDR +allow patterns permit the connection while a CIDR deny / a non-matching allow +refuse it. + +The config sets NO global hosts allow: an inherited global allow-list would +match (e.g. via "localhost") and short-circuit before a module's deny is +consulted, so the per-module patterns must be the sole decider here. +""" + +import subprocess + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, + make_tree, require_tcp, rmtree, rsync_argv, start_test_daemon, test_fail, +) + +DAEMON_PORT = 12892 +require_tcp("hosts allow/deny address matching needs a real TCP peer") + +src = FROMDIR +rmtree(src) +make_tree(src, depth=2) + +conf = SCRATCHDIR / 'access-ip.conf' +conf.write_text( + f"pid file = {SCRATCHDIR}/rsyncd.pid\n" + "use chroot = no\n" + f"log file = {SCRATCHDIR}/rsyncd.log\n" + f"\n[allow-exact]\n\tpath = {src}\n\tread only = yes\n\thosts allow = 127.0.0.1\n" + f"\n[allow-cidr]\n\tpath = {src}\n\tread only = yes\n\thosts allow = 127.0.0.0/8\n" + f"\n[deny-cidr]\n\tpath = {src}\n\tread only = yes\n\thosts deny = 127.0.0.0/8\n" + f"\n[allow-other]\n\tpath = {src}\n\tread only = yes\n\thosts allow = 10.0.0.0/8\n" +) +url = start_test_daemon(conf, DAEMON_PORT) + + +def connect(mod): + """Return rsync's exit code for listing the module over the daemon.""" + return subprocess.run(rsync_argv('-r', f'{url}{mod}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + text=True).returncode + + +for mod in ('allow-exact', 'allow-cidr'): + if connect(mod) != 0: + test_fail(f"connection to {mod} should be ALLOWED but was refused") +for mod in ('deny-cidr', 'allow-other'): + if connect(mod) == 0: + test_fail(f"connection to {mod} should be DENIED but succeeded") + +# Client --address binds the outgoing socket to a local address (socket.c +# try_bind_local) before connecting to the daemon. +proc = subprocess.run( + rsync_argv('-r', '--address=127.0.0.1', f'{url}allow-cidr/'), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) +if proc.returncode != 0: + test_fail(f"--address=127.0.0.1 client connection failed: {proc.stderr}") + +print("daemon-access-ip: hosts allow/deny matching + client --address verified") diff --git a/testsuite/daemon-config_test.py b/testsuite/daemon-config_test.py new file mode 100644 index 00000000..b680c73c --- /dev/null +++ b/testsuite/daemon-config_test.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Daemon coverage: the &include config directive (params.c include_config / +parse_directives) and a module whose path doesn't exist (clientserver.c +path_failure). + +Uses a hand-written rsyncd.conf because &include is a directive line, not a +`name = value` parameter. +""" + +import subprocess + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, + make_tree, rmtree, rsync_argv, start_test_daemon, test_fail, +) + +DAEMON_PORT = 12893 + +src = FROMDIR +rmtree(src) +make_tree(src, depth=2) + +inc = SCRATCHDIR / 'included.conf' +inc.write_text(f"[inc-mod]\n\tpath = {src}\n\tread only = yes\n\tcomment = via-include\n") + +conf = SCRATCHDIR / 'daemon-config.conf' +conf.write_text( + f"pid file = {SCRATCHDIR}/rsyncd.pid\n" + "use chroot = no\n" + "hosts allow = localhost 127.0.0.0/8\n" + f"log file = {SCRATCHDIR}/rsyncd.log\n" + f"&include {inc}\n" + f"\n[badpath]\n\tpath = {SCRATCHDIR}/no-such-dir\n\tread only = yes\n" +) +url = start_test_daemon(conf, DAEMON_PORT) + +# &include pulled in inc-mod: it must be listable and present in the module list. +proc = subprocess.run(rsync_argv('-r', f'{url}inc-mod/'), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) +if proc.returncode != 0: + test_fail(f"&include-defined module not reachable: {proc.stderr}") +proc = subprocess.run(rsync_argv(url), capture_output=True, text=True) +if 'inc-mod' not in proc.stdout: + test_fail(f"&include-defined module absent from the listing:\n{proc.stdout}") + +# A module whose path does not exist must fail the connection (path_failure). +proc = subprocess.run(rsync_argv('-r', f'{url}badpath/'), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) +if proc.returncode == 0: + test_fail("a module with a non-existent path unexpectedly served a connection") + +print("daemon-config: &include directive + bad-path failure verified") diff --git a/testsuite/stop-time_test.py b/testsuite/stop-time_test.py new file mode 100644 index 00000000..8642ce49 --- /dev/null +++ b/testsuite/stop-time_test.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Coverage of --stop-at (options.c parse_time) and --stop-after. + +--stop-at parses an absolute y-m-dTh:m time (parse_time): a future time is +accepted and the transfer proceeds; a past time is rejected at parse. --stop-after +takes a minute count. These exercise the OPT_STOP_AT/OPT_STOP_AFTER option +handling that no other test reaches. +""" + +from datetime import datetime, timedelta + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, test_fail, walk_files, +) + +src = FROMDIR +rmtree(src) +make_tree(src, depth=2) +rels = [p.relative_to(src) for p in walk_files(src)] + +# --- --stop-at in the future: parses, transfer completes normally ----------- +# Generated relative to now (a day ahead) rather than a fixed far-future date: +# rsync rejects a --stop-at that isn't strictly in the future, and a hard-coded +# year would date-rot and can overflow a 32-bit time_t. +future = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%dT%H:%M') +rmtree(TODIR) +run_rsync('-a', f'--stop-at={future}', f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'--stop-at future {rel}') + +# --- --stop-at in the past: rejected at parse ------------------------------- +rmtree(TODIR) +proc = run_rsync('-a', '--stop-at=2000-01-01T00:00', f'{src}/', f'{TODIR}/', + check=False) +if proc.returncode == 0: + test_fail("--stop-at with a past time was not rejected") + +# --- --stop-after (minutes): parses, transfer completes --------------------- +rmtree(TODIR) +run_rsync('-a', '--stop-after=60', f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'--stop-after {rel}') + +print("stop-time: --stop-at future/past and --stop-after verified")