--- /dev/null
+#!/usr/bin/env python3
+"""Daemon coverage: module path, read only, write only, list.
+
+Drives a loopback daemon (secure stdio-pipe transport by default) and checks
+the access-control parameters, transferring a >=3-deep tree through each module
+and pulling a deep sub-path to exercise in-module path resolution.
+"""
+
+import subprocess
+
+from rsyncfns import (
+ FROMDIR, SCRATCHDIR, TODIR,
+ assert_same, make_tree, makepath, rmtree, rsync_argv, run_rsync,
+ start_test_daemon, test_fail, verify_dirs, walk_files, write_daemon_conf,
+)
+
+DAEMON_PORT = 12886
+
+src = FROMDIR
+rwdir = SCRATCHDIR / 'rwdest'
+wodir = SCRATCHDIR / 'wodest'
+pulld = SCRATCHDIR / 'pulled'
+for d in (rwdir, wodir, pulld, TODIR):
+ rmtree(d)
+makepath(rwdir, wodir)
+rmtree(src)
+make_tree(src, depth=3)
+rels = [p.relative_to(src) for p in walk_files(src)]
+
+conf = write_daemon_conf([
+ ('ro', {'path': src, 'read only': 'yes', 'comment': 'r/o'}),
+ ('rw', {'path': rwdir, 'read only': 'no', 'comment': 'r/w'}),
+ ('wo', {'path': wodir, 'read only': 'no', 'write only': 'yes'}),
+ ('hidden', {'path': src, 'list': 'no'}),
+])
+url = start_test_daemon(conf, DAEMON_PORT)
+
+
+def fails(args, what):
+ proc = subprocess.run(rsync_argv(*args),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
+ text=True)
+ if proc.returncode == 0:
+ test_fail(f"{what} unexpectedly succeeded")
+ return proc.stderr
+
+
+# --- read only module: pull works (deep), push refused ----------------------
+run_rsync('-a', f'{url}ro/', f'{pulld}/', check=False) # codes 0/23 ok
+for rel in rels:
+ assert_same(pulld / rel, src / rel, label=f'pull ro {rel}')
+# deep sub-path pull
+rmtree(pulld)
+makepath(pulld)
+run_rsync('-a', f'{url}ro/d1/d2/', f'{pulld}/', check=False)
+assert_same(pulld / 'f2', src / 'd1' / 'd2' / 'f2', label='deep sub-path pull')
+fails(['-a', f'{src}/', f'{url}ro/'], "push to a read-only module")
+
+# --- read/write module: push works at depth ---------------------------------
+run_rsync('-a', f'{src}/', f'{url}rw/', check=False)
+verify_dirs(src, rwdir, label="push to rw module")
+
+# --- write only module: push works, pull refused ----------------------------
+run_rsync('-a', f'{src}/', f'{url}wo/', check=False)
+verify_dirs(src, wodir, label="push to wo module")
+fails(['-a', f'{url}wo/', f'{pulld}/'], "pull from a write-only module")
+
+# --- list: hidden module absent from the listing, ro/rw/wo present ----------
+proc = subprocess.run(rsync_argv(url), capture_output=True, text=True)
+listing = proc.stdout
+for m in ('ro', 'rw', 'wo'):
+ if m not in listing:
+ test_fail(f"module {m} missing from the daemon listing:\n{listing}")
+if 'hidden' in listing:
+ test_fail(f"list=no module leaked into the listing:\n{listing}")
+# ...but the hidden module is still usable by name.
+rmtree(pulld)
+makepath(pulld)
+run_rsync('-a', f'{url}hidden/f0', f'{pulld}/', check=False)
+assert_same(pulld / 'f0', src / 'f0', label='hidden module usable by name')
+
+print("daemon-access: read only / write only / list / deep paths verified")
--- /dev/null
+#!/usr/bin/env python3
+"""Daemon coverage: auth users, secrets file, strict modes.
+
+A module with auth users + a secrets file must accept the right password,
+reject a wrong one, and (with the default strict modes) refuse a
+world-readable secrets file. Authentication happens in the daemon protocol, so
+it works over the default secure stdio-pipe transport.
+"""
+
+import os
+import subprocess
+
+from rsyncfns import (
+ FROMDIR, SCRATCHDIR,
+ make_tree, makepath, rmtree, rsync_argv, start_test_daemon, test_fail,
+ verify_dirs, write_daemon_conf,
+)
+
+DAEMON_PORT = 12888
+
+# When a daemon module needs auth and no password is available, rsync falls back
+# to an interactive getpass() prompt that reads /dev/tty directly -- which the
+# test harness cannot redirect, so it would hang `make coverage` (or any run
+# with a controlling terminal). Give every client a fallback password via the
+# environment so it never prompts: the --password-file cases below override it,
+# and the invalid-credentials case uses it and is correctly rejected.
+os.environ['RSYNC_PASSWORD'] = 'env-fallback-wrong'
+
+src = FROMDIR
+rmtree(src)
+make_tree(src, depth=3)
+
+authdir = SCRATCHDIR / 'authdest'
+secrets = SCRATCHDIR / 'rsyncd.secrets'
+secrets.write_text('tuser:secretpass\n')
+secrets.chmod(0o600)
+
+conf = write_daemon_conf([
+ ('auth', {'path': authdir, 'read only': 'no',
+ 'auth users': 'tuser', 'secrets file': secrets}),
+])
+url = start_test_daemon(conf, DAEMON_PORT)
+userurl = url.replace('rsync://', 'rsync://tuser@', 1)
+
+
+def pwfile(name, text):
+ p = SCRATCHDIR / name
+ p.write_text(text)
+ p.chmod(0o600)
+ return p
+
+
+def push(pw, **kw):
+ rmtree(authdir)
+ makepath(authdir)
+ return subprocess.run(
+ rsync_argv('-a', f'--password-file={pw}', f'{src}/', f'{userurl}auth/'),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, **kw)
+
+
+# --- correct password succeeds ----------------------------------------------
+ok = pwfile('pw.ok', 'secretpass\n')
+proc = push(ok)
+if proc.returncode not in (0, 23):
+ test_fail(f"auth with the correct password failed: {proc.stderr}")
+verify_dirs(src, authdir, label="auth success")
+
+# --- wrong password is rejected ---------------------------------------------
+bad = pwfile('pw.bad', 'wrongpass\n')
+proc = push(bad)
+if proc.returncode == 0:
+ test_fail("auth with the wrong password unexpectedly succeeded")
+
+# --- a request with invalid credentials is rejected ------------------------
+# Local user (not an auth user) with the wrong env-supplied password; rejected
+# without ever prompting on the tty.
+proc = subprocess.run(
+ rsync_argv('-a', f'{src}/', f'{url}auth/'),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
+ stdin=subprocess.DEVNULL)
+if proc.returncode == 0:
+ test_fail("a request with invalid credentials succeeded against an "
+ "auth-users module")
+
+# --- strict modes rejects a world-readable secrets file ---------------------
+secrets.chmod(0o644)
+proc = push(ok)
+if proc.returncode == 0:
+ test_fail("strict modes did not reject a world-readable secrets file")
+secrets.chmod(0o600)
+
+print("daemon-auth: auth users / secrets file / strict modes verified")
--- /dev/null
+#!/usr/bin/env python3
+"""Daemon coverage: pre-xfer exec and post-xfer exec.
+
+A module's pre-xfer/post-xfer exec hooks must run with the documented
+environment (RSYNC_MODULE_NAME, RSYNC_EXIT_STATUS, ...), and a non-zero
+pre-xfer exec must abort the transfer.
+"""
+
+import subprocess
+import time
+
+from rsyncfns import (
+ FROMDIR, SCRATCHDIR,
+ make_tree, makepath, rmtree, rsync_argv, start_test_daemon, test_fail,
+ write_daemon_conf,
+)
+
+
+def wait_for(path, want, secs=5):
+ """Poll for a marker file to contain `want`. post-xfer exec runs on the
+ daemon side after the client disconnects (a real race under --use-tcp),
+ so we must wait for it rather than check immediately."""
+ deadline = time.monotonic() + secs
+ while time.monotonic() < deadline:
+ if path.is_file() and path.read_text().strip() == want:
+ return True
+ time.sleep(0.05)
+ return False
+
+DAEMON_PORT = 12889
+
+src = FROMDIR
+rmtree(src)
+make_tree(src, depth=3)
+
+markers = SCRATCHDIR / 'markers'
+rmtree(markers)
+makepath(markers)
+hookdir = SCRATCHDIR / 'hookdest'
+faildir = SCRATCHDIR / 'faildest'
+makepath(hookdir, faildir)
+
+
+def script(name, body):
+ p = SCRATCHDIR / name
+ p.write_text('#!/bin/sh\n' + body)
+ p.chmod(0o755)
+ return p
+
+
+pre = script('pre.sh', f'echo "$RSYNC_MODULE_NAME" > {markers}/pre.out\nexit 0\n')
+post = script('post.sh', f'echo "$RSYNC_EXIT_STATUS" > {markers}/post.out\n'
+ 'exit 0\n')
+prefail = script('prefail.sh', 'exit 1\n')
+
+conf = write_daemon_conf([
+ ('hook', {'path': hookdir, 'read only': 'no',
+ 'pre-xfer exec': pre, 'post-xfer exec': post}),
+ ('failhook', {'path': faildir, 'read only': 'no',
+ 'pre-xfer exec': prefail}),
+])
+url = start_test_daemon(conf, DAEMON_PORT)
+
+# --- pre/post hooks run with the documented environment ---------------------
+proc = subprocess.run(rsync_argv('-a', f'{src}/', f'{url}hook/'),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
+ text=True)
+if proc.returncode not in (0, 23):
+ test_fail(f"transfer through exec-hook module failed: {proc.stderr}")
+if not wait_for(markers / 'pre.out', 'hook'):
+ test_fail("pre-xfer exec did not run with RSYNC_MODULE_NAME=hook")
+if not wait_for(markers / 'post.out', '0'):
+ test_fail("post-xfer exec did not run with RSYNC_EXIT_STATUS=0")
+
+# --- a failing pre-xfer exec aborts the transfer ----------------------------
+proc = subprocess.run(rsync_argv('-a', f'{src}/', f'{url}failhook/'),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
+ text=True)
+if proc.returncode == 0:
+ test_fail("a failing pre-xfer exec did not abort the transfer")
+if list(faildir.iterdir()):
+ test_fail("transfer wrote files despite a failing pre-xfer exec")
+
+print("daemon-exec: pre-xfer/post-xfer exec env + abort-on-failure verified")
--- /dev/null
+#!/usr/bin/env python3
+"""Daemon coverage: exclude, incoming chmod, outgoing chmod (at depth).
+
+A daemon-side exclude must hide matching files everywhere in the module tree;
+incoming/outgoing chmod must rewrite the permissions of every transferred file,
+including ones several levels deep.
+"""
+
+import os
+import subprocess
+
+from rsyncfns import (
+ FROMDIR, SCRATCHDIR,
+ assert_mode, assert_not_exists, assert_same, make_tree, makepath, rmtree,
+ rsync_argv, start_test_daemon, test_fail, walk_files, write_daemon_conf,
+)
+
+DAEMON_PORT = 12887
+
+src = FROMDIR
+rmtree(src)
+make_tree(src, depth=3)
+(src / 'a.secret').write_text('s\n')
+(src / 'd1' / 'd2' / 'b.secret').write_text('s\n')
+rels = [p.relative_to(src) for p in walk_files(src)]
+
+incdir = SCRATCHDIR / 'incdest'
+for d in (incdir,):
+ rmtree(d)
+makepath(incdir)
+
+conf = write_daemon_conf([
+ ('filt', {'path': src, 'read only': 'yes', 'exclude': '*.secret'}),
+ ('inc', {'path': incdir, 'read only': 'no', 'incoming chmod': 'F600'}),
+ ('out', {'path': src, 'read only': 'yes', 'outgoing chmod': 'Fg-r,Fo-r'}),
+])
+url = start_test_daemon(conf, DAEMON_PORT)
+
+
+def pull(mod, dest):
+ rmtree(dest)
+ makepath(dest)
+ subprocess.run(rsync_argv('-a', f'{url}{mod}/', f'{dest}/'),
+ stdout=subprocess.DEVNULL)
+
+
+# --- daemon exclude hides *.secret everywhere in the module -----------------
+fp = SCRATCHDIR / 'filtpull'
+pull('filt', fp)
+assert_not_exists(fp / 'a.secret', label='daemon exclude top')
+assert_not_exists(fp / 'd1' / 'd2' / 'b.secret', label='daemon exclude deep')
+assert_same(fp / 'd1' / 'd2' / 'f2', src / 'd1' / 'd2' / 'f2',
+ label='daemon exclude kept others')
+
+# --- incoming chmod rewrites pushed file modes at depth ---------------------
+subprocess.run(rsync_argv('-a', f'{src}/', f'{url}inc/'),
+ stdout=subprocess.DEVNULL)
+checked = 0
+for rel in rels:
+ p = incdir / rel
+ if p.is_file():
+ assert_mode(p, 0o600, label=f'incoming chmod {rel}')
+ checked += 1
+if checked == 0:
+ test_fail("incoming chmod test transferred no files")
+
+# --- outgoing chmod rewrites pulled file modes at depth ---------------------
+op = SCRATCHDIR / 'outpull'
+pull('out', op)
+for rel in rels:
+ p = op / rel
+ if p.is_file() and (os.stat(p).st_mode & 0o044):
+ test_fail(f"outgoing chmod did not clear group/other read on {rel}: "
+ f"{oct(os.stat(p).st_mode & 0o777)}")
+
+print("daemon-filter: exclude / incoming chmod / outgoing chmod verified at depth")
--- /dev/null
+#!/usr/bin/env python3
+"""Daemon coverage: munge symlinks.
+
+A module with "munge symlinks = yes" stores incoming symlinks with a
+/rsyncd-munged/ prefix (so they can't be used to escape the module) and strips
+that prefix from outgoing symlinks. Verify both directions on a symlink several
+levels deep.
+"""
+
+import os
+import subprocess
+
+from rsyncfns import (
+ FROMDIR, SCRATCHDIR,
+ assert_is_symlink, make_tree, makepath, rmtree, rsync_argv,
+ start_test_daemon, test_fail, write_daemon_conf,
+)
+
+DAEMON_PORT = 12890
+
+src = FROMDIR
+deep = os.path.join('d1', 'd2')
+rmtree(src)
+make_tree(src, depth=3)
+os.symlink('f3', src / deep / 'sl') # deep symlink -> f3
+
+mungedest = SCRATCHDIR / 'mungedest'
+pulled = SCRATCHDIR / 'mungepull'
+for d in (mungedest, pulled):
+ rmtree(d)
+makepath(mungedest, pulled)
+
+conf = write_daemon_conf([
+ ('munge', {'path': mungedest, 'read only': 'no', 'munge symlinks': 'yes'}),
+])
+url = start_test_daemon(conf, DAEMON_PORT)
+
+# --- push: the stored symlink is munged with the /rsyncd-munged/ prefix ------
+subprocess.run(rsync_argv('-al', f'{src}/', f'{url}munge/'),
+ stdout=subprocess.DEVNULL)
+stored = mungedest / deep / 'sl'
+assert_is_symlink(stored, label='munge stored symlink')
+target = os.readlink(stored)
+if target != '/rsyncd-munged/f3':
+ test_fail(f"munge symlinks stored {target!r}, expected '/rsyncd-munged/f3'")
+
+# --- pull: the prefix is stripped back off on the way out -------------------
+subprocess.run(rsync_argv('-al', f'{url}munge/', f'{pulled}/'),
+ stdout=subprocess.DEVNULL)
+out = pulled / deep / 'sl'
+assert_is_symlink(out, target='f3', label='munge stripped on pull')
+
+print("daemon-munge: munge symlinks adds/strips /rsyncd-munged/ at depth")
--- /dev/null
+#!/usr/bin/env python3
+"""Daemon coverage: refuse options (a named option, a wildcard, and the
+allow-list negation form).
+
+daemon-refuse-compress_test.py covers the basic case; this widens it to a
+different named option, a wildcard pattern, and the "* !a !v" allow-list idiom
+documented in rsyncd.conf.5.
+"""
+
+import subprocess
+
+from rsyncfns import (
+ FROMDIR, SCRATCHDIR,
+ make_tree, makepath, rmtree, rsync_argv, run_rsync, start_test_daemon,
+ test_fail, verify_dirs, write_daemon_conf,
+)
+
+DAEMON_PORT = 12891
+
+src = FROMDIR
+rmtree(src)
+make_tree(src, depth=3)
+
+deldir = SCRATCHDIR / 'deldest'
+makepath(deldir)
+
+conf = write_daemon_conf([
+ ('refuse-delete', {'path': deldir, 'read only': 'no',
+ 'refuse options': 'delete'}),
+ ('refuse-wild', {'path': src, 'read only': 'yes',
+ 'refuse options': 'checksum*'}),
+ ('only-av', {'path': src, 'read only': 'yes',
+ 'refuse options': '* !a !v'}),
+])
+url = start_test_daemon(conf, DAEMON_PORT)
+
+
+def refused(args, what):
+ proc = subprocess.run(rsync_argv(*args),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
+ text=True)
+ if proc.returncode == 0:
+ test_fail(f"{what} was not refused")
+ return proc.stderr
+
+
+def allowed(args, what):
+ proc = subprocess.run(rsync_argv(*args),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
+ text=True)
+ if proc.returncode not in (0, 23):
+ test_fail(f"{what} was unexpectedly refused: {proc.stderr}")
+
+
+# --- a named refused option (delete) ----------------------------------------
+refused(['-a', '--delete', f'{src}/', f'{url}refuse-delete/'],
+ "--delete on a refuse=delete module")
+allowed(['-a', f'{src}/', f'{url}refuse-delete/'],
+ "plain push to a refuse=delete module")
+
+# --- a wildcard refused option (checksum*) ----------------------------------
+dest = SCRATCHDIR / 'wilddest'
+makepath(dest)
+refused(['-a', '--checksum', f'{url}refuse-wild/', f'{dest}/'],
+ "--checksum on a refuse=checksum* module")
+
+# --- the "* !a !v" allow-list: -av allowed, -z refused ----------------------
+rmtree(dest)
+makepath(dest)
+allowed(['-av', f'{url}only-av/', f'{dest}/'], "-av on an allow-list module")
+refused(['-avz', f'{url}only-av/', f'{dest}/'],
+ "-z on an allow-list module")
+
+print("daemon-refuse: named / wildcard / allow-list refuse options verified")