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.
This commit is contained in:
Andrew Tridgell committed 2026-08-02 08:13:25 +10:00
1 parent b1d2c32b1d
commit 0789709aeb
27 files changed
+128 -81

No files matched your search

+5 -5
View File
@@ -9,12 +9,12 @@ import os
from rsyncfns import (
FROMDIR, RSYNC, RSYNC_PEER, SRCDIR, TODIR,
checkit, run_rsync, test_fail,
checkit, run_rsync, test_fail, rsync_path_arg, rsh_cmd,
)
# Set RSYNC_RSH so rsync picks up lsh.sh for the "lh:" hosts below.
os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
os.environ['RSYNC_RSH'] = rsh_cmd()
# Basic help dumps must not crash.
if run_rsync('--version', check=False).returncode != 0:
@@ -39,7 +39,7 @@ def append_line(line: str) -> None:
def copy_weird(args: list, src_host: str, dst_host: str) -> None:
checkit(
[*args, f'--rsync-path={RSYNC_PEER}',
[*args, f'--rsync-path={rsync_path_arg()}',
f'{src_host}{weird_dir}/',
f'{dst_host}{TODIR / weird_name}'],
FROMDIR, TODIR,
@@ -70,7 +70,7 @@ print('test6')
saved = os.getcwd()
os.chdir(FROMDIR)
try:
run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC_PEER}',
run_rsync('-ai', '--old-args', f'--rsync-path={rsync_path_arg()}',
'lh:one two', f'{TODIR}/')
finally:
os.chdir(saved)
@@ -91,7 +91,7 @@ from rsyncfns import rsync_argv
os.chdir(FROMDIR)
try:
subprocess.run(
rsync_argv('-ai', f'--rsync-path={RSYNC_PEER}',
rsync_argv('-ai', f'--rsync-path={rsync_path_arg()}',
'lh:one two', f'{TODIR}/'),
env=env, check=True,
)
+3 -3
View File
@@ -12,7 +12,7 @@ import time
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
checkit, hands_setup, rmtree, run_rsync, test_fail,
checkit, hands_setup, rmtree, run_rsync, test_fail, rsync_path_arg, rsh_cmd,
)
@@ -20,7 +20,7 @@ alt1dir = TMPDIR / 'alt1'
alt2dir = TMPDIR / 'alt2'
alt3dir = TMPDIR / 'alt3'
SSH = str(SRCDIR / 'support' / 'lsh.sh')
SSH = rsh_cmd()
hands_setup()
@@ -73,7 +73,7 @@ for maybe_inplace in ([], ['--inplace']):
for srchost in ('', 'localhost:'):
desthost = 'localhost:' if not srchost else ''
rmtree(TODIR)
checkit(['-ave', SSH, f'--rsync-path={RSYNC_PEER}', *maybe_inplace,
checkit(['-ave', SSH, f'--rsync-path={rsync_path_arg()}', *maybe_inplace,
f'--copy-dest={alt3dir}',
f'{srchost}{FROMDIR}/', f'{desthost}{TODIR}/'],
FROMDIR, TODIR)
+2 -1
View File
@@ -35,6 +35,7 @@
# - neither flag -> the injection never took effect -> FAIL (vacuous)
import os
import shlex
import subprocess
import time
from pathlib import Path
@@ -154,7 +155,7 @@ def attempt():
conf = write_daemon_conf(
[('m', {'path': str(serversrc), 'read only': 'yes', 'use chroot': 'no'})],
name='mal-xname-rsyncd.conf')
os.environ['RSYNC_CONNECT_PROG'] = f'{mal_rsync} --config={conf} --daemon'
os.environ['RSYNC_CONNECT_PROG'] = f'{shlex.quote(str(mal_rsync))} --config={shlex.quote(str(conf))} --daemon'
os.environ['RSYNC_MAL_XNAME'] = '../secret' # from basis_dir[0] == linkdest
proc = subprocess.run(
rsync_argv('-a', f'--link-dest={linkdest}',
+2 -2
View File
@@ -16,14 +16,14 @@ import subprocess
from rsyncfns import (
CHKFILE, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR,
build_rsyncd_conf, get_rootuid, get_testuid, makepath,
rsync_argv, run_rsync, start_test_daemon, test_fail,
rsync_argv, run_rsync, start_test_daemon, test_fail, rsh_cmd,
)
DAEMON_PORT = 12877
SSH = f"{SRCDIR / 'support' / 'lsh.sh'} --no-cd"
SSH = rsh_cmd(None, '--no-cd')
def listed_paths(text: str) -> set:
+13 -13
View File
@@ -17,7 +17,7 @@ import sys
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
all_plus, allspace, dots,
checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail,
checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail, rsync_path_arg, rsh_cmd,
)
@@ -25,8 +25,8 @@ os.environ['CVSIGNORE'] = '*.junk'
script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
if 'lsh' in script_name:
os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
rpath = [f'--rsync-path={RSYNC_PEER}']
os.environ['RSYNC_RSH'] = rsh_cmd()
rpath = [f'--rsync-path={rsync_path_arg()}']
host = 'lh:'
else:
rpath = []
@@ -116,7 +116,7 @@ excl.write_text(
# --- main checks ------------------------------------------------------------
# Start with a check of --prune-empty-dirs.
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'-f', '-_foo/too/', '-f', '-_foo/down/',
'-f', '-_foo/and/', '-f', '-_new/',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
@@ -162,7 +162,7 @@ for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('file[235-9]'):
(up2 / 'dst-newness').touch()
# Un-tweak the directory times in our first (weak) exclude test.
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--existing', '--include=*/', '--exclude=*',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
@@ -181,7 +181,7 @@ for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.junk'):
(CHKDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').unlink()
(CHKDIR / 'mid' / 'one-in-one-out').unlink()
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--existing', '--filter=exclude,! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
@@ -205,7 +205,7 @@ from rsyncfns import cp_touch
cp_touch(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to',
CHKDIR / 'bar' / 'down' / 'to' / 'foo')
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--existing', '-f', 'show .filt*', '-f', 'hide,! */', '--del',
f'{host}{FROMDIR}/', f'{TODIR}/')
@@ -213,7 +213,7 @@ run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
cp_touch(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep',
CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz')
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--existing', '--filter=-! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
@@ -255,7 +255,7 @@ verify_dirs(CHKDIR, TODIR, label="dir-merge + merge-from-stdin")
(CHKDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').unlink()
(CHKDIR / 'mid' / '.filt').unlink()
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--existing', '--include=*/', '--exclude=*',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
@@ -275,15 +275,15 @@ verify_dirs(CHKDIR, TODIR, label="delete-before with merge")
"+ file3\n*.bak\n"
)
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--del', f'{host}{FROMDIR}/', f'{CHKDIR}/')
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').unlink()
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--existing', '--filter=-! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
run_rsync('-av', f'--rsync-path={rsync_path_arg()}',
'--delete-excluded', '--exclude=*',
f'{host}{FROMDIR}/', f'{TODIR}/')
@@ -293,7 +293,7 @@ checkit(['-avv', *rpath, '-f', 'dir-merge,-_.excl',
# Combine with --relative.
relative_opts = ['--relative', '--chmod=Du+w', '--copy-unsafe-links']
run_rsync('-av', f'--rsync-path={RSYNC_PEER}', *relative_opts,
run_rsync('-av', f'--rsync-path={rsync_path_arg()}', *relative_opts,
f'{host}{FROMDIR}/foo', f'{CHKDIR}/')
shutil.rmtree(str(CHKDIR) + str(FROMDIR) + '/foo/down', ignore_errors=True)
run_rsync('-av', *relative_opts, '--existing', '--filter=-! */',
+3 -3
View File
@@ -7,11 +7,11 @@
from rsyncfns import (
CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR,
checkit, hands_setup, rmtree, run_rsync,
checkit, hands_setup, rmtree, run_rsync, rsync_path_arg, rsh_cmd,
)
SSH = str(SRCDIR / 'support' / 'lsh.sh')
SSH = rsh_cmd()
hands_setup()
@@ -44,7 +44,7 @@ for filehost in ('', 'localhost:'):
rmtree(TODIR)
checkit(
['-avse', SSH, f'--rsync-path={RSYNC_PEER}',
['-avse', SSH, f'--rsync-path={rsync_path_arg()}',
f'--files-from={filehost}{filelist}',
f'{srchost}{SCRATCHDIR}', f'{desthost}{TODIR}/'],
CHKDIR, TODIR,
+3 -3
View File
@@ -13,11 +13,11 @@ import subprocess
from rsyncfns import (
CHKDIR, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SRCDIR, TODIR,
checkit, makepath, rsync_argv, test_fail, test_skipped,
checkit, makepath, rsync_argv, test_fail, test_skipped, rsync_path_arg, rsh_cmd,
)
SSH = str(SRCDIR / 'support' / 'lsh.sh')
SSH = rsh_cmd()
FROMDIR.mkdir(parents=True, exist_ok=True)
name1 = FROMDIR / 'name1'
@@ -64,7 +64,7 @@ for x in chars:
os.link(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file')
(TODIR / 'text').unlink()
checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC_PEER}',
checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={rsync_path_arg()}',
f'{FROMDIR}/', f'localhost:{TODIR}/'], FROMDIR, TODIR)
# --link-dest and --copy-dest should also keep hard-linked entries.
@@ -42,6 +42,7 @@
# diagnostic).
import os
import shlex
import shutil
import subprocess
@@ -126,7 +127,7 @@ conf = write_daemon_conf(
'read only': 'yes',
'use chroot': 'no'})],
name='mal-rsyncd.conf')
os.environ['RSYNC_CONNECT_PROG'] = f'{mal_rsync} --config={conf} --daemon'
os.environ['RSYNC_CONNECT_PROG'] = f'{shlex.quote(str(mal_rsync))} --config={shlex.quote(str(conf))} --daemon'
# -- The attack pull: -rR --delete from the malicious daemon -----------------
@@ -9,7 +9,7 @@
import os
import subprocess
from rsyncfns import SCRATCHDIR, SRCDIR, rmtree, rsync_argv, test_fail
from rsyncfns import SCRATCHDIR, SRCDIR, rmtree, rsync_argv, test_fail, rsh_cmd
base = SCRATCHDIR / 'remote-shell-newline'
rmtree(base)
@@ -19,7 +19,7 @@ src.mkdir(parents=True)
sentinel = base / 'pwned'
env = os.environ.copy()
env['RSYNC_RSH'] = f"{SRCDIR / 'support' / 'lsh.sh'} --no-cd"
env['RSYNC_RSH'] = rsh_cmd(None, '--no-cd')
dest = f"lh:{base}/dest\ntouch {sentinel}\n#"
subprocess.run(rsync_argv('-a', f'{src}/', dest),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
@@ -10,6 +10,7 @@ out-of-tree content lands where it can be read back.
"""
import os
import shlex
import signal
import subprocess
import sys
@@ -25,7 +26,8 @@ if '--shell' in sys.argv:
os.execve(wrapper, [wrapper, '-wo', root], env)
from rsyncfns import (RSYNC, SCRATCHDIR, makepath, patched_rrsync,
proc_self_fd_pins, rmtree, rsync_argv)
proc_self_fd_pins, rmtree, rsync_argv, rsync_path_arg,
)
T = 1234567890
@@ -59,10 +61,10 @@ os.utime(outside / 'f0', (T, T))
# 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 + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{sys.executable} {os.path.abspath(__file__)} --shell'
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(
@@ -2,6 +2,7 @@
"""rrsync must not let one transfer create and consume an option-path symlink."""
import os
import shlex
import signal
import subprocess
import sys
@@ -18,7 +19,8 @@ if '--shell' in sys.argv:
os.execve(wrapper, [wrapper, '-wo', root], env)
from rsyncfns import (RSYNC, SCRATCHDIR, makepath, patched_rrsync,
proc_self_fd_pins, rmtree, rsync_argv)
proc_self_fd_pins, rmtree, rsync_argv, rsync_path_arg,
)
# Handing rsync an inode instead of a name needs the /proc/self/fd primitive.
# Where it is missing there is no safe way to pass a peer-suppliable option
@@ -43,10 +45,10 @@ os.symlink('../outside', src / 'a')
# 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 + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{sys.executable} {os.path.abspath(__file__)} --shell'
rsh = f'{shlex.quote(sys.executable)} {shlex.quote(os.path.abspath(__file__))} --shell'
env = {**os.environ, 'RRSYNC_WRAPPER': str(wrapper), 'RRSYNC_ROOT': str(root)}
seed = subprocess.run(
+2 -2
View File
@@ -25,7 +25,7 @@ import shlex
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, test_fail,
RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, test_fail, rsync_path_arg,
)
DATA = 'FILES-FROM-CONTENT\n'
@@ -47,7 +47,7 @@ listfile.write_text('f1\nf2\n')
# $RSYNC may carry a wrapper (valgrind, --protocol=N); rrsync wants a single
# executable, so hand it a shim that re-expands the whole command line.
shim = base / 'rsync-shim'
shim.write_text('#!/bin/sh\nexec ' + RSYNC + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
rrsync = patched_rrsync(base, rsync_path=str(shim))
@@ -8,6 +8,7 @@ file can land on a protected name that the source itself also carries.
"""
import os
import shlex
import signal
import subprocess
import sys
@@ -23,7 +24,7 @@ if '--shell' in sys.argv:
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
from rsyncfns import RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, rsync_path_arg
base = SCRATCHDIR / 'rrsync-no-overwrite-backup-collision'
rmtree(base)
@@ -46,10 +47,10 @@ plant(root)
# 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 + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{sys.executable} {os.path.abspath(__file__)} --shell'
rsh = f'{shlex.quote(sys.executable)} {shlex.quote(os.path.abspath(__file__))} --shell'
env = {**os.environ, 'RRSYNC_WRAPPER': str(wrapper), 'RRSYNC_ROOT': str(root)}
# The stock client sends -b inside the remote short-option bundle, so the
@@ -2,6 +2,7 @@
"""rrsync -no-overwrite must protect implicit delay-update partial files."""
import os
import shlex
import signal
import subprocess
import sys
@@ -17,7 +18,7 @@ if '--shell' in sys.argv:
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
from rsyncfns import RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, rsync_path_arg
base = SCRATCHDIR / 'rrsync-no-overwrite-delay-updates'
rmtree(base)
@@ -32,10 +33,10 @@ protected.write_bytes(b'POLICY')
# 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 + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{sys.executable} {os.path.abspath(__file__)} --shell'
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', '--delay-updates', '-e', rsh,
@@ -2,6 +2,7 @@
"""rrsync -no-overwrite must not let the peer append to an existing file."""
import os
import shlex
import signal
import subprocess
import sys
@@ -17,7 +18,7 @@ if '--shell' in sys.argv:
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
from rsyncfns import RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, rsync_path_arg
base = SCRATCHDIR / 'rrsync-no-overwrite-logfile'
rmtree(base)
@@ -32,10 +33,10 @@ protected.write_bytes(b'POLICY\n')
# 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 + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{sys.executable} {os.path.abspath(__file__)} --shell'
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', '-M--log-file=protected',
@@ -2,6 +2,7 @@
"""rrsync -no-overwrite must protect existing partial-dir files too."""
import os
import shlex
import signal
import subprocess
import sys
@@ -17,7 +18,7 @@ if '--shell' in sys.argv:
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
from rsyncfns import RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, rsync_path_arg
base = SCRATCHDIR / 'rrsync-no-overwrite-partial-dir'
rmtree(base)
@@ -32,10 +33,10 @@ protected.write_bytes(b'POLICY')
# 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 + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{sys.executable} {os.path.abspath(__file__)} --shell'
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',
+2 -2
View File
@@ -23,7 +23,7 @@ import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR, forced_protocol, makepath, patched_rrsync, rmtree,
rsync_argv, test_fail,
rsync_argv, test_fail, rsync_path_arg,
)
base = SCRATCHDIR / 'rrsync-arg-shapes'
@@ -53,7 +53,7 @@ xonly.mkdir()
xonly.chmod(0o111)
shim = base / 'rsync-shim'
shim.write_text('#!/bin/sh\nexec ' + RSYNC + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
rrsync = patched_rrsync(base, rsync_path=str(shim))
@@ -28,7 +28,7 @@ import shlex
import subprocess
from rsyncfns import (
RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, test_fail,
RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, test_fail, rsync_path_arg,
)
PULL_DATA = 'REAL-FILE-CONTENT\n'
@@ -50,7 +50,7 @@ makepath(restricted / 'inbox')
# $RSYNC may carry a wrapper (valgrind, --protocol=N); rrsync wants a single
# executable, so hand it a shim that re-expands the whole command line.
shim = base / 'rsync-shim'
shim.write_text('#!/bin/sh\nexec ' + RSYNC + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
rrsync = patched_rrsync(base, rsync_path=str(shim))
+2 -2
View File
@@ -38,7 +38,7 @@ import time
from rsyncfns import (
RACE_TIMEOUT, RSYNC, SCRATCHDIR, makepath, patched_rrsync,
proc_self_fd_pins, rmtree, rsync_argv, start_path_flipper, stop_flipper,
test_fail, test_skipped,
test_fail, test_skipped, rsync_path_arg,
)
if not proc_self_fd_pins():
@@ -58,7 +58,7 @@ makepath(restricted, outside / 'dir', dest)
(outside / 'dir' / 'loot').write_text(MARKER + '\n')
shim = base / 'rsync-shim'
shim.write_text('#!/bin/sh\nexec ' + RSYNC + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
rrsync = patched_rrsync(base, rsync_path=str(shim))
+3 -3
View File
@@ -34,7 +34,7 @@ import subprocess
from rsyncfns import (
RSYNC, RSYNC_PREFIX, SCRATCHDIR, patched_rrsync, rmtree, rsync_argv,
test_fail, xattr_set, xattrs_supported,
test_fail, xattr_set, xattrs_supported, rsync_path_arg,
)
base = SCRATCHDIR / 'rrsync-specials'
@@ -113,7 +113,7 @@ served.mkdir()
pushwrap = base / 'pushwrap'
pushwrap.mkdir()
shim = base / 'rsync-shim'
shim.write_text('#!/bin/sh\nexec ' + RSYNC + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
rrsync_push = patched_rrsync(pushwrap, rsync_path=str(shim))
rsh = base / 'fake-rsh'
@@ -175,7 +175,7 @@ if xattrs_supported():
devwrap = base / 'devwrap'
devwrap.mkdir()
devshim = base / 'rsync-shim-fake'
devshim.write_text('#!/bin/sh\nexec ' + RSYNC + ' --fake-super "$@"\n')
devshim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' --fake-super "$@"\n')
devshim.chmod(0o755)
rrsync_dev = patched_rrsync(devwrap, rsync_path=str(devshim))
devrsh = base / 'fake-rsh-dev'
@@ -2,6 +2,7 @@
"""A forced rrsync -wo endpoint must not return a local --files-from file."""
import os
import shlex
import signal
import subprocess
import sys
@@ -16,7 +17,7 @@ if '--shell' in sys.argv:
wrapper, root = env['RRSYNC_WRAPPER'], env['RRSYNC_ROOT']
os.execve(wrapper, [wrapper, '-wo', '-no-lock', root], env)
from rsyncfns import RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv
from rsyncfns import RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv, rsync_path_arg
base = SCRATCHDIR / 'rrsync-write-only-files-from'
rmtree(base)
@@ -30,10 +31,10 @@ secret_name = 'SERVER-ONLY-SECRET-NAME'
# 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 + ' "$@"\n')
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
shim.chmod(0o755)
wrapper = patched_rrsync(base, rsync_path=str(shim))
rsh = f'{sys.executable} {os.path.abspath(__file__)} --shell'
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('-r', '--files-from=:protected-list',
+38 -2
View File
@@ -23,6 +23,7 @@ import filecmp
import errno
import os
import platform
import re
import shlex
import shutil
import signal
@@ -95,6 +96,14 @@ def split_rsync_cmd(cmd: str) -> list:
"""
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)
@@ -658,7 +667,10 @@ def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str:
port = claim_free_port(port)
start_rsyncd(conf_path, port, daemon_cmd)
return f'rsync://localhost:{port}/'
os.environ['RSYNC_CONNECT_PROG'] = f'{daemon_cmd} --config={conf_path} --daemon'
# 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/'
@@ -677,7 +689,7 @@ def require_asan(reason: str, which: str = None) -> 'None':
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 = shlex.split(which or RSYNC_PEER)
cmd = split_rsync_cmd(which or RSYNC_PEER)
try:
r = subprocess.run(cmd + ['--version'],
env={**os.environ, 'ASAN_OPTIONS': 'help=1'},
@@ -690,6 +702,30 @@ def require_asan(reason: str, which: str = None) -> 'None':
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.
+3 -3
View File
@@ -7,10 +7,10 @@
import os
import subprocess
from rsyncfns import (
FROMDIR, RSYNC_PEER, SRCDIR, SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail,
FROMDIR, RSYNC_PEER, SRCDIR, SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail, rsync_path_arg, rsh_cmd,
)
LSH = str(SRCDIR / 'support' / 'lsh.sh')
LSH = rsh_cmd()
os.environ['RSYNC_RSH'] = LSH
src = FROMDIR
@@ -27,7 +27,7 @@ def run(label, extra, local=False):
argv = rsync_argv('-a', *extra, f'{src}/', f'{dest}/')
else:
argv = rsync_argv('-a', *extra, '-e', LSH,
f'--rsync-path={RSYNC_PEER}', f'{src}/', f'localhost:{dest}/')
f'--rsync-path={rsync_path_arg()}', f'{src}/', f'localhost:{dest}/')
r = subprocess.run(argv, capture_output=True, text=True)
if r.returncode < 0 or r.returncode >= 128:
test_fail(f'{label}: rsync crashed (rc={r.returncode}): {r.stderr.strip()[:200]}')
+4 -4
View File
@@ -12,11 +12,11 @@ import subprocess
from rsyncfns import (
FROMDIR, SRCDIR, TODIR,
checkit, hands_setup, runtest, test_skipped,
checkit, hands_setup, runtest, test_skipped, rsync_path_arg, rsh_cmd,
)
SSH = str(SRCDIR / 'support' / 'lsh.sh')
SSH = rsh_cmd()
# Allow opting into real ssh via the shconfig variable, like the shell test.
if os.environ.get('rsync_enable_ssh_tests') == 'yes':
@@ -42,13 +42,13 @@ from rsyncfns import RSYNC, RSYNC_PEER
def _basic():
checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}',
checkit(['-avH', '-e', SSH, f'--rsync-path={rsync_path_arg()}',
f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
def _delete_after_rename():
shutil.move(str(TODIR / 'text'), str(TODIR / 'ThisShouldGo'))
checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}',
checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={rsync_path_arg()}',
f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
+3 -3
View File
@@ -19,7 +19,7 @@ import time
from rsyncfns import (
RSYNC, SCRATCHDIR, RSYNC_PEER, SRCDIR, TMPDIR,
make_data_file, rsync_argv, test_fail,
make_data_file, rsync_argv, test_fail, rsync_path_arg, rsh_cmd,
)
@@ -28,7 +28,7 @@ from rsyncfns import (
# target off held dirfds (issue #715), so this transfer through a dir-symlinked
# basis succeeds uniformly.
os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
os.environ['RSYNC_RSH'] = rsh_cmd()
# HOME -> SCRATCHDIR is set up by rsyncfns import.
srcbase = TMPDIR / 'src_files' # avoid clash with the runner's $scratchdir/src symlink
@@ -50,7 +50,7 @@ def push(*args, label: str) -> None:
os.chdir(srcbase)
try:
proc = subprocess.run(
rsync_argv(f'--rsync-path={RSYNC_PEER}', *args),
rsync_argv(f'--rsync-path={rsync_path_arg()}', *args),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
print(proc.stdout, end='')
+2 -2
View File
@@ -26,10 +26,10 @@ from rsyncfns import (
RSYNC, RSYNC_PEER, SRCDIR, SCRATCHDIR,
make_variety_tree, compare_trees, rmtree,
xattrs_supported, acls_supported, devices_supported, owners_supported,
test_skipped, test_fail, split_rsync_cmd,
test_skipped, test_fail, split_rsync_cmd, rsh_cmd,
)
SSH = str(SRCDIR / 'support' / 'lsh.sh')
SSH = rsh_cmd()
WITH_X = xattrs_supported()
WITH_A = acls_supported()
+2 -2
View File
@@ -34,10 +34,10 @@ from rsyncfns import (
make_variety_tree, compare_trees, rmtree,
xattrs_supported, acls_supported, devices_supported, owners_supported,
write_daemon_conf, start_test_daemon,
test_fail, split_rsync_cmd,
test_fail, split_rsync_cmd, rsh_cmd,
)
SSH = str(SRCDIR / 'support' / 'lsh.sh')
SSH = rsh_cmd()
DAEMON_PORT_OLD = 12931
DAEMON_PORT_NEW = 12932