diff --git a/testsuite/00-hello_test.py b/testsuite/00-hello_test.py index 9077ab97..cdc15379 100644 --- a/testsuite/00-hello_test.py +++ b/testsuite/00-hello_test.py @@ -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, ) diff --git a/testsuite/alt-dest_test.py b/testsuite/alt-dest_test.py index d5bb37b2..19bce222 100644 --- a/testsuite/alt-dest_test.py +++ b/testsuite/alt-dest_test.py @@ -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) diff --git a/testsuite/basis-xname-traversal_test.py b/testsuite/basis-xname-traversal_test.py index a51e6efe..de004c0e 100644 --- a/testsuite/basis-xname-traversal_test.py +++ b/testsuite/basis-xname-traversal_test.py @@ -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}', diff --git a/testsuite/daemon_test.py b/testsuite/daemon_test.py index 525c7c70..3b1adda4 100644 --- a/testsuite/daemon_test.py +++ b/testsuite/daemon_test.py @@ -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: diff --git a/testsuite/exclude_test.py b/testsuite/exclude_test.py index bd528f61..a35901c2 100644 --- a/testsuite/exclude_test.py +++ b/testsuite/exclude_test.py @@ -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=-! */', diff --git a/testsuite/files-from_test.py b/testsuite/files-from_test.py index 0d10d2d5..faab43b9 100644 --- a/testsuite/files-from_test.py +++ b/testsuite/files-from_test.py @@ -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, diff --git a/testsuite/hardlinks_test.py b/testsuite/hardlinks_test.py index a578d63c..728264f0 100644 --- a/testsuite/hardlinks_test.py +++ b/testsuite/hardlinks_test.py @@ -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. diff --git a/testsuite/malicious-sender-delete-scope_test.py b/testsuite/malicious-sender-delete-scope_test.py index 3fb32463..34f10943 100644 --- a/testsuite/malicious-sender-delete-scope_test.py +++ b/testsuite/malicious-sender-delete-scope_test.py @@ -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 ----------------- diff --git a/testsuite/remote-shell-newline-escaping_test.py b/testsuite/remote-shell-newline-escaping_test.py index 3c339fc5..89b28147 100644 --- a/testsuite/remote-shell-newline-escaping_test.py +++ b/testsuite/remote-shell-newline-escaping_test.py @@ -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) diff --git a/testsuite/rrsync-alt-dest-inband-pivot_test.py b/testsuite/rrsync-alt-dest-inband-pivot_test.py index 3b6cefaa..ad60128d 100644 --- a/testsuite/rrsync-alt-dest-inband-pivot_test.py +++ b/testsuite/rrsync-alt-dest-inband-pivot_test.py @@ -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( diff --git a/testsuite/rrsync-backup-dir-inband-pivot_test.py b/testsuite/rrsync-backup-dir-inband-pivot_test.py index e15d9d1d..3a2a15a4 100644 --- a/testsuite/rrsync-backup-dir-inband-pivot_test.py +++ b/testsuite/rrsync-backup-dir-inband-pivot_test.py @@ -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( diff --git a/testsuite/rrsync-files-from-stdin_test.py b/testsuite/rrsync-files-from-stdin_test.py index 13696abf..24eabeae 100644 --- a/testsuite/rrsync-files-from-stdin_test.py +++ b/testsuite/rrsync-files-from-stdin_test.py @@ -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)) diff --git a/testsuite/rrsync-no-overwrite-backup-collision_test.py b/testsuite/rrsync-no-overwrite-backup-collision_test.py index 308e3e2d..c3009a8f 100644 --- a/testsuite/rrsync-no-overwrite-backup-collision_test.py +++ b/testsuite/rrsync-no-overwrite-backup-collision_test.py @@ -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 diff --git a/testsuite/rrsync-no-overwrite-delay-updates_test.py b/testsuite/rrsync-no-overwrite-delay-updates_test.py index c2e681cf..0cd53f09 100644 --- a/testsuite/rrsync-no-overwrite-delay-updates_test.py +++ b/testsuite/rrsync-no-overwrite-delay-updates_test.py @@ -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, diff --git a/testsuite/rrsync-no-overwrite-logfile_test.py b/testsuite/rrsync-no-overwrite-logfile_test.py index edfb4b05..89d6b6e3 100644 --- a/testsuite/rrsync-no-overwrite-logfile_test.py +++ b/testsuite/rrsync-no-overwrite-logfile_test.py @@ -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', diff --git a/testsuite/rrsync-no-overwrite-partial-dir_test.py b/testsuite/rrsync-no-overwrite-partial-dir_test.py index fe5ae17f..5b5a15e4 100644 --- a/testsuite/rrsync-no-overwrite-partial-dir_test.py +++ b/testsuite/rrsync-no-overwrite-partial-dir_test.py @@ -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', diff --git a/testsuite/rrsync-pull-arg-shapes_test.py b/testsuite/rrsync-pull-arg-shapes_test.py index eff3ffc4..f359f70c 100644 --- a/testsuite/rrsync-pull-arg-shapes_test.py +++ b/testsuite/rrsync-pull-arg-shapes_test.py @@ -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)) diff --git a/testsuite/rrsync-pull-delivers-content_test.py b/testsuite/rrsync-pull-delivers-content_test.py index 668abdd2..4947a58f 100644 --- a/testsuite/rrsync-pull-delivers-content_test.py +++ b/testsuite/rrsync-pull-delivers-content_test.py @@ -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)) diff --git a/testsuite/rrsync-sender-leaf-flip_test.py b/testsuite/rrsync-sender-leaf-flip_test.py index d53db605..45e80f8f 100644 --- a/testsuite/rrsync-sender-leaf-flip_test.py +++ b/testsuite/rrsync-sender-leaf-flip_test.py @@ -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)) diff --git a/testsuite/rrsync-specials-denied_test.py b/testsuite/rrsync-specials-denied_test.py index 2b40949c..60d83723 100644 --- a/testsuite/rrsync-specials-denied_test.py +++ b/testsuite/rrsync-specials-denied_test.py @@ -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' diff --git a/testsuite/rrsync-write-only-files-from_test.py b/testsuite/rrsync-write-only-files-from_test.py index 41f4f8e4..3d24f99f 100644 --- a/testsuite/rrsync-write-only-files-from_test.py +++ b/testsuite/rrsync-write-only-files-from_test.py @@ -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', diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index ad532841..c7bdabe5 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -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. diff --git a/testsuite/scanner-argv-bounds_test.py b/testsuite/scanner-argv-bounds_test.py index 02682877..f2eeea17 100644 --- a/testsuite/scanner-argv-bounds_test.py +++ b/testsuite/scanner-argv-bounds_test.py @@ -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]}') diff --git a/testsuite/ssh-basic_test.py b/testsuite/ssh-basic_test.py index eb658118..a02d55e9 100644 --- a/testsuite/ssh-basic_test.py +++ b/testsuite/ssh-basic_test.py @@ -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) diff --git a/testsuite/symlink-dirlink-basis_test.py b/testsuite/symlink-dirlink-basis_test.py index 9785a290..ffe368e2 100644 --- a/testsuite/symlink-dirlink-basis_test.py +++ b/testsuite/symlink-dirlink-basis_test.py @@ -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='') diff --git a/testsuite/variety-symlink-traversal_test.py b/testsuite/variety-symlink-traversal_test.py index 89762d3a..9d37b3d4 100644 --- a/testsuite/variety-symlink-traversal_test.py +++ b/testsuite/variety-symlink-traversal_test.py @@ -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() diff --git a/testsuite/variety_test.py b/testsuite/variety_test.py index bff73972..dfea53a3 100644 --- a/testsuite/variety_test.py +++ b/testsuite/variety_test.py @@ -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