]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: cover daemon access-control, config includes, --stop-at
authorAndrew Tridgell <andrew@tridgell.net>
Sun, 24 May 2026 03:24:03 +0000 (13:24 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Sun, 24 May 2026 21:44:12 +0000 (07:44 +1000)
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) <noreply@anthropic.com>
testsuite/daemon-access-ip_test.py [new file with mode: 0644]
testsuite/daemon-config_test.py [new file with mode: 0644]
testsuite/stop-time_test.py [new file with mode: 0644]

diff --git a/testsuite/daemon-access-ip_test.py b/testsuite/daemon-access-ip_test.py
new file mode 100644 (file)
index 0000000..52d6166
--- /dev/null
@@ -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 (file)
index 0000000..b680c73
--- /dev/null
@@ -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 (file)
index 0000000..8642ce4
--- /dev/null
@@ -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")