mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-14 14:18:23 -04:00
daemon-exclude-namebased bound its daemon on 13010, and the setup_chroot_inner helper hashed into 12940-13139 -- both reach into 13000+, where ASUS Armoury Crate on the Cygwin CI host parks localhost listeners (13010, 13030-13032), making the port probe fail the test. The helper also used str hash(), which is per-process randomized (PYTHONHASHSEED), so its port wandered run to run. Move the fixed port to 12931 and the helper to a deterministic crc32-based slot in the otherwise-unused 12800-12859 band.
2911 lines
117 KiB
Python
2911 lines
117 KiB
Python
"""Shared helpers for rsync's Python test scripts.
|
|
|
|
This is the Python counterpart of testsuite/rsync.fns. It exposes only what
|
|
the Python-rewritten tests actually need; grow it as more shell tests are
|
|
ported.
|
|
|
|
Conventions matching the shell harness:
|
|
* Exit codes (see the Exit enum): 0=pass, 1=fail, 2=error, 77=skip, 78=xfail.
|
|
* The runner sets these environment variables before invoking each test:
|
|
scratchdir per-test scratch directory
|
|
srcdir rsync source directory
|
|
TOOLDIR build directory (holds the rsync binary and helpers)
|
|
RSYNC the rsync command line (may include valgrind / --protocol=N)
|
|
TLS_ARGS extra arguments to pass to the 'tls' helper
|
|
suitedir this directory (testsuite/)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import fcntl
|
|
import filecmp
|
|
import errno
|
|
import math
|
|
import os
|
|
import platform
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket as _socket
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
from exitcodes import Exit # re-exported: tests may `from rsyncfns import Exit`
|
|
|
|
|
|
# --- environment -----------------------------------------------------------
|
|
|
|
def _required(name: str) -> str:
|
|
v = os.environ.get(name)
|
|
if not v:
|
|
sys.stderr.write(
|
|
f"rsyncfns: required environment variable {name} is not set; "
|
|
"run this test via runtests.py rather than directly.\n"
|
|
)
|
|
sys.exit(Exit.ERROR)
|
|
return v
|
|
|
|
|
|
SCRATCHDIR = Path(_required('scratchdir'))
|
|
SRCDIR = Path(_required('srcdir'))
|
|
TOOLDIR = Path(_required('TOOLDIR'))
|
|
SUITEDIR = Path(os.environ.get('suitedir', SRCDIR / 'testsuite'))
|
|
|
|
# rsync.fns set `umask 022` for every shell test, so the suite's expected
|
|
# file/dir modes are computed against that baseline. Mirror it here so the
|
|
# Python tests are deterministic regardless of the caller's ambient umask
|
|
# (e.g. a CI runner with umask 077) -- several permission tests depend on
|
|
# newly-created dirs being 0755. Individual tests may still narrow it (e.g.
|
|
# chmod-option uses 002 for its --chmod comparison).
|
|
os.umask(0o022)
|
|
|
|
# rsync.fns overrides HOME to $scratchdir; tests that exercise ssh-style
|
|
# transfers with no path component (e.g. localhost: at end of args) rely on
|
|
# HOME pointing at the per-test scratch dir.
|
|
os.environ['HOME'] = str(SCRATCHDIR)
|
|
RSYNC = _required('RSYNC') # full command line, possibly with valgrind/protocol
|
|
|
|
# The "peer" rsync command -- used for the SERVER side of two-sided transfers
|
|
# (the daemon process; the remote-shell --rsync-path target). The runner sets
|
|
# RSYNC_PEER to a second binary when invoked with --rsync-bin2, letting a run
|
|
# mix two rsync versions over the wire. When no second binary was selected,
|
|
# RSYNC_PEER == RSYNC, so every consumer below behaves exactly as before and
|
|
# single-binary runs are unchanged. Use .get (not _required) so a test invoked
|
|
# by hand without the runner still works.
|
|
RSYNC_PEER = os.environ.get('RSYNC_PEER', RSYNC)
|
|
|
|
|
|
def split_rsync_cmd(cmd: str) -> list:
|
|
"""Split an rsync command string into argv, tolerating spaces in the path.
|
|
|
|
RSYNC may be a wrapper command ('valgrind --tool=memcheck /build/rsync'),
|
|
which has to be split, or a plain path to the binary, which must not be if
|
|
it contains a space -- shlex.split() would turn '/ws test/rsync' into two
|
|
nonexistent programs. A path that exists is one word by definition, so
|
|
check that first and only fall back to splitting for a real command line.
|
|
|
|
Call this at use time, never once at import: tests such as chown-fake
|
|
append ' --fake-super' to rsyncfns.RSYNC part-way through, and a cached
|
|
split would keep handing back the pre-mutation command.
|
|
"""
|
|
if os.path.isfile(cmd):
|
|
return [cmd]
|
|
# The path may be followed by options -- chown-fake and friends append
|
|
# ' --fake-super' to RSYNC -- so the whole string is no longer a filename.
|
|
# Take the longest leading run that names an existing file as the program
|
|
# and split only what follows.
|
|
for m in reversed(list(re.finditer(r'\s+', cmd))):
|
|
head = cmd[:m.start()]
|
|
if os.path.isfile(head):
|
|
return [head] + shlex.split(cmd[m.start():])
|
|
return shlex.split(cmd)
|
|
|
|
|
|
def _under_valgrind():
|
|
"""True when the runner wrapped rsync in valgrind (runtests.py --valgrind).
|
|
|
|
Match the wrapper's program name (first token of RSYNC or RSYNC_PEER), not a
|
|
bare 'valgrind' substring, so an rsync path that merely contains the word
|
|
does not false-trigger.
|
|
"""
|
|
for cmd in (RSYNC, RSYNC_PEER):
|
|
if os.path.basename(shlex.split(cmd)[0]) == 'valgrind':
|
|
return True
|
|
return False
|
|
|
|
# TLS_ARGS controls how the 'tls' helper formats listings (e.g. --atimes,
|
|
# -l, -L). Tests that exercise non-default rsync features (atimes, etc.)
|
|
# 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'
|
|
|
|
# Budget (seconds) a TOCTOU symlink-race test may spend trying to win its race
|
|
# before giving up. Set by runtests.py --race-timeout; when the operator did not
|
|
# pass it, each test keeps its own default (see race_budget).
|
|
_RACE_TIMEOUT_SET = 'race_timeout' in os.environ
|
|
try:
|
|
RACE_TIMEOUT = float(os.environ.get('race_timeout', '5'))
|
|
except ValueError:
|
|
# A malformed value counts as NOT set, so every test keeps its own default.
|
|
# Falling back to the 5s baseline while still counting as "set" would
|
|
# silently cut a 10s or 15s oracle in half -- weakening a security test
|
|
# that nobody asked to shorten, which is the exact failure this knob's
|
|
# validation exists to prevent.
|
|
RACE_TIMEOUT = 5.0
|
|
_RACE_TIMEOUT_SET = False
|
|
|
|
|
|
def race_budget(default: float = 5.0) -> float:
|
|
"""Seconds this race test may spend trying to provoke an escape.
|
|
|
|
A race test is a NEGATIVE oracle: it passes by *failing* to break in before
|
|
the budget runs out, so it always spends the whole budget. The budget is
|
|
therefore the test's runtime -- these are the suite's slowest tests, which
|
|
is exactly why the knob has to work.
|
|
|
|
Tests needing longer than the 5s baseline to be a credible oracle pass their
|
|
own `default`. An explicit --race-timeout overrides every default, in both
|
|
directions: the old `max(RACE_TIMEOUT, 10.0)` idiom ignored the option below
|
|
10s, so the documented knob did nothing for most of these tests.
|
|
|
|
Lowering the budget weakens the oracle (fewer flips observed = less chance
|
|
of catching a regression), so the defaults here are deliberately generous.
|
|
|
|
A non-positive or non-finite budget is never a legitimate request: the race
|
|
loop would run zero times and the test would report PASS without testing
|
|
anything. runtests.py rejects that at the command line; ignoring it here too
|
|
keeps the guarantee when race_timeout arrives straight from the environment.
|
|
"""
|
|
if _RACE_TIMEOUT_SET and math.isfinite(RACE_TIMEOUT) and RACE_TIMEOUT > 0:
|
|
return RACE_TIMEOUT
|
|
return default
|
|
|
|
# Mnemonics for rsync's itemize-changes (-i / -ii) format:
|
|
# all_plus -> +++++++++ every attribute changed (an additive create)
|
|
# allspace -> every attribute unchanged
|
|
# dots -> ..... trailing dots after the change columns
|
|
all_plus = '+++++++++'
|
|
allspace = ' '
|
|
dots = '.....'
|
|
|
|
# The "$tmpdir/from", "$tmpdir/to", "$tmpdir/chk" layout from rsync.fns.
|
|
TMPDIR = SCRATCHDIR
|
|
FROMDIR = SCRATCHDIR / 'from'
|
|
TODIR = SCRATCHDIR / 'to'
|
|
CHKDIR = SCRATCHDIR / 'chk'
|
|
CHKFILE = SCRATCHDIR / 'rsync.chk'
|
|
OUTFILE = SCRATCHDIR / 'rsync.out'
|
|
|
|
|
|
# --- result reporting ------------------------------------------------------
|
|
|
|
def test_fail(msg: str) -> 'None':
|
|
sys.stderr.write(msg.rstrip() + '\n')
|
|
sys.exit(Exit.FAIL)
|
|
|
|
|
|
def test_skipped(msg: str) -> 'None':
|
|
sys.stderr.write(msg.rstrip() + '\n')
|
|
(TMPDIR / 'whyskipped').write_text(msg.rstrip() + '\n')
|
|
sys.exit(Exit.SKIP)
|
|
|
|
|
|
def test_xfail(msg: str) -> 'None':
|
|
sys.stderr.write(msg.rstrip() + '\n')
|
|
sys.exit(Exit.XFAIL)
|
|
|
|
|
|
# --- rsync invocation ------------------------------------------------------
|
|
|
|
# --- TCP port coordination across parallel tests ---------------------------
|
|
|
|
_PORT_LOCK_PATH = '/tmp/rsync_test.lck'
|
|
_port_lock_fd = None
|
|
_reaped_stale = False
|
|
|
|
# The lock file doubles as a registry of the rsyncd pid bound to each port, so a
|
|
# later run that wins the (orphan-released) lock can find and reap a daemon a
|
|
# SIGKILLed run stranded. The byte-range LOCKS sit at offsets 0..65535 (one byte
|
|
# per port number); the pid RECORDS sit in a separate region past them, one
|
|
# native-endian int32 (a pid_t) per port, written/read only while holding that
|
|
# port's lock so they're never raced. The file is host-local, so native endian is
|
|
# fine; an all-zero record (a sparse/older lock file) reads back as pid 0.
|
|
_PORT_PID_BASE = 1 << 16 # past every possible port lock byte (port < 65536)
|
|
_PORT_PID_REC = 8 # two native-endian int32 per port: (pgid, pid)
|
|
|
|
# Bytes 0..3 hold a magic identifying the lock-file layout. A fresh (all-zero)
|
|
# file gets it written under the byte-0 lock; a non-zero value that doesn't match
|
|
# means a stale file from an incompatible testsuite layout -- we error rather
|
|
# than misread the (pgid, pid) records. Bytes 0..3 also sit in the port-lock byte
|
|
# region, but ports 0..3 are never test ports so the overlap is harmless. Fixed
|
|
# arbitrary value; bump it on any on-disk layout change.
|
|
_LOCK_MAGIC = 0x9d4f2b8a
|
|
|
|
|
|
def _open_lock_file() -> int:
|
|
"""Open (or create) the host-wide port-lock file, defending against a
|
|
local attacker who pre-plants the well-known /tmp path. CI runs some
|
|
tests under sudo, so we must never let root open/chmod an attacker-
|
|
controlled target.
|
|
|
|
Strategy:
|
|
* Try an O_EXCL|O_CREAT create. If we win, the file is brand-new,
|
|
regular, owned by us and nlink==1 -- the ONLY case where we widen
|
|
the mode to 0o666 (so a second user sharing the lock can open it
|
|
RDWR; the create mode is otherwise narrowed by umask).
|
|
* If it already exists, open it WITHOUT O_CREAT, WITHOUT chmod, and
|
|
with O_NOFOLLOW so a planted symlink fails (ELOOP) rather than
|
|
being followed. Then require a pristine regular file with nlink==1,
|
|
rejecting a hard link to some other (e.g. root-owned 0600) file --
|
|
O_NOFOLLOW alone does not catch hard links.
|
|
"""
|
|
nofollow = getattr(os, 'O_NOFOLLOW', 0)
|
|
|
|
# Path 1: we create it ourselves, exclusively.
|
|
try:
|
|
fd = os.open(_PORT_LOCK_PATH,
|
|
os.O_CREAT | os.O_EXCL | os.O_RDWR | nofollow, 0o666)
|
|
except FileExistsError:
|
|
fd = None
|
|
if fd is not None:
|
|
try:
|
|
os.fchmod(fd, 0o666) # we own this fresh file; undo umask
|
|
except OSError:
|
|
pass
|
|
_check_or_write_magic(fd)
|
|
return fd
|
|
|
|
# Path 2: it already exists -- open without creating or chmod'ing.
|
|
try:
|
|
fd = os.open(_PORT_LOCK_PATH, os.O_RDWR | nofollow)
|
|
except OSError as e:
|
|
test_fail(f"cannot open lock file {_PORT_LOCK_PATH}: {e} "
|
|
"(refusing to follow a symlink -- possible tampering)")
|
|
st = os.fstat(fd)
|
|
if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
|
|
os.close(fd)
|
|
test_fail(f"lock file {_PORT_LOCK_PATH} is not a pristine regular "
|
|
f"file (type/nlink check failed -- possible tampering)")
|
|
_check_or_write_magic(fd)
|
|
return fd
|
|
|
|
|
|
def _check_or_write_magic(fd: int) -> 'None':
|
|
"""Validate (or stamp) the layout-version magic in bytes 0..3.
|
|
|
|
Serialise on the byte-0 lock (also port 0's lock byte, never a test port) so
|
|
two starting runs don't race the stamp. An all-zero header is a fresh file --
|
|
write the magic. A non-zero header that doesn't match means a stale lock file
|
|
from an incompatible testsuite layout (e.g. the old 4-byte pid records); error
|
|
out so we never misread its records as (pgid, pid)."""
|
|
fcntl.lockf(fd, fcntl.LOCK_EX, 4, 0)
|
|
try:
|
|
rec = os.pread(fd, 4, 0)
|
|
cur = struct.unpack('=I', rec)[0] if len(rec) == 4 else 0
|
|
if cur == 0:
|
|
os.pwrite(fd, struct.pack('=I', _LOCK_MAGIC), 0)
|
|
elif cur != _LOCK_MAGIC:
|
|
os.close(fd)
|
|
test_fail(f"lock file {_PORT_LOCK_PATH} has layout magic "
|
|
f"{cur:#010x}, expected {_LOCK_MAGIC:#010x} -- a stale file "
|
|
"from an incompatible testsuite. Remove it and retry.")
|
|
except (OSError, struct.error):
|
|
pass
|
|
finally:
|
|
try:
|
|
fcntl.lockf(fd, fcntl.LOCK_UN, 4, 0)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _record_port_proc(port: int, pgid: int, pid: int) -> 'None':
|
|
"""Record (or clear, with pgid==pid==0) the test process group and rsyncd pid
|
|
bound to `port`. The caller holds the port's lock. The pgid reaps the whole
|
|
test (daemon + clients + flipper) with one killpg; the pid is the recycle
|
|
guard (_pid_is_rsync) so we only kill a group still running our rsync."""
|
|
if _port_lock_fd is None:
|
|
return
|
|
try:
|
|
os.pwrite(_port_lock_fd, struct.pack('=ii', pgid, pid),
|
|
_PORT_PID_BASE + port * _PORT_PID_REC)
|
|
except (OSError, struct.error):
|
|
pass
|
|
|
|
|
|
def _read_port_proc(port: int) -> 'tuple':
|
|
"""Read the recorded (pgid, pid) for `port`, or (0, 0) if none. Caller holds
|
|
the lock.
|
|
|
|
Normalises a pid <= 1 to (0, 0): a record holding 0 / negative / garbage must
|
|
NEVER be treated as a real pid (os.kill/os.killpg of 0 or -N would signal a
|
|
whole process group). Only pid > 1 is a candidate, and _pid_is_rsync() still
|
|
verifies it before any kill."""
|
|
if _port_lock_fd is None:
|
|
return (0, 0)
|
|
try:
|
|
rec = os.pread(_port_lock_fd, _PORT_PID_REC,
|
|
_PORT_PID_BASE + port * _PORT_PID_REC)
|
|
if len(rec) != _PORT_PID_REC:
|
|
return (0, 0)
|
|
pgid, pid = struct.unpack('=ii', rec)
|
|
except (OSError, struct.error):
|
|
return (0, 0)
|
|
return (pgid, pid) if pid > 1 else (0, 0)
|
|
|
|
|
|
def _pid_is_rsync(pid: int) -> bool:
|
|
"""True if `pid` is a live process whose command is rsync. Guards against a
|
|
recycled pid before we kill it. Tries `ps -p N -o comm=` (precise, Linux/BSD/
|
|
Solaris/macOS) and falls back to plain `ps -p N` (Cygwin's ps rejects -o but
|
|
still prints the command). If neither confirms it, return False (leave the
|
|
process alone)."""
|
|
if pid <= 1:
|
|
return False # 0/-N would make os.kill signal a whole process group
|
|
if pid == os.getpid():
|
|
return False # never signal ourselves
|
|
try:
|
|
os.kill(pid, 0)
|
|
except OSError:
|
|
return False
|
|
for argv in (['ps', '-p', str(pid), '-o', 'comm='], ['ps', '-p', str(pid)]):
|
|
try:
|
|
r = subprocess.run(argv, stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL, text=True, timeout=5)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
if r.returncode == 0: # ps understood the form -> answer is definitive
|
|
return 'rsync' in r.stdout
|
|
return False # no ps form worked -> don't kill
|
|
|
|
|
|
def _wait_pid_gone(pid: int, timeout: float) -> bool:
|
|
"""Poll up to `timeout` seconds for `pid` to stop being a live rsync.
|
|
|
|
SIGKILL is asynchronous: the process may still be visible for a moment
|
|
after it is signalled, so a single immediate check would call a dying
|
|
process "still alive"."""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
if not _pid_is_rsync(pid):
|
|
return True
|
|
if time.monotonic() >= deadline:
|
|
return False
|
|
time.sleep(0.1)
|
|
|
|
|
|
def _reap_group(pgid: int, pid: int) -> bool:
|
|
"""Kill the test's whole process group (daemon + its clients + flipper) when
|
|
`pid` is still a live rsync -- the portable recycle guard, so we only ever
|
|
signal a group still running our rsync. killpg(pgid) sweeps the group in one
|
|
shot (the test driver runs in its own session, so the group is exactly that
|
|
test's tree); if the pgid is unusable, fall back to killing the daemon pid
|
|
alone.
|
|
|
|
Returns True only when the daemon is CONFIRMED GONE, not merely when a
|
|
signal was accepted. The caller clears the port's registry entry on a True
|
|
return, and that entry is the only handle anyone has on the process; on
|
|
Cygwin a signal is routinely accepted by a process that then ignores it, so
|
|
trusting the accept would discard the record while the port stays squatted
|
|
-- leaving an occupant nothing can identify or reap.
|
|
|
|
The confirmation is a bounded poll, not a single immediate check: SIGKILL is
|
|
asynchronous, so a process still winding down is normal and answering
|
|
"not gone" for it would make _probe_bindable() skip its retry and fail the
|
|
test for a port that was about to free itself."""
|
|
if not _pid_is_rsync(pid):
|
|
return False
|
|
try:
|
|
if pgid > 1 and pgid != os.getpgrp():
|
|
os.killpg(pgid, signal.SIGKILL)
|
|
else:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
if _wait_pid_gone(pid, 2.0):
|
|
return True
|
|
# Survived SIGKILL: on Cygwin that means it is stuck in a Windows call,
|
|
# where only Windows can end it.
|
|
_win_force_kill(pid)
|
|
return _wait_pid_gone(pid, 2.0)
|
|
|
|
|
|
def _reap_orphan_daemon(port: int) -> bool:
|
|
"""Kill an orphaned test process group squatting `port`, if we can identify it.
|
|
|
|
We hold the claim_ports() exclusive lock for `port`, so nothing we coordinate
|
|
with owns it -- a still-bound port is an orphan a SIGKILLed run stranded (off
|
|
Linux there's no PR_SET_PDEATHSIG backstop, so its --no-detach rsyncd outlives
|
|
the test). start_rsyncd recorded that test's (pgid, pid); if the pid is still a
|
|
live rsync, killpg the group. Returns True if it signalled something (caller
|
|
re-probes the bind). Pure os/ps calls -> every platform."""
|
|
pgid, pid = _read_port_proc(port)
|
|
if not _reap_group(pgid, pid):
|
|
return False
|
|
_record_port_proc(port, 0, 0)
|
|
time.sleep(0.2) # let the kernel release the socket before the re-probe
|
|
return True
|
|
|
|
|
|
def _reap_stale_daemons() -> 'None':
|
|
"""Intra-run sweep: kill every orphaned test rsyncd recorded in the lock file
|
|
whose port-lock is free (no live test owns it), and clear its record.
|
|
|
|
_reap_orphan_daemon() only fires when a NEW test claims the *same* port an
|
|
orphan still squats; a daemon a SIGKILLed/timed-out test stranded on a port
|
|
nothing else re-claims would otherwise linger for the whole run (off Linux
|
|
there's no PR_SET_PDEATHSIG backstop), accumulating and exhausting ports until
|
|
a later race test wedges. This sweeps the whole pid registry so each test
|
|
process reaps the leaks left by earlier ones.
|
|
|
|
Run once per test process at the first claim_ports(), BEFORE this process has
|
|
recorded any daemon of its own, so it never kills our own rsyncd. A port a
|
|
live concurrent test holds keeps its byte-lock, so LOCK_NB skips it; only a
|
|
free-locked port with a recorded live rsync pid is a genuine orphan."""
|
|
if _port_lock_fd is None:
|
|
return
|
|
try:
|
|
size = os.fstat(_port_lock_fd).st_size
|
|
except OSError:
|
|
return
|
|
if size <= _PORT_PID_BASE:
|
|
return
|
|
try:
|
|
region = os.pread(_port_lock_fd, size - _PORT_PID_BASE, _PORT_PID_BASE)
|
|
except OSError:
|
|
return
|
|
for port in range(min(len(region) // _PORT_PID_REC, 65536)):
|
|
pgid, pid = struct.unpack('=ii', region[port*_PORT_PID_REC:(port+1)*_PORT_PID_REC])
|
|
if pid <= 1:
|
|
continue
|
|
# Grab the port's byte-lock non-blocking: success => no live test owns it.
|
|
try:
|
|
fcntl.lockf(_port_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB, 1, port)
|
|
except OSError:
|
|
continue # a live test holds it -- not an orphan, leave it alone
|
|
try:
|
|
if _reap_group(pgid, pid):
|
|
_record_port_proc(port, 0, 0)
|
|
finally:
|
|
try:
|
|
fcntl.lockf(_port_lock_fd, fcntl.LOCK_UN, 1, port)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _probe_bindable(port: int, _reaped: bool = False, fatal: bool = True) -> bool:
|
|
"""Confirm `port` is actually free once we hold its claim_ports() lock.
|
|
|
|
The byte-range lock only coordinates *live* test drivers, and the kernel
|
|
releases it the instant the holding process dies -- even if that driver left
|
|
an orphaned daemon still bound to the port. That happens when a run is
|
|
SIGKILLed (or its ssh drops) on a platform with no parent-death backstop:
|
|
rsyncfns only arms PR_SET_PDEATHSIG, which is Linux-only, so on the
|
|
BSDs/Solaris/macOS a killed fleettest run can strand its rsyncd, which then
|
|
squats the fixed test port. Because we recorded that rsyncd's pid in the lock
|
|
file (and hold the lock now, proving it's not a live run), we can reap it and
|
|
retry rather than failing -- see _reap_orphan_daemon.
|
|
|
|
So actually try to bind it. SO_REUSEADDR is used so a port merely in
|
|
TIME_WAIT (recently and cleanly closed) is NOT a false positive; only a
|
|
live bound/listening socket -- a real squatter -- makes the bind fail. The
|
|
probe socket is closed immediately, freeing the port for the daemon that is
|
|
about to bind it.
|
|
"""
|
|
s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
s.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
|
|
try:
|
|
s.bind(('127.0.0.1', port))
|
|
return True
|
|
except OSError as e:
|
|
err = e
|
|
finally:
|
|
s.close()
|
|
# Bound by a squatter. If it's our own stranded orphan, kill it and retry once.
|
|
if not _reaped and _reap_orphan_daemon(port):
|
|
return _probe_bindable(port, _reaped=True, fatal=fatal)
|
|
if not fatal:
|
|
return False
|
|
test_fail(
|
|
f"port {port} was claimed for this run but something is still bound "
|
|
f"to 127.0.0.1:{port} ({err.strerror}). The claim_ports() lock only "
|
|
"serializes live test runs, so a still-bound port almost always "
|
|
"means an orphaned 'rsync --daemon' from a previously killed run "
|
|
f"(find it with `fstat | grep {port}` / `netstat -an | grep {port}` "
|
|
"and kill it, or run `fleettest.py --cleanup`), then retry.")
|
|
return False
|
|
|
|
|
|
def claim_ports(*ports: int) -> 'None':
|
|
"""Reserve the given TCP port numbers for the rest of this process.
|
|
|
|
Uses POSIX byte-range locks on /tmp/rsync_test.lck (one byte per port,
|
|
offset = port number) so that any number of tests can run in parallel
|
|
without colliding on a port: if another test has already claimed any of
|
|
the requested ports the call blocks until that test exits. The kernel
|
|
drops POSIX advisory locks automatically when the holding process
|
|
terminates, so a crashed test releases its ports without manual
|
|
cleanup.
|
|
|
|
Ports are claimed in sorted order, so two callers that ask for the same
|
|
set in different orders can't deadlock against each other.
|
|
|
|
Call once near the top of any test that binds to a specific TCP port,
|
|
BEFORE the bind:
|
|
|
|
from rsyncfns import claim_ports
|
|
claim_ports(12873)
|
|
listener = socket.socket(...)
|
|
listener.bind(('127.0.0.1', 12873))
|
|
|
|
The lock file lives in /tmp so it's shared across all rsync test
|
|
processes on the host. Ports outside the claim_ports() ecosystem are
|
|
not protected -- nothing stops an unrelated process from binding the
|
|
port. For the rsync testsuite that's fine; we just need to avoid
|
|
collisions between concurrent test scripts.
|
|
"""
|
|
global _port_lock_fd, _reaped_stale
|
|
if _port_lock_fd is None:
|
|
_port_lock_fd = _open_lock_file()
|
|
if not _reaped_stale:
|
|
# Intra-run cleanup: reap any daemon an earlier test in this run stranded,
|
|
# BEFORE we record one of our own. Once per process is enough.
|
|
_reaped_stale = True
|
|
_reap_stale_daemons()
|
|
for port in sorted(ports):
|
|
# F_SETLKW via fcntl.lockf(LOCK_EX, length, start): exclusive
|
|
# byte-range lock on byte `port`, blocking until acquired.
|
|
fcntl.lockf(_port_lock_fd, fcntl.LOCK_EX, 1, port)
|
|
# The lock only proves no other live test run owns the port; an orphaned
|
|
# daemon from a killed run can still squat it (see _probe_bindable).
|
|
_probe_bindable(port)
|
|
|
|
|
|
def claim_free_port(preferred: int) -> int:
|
|
"""Claim `preferred`, or a nearby port if something else is squatting it.
|
|
|
|
claim_ports() fails loudly on an occupied port, which is right for a test
|
|
that binds the port itself: it must not silently drift away from the number
|
|
it is about to bind. start_test_daemon() owns both the bind and the URL it
|
|
hands back, so it can simply move instead -- and needs to, because a fixed
|
|
test port can be permanently held by unrelated software on a shared CI box
|
|
(a vendor service was found sitting on 13010 on the Windows/Cygwin target),
|
|
which no amount of orphan reaping will free.
|
|
|
|
Returns the port actually claimed.
|
|
"""
|
|
global _port_lock_fd, _reaped_stale
|
|
if _port_lock_fd is None:
|
|
_port_lock_fd = _open_lock_file()
|
|
if not _reaped_stale:
|
|
_reaped_stale = True
|
|
_reap_stale_daemons()
|
|
candidates = [preferred] + [preferred + off for off in (1000, 2000, 3000, 4000)]
|
|
for port in candidates:
|
|
if not 1024 < port < 65536:
|
|
continue
|
|
fcntl.lockf(_port_lock_fd, fcntl.LOCK_EX, 1, port)
|
|
if _probe_bindable(port, fatal=False):
|
|
return port
|
|
test_fail(
|
|
f"no usable TCP port near {preferred}: every candidate "
|
|
f"({', '.join(str(p) for p in candidates)}) is bound by something "
|
|
"outside the testsuite. Check with `netstat -an` and free one, or "
|
|
"run `fleettest.py --cleanup` if they are stranded test daemons.")
|
|
return preferred
|
|
|
|
|
|
# --- 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.
|
|
|
|
The daemon deliberately stays in the TEST's process group: runtests.py
|
|
killpg's that group when a test times out, and that is what keeps a
|
|
timed-out test from stranding its daemon. Giving the daemon a group of
|
|
its own would put it out of reach of that sweep; its per-connection
|
|
children are handled by _daemon_children() instead."""
|
|
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 _win_force_kill(pid: int) -> 'None':
|
|
"""Cygwin last resort: terminate `pid` through Windows.
|
|
|
|
Cygwin signals are cooperative -- delivered via a helper thread in the
|
|
target -- so a process sitting in a Windows call ignores even SIGKILL.
|
|
Such a process keeps its listening socket and cannot be reaped by any
|
|
amount of kill/killpg/pkill. Map the cygwin pid to its Windows pid (the
|
|
4th column of `ps -W`) and let Windows do it. A no-op elsewhere."""
|
|
if not sys.platform.startswith('cygwin'):
|
|
return
|
|
try:
|
|
r = subprocess.run(['ps', '-W'], capture_output=True, text=True, timeout=10)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return
|
|
for line in r.stdout.splitlines():
|
|
f = line.split()
|
|
if len(f) >= 4 and f[0] == str(pid):
|
|
try:
|
|
subprocess.run(['taskkill', '/F', '/PID', f[3]],
|
|
capture_output=True, timeout=10)
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return
|
|
|
|
|
|
def _daemon_children(pid: int) -> list:
|
|
"""PIDs whose parent is `pid` -- the connection handlers rsyncd forked.
|
|
|
|
Cygwin's ps understands neither -A nor -o, but `ps -W` prints
|
|
PID PPID PGID WINPID as its first four columns; everywhere else POSIX
|
|
`ps -A -o pid=,ppid=` does the job. Returns [] if neither works: the
|
|
caller then just kills the parent, which is the old behaviour."""
|
|
argv = (['ps', '-W'] if sys.platform.startswith('cygwin')
|
|
else ['ps', '-A', '-o', 'pid=,ppid='])
|
|
try:
|
|
r = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return []
|
|
kids = []
|
|
for line in r.stdout.splitlines():
|
|
f = line.split()
|
|
if len(f) >= 2 and f[1] == str(pid):
|
|
try:
|
|
kids.append(int(f[0]))
|
|
except ValueError:
|
|
pass
|
|
return kids
|
|
|
|
|
|
def _kill_pid(pid: int) -> 'None':
|
|
"""SIGTERM, then SIGKILL, then -- on Cygwin -- Windows.
|
|
|
|
Gated on _pid_is_rsync at every step: these pids were snapshotted before the
|
|
parent was killed, so between the snapshot and the signal a child can exit
|
|
and the kernel can hand its pid to something else. Re-checking that the pid
|
|
is STILL a live rsync before each signal keeps a recycled pid from getting
|
|
the harness to kill an unrelated process.
|
|
|
|
KNOWN LIMITATION: check-then-signal is inherently a TOCTOU, so the window is
|
|
narrowed to microseconds rather than closed -- the pid would have to be
|
|
recycled onto ANOTHER rsync in that gap to matter. Closing it properly needs
|
|
a stable process reference (pidfd, or a retained Windows handle), which is
|
|
per-platform machinery this suite has to run on seven of; the same guard is
|
|
what _reap_group() has always used."""
|
|
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
if not _pid_is_rsync(pid):
|
|
return # gone, or the pid now belongs to someone else
|
|
try:
|
|
os.kill(pid, sig)
|
|
except OSError:
|
|
return # already gone
|
|
time.sleep(0.3)
|
|
if _pid_is_rsync(pid):
|
|
_win_force_kill(pid)
|
|
|
|
|
|
def _stop_rsyncd(proc) -> 'None':
|
|
"""Stop the test daemon AND the connection handlers it forked.
|
|
|
|
Killing proc alone -- the only pid the Popen handle knows -- is what left
|
|
the orphan: rsyncd forks a child per connection, and one still winding up
|
|
or down when the test ends outlives the parent, inherits the listening
|
|
socket and squats the port. Snapshot the children BEFORE killing the
|
|
parent, because once it is gone they are reparented to init and no longer
|
|
identifiable as ours.
|
|
|
|
KNOWN LIMITATION: that is also why nothing is collected when the parent has
|
|
ALREADY exited on its own (it crashed, say) while a child still holds the
|
|
port -- by then the parent-child link this relies on is gone. The runner's
|
|
per-test-timeout killpg covers the common case, since the daemon stays in
|
|
the test's process group; `fleettest.py --cleanup` sweeps the rest."""
|
|
if proc.poll() is not None:
|
|
return
|
|
kids = _daemon_children(proc.pid)
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
proc.kill()
|
|
proc.wait(timeout=1)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
pass
|
|
_win_force_kill(proc.pid)
|
|
except OSError:
|
|
pass
|
|
for kid in kids:
|
|
_kill_pid(kid)
|
|
|
|
|
|
def _cleanup_rsyncd(proc, port: int) -> 'None':
|
|
"""atexit handler: stop the daemon and clear its pid slot. A clean exit thus
|
|
leaves no orphan to reap; only a SIGKILL (which skips atexit) leaves the slot
|
|
set -- exactly the case _reap_orphan_daemon() needs it for.
|
|
|
|
The slot is kept only while it still NAMES something reapable -- i.e. our
|
|
daemon pid is somehow still a live rsync, which on Cygwin means it ignored
|
|
the kill. That is the only case where the record buys a later run anything.
|
|
|
|
Keying this on the port being busy instead looks safer but is worse: a port
|
|
sits in TIME_WAIT after a passing test, so a plain bind fails and a record
|
|
naming an already-dead pid is retained forever. Nothing ever clears such a
|
|
record, and if that pid is later recycled onto an unrelated rsync the stale
|
|
sweep would signal it. A record naming a dead process cannot help anyone --
|
|
every reaper rejects it at the _pid_is_rsync guard -- so clearing it is
|
|
strictly better.
|
|
|
|
KNOWN LIMITATION: if the daemon parent died on its own while a connection
|
|
child still held the port, that child is unreachable either way -- the
|
|
record only ever named the parent. `fleettest.py --cleanup` sweeps it."""
|
|
_stop_rsyncd(proc)
|
|
if not _pid_is_rsync(proc.pid):
|
|
_record_port_proc(port, 0, 0)
|
|
|
|
|
|
def start_rsyncd(conf_path, port: int, rsync_cmd: str = None) -> '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`.
|
|
|
|
rsync_cmd selects the binary to run as the daemon; it defaults to
|
|
RSYNC_PEER (the peer side of a two-sided run), so ordinary daemon tests
|
|
get current-client <-> peer-daemon. The reverse-direction test passes
|
|
rsync_cmd=RSYNC to put the current build on the daemon side and drive with
|
|
the old client.
|
|
|
|
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_cmd or RSYNC_PEER) + [
|
|
'--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,
|
|
)
|
|
# Record this test's process group (os.getpgrp() -- the daemon and its clients
|
|
# and flipper all live in the per-test session runtests.py started) together
|
|
# with this --no-detach rsyncd's pid, while we still hold the port's lock, so a
|
|
# later test/run can killpg the whole stranded tree (see _reap_orphan_daemon).
|
|
_record_port_proc(port, os.getpgrp(), proc.pid)
|
|
atexit.register(_cleanup_rsyncd, proc, port)
|
|
|
|
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, rsync_cmd: str = None) -> str:
|
|
"""Bring up the test daemon and return a URL prefix for client commands.
|
|
|
|
rsync_cmd selects the daemon-side binary (default RSYNC_PEER); pass
|
|
rsync_cmd=RSYNC for the reverse-direction test (current daemon, old client).
|
|
|
|
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.
|
|
"""
|
|
daemon_cmd = rsync_cmd or RSYNC_PEER
|
|
if USE_TCP:
|
|
port = claim_free_port(port)
|
|
start_rsyncd(conf_path, port, daemon_cmd)
|
|
return f'rsync://localhost:{port}/'
|
|
# RSYNC_CONNECT_PROG is run by a shell, so every word has to survive
|
|
# re-parsing: a build path with a space would otherwise exec its prefix.
|
|
os.environ['RSYNC_CONNECT_PROG'] = (
|
|
f'{rsync_path_arg(daemon_cmd)} --config={shlex.quote(str(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 require_asan(reason: str, which: str = None) -> 'None':
|
|
"""Skip the test (exit 77) unless the rsync binary is AddressSanitizer-
|
|
instrumented. `which` defaults to the daemon/peer command (RSYNC_PEER);
|
|
pass RSYNC to check the client side. Detection runs the binary with
|
|
ASAN_OPTIONS=help=1, which makes an instrumented binary print the ASan
|
|
flag help banner to stderr."""
|
|
cmd = split_rsync_cmd(which or RSYNC_PEER)
|
|
try:
|
|
r = subprocess.run(cmd + ['--version'],
|
|
env={**os.environ, 'ASAN_OPTIONS': 'help=1'},
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
timeout=15)
|
|
except Exception:
|
|
test_skipped(reason)
|
|
return
|
|
if b'AddressSanitizer' not in r.stderr:
|
|
test_skipped(reason)
|
|
|
|
|
|
def rsh_cmd(cmd: str = None, *opts: str) -> str:
|
|
"""Build an RSYNC_RSH / --rsh value, quoted for rsync's own tokenizer.
|
|
|
|
rsync splits this string on spaces itself -- honouring ' and ", see do_cmd()
|
|
in main.c -- so a remote-shell path containing a space must be quoted or
|
|
rsync execs only the first word. The testsuite's srcdir can contain one.
|
|
"""
|
|
if cmd is None:
|
|
cmd = str(SRCDIR / 'support' / 'lsh.sh')
|
|
return ' '.join([shlex.quote(cmd), *opts])
|
|
|
|
|
|
def rsync_path_arg(cmd: str = None) -> str:
|
|
"""Value for --rsync-path, quoted for the shell that will re-parse it.
|
|
|
|
--rsync-path is a command line run by the remote shell, not a filename, so
|
|
rsync hands it over unquoted and the far side word-splits it. A build path
|
|
containing a space therefore needs quoting here, while a wrapper command
|
|
('valgrind ... /build/rsync') must stay several words. Splitting and
|
|
re-joining with shlex gives both: each word is quoted only if it needs it.
|
|
"""
|
|
return shlex.join(split_rsync_cmd(RSYNC_PEER if cmd is None else cmd))
|
|
|
|
|
|
def rsync_argv(*args: str) -> list:
|
|
"""Return the argv for invoking rsync with the given extra arguments.
|
|
|
|
RSYNC may be a multi-word command (e.g. 'valgrind ... /build/rsync'); we
|
|
shlex-split it so subprocess sees a proper argv list. Each *args entry
|
|
is appended verbatim, so callers should pass tokens already split (no
|
|
embedded option/value joined by spaces).
|
|
"""
|
|
return split_rsync_cmd(RSYNC) + list(args)
|
|
|
|
|
|
import functools as _functools
|
|
|
|
|
|
@_functools.lru_cache(maxsize=64)
|
|
def rsync_supports(flag: str) -> bool:
|
|
"""Does the configured rsync binary accept ``flag``?
|
|
|
|
Probes by invoking ``rsync <flag> --version`` and checking the exit code +
|
|
stderr. C rsync accepts every flag we'd care about and exits 0 before
|
|
--version prints; other implementations (gokrazy/rsync, openrsync) reject
|
|
unsupported flags with "unknown option" / "unrecognized option" /
|
|
"no such option" and a non-zero exit.
|
|
|
|
Used by tests that want to *optionally* pass a hardening flag like
|
|
`--no-inc-recursive` (only meaningful where the implementation has
|
|
incremental recursion to disable). When the probe is inconclusive (e.g.
|
|
timeout) the helper returns True so tests fall back to today's C-rsync
|
|
behaviour.
|
|
"""
|
|
try:
|
|
r = subprocess.run(rsync_argv(flag, '--version'),
|
|
capture_output=True, text=True, timeout=5)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
return True
|
|
if r.returncode == 0:
|
|
return True
|
|
stderr = (r.stderr or '').lower()
|
|
for marker in ('unknown option', 'unrecognized option', 'no such option'):
|
|
if marker in stderr:
|
|
return False
|
|
# Non-zero exit but no recognizable "unknown" marker -- assume supported.
|
|
return True
|
|
|
|
|
|
def forced_protocol():
|
|
"""The protocol version pinned via --protocol=N in the RSYNC command, or
|
|
None when the run isn't pinning one (so the binary negotiates its newest).
|
|
Protocol-sensitive tests use this to gate sub-cases -- e.g. the split
|
|
between --append and --append-verify only exists at protocol >= 30; at
|
|
protocol 29 plain --append behaves like the old verifying append."""
|
|
import re
|
|
m = re.search(r'--protocol[ =](\d+)', RSYNC)
|
|
return int(m.group(1)) if m else None
|
|
|
|
|
|
def run_rsync(*args: str, check: bool = True,
|
|
capture_output: bool = False) -> subprocess.CompletedProcess:
|
|
"""Run rsync with the given arguments.
|
|
|
|
By default, stdout/stderr inherit (so the runner captures them in the
|
|
per-test log). Set capture_output=True if the test needs to inspect the
|
|
output. If check is True (the default), a non-zero exit calls
|
|
test_fail() with the rsync command line.
|
|
"""
|
|
argv = rsync_argv(*args)
|
|
if capture_output:
|
|
proc = subprocess.run(argv, capture_output=True, text=True)
|
|
else:
|
|
proc = subprocess.run(argv)
|
|
if check and proc.returncode != 0:
|
|
test_fail(f"rsync exited {proc.returncode}: {' '.join(argv)}")
|
|
return proc
|
|
|
|
|
|
# --- filesystem helpers ----------------------------------------------------
|
|
|
|
def makepath(*paths) -> 'None':
|
|
"""Equivalent of rsync.fns makepath: mkdir -p, but for multiple paths."""
|
|
for p in paths:
|
|
os.makedirs(p, exist_ok=True)
|
|
|
|
|
|
def rmtree(path) -> 'None':
|
|
"""Remove a tree if it exists, ignoring missing entries."""
|
|
p = Path(path)
|
|
if p.exists() or p.is_symlink():
|
|
shutil.rmtree(p, ignore_errors=True)
|
|
|
|
|
|
def is_a_link(path) -> bool:
|
|
"""True if 'path' is a symbolic link (dangling or not)."""
|
|
return os.path.islink(path)
|
|
|
|
|
|
def start_path_flipper(name_a, name_b):
|
|
"""Spawn a separate PROCESS that repeatedly swaps two sibling paths
|
|
name_a <-> name_b in a tight rename loop, for TOCTOU symlink-race tests:
|
|
point one at a real directory and the other at a symlink so the shared name
|
|
keeps flipping between a directory and a symlink under a running rsync.
|
|
|
|
A separate process (not a thread) is used deliberately: a Python thread
|
|
contends with the test's own loop for the GIL and flips far too slowly to
|
|
win the race. The swap is three renames via a scratch name in the same
|
|
directory, so the shared name is absent only for the brief instant between
|
|
two renames (rsync just gets ENOENT and retries).
|
|
|
|
The caller should stop it with stop_flipper(), but the flipper also
|
|
self-terminates: it exits when its parent (the test process) goes away --
|
|
os.getppid() changes once the test is reaped -- and after a hard deadline as a
|
|
backstop. Without this, a test killed before its stop_flipper() finally (a
|
|
timeout, a crash) would leak an orphan that keeps renaming paths in the shared
|
|
scratch and poisons later tests on the same box. os.getppid() is POSIX, so
|
|
this is portable across the fleet.
|
|
|
|
Returns a subprocess.Popen; the caller must stop it with stop_flipper()."""
|
|
code = (
|
|
"import os, sys, time\n"
|
|
"a, b = sys.argv[1], sys.argv[2]\n"
|
|
"tmp = a + '.flip'\n"
|
|
"parent = os.getppid()\n"
|
|
"deadline = time.monotonic() + 300\n"
|
|
"while os.getppid() == parent and time.monotonic() < deadline:\n"
|
|
" try:\n"
|
|
" os.rename(a, tmp); os.rename(b, a); os.rename(tmp, b)\n"
|
|
" except OSError:\n"
|
|
" pass\n"
|
|
)
|
|
return subprocess.Popen([sys.executable, '-c', code, str(name_a), str(name_b)])
|
|
|
|
|
|
def stop_flipper(proc):
|
|
"""Stop a start_path_flipper()/start_c_flipper() process."""
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
|
|
# A compiled flipper wins TOCTOU races far more reliably than the Python one:
|
|
# on a journaled disk fs it does ~2x the swaps/sec with a plain rename loop and
|
|
# ~7x with renameat2(RENAME_EXCHANGE) -- which is also denser, as EXCHANGE is a
|
|
# single atomic syscall with no transient missing-name window. Compiled on
|
|
# demand against the build's config.h so the renameat2/portability guards match
|
|
# the target; falls back to the Python flipper where no compiler is available.
|
|
_C_FLIPPER_SRC = r'''
|
|
/* testsuite flipper: repeatedly swap two sibling names a<->b so a shared path
|
|
* keeps flipping (typically real-dir <-> symlink) under a running rsync.
|
|
* Prefers atomic renameat2(RENAME_EXCHANGE); falls back to a 3-rename dance.
|
|
* Self-terminates when its parent (the test) goes away, plus a deadline
|
|
* backstop, so a killed test never leaks an orphan that poisons later tests.
|
|
* Built on demand by rsyncfns.compile_c_flipper(); not linked into rsync. */
|
|
#define _GNU_SOURCE 1
|
|
#include "config.h"
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
#include <fcntl.h>
|
|
#include <errno.h>
|
|
#include <time.h>
|
|
#include <sys/stat.h>
|
|
#if defined(__linux__)
|
|
# include <sys/syscall.h>
|
|
# ifndef RENAME_EXCHANGE
|
|
# define RENAME_EXCHANGE (1 << 1)
|
|
# endif
|
|
#endif
|
|
|
|
static double mono(void) {
|
|
struct timespec t;
|
|
clock_gettime(CLOCK_MONOTONIC, &t);
|
|
return t.tv_sec + t.tv_nsec / 1e9;
|
|
}
|
|
|
|
static int use_exchange = 1;
|
|
|
|
static void flip(const char *a, const char *b) {
|
|
#if defined(__linux__) && defined(SYS_renameat2)
|
|
if (use_exchange) {
|
|
if (syscall(SYS_renameat2, AT_FDCWD, a, AT_FDCWD, b, RENAME_EXCHANGE) == 0)
|
|
return;
|
|
if (errno == ENOSYS || errno == EINVAL || errno == EOPNOTSUPP)
|
|
use_exchange = 0; /* kernel or filesystem lacks EXCHANGE */
|
|
else
|
|
return; /* transient race error (e.g. ENOENT): retry */
|
|
}
|
|
#endif
|
|
{
|
|
char tmp[4096];
|
|
if (snprintf(tmp, sizeof tmp, "%s.flip", a) >= (int)sizeof tmp)
|
|
return; /* too long: don't act on a truncated name */
|
|
rmdir(tmp); unlink(tmp); /* clear a stale scratch from a wedged half-swap */
|
|
if (rename(a, tmp) != 0) {
|
|
mkdir(a, 0700); /* a was consumed: recreate so the next loop swaps */
|
|
return;
|
|
}
|
|
if (rename(b, a) != 0)
|
|
rename(tmp, a); /* b gone: restore a, retry next loop */
|
|
else
|
|
rename(tmp, b); /* complete the swap */
|
|
}
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc < 3) {
|
|
fprintf(stderr, "usage: %s PATH_A PATH_B\n", argv[0]);
|
|
return 2;
|
|
}
|
|
const char *a = argv[1], *b = argv[2];
|
|
pid_t parent = getppid();
|
|
double deadline = mono() + 300.0; /* backstop if never reaped */
|
|
while (getppid() == parent && mono() < deadline)
|
|
flip(a, b);
|
|
return 0;
|
|
}
|
|
'''
|
|
|
|
_c_flipper_bin = None # None=untried, ''=unavailable, else path
|
|
|
|
|
|
def _detect_cc():
|
|
"""The C compiler the tree was built with (so the flipper matches the
|
|
target), falling back to a plain cc/gcc/clang on PATH."""
|
|
cc = os.environ.get('CC')
|
|
if cc:
|
|
return cc
|
|
import re
|
|
# The generated Makefile lives in the BUILD dir (TOOLDIR); for a VPATH build
|
|
# that is not SRCDIR. Prefer TOOLDIR, fall back to SRCDIR for an in-tree build.
|
|
for d in (TOOLDIR, SRCDIR):
|
|
mk = d / 'Makefile'
|
|
if mk.is_file():
|
|
m = re.search(r'(?m)^CC\s*=\s*(.+?)\s*$', mk.read_text())
|
|
if m and m.group(1):
|
|
return m.group(1)
|
|
for c in ('cc', 'gcc', 'clang'):
|
|
if shutil.which(c):
|
|
return c
|
|
return None
|
|
|
|
|
|
def compile_c_flipper():
|
|
"""Build (once, cached) the C flipper against the build's config.h. Returns
|
|
its path, or None if no compiler is available (caller uses the Python flipper)."""
|
|
global _c_flipper_bin
|
|
if _c_flipper_bin is not None:
|
|
return _c_flipper_bin or None
|
|
cc = _detect_cc()
|
|
src = SCRATCHDIR / 't_flipper.c'
|
|
out = SCRATCHDIR / ('t_flipper' + ('.exe' if os.name == 'nt' else ''))
|
|
if not cc:
|
|
_c_flipper_bin = ''
|
|
return None
|
|
src.write_text(_C_FLIPPER_SRC)
|
|
# config.h is generated into the BUILD dir (TOOLDIR); the Makefile compiles
|
|
# with `-I. -I$(srcdir)`, so mirror that (TOOLDIR first, then SRCDIR).
|
|
cmd = (shlex.split(cc)
|
|
+ ['-O2', f'-I{TOOLDIR}', f'-I{SRCDIR}', '-o', str(out), str(src)])
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True)
|
|
if proc.returncode != 0 or not os.access(out, os.X_OK):
|
|
_c_flipper_bin = ''
|
|
return None
|
|
_c_flipper_bin = str(out)
|
|
return _c_flipper_bin
|
|
|
|
|
|
def start_c_flipper(name_a, name_b):
|
|
"""Like start_path_flipper() but execs the compiled flipper for a far higher
|
|
flip rate (a stronger RED oracle on slow filesystems). Transparently falls
|
|
back to the Python flipper where no compiler is available. Returns a Popen;
|
|
stop with stop_flipper()."""
|
|
binpath = compile_c_flipper()
|
|
if binpath:
|
|
return subprocess.Popen([binpath, str(name_a), str(name_b)])
|
|
return start_path_flipper(name_a, name_b)
|
|
|
|
|
|
def cp_p(src, dst) -> 'None':
|
|
"""Equivalent of rsync.fns cp_p: copy preserving mode + timestamps."""
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
def make_data_file(path, size: int) -> 'None':
|
|
"""Equivalent of rsync.fns make_data_file: create `path` with `size`
|
|
bytes of non-trivial content suitable for rsync's delta algorithm.
|
|
|
|
Prefers /dev/urandom for speed. Falls back to a deterministic LCG
|
|
seeded from PID and the destination path so successive calls produce
|
|
distinct content -- matching the shell helper.
|
|
"""
|
|
path = str(path)
|
|
if os.path.exists('/dev/urandom'):
|
|
try:
|
|
with open('/dev/urandom', 'rb') as src, open(path, 'wb') as dst:
|
|
remaining = size
|
|
while remaining:
|
|
chunk = src.read(min(remaining, 1 << 16))
|
|
if not chunk:
|
|
break
|
|
dst.write(chunk)
|
|
remaining -= len(chunk)
|
|
if remaining == 0:
|
|
return
|
|
except OSError:
|
|
pass
|
|
|
|
# Fallback: BSD-LCG to printable-ASCII (33..126), so output stays
|
|
# exactly `size` bytes regardless of awk/utf8 quirks the shell
|
|
# version worked around.
|
|
path_seed = int.from_bytes(path.encode(), 'big') & 0xFFFFFFFF
|
|
state = (os.getpid() + path_seed) % 2147483648
|
|
with open(path, 'wb') as f:
|
|
out = bytearray(size)
|
|
for i in range(size):
|
|
state = (state * 1103515245 + 12345) % 2147483648
|
|
out[i] = ((state >> 16) % 94) + 33
|
|
f.write(bytes(out))
|
|
|
|
|
|
def make_text_file(path, lines: int = 100) -> 'None':
|
|
"""Write a predictable, self-contained text file of `lines` lines.
|
|
|
|
This replaces the old habit of capturing `ls -l /etc` / `ls -l /bin`
|
|
(falling back to `ls /`) into the test tree. Those tied the fixtures
|
|
to the host filesystem layout: the directories are absent or
|
|
unreadable on Android/Termux and other minimal environments, where
|
|
`ls /` fails outright, and the captured content was never
|
|
reproducible. The output here is deterministic and depends on nothing
|
|
outside the suite, so every platform builds the identical fixture.
|
|
"""
|
|
content = ''.join(
|
|
"line %06d the quick brown fox jumps over the lazy dog %d %d\n"
|
|
% (i, (i * 31) % 97, (i * 131) % 89)
|
|
for i in range(1, lines + 1)
|
|
)
|
|
with open(str(path), 'w') as f:
|
|
f.write(content)
|
|
|
|
|
|
def get_testuid() -> int:
|
|
return os.getuid()
|
|
|
|
|
|
def get_rootuid() -> int:
|
|
return 0
|
|
|
|
|
|
def get_rootgid() -> int:
|
|
return 0
|
|
|
|
|
|
def build_rsyncd_conf() -> 'Path':
|
|
"""Equivalent of rsync.fns build_rsyncd_conf.
|
|
|
|
Writes $scratchdir/test-rsyncd.conf with the four standard modules
|
|
(test-from, test-to, test-scratch, test-hidden) and a $scratchdir/
|
|
ignore23 wrapper that propagates rsync's exit status except for
|
|
code 23 (vanished/missing source files), which it eats so that the
|
|
surrounding test can tolerate the partial-transfer case.
|
|
|
|
Returns the path to the config file. Tests typically follow up by
|
|
setting RSYNC_CONNECT_PROG so rsync forks an in-tree daemon instead
|
|
of contacting one over the network.
|
|
"""
|
|
conf = SCRATCHDIR / 'test-rsyncd.conf'
|
|
pidfile = SCRATCHDIR / 'rsyncd.pid'
|
|
logfile = SCRATCHDIR / 'rsyncd.log'
|
|
|
|
my_uid = get_testuid()
|
|
root_uid = get_rootuid()
|
|
root_gid = get_rootgid()
|
|
|
|
if my_uid != root_uid:
|
|
# Non-root cannot specify uid/gid in rsyncd.conf.
|
|
uid_line = f"#uid = {root_uid}"
|
|
gid_line = f"#gid = {root_gid}"
|
|
else:
|
|
uid_line = f"uid = {root_uid}"
|
|
gid_line = f"gid = {root_gid}"
|
|
|
|
conf.write_text(f"""\
|
|
# rsyncd configuration file autogenerated by rsyncfns.build_rsyncd_conf
|
|
|
|
pid file = {pidfile}
|
|
use chroot = no
|
|
munge symlinks = no
|
|
# 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
|
|
# to the internal static string (since we had a crash trying to tweak it).
|
|
exclude = ? foobar.baz
|
|
max verbosity = 4
|
|
{uid_line}
|
|
{gid_line}
|
|
|
|
[test-from]
|
|
\tpath = {FROMDIR}
|
|
\tlog format = %i %h [%a] %m (%u) %l %f%L
|
|
\tread only = yes
|
|
\tcomment = r/o
|
|
|
|
[test-to]
|
|
\tpath = {TODIR}
|
|
\tlog format = %i %h [%a] %m (%u) %l %f%L
|
|
\tread only = no
|
|
\tcomment = r/w
|
|
|
|
[test-scratch]
|
|
\tpath = {SCRATCHDIR}
|
|
\tlog format = %i %h [%a] %m (%u) %l %f%L
|
|
\tread only = no
|
|
|
|
[test-hidden]
|
|
\tpath = {FROMDIR}
|
|
\tlist = no
|
|
""")
|
|
|
|
ignore23 = SCRATCHDIR / 'ignore23'
|
|
ignore23.write_text(
|
|
'#!/bin/sh\n'
|
|
'if "${@}"; then exit; fi\n'
|
|
'ret=$?\n'
|
|
'if test $ret = 23; then exit; fi\n'
|
|
'exit $ret\n'
|
|
)
|
|
ignore23.chmod(0o755)
|
|
|
|
return conf
|
|
|
|
|
|
def rsync_getgroups() -> list:
|
|
"""List of group ids the test user is a member of, via the getgroups
|
|
test helper binary. Mirrors rsync.fns rsync_getgroups."""
|
|
out = subprocess.check_output([str(TOOLDIR / 'getgroups')], text=True)
|
|
return out.split()
|
|
|
|
|
|
# --- extended attributes (per-OS surface) ----------------------------------
|
|
# Mirrors the per-OS xset/xls/RSYNC_PREFIX/RUSR logic from the old
|
|
# testsuite/rsync.fns + xattrs.test so the xattr / fake-super tests run on
|
|
# Linux, macOS and FreeBSD (not just Linux). Test attributes use literal
|
|
# names ("user.foo" etc., exactly as the shell did on every platform); only
|
|
# rsync's own fake-super attribute name (RSYNC_PREFIX, used for the
|
|
# "%stat" attr) and the special "equal" attr (RUSR) vary by OS.
|
|
|
|
_SYSTEM = platform.system()
|
|
|
|
# Cygwin reports "CYGWIN_NT-10.0-..." and uses Linux-style user.* xattrs
|
|
# (rsync builds there with HAVE_LINUX_XATTRS), but CPython on Cygwin lacks
|
|
# os.*xattr, so we drive the getfattr/setfattr CLIs there instead.
|
|
_CYGWIN = _SYSTEM.startswith('CYGWIN')
|
|
|
|
# Platforms whose user xattrs live in the "user." namespace encoded in the
|
|
# attribute name (Linux and Cygwin). macOS/FreeBSD carry the namespace out
|
|
# of band and a literal "user." prefix is actually rejected there.
|
|
_LINUX_NS = _SYSTEM == 'Linux' or _CYGWIN
|
|
|
|
# Test attribute names are LOGICAL (un-prefixed, e.g. "foo", "rsync.%stat");
|
|
# _xattr_full() adds the "user." prefix on the Linux-namespace platforms.
|
|
# RSYNC_PREFIX is the logical name of rsync's own fake-super attr ("rsync"
|
|
# -> "rsync.%stat", and "user.rsync.%stat" on Linux/Cygwin). RUSR is the
|
|
# prefix for the test's "equal" attr; macOS and Solaris use "rsync.nonuser"
|
|
# to stay clear of rsync's reserved "rsync.*" space.
|
|
RSYNC_PREFIX = 'rsync'
|
|
RUSR = 'rsync.nonuser' if _SYSTEM in ('Darwin', 'SunOS') else 'rsync'
|
|
|
|
|
|
def _xattr_full(name: str) -> str:
|
|
"""Map a logical user-xattr name to the on-disk name for this OS."""
|
|
return ('user.' + name) if _LINUX_NS else name
|
|
|
|
|
|
def xattrs_supported() -> bool:
|
|
"""True if this rsync was built with xattr support AND this platform has
|
|
a way for the tests to set/list user xattrs."""
|
|
vv = run_rsync('-VV', check=True, capture_output=True).stdout
|
|
if '"xattrs": true' not in vv:
|
|
return False
|
|
if _SYSTEM == 'Linux':
|
|
return hasattr(os, 'setxattr')
|
|
if _CYGWIN:
|
|
return shutil.which('setfattr') is not None
|
|
if _SYSTEM == 'Darwin':
|
|
return shutil.which('xattr') is not None
|
|
if _SYSTEM == 'FreeBSD':
|
|
return shutil.which('setextattr') is not None
|
|
if _SYSTEM == 'SunOS':
|
|
return shutil.which('runat') is not None
|
|
return False # NetBSD/etc.: not yet ported
|
|
|
|
|
|
class XattrError(OSError):
|
|
"""An xattr CLI refused an operation.
|
|
|
|
Deliberately an OSError: on Linux xattr_set() calls os.setxattr(), where a
|
|
refusal IS an OSError, so callers write `except OSError`. Every other
|
|
platform shells out, and a bare CalledProcessError -- not an OSError --
|
|
sails straight past that handler. A refusal the suite tolerates on Linux
|
|
would then be fatal everywhere else.
|
|
|
|
.errno is set only when the tool named one. Most of these tools report a
|
|
message rather than a number, and guessing an errno from localised
|
|
strerror text would be worse than admitting we do not know, so a caller
|
|
that switches on .errno must cope with None.
|
|
"""
|
|
|
|
|
|
def _tool_errno(tool: str, msg: str) -> 'int | None':
|
|
"""The errno a tool named for itself, or None if it named none.
|
|
|
|
Only macOS's xattr(1) reports one, as "xattr: [Errno 13] Permission
|
|
denied: '/some/path'". Match that tool's own prefix, and only on the
|
|
first line: the rest of the line is a filename, and a file can perfectly
|
|
well be called "[Errno 5]" -- searching the whole message would let the
|
|
file being operated on dictate the errno we report.
|
|
"""
|
|
if tool != 'xattr' or not msg:
|
|
return None
|
|
import re
|
|
m = re.match(r'xattr: \[Errno (\d+)\]', msg.splitlines()[0])
|
|
return int(m.group(1)) if m else None
|
|
|
|
|
|
def _xattr_run(argv, **kwargs) -> 'None':
|
|
"""Run an xattr CLI, raising XattrError rather than CalledProcessError."""
|
|
proc = subprocess.run(argv, capture_output=True, text=True, **kwargs)
|
|
if proc.returncode == 0:
|
|
return
|
|
msg = (proc.stderr or proc.stdout or '').strip()
|
|
err = XattrError(f'{argv[0]} exited {proc.returncode}'
|
|
+ (f': {msg}' if msg else ''))
|
|
err.errno = _tool_errno(argv[0], msg)
|
|
raise err
|
|
|
|
|
|
def xattr_set(name: str, value: str, *paths) -> 'None':
|
|
"""Set the user-namespace xattr `name` (logical) = `value` on each path.
|
|
|
|
Raises OSError (see XattrError) on every platform if the set is refused."""
|
|
full = _xattr_full(name)
|
|
for p in paths:
|
|
p = str(p)
|
|
if _SYSTEM == 'Linux':
|
|
os.setxattr(p, full.encode(), value.encode())
|
|
elif _CYGWIN:
|
|
_xattr_run(['setfattr', '-n', full, '-v', value, p])
|
|
elif _SYSTEM == 'Darwin':
|
|
_xattr_run(['xattr', '-w', full, value, p])
|
|
elif _SYSTEM == 'FreeBSD':
|
|
_xattr_run(['setextattr', '-h', 'user', full, value, p])
|
|
elif _SYSTEM == 'SunOS':
|
|
# Solaris extended attributes are a per-file namespace; runat
|
|
# cd's into it and runs a shell that reads the script on stdin
|
|
# (the -c form mangles args). Pass name/value via the environment
|
|
# to dodge quoting; printf writes the value with no trailing
|
|
# newline, matching the byte-exact value other platforms store.
|
|
_xattr_run(
|
|
['runat', p, '/bin/sh'],
|
|
input='printf %s "$XVAL" > "$XNAME"\n',
|
|
env={**os.environ, 'XNAME': full, 'XVAL': value})
|
|
else:
|
|
raise NotImplementedError(f"xattr_set on {_SYSTEM}")
|
|
|
|
|
|
def xattr_dump(*paths) -> str:
|
|
"""Return a deterministic name=value dump of the user xattrs on `paths`,
|
|
for comparing a source tree against its rsync'd copy. The format only
|
|
needs to be self-consistent on a given OS (we never compare across OSes),
|
|
mirroring the per-OS xls() in the old xattrs.test."""
|
|
if _SYSTEM == 'Linux':
|
|
# Read xattrs natively (symmetric with the os.setxattr used in
|
|
# xattr_set) so the suite needs no external getfattr. The attr
|
|
# package's CLI tools are frequently absent -- on Android/Termux
|
|
# and minimal CI images -- even when the filesystem itself supports
|
|
# user xattrs, in which case shelling out to getfattr would crash
|
|
# the test instead of exercising it. The output mimics "getfattr
|
|
# -d": a "# file:" header then sorted name="value" lines, files
|
|
# with no user xattrs omitted.
|
|
out = []
|
|
for p in paths:
|
|
sp = str(p)
|
|
names = sorted(n for n in os.listxattr(sp) if n.startswith('user.'))
|
|
if not names:
|
|
continue
|
|
out.append(f'# file: {sp}\n')
|
|
for n in names:
|
|
v = os.getxattr(sp, n).decode('utf-8', 'surrogateescape')
|
|
out.append(f'{n}="{v}"\n')
|
|
out.append('\n')
|
|
return ''.join(out)
|
|
if _CYGWIN:
|
|
# Python on Cygwin lacks os.*xattr, so use the CLI there.
|
|
return subprocess.check_output(
|
|
['getfattr', '-d', *(str(p) for p in paths)], text=True)
|
|
if _SYSTEM == 'Darwin':
|
|
out = []
|
|
for p in paths:
|
|
t = subprocess.check_output(['xattr', '-l', str(p)], text=True)
|
|
out.append('\n'.join(ln.lstrip(' \t') for ln in t.splitlines()))
|
|
out.append('\n')
|
|
return ''.join(out)
|
|
if _SYSTEM == 'FreeBSD':
|
|
out = []
|
|
for p in paths:
|
|
names = subprocess.check_output(
|
|
['lsextattr', '-q', '-h', 'user', str(p)], text=True).split()
|
|
for n in sorted(names):
|
|
out.append(subprocess.check_output(
|
|
['getextattr', '-h', 'user', n, str(p)], text=True))
|
|
return ''.join(out)
|
|
if _SYSTEM == 'SunOS':
|
|
# List the file's extended-attribute namespace via runat (script on
|
|
# stdin), skipping the always-present SUNWattr_* system attrs, and
|
|
# dump name=value (sorted glob order; $(cat) drops a trailing newline).
|
|
script = ('for x in *; do case "$x" in SUNWattr_*) continue;; esac; '
|
|
'printf "%s=%s\\n" "$x" "$(cat "$x")"; done\n')
|
|
out = []
|
|
for p in paths:
|
|
out.append(subprocess.run(
|
|
['runat', str(p), '/bin/sh'], input=script,
|
|
capture_output=True, text=True, check=True).stdout)
|
|
return ''.join(out)
|
|
raise NotImplementedError(f"xattr_dump on {_SYSTEM}")
|
|
|
|
|
|
def runtest(label: str, fn, *args, **kwargs):
|
|
"""Run a sub-test step with an echoed label, like rsync.fns runtest.
|
|
|
|
The shell helper does `Test $1: $2 ... done.` -- this prints a similar
|
|
banner and propagates exceptions (which surface as a failing test).
|
|
"""
|
|
print(f"Test {label}: ", end="", flush=True)
|
|
fn(*args, **kwargs)
|
|
print("done.")
|
|
|
|
|
|
def cp_touch(src, dst) -> 'None':
|
|
"""Equivalent of rsync.fns cp_touch: copy preserving timestamps, then
|
|
forcibly re-touch both source and destination to identical times.
|
|
|
|
On some filesystems cp rounds microsecond timestamps on the destination;
|
|
rsync.fns works around this by then `touch -r dst src dst`. Here we set
|
|
both src and dst to dst's mtime/atime after the copy, so a diff of the
|
|
tls output (which prints times) sees identical entries on both sides.
|
|
"""
|
|
shutil.copy2(src, dst)
|
|
if os.path.isdir(dst):
|
|
dst = os.path.join(dst, os.path.basename(src))
|
|
st = os.stat(dst, follow_symlinks=False)
|
|
os.utime(src, ns=(st.st_atime_ns, st.st_mtime_ns), follow_symlinks=False)
|
|
os.utime(dst, ns=(st.st_atime_ns, st.st_mtime_ns), follow_symlinks=False)
|
|
|
|
|
|
def build_symlinks() -> 'None':
|
|
"""Equivalent of rsync.fns build_symlinks: a set of canonical relative,
|
|
absolute, dangling and unsafe symlinks under FROMDIR for symlink tests.
|
|
"""
|
|
FROMDIR.mkdir(parents=True, exist_ok=True)
|
|
(FROMDIR / 'referent').write_text(
|
|
subprocess.check_output(['date'], text=True)
|
|
)
|
|
os.symlink('referent', FROMDIR / 'relative')
|
|
os.symlink(str(FROMDIR / 'referent'), FROMDIR / 'absolute')
|
|
os.symlink('nonexistent', FROMDIR / 'dangling')
|
|
os.symlink(str(SRCDIR / 'rsync.c'), FROMDIR / 'unsafe')
|
|
|
|
|
|
def hands_setup() -> 'None':
|
|
"""Populate FROMDIR with a varied tree of files and directories for the
|
|
canonical 'hands' transfer test.
|
|
|
|
All content is generated from within the suite (srcdir contents plus
|
|
make_text_file output) so the fixture is self-contained and
|
|
reproducible on every platform.
|
|
"""
|
|
rmtree(FROMDIR)
|
|
rmtree(TODIR)
|
|
TMPDIR.mkdir(parents=True, exist_ok=True)
|
|
FROMDIR.mkdir(parents=True, exist_ok=True)
|
|
TODIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
(FROMDIR / 'empty').touch()
|
|
(FROMDIR / 'emptydir').mkdir(exist_ok=True)
|
|
|
|
# File list of srcdir contents, generated through the tls helper so it
|
|
# matches the format the rest of the suite uses.
|
|
(FROMDIR / 'filelist').write_text(rsync_ls_lR(SRCDIR))
|
|
|
|
# The shell test uses `echo -n` semantics; write_text without a trailing
|
|
# newline is the cleanest equivalent.
|
|
(FROMDIR / 'nolf').write_text("This file has no trailing lf")
|
|
|
|
old_umask = os.umask(0)
|
|
try:
|
|
os.symlink('nolf', FROMDIR / 'nolf-symlink')
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
# Concatenate all *.c files in srcdir into a single 'text' file.
|
|
text = bytearray()
|
|
for c in sorted(SRCDIR.glob('*.c')):
|
|
text.extend(c.read_bytes())
|
|
(FROMDIR / 'text').write_bytes(bytes(text))
|
|
|
|
(FROMDIR / 'dir').mkdir(exist_ok=True)
|
|
shutil.copy(FROMDIR / 'text', FROMDIR / 'dir')
|
|
(FROMDIR / 'dir' / 'subdir').mkdir(exist_ok=True)
|
|
(FROMDIR / 'dir' / 'subdir' / 'foobar.baz').write_text("some data\n")
|
|
(FROMDIR / 'dir' / 'subdir' / 'subsubdir').mkdir(exist_ok=True)
|
|
# Predictable, self-contained fixture files (the names etc-ltr-list /
|
|
# bin-lt-list are kept because other tests reference them by name).
|
|
make_text_file(FROMDIR / 'dir' / 'subdir' / 'subsubdir' / 'etc-ltr-list', 120)
|
|
|
|
(FROMDIR / 'dir' / 'subdir' / 'subsubdir2').mkdir(exist_ok=True)
|
|
make_text_file(FROMDIR / 'dir' / 'subdir' / 'subsubdir2' / 'bin-lt-list', 200)
|
|
|
|
|
|
# --- listing / verification ------------------------------------------------
|
|
|
|
def rsync_ls_lR(directory) -> str:
|
|
"""Equivalent of rsync.fns rsync_ls_lR: print a sorted ls-style listing
|
|
of `directory`, pruning .git / auto-build-save / testtmp subtrees, using
|
|
the project's `tls` helper so the output format matches the rest of the
|
|
suite.
|
|
"""
|
|
cmd = (
|
|
"find . -name .git -prune -o -name auto-build-save -prune "
|
|
"-o -name testtmp -prune -o -print | sort | sed 's/ /\\\\ /g' | "
|
|
f"xargs '{TOOLDIR}/tls' {TLS_ARGS}"
|
|
)
|
|
# tls can emit bytes that are not valid UTF-8 (a filename or symlink target
|
|
# with high bytes); decode with backslashreplace so Python 3.14's strict
|
|
# UTF-8 text mode doesn't raise UnicodeDecodeError. Unlike surrogateescape
|
|
# this yields a clean str (bad bytes shown as \xNN) that callers can safely
|
|
# write_text()/print() without re-raising, and it is deterministic so the
|
|
# listing still compares consistently.
|
|
proc = subprocess.run(['sh', '-c', cmd], capture_output=True,
|
|
encoding='utf-8', errors='backslashreplace',
|
|
cwd=str(directory))
|
|
return proc.stdout
|
|
|
|
|
|
def checkit(args, expected_dir, actual_dir, skip_file_diff: bool = False,
|
|
allowed_codes=(0,)) -> 'None':
|
|
"""Run rsync with `args` (a list of extra rsync arguments) and then
|
|
verify two things:
|
|
|
|
1. The tls-formatted listings of `expected_dir` and `actual_dir`
|
|
are identical.
|
|
2. (Unless skip_file_diff) diff -r against the two trees reports
|
|
no differences.
|
|
|
|
`allowed_codes` is the tuple of exit codes treated as success.
|
|
Pass (0, 23) for daemon-mode transfers that may report partial-
|
|
transfer codes even when the listings still match.
|
|
|
|
Calls test_fail() on any mismatch. Mirrors the rsync.fns checkit shell
|
|
helper; callers pass rsync arguments as a Python list rather than as a
|
|
pre-quoted command string, which avoids the shell-quoting gymnastics
|
|
that the shell version needed.
|
|
"""
|
|
expected_dir = str(expected_dir)
|
|
actual_dir = str(actual_dir)
|
|
|
|
failed = []
|
|
|
|
# If TLS_ARGS asks for atimes, the listing must be captured BEFORE the
|
|
# rsync run because diff'ing files afterwards updates their atimes.
|
|
ls_from = None
|
|
if '--atimes' in TLS_ARGS:
|
|
ls_from = rsync_ls_lR(expected_dir)
|
|
|
|
print(f"Running: rsync {' '.join(args)}")
|
|
proc = subprocess.run(rsync_argv(*args))
|
|
if proc.returncode not in allowed_codes:
|
|
failed.append(f"status={proc.returncode}")
|
|
|
|
if ls_from is None:
|
|
ls_from = rsync_ls_lR(expected_dir)
|
|
ls_to = rsync_ls_lR(actual_dir)
|
|
|
|
print("-------------")
|
|
print("check how the directory listings compare with diff:")
|
|
print()
|
|
if ls_from != ls_to:
|
|
ls_from_path = TMPDIR / 'ls-from'
|
|
ls_to_path = TMPDIR / 'ls-to'
|
|
ls_from_path.write_text(ls_from)
|
|
ls_to_path.write_text(ls_to)
|
|
diff = subprocess.run(
|
|
['diff', '-u', str(ls_from_path), str(ls_to_path)],
|
|
capture_output=True, text=True,
|
|
)
|
|
sys.stdout.write(diff.stdout)
|
|
failed.append("dir-diff")
|
|
|
|
print("-------------")
|
|
print("check how the files compare with diff:")
|
|
print()
|
|
if skip_file_diff:
|
|
print(" === Skipping (as directed) ===")
|
|
else:
|
|
diff = subprocess.run(['diff', '-r', '-u', expected_dir, actual_dir])
|
|
if diff.returncode != 0:
|
|
failed.append("file-diff")
|
|
|
|
print("-------------")
|
|
if failed:
|
|
test_fail("Failed: " + " ".join(failed))
|
|
|
|
|
|
def verify_dirs(expected_dir, actual_dir, skip_file_diff: bool = False,
|
|
label: str = '') -> 'None':
|
|
"""Verify two directory trees match: identical tls listings and
|
|
(unless skip_file_diff) identical file contents. Same comparison
|
|
logic as checkit() but with no rsync invocation -- useful when the
|
|
rsync that produced `actual_dir` had to be driven manually so that
|
|
its output could be captured for inspection."""
|
|
expected_dir = str(expected_dir)
|
|
actual_dir = str(actual_dir)
|
|
tag = f"{label}: " if label else ""
|
|
|
|
ls_expected = rsync_ls_lR(expected_dir)
|
|
ls_actual = rsync_ls_lR(actual_dir)
|
|
if ls_expected != ls_actual:
|
|
ls_expected_path = TMPDIR / 'ls-from'
|
|
ls_actual_path = TMPDIR / 'ls-to'
|
|
ls_expected_path.write_text(ls_expected)
|
|
ls_actual_path.write_text(ls_actual)
|
|
diff = subprocess.run(
|
|
['diff', '-u', str(ls_expected_path), str(ls_actual_path)],
|
|
capture_output=True, text=True,
|
|
)
|
|
sys.stdout.write(diff.stdout)
|
|
test_fail(f"{tag}directory listings differ between "
|
|
f"{expected_dir} and {actual_dir}")
|
|
|
|
if not skip_file_diff:
|
|
diff = subprocess.run(['diff', '-r', '-u', expected_dir, actual_dir])
|
|
if diff.returncode != 0:
|
|
test_fail(f"{tag}file content differs between "
|
|
f"{expected_dir} and {actual_dir}")
|
|
|
|
|
|
def v_filt(text: str) -> str:
|
|
"""Strip the boilerplate lines rsync emits at -v / -vv so callers can
|
|
diff only the file/directory change lines. Mirrors rsync.fns v_filt:
|
|
delete the build/progress banners, then everything from the first
|
|
blank line to end-of-text."""
|
|
out = []
|
|
skip_prefix = (
|
|
'building file list ',
|
|
'sending incremental file list',
|
|
'created directory ',
|
|
'total: ',
|
|
'client charset: ',
|
|
'server charset: ',
|
|
)
|
|
for line in text.splitlines():
|
|
if line == '':
|
|
break
|
|
if line.startswith(skip_prefix):
|
|
continue
|
|
if line == 'done':
|
|
continue
|
|
if line.endswith(' --whole-file'):
|
|
continue
|
|
out.append(line)
|
|
return '\n'.join(out) + ('\n' if out else '')
|
|
|
|
|
|
def checkdiff(args, expected: str, *, filter=None, allowed_codes=(0,),
|
|
direct: bool = False) -> 'None':
|
|
"""Run a command, capture its stdout, optionally pipe through `filter`,
|
|
then compare to `expected`. Mirrors rsync.fns checkdiff/checkdiff2.
|
|
|
|
args is normally a list of rsync arguments -- the rsync binary is
|
|
prepended via rsync_argv. Pass direct=True to run `args` as a literal
|
|
command (used by tests that drive a wrapper such as BATCH.sh).
|
|
"""
|
|
if direct:
|
|
argv = list(args)
|
|
label = ' '.join(argv)
|
|
else:
|
|
argv = rsync_argv(*args)
|
|
label = 'rsync ' + ' '.join(args)
|
|
print(f"Running: {label}")
|
|
proc = subprocess.run(argv, capture_output=True, text=True)
|
|
stdout = proc.stdout
|
|
if proc.stderr:
|
|
sys.stderr.write(proc.stderr)
|
|
sys.stdout.write(stdout)
|
|
|
|
failed = []
|
|
if proc.returncode not in allowed_codes:
|
|
failed.append(f"status={proc.returncode}")
|
|
|
|
if filter is not None:
|
|
stdout = filter(stdout)
|
|
|
|
if stdout != expected:
|
|
from difflib import unified_diff
|
|
diff = unified_diff(
|
|
expected.splitlines(keepends=True),
|
|
stdout.splitlines(keepends=True),
|
|
fromfile='expected', tofile='got',
|
|
)
|
|
sys.stdout.write(''.join(diff))
|
|
failed.append("output differs")
|
|
|
|
if failed:
|
|
test_fail("Failed: " + " ".join(failed))
|
|
|
|
|
|
def check_perms(path, expected: str) -> 'None':
|
|
"""Verify that the 9-char rwx permission string of `path` matches
|
|
`expected` (e.g. 'rwx------'). Calls test_fail() on mismatch."""
|
|
mode = os.stat(path, follow_symlinks=False).st_mode
|
|
bits = [
|
|
(0o400, 'r'), (0o200, 'w'), (0o100, 'x'),
|
|
(0o040, 'r'), (0o020, 'w'), (0o010, 'x'),
|
|
(0o004, 'r'), (0o002, 'w'), (0o001, 'x'),
|
|
]
|
|
chars = [c if mode & bit else '-' for bit, c in bits]
|
|
# Layer the setuid/setgid/sticky bits over x as the long-listing format does.
|
|
if mode & 0o4000:
|
|
chars[2] = 's' if mode & 0o100 else 'S'
|
|
if mode & 0o2000:
|
|
chars[5] = 's' if mode & 0o010 else 'S'
|
|
if mode & 0o1000:
|
|
chars[8] = 't' if mode & 0o001 else 'T'
|
|
perms = ''.join(chars)
|
|
if perms != expected:
|
|
print(f"permissions: {perms} on {path}")
|
|
print(f"should be: {expected}")
|
|
test_fail(f"check_perms failed for {path}")
|
|
|
|
|
|
# --- depth / cross-dir coverage helpers ------------------------------------
|
|
# Added for the option-coverage expansion (see testsuite/COVERAGE.md).
|
|
# The path-handling restructure changes how parent components resolve, so its
|
|
# bugs surface only at DEPTH and across directory boundaries -- these helpers
|
|
# build trees with an entry at every level and assert the concrete property an
|
|
# option controls (not just dest == src).
|
|
|
|
def make_tree(root, depth: int = 3, *, data: bool = False,
|
|
content_lines: int = 20, data_size: int = 4096,
|
|
dirname: str = 'd', leaf: str = 'f'):
|
|
"""Create a layered directory tree with one regular file at every level.
|
|
|
|
For depth=3 under `root`:
|
|
root/f0
|
|
root/d1/f1
|
|
root/d1/d2/f2
|
|
root/d1/d2/d3/f3
|
|
so an option's effect can be checked at the tree root AND >=3 levels deep
|
|
(the parent-component resolution the path restructure rewrites).
|
|
|
|
Returns (dirs, files): `dirs` the created subdirectories outermost-first,
|
|
`files` the regular files shallow-first. Content is deterministic
|
|
(make_text_file) unless data=True (make_data_file, delta-friendly).
|
|
"""
|
|
root = Path(root)
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
dirs = []
|
|
files = []
|
|
cur = root
|
|
for level in range(depth + 1):
|
|
f = cur / f'{leaf}{level}'
|
|
if data:
|
|
make_data_file(f, data_size)
|
|
else:
|
|
make_text_file(f, content_lines)
|
|
files.append(f)
|
|
if level < depth:
|
|
cur = cur / f'{dirname}{level + 1}'
|
|
cur.mkdir(exist_ok=True)
|
|
dirs.append(cur)
|
|
return dirs, files
|
|
|
|
|
|
def walk_files(root) -> list:
|
|
"""Every regular (non-symlink) file under `root`, sorted, recursively.
|
|
For asserting a per-entry property holds at every depth."""
|
|
root = Path(root)
|
|
return sorted(p for p in root.rglob('*')
|
|
if p.is_file() and not p.is_symlink())
|
|
|
|
|
|
def walk_dirs(root) -> list:
|
|
"""Every subdirectory under `root`, sorted, recursively."""
|
|
root = Path(root)
|
|
return sorted(p for p in root.rglob('*')
|
|
if p.is_dir() and not p.is_symlink())
|
|
|
|
|
|
def _tag(label: str) -> str:
|
|
return f"{label}: " if label else ""
|
|
|
|
|
|
def assert_same(a, b, label: str = '') -> 'None':
|
|
"""Fail unless files `a` and `b` have byte-identical content."""
|
|
if not filecmp.cmp(str(a), str(b), shallow=False):
|
|
test_fail(f"{_tag(label)}content differs between {a} and {b}")
|
|
|
|
|
|
def assert_mode(path, expected_octal: int, label: str = '') -> 'None':
|
|
"""Fail unless the permission bits (low 12) of `path` equal expected_octal
|
|
(pass an int like 0o644). Does not follow symlinks."""
|
|
mode = stat.S_IMODE(os.stat(path, follow_symlinks=False).st_mode)
|
|
if mode != expected_octal:
|
|
test_fail(f"{_tag(label)}mode {mode:04o} != expected "
|
|
f"{expected_octal:04o} on {path}")
|
|
|
|
|
|
def assert_mtime_close(a, b, tol: float = 1.0, label: str = '') -> 'None':
|
|
"""Fail unless the mtimes of `a` and `b` are within `tol` seconds.
|
|
`b` may be a number (an explicit epoch mtime) instead of a path."""
|
|
ma = os.stat(a, follow_symlinks=False).st_mtime
|
|
mb = b if isinstance(b, (int, float)) else os.stat(
|
|
b, follow_symlinks=False).st_mtime
|
|
if abs(ma - mb) > tol:
|
|
test_fail(f"{_tag(label)}mtime {ma} vs {mb} differ by > {tol}s "
|
|
f"(checking {a})")
|
|
|
|
|
|
def assert_is_symlink(path, target: str = None, label: str = '') -> 'None':
|
|
"""Fail unless `path` is a symlink (optionally pointing exactly at
|
|
`target`)."""
|
|
if not os.path.islink(path):
|
|
test_fail(f"{_tag(label)}{path} is not a symlink")
|
|
if target is not None:
|
|
actual = os.readlink(path)
|
|
if actual != target:
|
|
test_fail(f"{_tag(label)}{path} -> {actual!r}, "
|
|
f"expected {target!r}")
|
|
|
|
|
|
def assert_hardlinked(a, b, label: str = '') -> 'None':
|
|
"""Fail unless `a` and `b` are the same inode (a hard link / --link-dest
|
|
result)."""
|
|
sa = os.stat(a, follow_symlinks=False)
|
|
sb = os.stat(b, follow_symlinks=False)
|
|
if (sa.st_dev, sa.st_ino) != (sb.st_dev, sb.st_ino):
|
|
test_fail(f"{_tag(label)}{a} and {b} are not hard-linked "
|
|
f"(ino {sa.st_ino} vs {sb.st_ino})")
|
|
|
|
|
|
def assert_not_hardlinked(a, b, label: str = '') -> 'None':
|
|
"""Fail if `a` and `b` share an inode (e.g. --copy-dest must copy, not
|
|
link)."""
|
|
sa = os.stat(a, follow_symlinks=False)
|
|
sb = os.stat(b, follow_symlinks=False)
|
|
if (sa.st_dev, sa.st_ino) == (sb.st_dev, sb.st_ino):
|
|
test_fail(f"{_tag(label)}{a} and {b} unexpectedly share "
|
|
f"inode {sa.st_ino}")
|
|
|
|
|
|
def assert_exists(path, label: str = '') -> 'None':
|
|
"""Fail unless `path` exists (a symlink counts even if dangling)."""
|
|
if not os.path.lexists(path):
|
|
test_fail(f"{_tag(label)}{path} does not exist")
|
|
|
|
|
|
def assert_not_exists(path, label: str = '') -> 'None':
|
|
"""Fail if `path` exists (a dangling symlink counts as existing)."""
|
|
if os.path.lexists(path):
|
|
test_fail(f"{_tag(label)}{path} exists but should not")
|
|
|
|
|
|
_psf_cache = None
|
|
|
|
|
|
def proc_self_fd_pins() -> bool:
|
|
"""True iff /proc/self/fd/N is a Linux-style magic symlink whose readlink
|
|
yields the open file's real path -- the primitive rrsync's realpath-vs-exec
|
|
inode-pin relies on. macOS/BSD lack the directory; Solaris HAS /proc/self/fd
|
|
but its entries are not such symlinks. Mirrors rrsync's own HAVE_PROC_SELF_FD
|
|
probe so the rrsync race test runs only where the protection actually exists
|
|
(it falls through unpinned, by design, elsewhere). Cached.
|
|
|
|
A correct readlink does not prove the fd pins anything, and two platforms
|
|
get this wrong in opposite directions: NetBSD makes the entry a symlink for
|
|
directories only (readlink of a regular file's entry fails with EINVAL),
|
|
while Cygwin's readlink is right but opening the magic link RE-RESOLVES the
|
|
path, so a rename under a held fd reaches the replacement. Only Linux gives
|
|
the inode-bound magic link, so require that and keep the probes as a guard
|
|
for Linux-like environments without /proc."""
|
|
global _psf_cache
|
|
if _psf_cache is not None:
|
|
return _psf_cache
|
|
if not sys.platform.startswith(('linux', 'android')):
|
|
_psf_cache = False
|
|
return _psf_cache
|
|
|
|
def resolves(path):
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY)
|
|
except OSError:
|
|
return False
|
|
try:
|
|
return os.readlink('/proc/self/fd/%d' % fd) == os.path.realpath(path)
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
os.close(fd)
|
|
|
|
_psf_cache = resolves('/') and resolves(os.path.realpath(__file__))
|
|
return _psf_cache
|
|
|
|
|
|
def write_daemon_conf(modules, globals=None, *,
|
|
name: str = 'test-rsyncd.conf') -> 'Path':
|
|
"""Write a custom rsyncd.conf for daemon-parameter tests.
|
|
|
|
`modules` is a list of (module_name, {param: value}) pairs; `globals` an
|
|
optional dict of global parameters that override the minimal defaults
|
|
(pid file / use chroot=no / hosts allow / log file / max verbosity).
|
|
Mirrors build_rsyncd_conf()'s root-aware uid/gid handling (only emitted
|
|
when running as root) and writes the same `ignore23` wrapper, but lets a
|
|
test set arbitrary parameters/modules beyond the fixed four. Returns the
|
|
config path; pair with start_test_daemon().
|
|
"""
|
|
conf = SCRATCHDIR / name
|
|
pidfile = SCRATCHDIR / 'rsyncd.pid'
|
|
logfile = SCRATCHDIR / 'rsyncd.log'
|
|
|
|
g = {
|
|
'pid file': str(pidfile),
|
|
'use chroot': 'no',
|
|
'hosts allow': 'localhost 127.0.0.0/8',
|
|
'log file': str(logfile),
|
|
'max verbosity': '4',
|
|
}
|
|
if globals:
|
|
g.update(globals)
|
|
if get_testuid() == get_rootuid():
|
|
g.setdefault('uid', str(get_rootuid()))
|
|
g.setdefault('gid', str(get_rootgid()))
|
|
else:
|
|
# Non-root cannot set uid/gid in rsyncd.conf.
|
|
g.pop('uid', None)
|
|
g.pop('gid', None)
|
|
|
|
lines = ['# autogenerated by rsyncfns.write_daemon_conf', '']
|
|
lines += [f'{k} = {v}' for k, v in g.items()]
|
|
lines.append('')
|
|
for mod_name, params in modules:
|
|
lines.append(f'[{mod_name}]')
|
|
lines += [f'\t{k} = {v}' for k, v in params.items()]
|
|
lines.append('')
|
|
conf.write_text('\n'.join(lines) + '\n')
|
|
|
|
ignore23 = SCRATCHDIR / 'ignore23'
|
|
if not ignore23.exists():
|
|
ignore23.write_text(
|
|
'#!/bin/sh\n'
|
|
'if "${@}"; then exit; fi\n'
|
|
'ret=$?\n'
|
|
'if test $ret = 23; then exit; fi\n'
|
|
'exit $ret\n'
|
|
)
|
|
ignore23.chmod(0o755)
|
|
|
|
return conf
|
|
|
|
|
|
# --- security regression helpers -------------------------------------------
|
|
|
|
def expect_fail(argv, text, env=None, cwd=None):
|
|
proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=True, env=env, cwd=cwd)
|
|
out = (proc.stdout or '') + (proc.stderr or '')
|
|
if proc.returncode == 0:
|
|
test_fail(f"command unexpectedly succeeded: {argv!r}\n{out}")
|
|
if text not in out:
|
|
test_fail(f"expected {text!r} in command output:\n{out}")
|
|
return proc
|
|
|
|
|
|
def patched_rrsync(workdir, rsync_path=None):
|
|
# The stub rsync just has to exec successfully; the BSDs keep true(1) in
|
|
# /usr/bin, not /bin, so resolve it on PATH rather than hard-coding /bin/true.
|
|
import re
|
|
if rsync_path is None:
|
|
rsync_path = shutil.which('true') or '/usr/bin/true'
|
|
src = SRCDIR / 'support' / 'rrsync'
|
|
dst = Path(workdir) / 'rrsync-under-test'
|
|
# Rewrite rrsync's hardcoded RSYNC path to our stub by matching the assignment
|
|
# line, not its value: a distro/port (e.g. FreeBSD's net/rsync) may ship a
|
|
# different path (/usr/local/bin/rsync), and a value-specific str.replace()
|
|
# would silently no-op and leave rrsync exec'ing the real rsync -- a
|
|
# server-mode hang. A callable replacement avoids re backslash-escaping.
|
|
text, n = re.subn(r"(?m)^RSYNC\s*=.*$",
|
|
lambda _m: f"RSYNC = {rsync_path!r}",
|
|
src.read_text())
|
|
if n != 1:
|
|
test_fail(f"patched_rrsync: expected exactly one 'RSYNC =' line in {src}, found {n}")
|
|
dst.write_text(text)
|
|
dst.chmod(0o755)
|
|
return dst
|
|
|
|
|
|
def run_rrsync_denied(command, expected):
|
|
base = SCRATCHDIR / expected.replace(' ', '_').replace('/', '_')
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
restricted = base / 'restricted'
|
|
restricted.mkdir(exist_ok=True)
|
|
rrsync = patched_rrsync(base)
|
|
env = {**os.environ, 'SSH_ORIGINAL_COMMAND': command}
|
|
expect_fail([str(rrsync), '-ro', '-no-lock', str(restricted)], expected, env=env)
|
|
|
|
|
|
def make_proxy_server(port, response):
|
|
listener = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
listener.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
|
|
listener.bind(('127.0.0.1', port))
|
|
listener.listen(1)
|
|
|
|
def serve():
|
|
conn, _ = listener.accept()
|
|
try:
|
|
conn.recv(65536)
|
|
conn.sendall(response)
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
finally:
|
|
listener.close()
|
|
|
|
import threading
|
|
t = threading.Thread(target=serve)
|
|
t.daemon = True
|
|
t.start()
|
|
return t
|
|
|
|
|
|
def run_proxy_probe(port, host, expected):
|
|
env = {**os.environ, 'RSYNC_PROXY': f'127.0.0.1:{port}'}
|
|
proc = subprocess.run(
|
|
rsync_argv(f'rsync://{host}/mod/', str(SCRATCHDIR / 'proxy-out')),
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
|
|
out = (proc.stdout or '') + (proc.stderr or '')
|
|
if proc.returncode == 0:
|
|
test_fail(f"proxy probe unexpectedly succeeded:\n{out}")
|
|
if expected not in out:
|
|
test_fail(f"expected {expected!r} in proxy probe output:\n{out}")
|
|
return proc
|
|
|
|
|
|
def setup_chroot_inner(name):
|
|
if get_testuid() != get_rootuid():
|
|
test_skipped("chroot /./ module regression requires root")
|
|
if _under_valgrind():
|
|
# The daemon's per-connection child chroots into the module, after
|
|
# which valgrind can no longer create its absolute --log-file %p path
|
|
# and the child dies (the transfer then resets) -- skip under valgrind.
|
|
test_skipped("daemon chroot prevents valgrind from writing its per-process log")
|
|
base = SCRATCHDIR / name
|
|
outer = base / 'outer'
|
|
inner = outer / 'inner'
|
|
outside = outer / 'outside'
|
|
src = base / 'src'
|
|
rmtree(base)
|
|
makepath(inner, outside, src)
|
|
os.symlink('../outside', inner / 'linkparent')
|
|
conf = write_daemon_conf([
|
|
('mod', {'path': str(outer) + '/./inner', 'read only': 'no',
|
|
'use chroot': 'yes', 'munge symlinks': 'no'}),
|
|
], name=f'{name}.conf')
|
|
# crc32, not hash(): str hash is per-process randomized (PYTHONHASHSEED),
|
|
# so the port would wander run to run -- and the old 12940+200 span reached
|
|
# into 13000+, where desktop bloatware (e.g. ASUS Armoury Crate on the
|
|
# Cygwin CI host) parks localhost listeners. 12800-12859 is otherwise
|
|
# unused by the suite.
|
|
url = start_test_daemon(conf, 12800 + (zlib.crc32(name.encode()) % 60))
|
|
return base, inner, outside, src, url
|
|
|
|
|
|
def run_checked(argv):
|
|
proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
return proc, (proc.stdout or '') + (proc.stderr or '')
|
|
|
|
|
|
def build_patched_rsync(name, replacements, append_cflags=None):
|
|
# Cygwin can't reliably rebuild a single patched unit from the copied tree:
|
|
# make leaves the copied object in place (coarse NTFS mtimes) so the patch is
|
|
# silently absent, and forcing the rebuild trips gcc-13's -fno-common link
|
|
# errors against the prebuilt objects. The malicious-peer behaviour these
|
|
# tests simulate is platform-independent and is exercised on every POSIX
|
|
# target, so skip the unbuildable simulation here rather than misreport it.
|
|
if sys.platform == 'cygwin' or platform.system().startswith('CYGWIN'):
|
|
test_skipped(f"{name}: build_patched_rsync is unreliable on Cygwin "
|
|
"(prebuilt-object staleness / -fno-common relink); the "
|
|
"patched-peer fix is validated on the POSIX targets")
|
|
if not (SRCDIR / 'Makefile').is_file():
|
|
test_skipped(f"{name}: needs a configured rsync source tree with a Makefile")
|
|
if not shutil.which('make'):
|
|
test_skipped(f"{name}: make(1) not on PATH")
|
|
if not shutil.which('gcc') and not shutil.which('cc'):
|
|
test_skipped(f"{name}: no C compiler on PATH")
|
|
|
|
work = SCRATCHDIR / name
|
|
rmtree(work)
|
|
shutil.copytree(
|
|
SRCDIR, work, symlinks=True,
|
|
ignore=shutil.ignore_patterns(
|
|
'testtmp', '.git', 'auto-build-save', 'autom4te.cache', '__pycache__'))
|
|
|
|
for relpath, old, new in replacements:
|
|
path = work / relpath
|
|
text = path.read_text()
|
|
if old not in text:
|
|
test_skipped(f"{name}: could not find patch target in {relpath}: {old!r}")
|
|
path.write_text(text.replace(old, new, 1))
|
|
# Drop the copied object for this unit. copytree() preserves mtimes, so
|
|
# on a target whose clock lags the host that pushed the tree -- or one
|
|
# with coarse mtime granularity -- the prebuilt .o can look NEWER than
|
|
# the just-patched .c, make reuses it, and the patch is silently absent.
|
|
# The test then exercises an unmodified peer and reports a vacuous
|
|
# result instead of a real one.
|
|
obj = (work / relpath).with_suffix('.o')
|
|
if obj.exists():
|
|
obj.unlink()
|
|
|
|
# Same hazard at the link step: a prebuilt binary with a future mtime looks
|
|
# up to date even once its objects have been rebuilt.
|
|
for stale in (work / 'rsync', work / 'rsync.exe'):
|
|
if stale.exists():
|
|
stale.unlink()
|
|
|
|
# Optionally append compiler flags (e.g. -fwrapv) to the already-configured
|
|
# CFLAGS line in the copied Makefile. This preserves the platform's
|
|
# configure-chosen flags and just adds to them, unlike a `make CFLAGS=...`
|
|
# override which would drop them.
|
|
if append_cflags:
|
|
import re
|
|
mkpath = work / 'Makefile'
|
|
mk = mkpath.read_text()
|
|
mk2 = re.sub(r'(?m)^(CFLAGS=.*)$', r'\1 ' + append_cflags, mk, count=1)
|
|
if mk2 == mk:
|
|
test_skipped(f"{name}: could not append {append_cflags!r} to CFLAGS in the Makefile")
|
|
mkpath.write_text(mk2)
|
|
# The copied tree carries prebuilt objects compiled with the ORIGINAL
|
|
# flags; since the sources aren't newer, make would reuse them and the
|
|
# appended flag would silently apply to nothing. Drop them so every
|
|
# unit recompiles with the new CFLAGS.
|
|
for obj in work.rglob('*.o'):
|
|
obj.unlink()
|
|
|
|
env = {**os.environ, 'CCACHE_DISABLE': '1'}
|
|
build = subprocess.run(['make', '-j2', 'rsync'], cwd=str(work), env=env,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
rsync = work / 'rsync'
|
|
if build.returncode != 0 or not rsync.is_file() or not os.access(rsync, os.X_OK):
|
|
test_skipped(
|
|
f"{name}: patched rsync build failed (rc={build.returncode}). "
|
|
"Tail of build output:\n" + '\n'.join(build.stdout.splitlines()[-20:]))
|
|
return rsync
|
|
|
|
|
|
# --- operator-supplied-path symlink policy matrix --------------------------
|
|
#
|
|
# Policy: an operator-supplied path follows a symlink at any component iff that
|
|
# symlink is owned by uid 0 or the running euid, and refuses one owned by any
|
|
# other uid -- for absolute and relative paths alike. --insecure-links is a
|
|
# LOCAL opt-out that restores legacy following (a daemon never honours it; that
|
|
# is covered by a separate daemon test). run_symlink_matrix() drives one
|
|
# path-taking option through {cross-uid vs same-uid} x {absolute vs relative} x
|
|
# {symlink-at-leaf vs symlink-in-a-parent-component} x {--insecure-links off/on}
|
|
# and asserts each cell against the policy: FOLLOW iff (insecure or same-uid).
|
|
|
|
def find_attacker_uid():
|
|
"""An untrusted uid (not 0, not the euid) for a cross-uid plant, else None."""
|
|
import pwd
|
|
for nm in ('nobody', 'nfsnobody', 'daemon'):
|
|
try:
|
|
u = pwd.getpwnam(nm).pw_uid
|
|
except KeyError:
|
|
continue
|
|
if u != 0 and u != os.geteuid():
|
|
return u
|
|
return None
|
|
|
|
|
|
def run_symlink_matrix(option, case, *, paths=('abs', 'rel'),
|
|
wheres=('leaf', 'parent'), label=''):
|
|
"""Run `case(ctx)` over the operator-path symlink matrix; assert the policy.
|
|
|
|
`case(ctx)` plants the option's path symlink (per ctx.where/ctx.abspath),
|
|
runs rsync (honouring ctx.insecure), and returns True if the symlink was
|
|
FOLLOWED (the op escaped to ctx.outside / read an out-of-tree object).
|
|
ctx carries: base, outside, plant (fresh Paths); owner ('cross'|'self');
|
|
att_uid; abspath ('abs'|'rel'); where ('leaf'|'parent'); insecure (bool);
|
|
and plant_link(at, target) which symlinks target->at and lchowns it to the
|
|
attacker uid when owner=='cross' (otherwise it stays euid-owned).
|
|
|
|
Cross-uid cells need root (to own a symlink by a foreign uid) and are
|
|
skipped otherwise; same-uid cells run at any uid.
|
|
"""
|
|
import re
|
|
import types
|
|
tag = option + (f' [{label}]' if label else '')
|
|
euid = os.geteuid()
|
|
att = find_attacker_uid() if euid == 0 else None
|
|
slug = re.sub(r'[^a-z0-9]+', '-', tag.lower()).strip('-')
|
|
|
|
for abspath in paths:
|
|
for where in wheres:
|
|
for insecure in (False, True):
|
|
owners = ('self', 'cross') if att is not None else ('self',)
|
|
for owner in owners:
|
|
base = SCRATCHDIR / (f"{slug}-{owner}-{abspath}-{where}-"
|
|
+ ('ins' if insecure else 'safe'))
|
|
rmtree(base)
|
|
base.mkdir(parents=True)
|
|
ctx = types.SimpleNamespace(
|
|
base=base, outside=base / 'outside', plant=base / 'plant',
|
|
owner=owner, att_uid=att, abspath=abspath, where=where,
|
|
insecure=insecure)
|
|
ctx.outside.mkdir()
|
|
ctx.plant.mkdir()
|
|
|
|
def plant_link(at, target, _c=ctx):
|
|
os.symlink(target, at)
|
|
if _c.owner == 'cross':
|
|
os.lchown(at, _c.att_uid, _c.att_uid)
|
|
ctx.plant_link = plant_link
|
|
|
|
followed = bool(case(ctx))
|
|
expect = insecure or owner == 'self'
|
|
cell = f"{abspath} {where} {'insecure' if insecure else 'safe'}"
|
|
if followed and not expect:
|
|
test_fail(
|
|
f"{tag}: CROSS-UID {cell}: the planted symlink was "
|
|
"FOLLOWED (op escaped to outside/). An operator path "
|
|
"must refuse a symlink not owned by uid 0 or the euid.")
|
|
if not followed and expect:
|
|
why = ("--insecure-links did not restore symlink following"
|
|
if insecure else
|
|
"the operator's OWN (euid-owned) symlink was refused")
|
|
test_fail(f"{tag}: {('CROSS' if owner=='cross' else 'SAME')}"
|
|
f"-UID {cell}: {why}.")
|
|
if att is None and euid != 0:
|
|
print(f"{tag}: same-uid cells confirmed; cross-uid cells need root (skipped)")
|
|
|
|
|
|
def plant_operator_symlink(ctx, rel_anchor, kind='dir'):
|
|
"""Plant this cell's option-path symlink and return (option_value, escape).
|
|
|
|
option_value is what to feed the option: an absolute path, or a name
|
|
relative to rel_anchor (the directory the option resolves a relative value
|
|
against -- e.g. the destination dir for --backup-dir/--link-dest, or the cwd
|
|
for --temp-dir). escape is the out-of-tree object the operation acts on IF
|
|
the symlink is followed.
|
|
|
|
kind='dir' (a directory option, e.g. --backup-dir/--temp-dir/--link-dest):
|
|
leaf -> the symlink itself is the dir; escape = ctx.outside.
|
|
parent -> a parent component is the symlink; escape = ctx.outside/'sub'.
|
|
kind='file' (a file option, e.g. --log-file/--files-from/--write-batch):
|
|
leaf -> the symlink targets the out-of-tree victim file directly.
|
|
parent -> a parent component is the symlink; the leaf name is appended.
|
|
escape = ctx.outside/'victim' either way.
|
|
"""
|
|
base = ctx.plant if ctx.abspath == 'abs' else rel_anchor
|
|
if kind == 'file':
|
|
victim = ctx.outside / 'victim'
|
|
if ctx.where == 'leaf':
|
|
link = base / 'osl'
|
|
ctx.plant_link(link, victim)
|
|
return (str(link) if ctx.abspath == 'abs' else 'osl'), victim
|
|
link = base / 'opd'
|
|
ctx.plant_link(link, ctx.outside)
|
|
return ((str(link / 'victim') if ctx.abspath == 'abs' else 'opd/victim'),
|
|
victim)
|
|
if ctx.where == 'leaf':
|
|
link = base / 'osl'
|
|
ctx.plant_link(link, ctx.outside)
|
|
return (str(link) if ctx.abspath == 'abs' else 'osl'), ctx.outside
|
|
link = base / 'opd'
|
|
ctx.plant_link(link, ctx.outside)
|
|
return ((str(link / 'sub') if ctx.abspath == 'abs' else 'opd/sub'),
|
|
ctx.outside / 'sub')
|
|
|
|
|
|
# --- variety tree (cross-version regression coverage) ----------------------
|
|
# A "variety tree" exercises every inode type rsync handles (dirs, regular
|
|
# files, symlinks, fifos, sockets, char/block devices) with a spread of
|
|
# permissions, xattrs, ACLs and (as root) ownership, plus heavy symlink
|
|
# coverage: links to each type, links that escape a transfer root via ../..,
|
|
# absolute links, and links whose intermediate components transit outside the
|
|
# tree. It is the source for differential tests that assert the current binary
|
|
# produces the same destination tree as an old release (see variety_test.py).
|
|
|
|
def acls_supported() -> bool:
|
|
"""True if this rsync was built with ACL support AND this platform has a
|
|
usable setfacl/getfacl (or macOS chmod +a). Mirrors xattrs_supported()."""
|
|
vv = run_rsync('-VV', check=True, capture_output=True).stdout
|
|
if '"ACLs": true' not in vv:
|
|
return False
|
|
if _SYSTEM in ('Linux', 'FreeBSD') or _CYGWIN:
|
|
return (shutil.which('setfacl') is not None
|
|
and shutil.which('getfacl') is not None)
|
|
if _SYSTEM == 'Darwin':
|
|
return shutil.which('chmod') is not None
|
|
return False
|
|
|
|
|
|
@_functools.lru_cache(maxsize=1)
|
|
def devices_supported() -> bool:
|
|
"""True if device nodes can be created here.
|
|
|
|
euid==0 is necessary but not sufficient: a user-namespaced container
|
|
(rootless podman/docker, buildd chroots) reports euid==0 yet lacks
|
|
CAP_MKNOD, so os.mknod() of a device fails EPERM. Probe once."""
|
|
if os.geteuid() != 0 or not hasattr(os, 'mknod'):
|
|
return False
|
|
with tempfile.TemporaryDirectory(prefix='rsync-devprobe.') as d:
|
|
try:
|
|
os.mknod(os.path.join(d, 'p'), 0o600 | stat.S_IFCHR, os.makedev(1, 3))
|
|
except (PermissionError, OSError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def hardlink_symlinks_supported(where=None) -> bool:
|
|
"""True if THIS FILESYSTEM can hard-link a symlink.
|
|
|
|
rsync's own "hardlink_symlinks" capability is a build-time answer and says
|
|
nothing about the filesystem underneath: HFS+ returns ENOTSUP for
|
|
link()-ing a symlink where APFS and Linux succeed. Probe where the test
|
|
data will actually live, not where the source tree is."""
|
|
d = tempfile.mkdtemp(prefix='rsync-hlsym.', dir=str(where) if where else None)
|
|
try:
|
|
link, hard = os.path.join(d, 's'), os.path.join(d, 'h')
|
|
os.symlink('target-need-not-exist', link)
|
|
try:
|
|
os.link(link, hard, follow_symlinks=False)
|
|
except NotImplementedError:
|
|
return False
|
|
except OSError as e:
|
|
# Only "the filesystem cannot do this" answers the question. EPERM,
|
|
# ENOSPC, EMLINK, EIO or a quota refusal would otherwise be reported
|
|
# as a capability difference and quietly reshape the caller's
|
|
# expectations, so let those propagate.
|
|
if e.errno in (errno.ENOTSUP, getattr(errno, 'EOPNOTSUPP', errno.ENOTSUP)):
|
|
return False
|
|
raise
|
|
return True
|
|
finally:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
|
|
def owners_supported() -> bool:
|
|
"""True if the builder may assign mixed uid/gid (euid==0)."""
|
|
return os.geteuid() == 0
|
|
|
|
|
|
def make_fifo(path) -> 'None':
|
|
"""Create a FIFO (named pipe) at `path`."""
|
|
os.mkfifo(str(path))
|
|
|
|
|
|
def make_socket(path) -> 'None':
|
|
"""Create a UNIX-domain socket inode at `path`.
|
|
|
|
The AF_UNIX sun_path is capped at ~108 bytes, which a depth-8 absolute path
|
|
can overflow, so we chdir to the parent and bind the bare (short) basename,
|
|
restoring the cwd in a finally. The bound inode persists as an S_IFSOCK on
|
|
disk after the socket is closed."""
|
|
path = Path(path)
|
|
old = os.getcwd()
|
|
s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
|
|
try:
|
|
os.chdir(path.parent)
|
|
s.bind(path.name)
|
|
finally:
|
|
s.close()
|
|
os.chdir(old)
|
|
|
|
|
|
def make_device(path, kind: str, major: int, minor: int,
|
|
mode: int = 0o644) -> 'None':
|
|
"""Create a char ('c') or block ('b') device node. Caller must gate on
|
|
devices_supported() -- unprivileged this raises PermissionError."""
|
|
fmt = stat.S_IFCHR if kind == 'c' else stat.S_IFBLK
|
|
os.mknod(str(path), mode | fmt, os.makedev(major, minor))
|
|
|
|
|
|
def _acl_env() -> dict:
|
|
"""Environment for getfacl/setfacl: scrub POSIXLY_CORRECT (which alters
|
|
some getfacl builds' flag/header semantics) and pin LC_ALL=C."""
|
|
env = dict(os.environ)
|
|
env.pop('POSIXLY_CORRECT', None)
|
|
env['LC_ALL'] = 'C'
|
|
return env
|
|
|
|
|
|
def acl_set(spec: str, path) -> bool:
|
|
"""Apply one ACL entry `spec` (e.g. 'u:0:rwx', 'g:0:r-x', 'd:u:0:rwx' for a
|
|
directory default entry) to `path` via setfacl. Returns True on success,
|
|
False if the filesystem rejects ACLs (EOPNOTSUPP) so the caller degrades
|
|
gracefully. macOS is not driven here (returns False)."""
|
|
if not (_SYSTEM in ('Linux', 'FreeBSD') or _CYGWIN):
|
|
return False
|
|
proc = subprocess.run(['setfacl', '-m', spec, str(path)],
|
|
capture_output=True, text=True, env=_acl_env())
|
|
return proc.returncode == 0
|
|
|
|
|
|
def _acl_sig(path) -> str:
|
|
"""Path-free signature of a node's ACL entries, for comparing two trees.
|
|
Strips getfacl's comment header (which embeds the path/owner) and sorts the
|
|
entries so the result depends only on the access/default ACL, not on where
|
|
the file lives. Empty when ACLs aren't readable here."""
|
|
if not (_SYSTEM in ('Linux', 'FreeBSD') or _CYGWIN):
|
|
return ''
|
|
try:
|
|
out = subprocess.run(['getfacl', str(path)], capture_output=True,
|
|
text=True, env=_acl_env()).stdout
|
|
except OSError:
|
|
return ''
|
|
return ';'.join(sorted(l for l in out.splitlines()
|
|
if l.strip() and not l.startswith('#')))
|
|
|
|
|
|
def _xattr_sig(path) -> str:
|
|
"""Path-free signature of a node's user xattrs, for comparing two trees.
|
|
Native on Linux (symmetric with xattr_set); getfattr with the '# file:'
|
|
header stripped on Cygwin. Returns '' elsewhere -- xattr fidelity is gated
|
|
on the Linux CI run, and tls still compares structure on every platform."""
|
|
p = str(path)
|
|
if _SYSTEM == 'Linux':
|
|
try:
|
|
names = sorted(n for n in os.listxattr(p, follow_symlinks=False)
|
|
if n.startswith('user.'))
|
|
except OSError:
|
|
return ''
|
|
out = []
|
|
for n in names:
|
|
try:
|
|
v = os.getxattr(p, n, follow_symlinks=False)
|
|
except OSError:
|
|
continue
|
|
out.append(n + '=' + v.decode('utf-8', 'surrogateescape'))
|
|
return ';'.join(out)
|
|
if _CYGWIN:
|
|
try:
|
|
d = subprocess.check_output(
|
|
['getfattr', '--no-dereference', '-d', p],
|
|
text=True, stderr=subprocess.DEVNULL)
|
|
except (subprocess.CalledProcessError, OSError):
|
|
return ''
|
|
return ';'.join(sorted(l for l in d.splitlines()
|
|
if l and not l.startswith('# file:')))
|
|
return ''
|
|
|
|
|
|
def _variety_fill(path, size: int, key: str) -> 'None':
|
|
"""Write `size` bytes of deterministic, non-trivial content derived from
|
|
`key`, so a variety tree is byte-reproducible across separate builds."""
|
|
import hashlib
|
|
buf = bytearray()
|
|
i = 0
|
|
while len(buf) < size:
|
|
buf += hashlib.sha256(f'{key}:{i}'.encode()).digest()
|
|
i += 1
|
|
with open(str(path), 'wb') as f:
|
|
f.write(bytes(buf[:size]))
|
|
|
|
|
|
def _all_entries(root) -> list:
|
|
"""Every entry under `root` (the root dir, real subdirs, files, specials,
|
|
and symlinks themselves) visited exactly once and WITHOUT following any
|
|
symlink. For chown/utime finalisation that must not escape the tree."""
|
|
root = Path(root)
|
|
res = []
|
|
for dp, dns, fns in os.walk(root): # followlinks=False
|
|
d = Path(dp)
|
|
res.append(d)
|
|
for n in fns:
|
|
res.append(d / n)
|
|
for n in dns:
|
|
sub = d / n
|
|
if sub.is_symlink(): # os.walk won't recurse into it
|
|
res.append(sub)
|
|
return res
|
|
|
|
|
|
def make_variety_tree(root, *, depth: int = 8, with_acls=None, with_xattrs=None,
|
|
with_devices=None, with_owners=None,
|
|
seed: int = 0x5A17) -> dict:
|
|
"""Build a deterministic 'variety tree' rooted at `root`.
|
|
|
|
Capability args default to None => auto-detect (xattrs_supported() etc.).
|
|
Tests pass EXPLICIT bools so the current- and old-binary source trees are
|
|
built with identical capabilities, keeping the differential comparison
|
|
apples-to-apples. Re-runnable: rmtree(root) first; `seed` drives only fixed
|
|
choices (no time/pid randomness) so two calls yield identical trees.
|
|
|
|
Layout (caller transfers root/transfer_root/):
|
|
root/above/ real nodes ABOVE the transfer root (escape targets)
|
|
root/transfer_root d0..d{depth-1} backbone; at each level a bouquet of
|
|
every type + a symlink to every type; plus abs_links/
|
|
(absolute links) and escape/ (../.. links that leave
|
|
the transfer root, one per above-root type).
|
|
|
|
Returns {'transfer_root': Path, 'above_targets': {type: Path},
|
|
'counts': {type: n}}.
|
|
"""
|
|
root = Path(root)
|
|
rmtree(root)
|
|
if with_xattrs is None:
|
|
with_xattrs = xattrs_supported()
|
|
if with_acls is None:
|
|
with_acls = acls_supported()
|
|
if with_devices is None:
|
|
with_devices = devices_supported()
|
|
if with_owners is None:
|
|
with_owners = owners_supported()
|
|
|
|
root.mkdir(parents=True)
|
|
above = root / 'above'
|
|
above.mkdir()
|
|
troot = root / 'transfer_root'
|
|
troot.mkdir()
|
|
|
|
counts = {}
|
|
def bump(t):
|
|
counts[t] = counts.get(t, 0) + 1
|
|
|
|
perm_cycle = [0o400, 0o640, 0o644, 0o600, 0o755]
|
|
|
|
def reg(p, size, mode=0o644):
|
|
_variety_fill(p, size, f'{seed:x}:{os.path.relpath(p, root)}')
|
|
os.chmod(p, mode)
|
|
bump('file')
|
|
return p
|
|
|
|
def mkdir1(p, mode=None):
|
|
p.mkdir()
|
|
if mode is not None:
|
|
os.chmod(p, mode)
|
|
bump('dir')
|
|
return p
|
|
|
|
def lnk(target, p):
|
|
os.symlink(target, p)
|
|
bump('symlink')
|
|
return p
|
|
|
|
# --- above-root real targets (escape/ links point here) ---
|
|
above_targets = {}
|
|
above_targets['dir'] = mkdir1(above / 'a_dir')
|
|
reg(above / 'a_dir' / 'inner', 256)
|
|
above_targets['file'] = reg(above / 'a_file', 8192)
|
|
above_targets['fifo'] = above / 'a_fifo'; make_fifo(above_targets['fifo']); bump('fifo')
|
|
above_targets['sock'] = above / 'a_sock'; make_socket(above_targets['sock']); bump('socket')
|
|
if with_devices:
|
|
above_targets['dev'] = above / 'a_dev_c'
|
|
make_device(above_targets['dev'], 'c', 1, 3); bump('device')
|
|
make_device(above / 'a_dev_b', 'b', 7, 0); bump('device')
|
|
above_targets['link'] = lnk('a_file', above / 'a_link')
|
|
|
|
# --- depth backbone with a bouquet at each level ---
|
|
cur = troot
|
|
for n in range(depth):
|
|
reg(cur / f'f{n}', 1024 * (n + 1))
|
|
if n % 2 == 0: # hard-link coverage for -H
|
|
os.link(cur / f'f{n}', cur / f'hl{n}')
|
|
bump('hardlink')
|
|
reg(cur / f'perm{n}', 700, perm_cycle[(seed + n) % len(perm_cycle)])
|
|
reg(cur / f'setuid{n}', 512, 0o4755)
|
|
mkdir1(cur / f'setgid{n}', 0o2775)
|
|
mkdir1(cur / f'sticky{n}', 0o1777)
|
|
for k in range(3): # widen toward ~200 entries
|
|
reg(cur / f'g{n}_{k}', 300)
|
|
make_fifo(cur / f'fifo{n}'); bump('fifo')
|
|
make_socket(cur / f'sk{n}'); bump('socket')
|
|
if with_devices:
|
|
make_device(cur / f'cdev{n}', 'c', 1, 5); bump('device')
|
|
make_device(cur / f'bdev{n}', 'b', 7, n); bump('device')
|
|
# symlink bouquet: one link to each type present at this level
|
|
lnk(f'f{n}', cur / f'ln2file{n}')
|
|
lnk(f'fifo{n}', cur / f'ln2fifo{n}')
|
|
lnk(f'sk{n}', cur / f'ln2sock{n}')
|
|
if with_devices:
|
|
lnk(f'cdev{n}', cur / f'ln2dev{n}')
|
|
lnk(f'ln2file{n}', cur / f'ln2ln{n}') # link to a symlink
|
|
lnk(f'nonexistent_{n}', cur / f'dangling{n}')
|
|
if n < depth - 1:
|
|
nxt = mkdir1(cur / f'd{n + 1}')
|
|
lnk(f'd{n + 1}', cur / f'ln2dir{n}') # link to a directory
|
|
cur = nxt
|
|
|
|
# --- absolute-path links (targets encode the source scratch path) ---
|
|
absd = mkdir1(troot / 'abs_links')
|
|
lnk(str((troot / 'f0').resolve()), absd / 'abs_file')
|
|
lnk(str(above_targets['file'].resolve()), absd / 'abs_above')
|
|
|
|
# --- escaping (unsafe) links: inside the transfer root, target outside ---
|
|
escd = mkdir1(troot / 'escape')
|
|
lnk('../../above/a_dir', escd / 'esc_dir')
|
|
lnk('../../above/a_file', escd / 'esc_file')
|
|
lnk('../../above/a_fifo', escd / 'esc_fifo')
|
|
lnk('../../above/a_sock', escd / 'esc_sock')
|
|
if with_devices:
|
|
lnk('../../above/a_dev_c', escd / 'esc_dev')
|
|
lnk('../../above/a_link', escd / 'esc_link')
|
|
# intermediate component transits OUTSIDE the whole tree, then back in
|
|
lnk(f'../../../{root.name}/above/a_file', escd / 'esc_deep')
|
|
|
|
# --- xattrs on dirs + regular files (symlink xattrs are unsupported on
|
|
# Linux, so skip them) ---
|
|
if with_xattrs:
|
|
for p in walk_dirs(troot) + walk_files(troot):
|
|
try:
|
|
xattr_set('variety', os.path.basename(str(p)), p)
|
|
except OSError:
|
|
pass
|
|
|
|
# --- ACLs on a deterministic subset ---
|
|
if with_acls:
|
|
dirs = walk_dirs(troot)
|
|
files = walk_files(troot)
|
|
for i, d in enumerate(dirs):
|
|
if i % 3 == 0:
|
|
acl_set('u:0:rwx', d)
|
|
if i % 5 == 0:
|
|
acl_set('d:u:0:rwx', d) # directory default entry
|
|
for i, f in enumerate(files):
|
|
if i % 4 == 0:
|
|
acl_set('g:0:r-x', f)
|
|
|
|
entries = sorted(_all_entries(root), key=lambda x: str(x))
|
|
|
|
# --- mixed ownership (root only), including symlinks ---
|
|
if with_owners:
|
|
idset = [(0, 0), (1, 1), (2, 2)]
|
|
for i, p in enumerate(entries):
|
|
uid, gid = idset[(seed + i) % len(idset)]
|
|
try:
|
|
os.chown(str(p), uid, gid, follow_symlinks=False)
|
|
except OSError:
|
|
pass
|
|
|
|
# --- deterministic, varied mtimes (last, so nothing resets them); makes
|
|
# the tls listing reproducible across separate builds ---
|
|
base = 1_000_000_000
|
|
for i, p in enumerate(entries):
|
|
t = base + (i * 7) % 1_000_000
|
|
try:
|
|
os.utime(str(p), (t, t), follow_symlinks=False)
|
|
except (OSError, NotImplementedError, ValueError):
|
|
if not os.path.islink(str(p)):
|
|
try:
|
|
os.utime(str(p), (t, t))
|
|
except OSError:
|
|
pass
|
|
|
|
return {'transfer_root': troot, 'above_targets': above_targets,
|
|
'counts': counts}
|
|
|
|
|
|
def _rel_nonlink_entries(root) -> list:
|
|
"""Relative paths of every non-symlink entry (real dirs + non-symlink
|
|
files/specials) under `root`, sorted. Used to compare per-entry metadata
|
|
without following or descending into symlinks."""
|
|
root = Path(root)
|
|
res = []
|
|
for dirpath, _dirnames, filenames in os.walk(root): # followlinks=False
|
|
d = Path(dirpath)
|
|
if d != root:
|
|
res.append(d.relative_to(root))
|
|
for fn in filenames:
|
|
fp = d / fn
|
|
if not fp.is_symlink():
|
|
res.append(fp.relative_to(root))
|
|
return sorted(res, key=lambda p: str(p))
|
|
|
|
|
|
def _safe_walk_files(root) -> list:
|
|
"""walk_files() variant that tolerates unreadable directories (skips them
|
|
instead of raising), for comparing trees whose mixed/foreign ownership can
|
|
leave some entries inaccessible to the current user."""
|
|
root = Path(root)
|
|
res = []
|
|
for dp, _dns, fns in os.walk(root): # onerror=None -> unreadable dirs skipped
|
|
d = Path(dp)
|
|
for n in fns:
|
|
p = d / n
|
|
try:
|
|
if p.is_file() and not p.is_symlink():
|
|
res.append(p)
|
|
except OSError:
|
|
continue
|
|
return sorted(res, key=lambda x: str(x))
|
|
|
|
|
|
def compare_trees(a, b, label: str = '', *,
|
|
with_acls: bool = True,
|
|
with_xattrs: bool = True) -> list:
|
|
"""Compare two trees WITHOUT ever opening a fifo/socket/device as a
|
|
stream. Returns a list of human-readable difference strings ([] == match);
|
|
the caller decides whether a difference is a fail or an xfail.
|
|
|
|
Checks: (1) the tls listings (type+mode+owner+size+mtime+symlink target for
|
|
every inode); (2) byte-equality of regular files by relative path; (3) user
|
|
xattrs; (4) POSIX ACLs. Never uses `diff -r` (it blocks on specials)."""
|
|
a = Path(a)
|
|
b = Path(b)
|
|
pre = f"{label}: " if label else ""
|
|
diffs = []
|
|
|
|
la = rsync_ls_lR(a)
|
|
lb = rsync_ls_lR(b)
|
|
if la != lb:
|
|
import difflib
|
|
ud = ''.join(difflib.unified_diff(
|
|
la.splitlines(keepends=True), lb.splitlines(keepends=True),
|
|
fromfile=f'{a} (tls)', tofile=f'{b} (tls)'))
|
|
diffs.append(f"{pre}tls listings differ:\n{ud}")
|
|
|
|
files_a = sorted(p.relative_to(a) for p in _safe_walk_files(a))
|
|
files_b = sorted(p.relative_to(b) for p in _safe_walk_files(b))
|
|
set_b = set(files_b)
|
|
if set(files_a) != set_b:
|
|
only_a = sorted(str(p) for p in set(files_a) - set_b)
|
|
only_b = sorted(str(p) for p in set_b - set(files_a))
|
|
diffs.append(f"{pre}regular-file set differs: "
|
|
f"only in a={only_a} only in b={only_b}")
|
|
for rel in files_a:
|
|
if rel not in set_b:
|
|
continue
|
|
try:
|
|
same = filecmp.cmp(str(a / rel), str(b / rel), shallow=False)
|
|
except OSError as e:
|
|
diffs.append(f"{pre}cannot compare contents of {rel} "
|
|
f"(permission denied?): {e}")
|
|
continue
|
|
if not same:
|
|
diffs.append(f"{pre}content differs: {rel}")
|
|
|
|
# hard-link grouping (catches an -H divergence the tls listing can't show)
|
|
def _hl_groups(rootp):
|
|
from collections import defaultdict
|
|
ino = defaultdict(list)
|
|
for p in _safe_walk_files(rootp):
|
|
try:
|
|
st = p.stat()
|
|
except OSError:
|
|
continue
|
|
if st.st_nlink > 1:
|
|
ino[(st.st_dev, st.st_ino)].append(str(p.relative_to(rootp)))
|
|
return sorted(tuple(sorted(v)) for v in ino.values() if len(v) > 1)
|
|
ga, gb = _hl_groups(a), _hl_groups(b)
|
|
if ga != gb:
|
|
diffs.append(f"{pre}hard-link grouping differs: a={ga} b={gb}")
|
|
|
|
if with_xattrs or with_acls:
|
|
for rel in _rel_nonlink_entries(a):
|
|
pa = a / rel
|
|
pb = b / rel
|
|
if not pb.exists():
|
|
continue
|
|
if with_xattrs:
|
|
xa, xb = _xattr_sig(pa), _xattr_sig(pb)
|
|
if xa != xb:
|
|
diffs.append(f"{pre}xattr differs: {rel} "
|
|
f"(a={xa!r} b={xb!r})")
|
|
if with_acls:
|
|
aa, ab = _acl_sig(pa), _acl_sig(pb)
|
|
if aa != ab:
|
|
diffs.append(f"{pre}ACL differs: {rel} "
|
|
f"(a={aa!r} b={ab!r})")
|
|
|
|
return diffs
|
|
|
|
|
|
def assert_trees_equal(a, b, label: str = '', **kwargs) -> 'None':
|
|
"""compare_trees(); test_fail() on any difference."""
|
|
diffs = compare_trees(a, b, label, **kwargs)
|
|
if diffs:
|
|
test_fail('\n'.join(diffs))
|