Files
rsync/testsuite/daemon-chroot_test.py
Andrew TridgellandGreg Kroah-Hartman 3d3150c24a testsuite: code-scanner daemon/metadata coverage tests + gcov flush before chroot
Adds six coverage tests from the code-scanner run, each closing a measured
gap in a daemon or metadata code path the suite never reached:

  - daemon-include-maxconn: rsyncd.conf &include/&merge directives +
    `max connections`/`lock file` (params.c include_config, connection.c
    claim_connection, util1.c lock_range).
  - fake-super-acl-xattr: --fake-super -A stores ACLs as user.rsync.%aacl/
    %dacl xattrs (acls.c am_root<0 IVAL/SIVAL pack, xattrs.c get/set/
    del_def_xattr_acl); Linux-only (the user.rsync.* namespace).
  - backup-crossdev-copy: make_backup() EXDEV copy-fallback for non-regular
    files (do_symlink_at/do_mknod_at/copy_file); skips without a cross-dev
    tmpfs.
  - daemon-http-proxy: RSYNC_PROXY HTTP CONNECT (socket.c
    establish_proxy_connection + base64 Proxy-Authorization + 503 branch).
  - daemon-module-options: motd file, socket options, incoming/outgoing
    chmod, dont compress, list=no, comment, --sockopts.
  - daemon-chroot: `use chroot = yes` incl. the /outer/./inner split and
    `temp dir`; probes CAP_SYS_CHROOT and skips cleanly without it.

clientserver.c flushes gcov counters just before chroot() in rsync_module()
so the per-connection child's pre-chroot lines reach disk (the build-tree
.gcda paths are unreachable post-chroot); no-op without --enable-coverage.

CI: the require_tcp-gated tests (daemon-chroot/-http-proxy/-module-options)
plus the Linux-only fake-super-acl-xattr and the cross-dev backup-crossdev-copy
are listed in the per-platform RSYNC_EXPECT_SKIPPED sets where they skip on the
pipe-transport make-check jobs.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00

120 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Daemon coverage: `use chroot = yes` and the `path = /outer/./inner`
chroot-with-inner-subdir syntax.
Every other daemon test in the suite forces `use chroot = no` (via
write_daemon_conf()'s globals) so the chroot setup in clientserver.c
rsync_module() -- the use_chroot path-split (~866-887), the actual
chroot(2) call (~1003-1012), and the `temp dir` module option -- was
never reached. Those lines are also where the per-connection child
loses the ability to write its .gcda counters (the build-tree paths
are outside the chroot), so a companion gcov_flush() lands just before
chroot() in clientserver.c; this test exercises that flush point and
everything before it.
Three modules:
chr use chroot = yes, plain path -> 880-887, 1003-1012
chrinner use chroot = yes, path = /outer/./in -> 868-877 split
chrtmp use chroot = yes, temp dir = /tmpd -> 1093-1095 lp_temp_dir
Requires CAP_SYS_CHROOT (root in the test container); skips cleanly
without it.
"""
import ctypes
import os
import subprocess
from rsyncfns import (
SCRATCHDIR, FROMDIR,
claim_ports, get_rootuid, get_testuid, make_tree, makepath, require_tcp,
rmtree, rsync_argv, start_test_daemon, test_fail, test_skipped,
write_daemon_conf,
)
if get_testuid() != get_rootuid():
test_skipped("use chroot = yes needs root (CAP_SYS_CHROOT)")
# Probe chroot() directly so a rootless/seccomp'd container that maps uid 0
# but withholds CAP_SYS_CHROOT skips cleanly instead of failing.
pid = os.fork()
if pid == 0:
try:
ctypes.CDLL(None).chroot(b"/")
os._exit(0)
except Exception:
os._exit(1)
_, st = os.waitpid(pid, 0)
if not (os.WIFEXITED(st) and os.WEXITSTATUS(st) == 0):
test_skipped("chroot(2) not permitted in this environment")
require_tcp("daemon chroot path needs the real start_daemon socket flow")
PORT = 19886
claim_ports(PORT)
src = FROMDIR
rmtree(src)
make_tree(src, depth=1)
# Module roots. For the /./ form the OUTER path is what gets chroot()'d
# and the INNER path is chdir'd inside it.
root_chr = SCRATCHDIR / 'chroot-plain'
root_outer = SCRATCHDIR / 'chroot-outer'
root_inner = root_outer / 'inner'
root_tmp = SCRATCHDIR / 'chroot-tmp'
tmpd = root_tmp / 'tmpd' # `temp dir` is interpreted INSIDE the chroot
for d in (root_chr, root_inner, root_tmp, tmpd):
rmtree(d)
makepath(root_chr, root_inner, root_tmp, tmpd)
mods = [
('chr', {
'path': str(root_chr),
'use chroot': 'yes',
'read only': 'no',
}),
('chrinner', {
# /outer/./inner: chroot to /outer, chdir to /inner inside.
'path': f'{root_outer}/./inner',
'use chroot': 'yes',
'read only': 'no',
}),
('chrtmp', {
'path': str(root_tmp),
'use chroot': 'yes',
'read only': 'no',
# Relative to the chroot root (= module path).
'temp dir': '/tmpd',
}),
]
conf = write_daemon_conf(mods, name='chroot.conf')
url = start_test_daemon(conf, PORT)
def push(module, dest):
r = subprocess.run(rsync_argv('-r', f'{src}/', f'{url}{module}/'),
capture_output=True, text=True)
if r.returncode != 0:
test_fail(f"push to [{module}] (use chroot=yes) failed "
f"(rc={r.returncode}):\n{r.stderr}")
if not any(dest.iterdir()):
test_fail(f"push to [{module}] wrote nothing into {dest}")
push('chr', root_chr)
push('chrinner', root_inner)
push('chrtmp', root_tmp)
# Pull from a chrooted module too (sender side under chroot).
pull_dst = SCRATCHDIR / 'pull-chr'
rmtree(pull_dst); makepath(pull_dst)
r = subprocess.run(rsync_argv('-r', f'{url}chr/', f'{pull_dst}/'),
capture_output=True, text=True)
if r.returncode != 0:
test_fail(f"pull from [chr] failed (rc={r.returncode}):\n{r.stderr}")
if not any(pull_dst.iterdir()):
test_fail("pull from [chr] produced nothing")
print("daemon-chroot: use chroot=yes (plain + /./inner + temp dir) push+pull ok")