Files
rsync/testsuite/rrsync-no-overwrite-partial-dir_test.py
Andrew Tridgell 0789709aeb testsuite: quote the rsync command everywhere a shell re-parses it
Second layer of the space-in-build-path work.  Quoting the Makefile got the
runner started; these are the places that then hand the binary's path to
something that splits on whitespace.

  - RSYNC_CONNECT_PROG is run by a shell.  This was the big one: an unquoted
    daemon command turned every daemon-mode test into
    "sh: 1: /path/to/ws: Permission denied".
  - RSYNC_RSH / --rsh is tokenised by rsync itself (do_cmd() in main.c, which
    honours ' and "), so support/lsh.sh needs quoting when srcdir has a space.
  - --rsync-path is a command line run by the REMOTE shell, so rsync passes it
    through unsplit and lsh.sh's eval re-parses it.
  - The generated rsync-shim scripts interpolate RSYNC into "#!/bin/sh\nexec
    ...", where it is shell syntax rather than an argv entry.

rsync_path_arg() and rsh_cmd() build those strings by splitting the command and
re-joining with shlex, so a plain path with a space comes back quoted while a
wrapper command ("valgrind ... /build/rsync") stays several words.

split_rsync_cmd() also has to cope with RSYNC once a test has appended options
to it -- chown-fake and friends do -- where the string is no longer a filename.
It now takes the longest leading run that names an existing file as the program
and splits only what follows.

In a directory with a space: 231 pass, 22 fail, from 0 able to run before the
first commit.  Unchanged in a normal path: 257 passed, 0 failed.
2026-08-02 08:13:25 +10:00

76 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""rrsync -no-overwrite must protect existing partial-dir files too."""
import os
import shlex
import signal
import subprocess
import sys
signal.signal(signal.SIGUSR1, signal.SIG_IGN)
signal.signal(signal.SIGUSR2, signal.SIG_IGN)
if '--shell' in sys.argv:
i = sys.argv.index('--shell') + 2
env = {**os.environ, 'SSH_ORIGINAL_COMMAND': ' '.join(sys.argv[i:])}
signal.signal(signal.SIGUSR1, signal.SIG_DFL)
signal.signal(signal.SIGUSR2, signal.SIG_DFL)
wrapper, root = env['RRSYNC_WRAPPER'], env['RRSYNC_ROOT']
flags = env.get('RRSYNC_FLAGS', '-wo -no-overwrite -no-lock').split()
os.execve(wrapper, [wrapper, *flags, root], env)
from rsyncfns import RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, rsync_path_arg
base = SCRATCHDIR / 'rrsync-no-overwrite-partial-dir'
rmtree(base)
src = base / 'src'
root = base / 'root'
makepath(src, root / 'protected')
(src / 'victim').write_bytes(b'NEW')
protected = root / 'protected' / 'victim'
protected.write_bytes(b'POLICY')
# RSYNC may be a multi-word command (the runner's --protocol=N, or valgrind)
# while rrsync hands its RSYNC to execlp() as a single executable name, so it
# has to be wrapped before patched_rrsync() sees it.
shim = base / 'rsync-shim'
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{shlex.quote(sys.executable)} {shlex.quote(os.path.abspath(__file__))} --shell'
env = {**os.environ, 'RRSYNC_WRAPPER': str(wrapper), 'RRSYNC_ROOT': str(root)}
got = subprocess.run(
rsync_argv('-rI', '--partial', '--partial-dir=protected',
'-e', rsh, f'{src}/victim', 'ignored:'),
env=env, capture_output=True, text=True, timeout=20)
state = protected.read_bytes() if protected.exists() else None
assert got.returncode != 0 and 'option --partial-dir has been disabled' in got.stderr, (
f'rrsync -no-overwrite accepted --partial-dir: rc={got.returncode}, '
f'stdout={got.stdout!r}, stderr={got.stderr!r}')
assert state == b'POLICY', (
f'rrsync consumed an existing partial file: rc={got.returncode}, '
f'state={state!r}, stderr={got.stderr!r}')
safe = subprocess.run(
rsync_argv('-rI', '-e', rsh, f'{src}/victim', 'ignored:'),
env=env, capture_output=True, text=True, timeout=20)
assert safe.returncode == 0 and (root / 'victim').read_bytes() == b'NEW', (
f'ordinary upload failed: rc={safe.returncode}, stderr={safe.stderr!r}')
# The refusal must be CONDITIONAL on -no-overwrite. Run the identical option
# through a wrapper without it: if that is refused too, the option is simply
# disabled for every rrsync deployment and the assertion above proves nothing
# about the policy. A fresh root keeps this from disturbing the checks above.
plain_root = base / 'root-plain'
rmtree(plain_root)
makepath(plain_root / 'protected')
(plain_root / 'protected' / 'victim').write_bytes(b'POLICY')
plain_env = {**env, 'RRSYNC_ROOT': str(plain_root), 'RRSYNC_FLAGS': '-wo -no-lock'}
plain = subprocess.run(
rsync_argv('-rI', '--partial', '--partial-dir=protected', '-e', rsh, f'{src}/victim', 'ignored:'),
env=plain_env, capture_output=True, text=True, timeout=20)
assert plain.returncode == 0, (
'without -no-overwrite the same option must be accepted, or the refusal '
'above is unconditional and says nothing about the policy: '
f'rc={plain.returncode}, stderr={plain.stderr!r}')