From e62b5ad167967d1e8746b55e82ee822ccf3b09ac Mon Sep 17 00:00:00 2001 From: Eduard Zateev Date: Wed, 21 Oct 2026 07:28:09 +0200 Subject: [PATCH 1/3] rrsync: pin the parent of existing receiver file arguments Passing an existing destination as the leaf's /proc/self/fd/N makes rsync lstat the procfs link and try to replace it, failing with EPERM. Use the held parent fd plus the leaf name, as for a missing destination. Keep directory arguments pinned directly and verify the parent's leaf still matches the already validated inode before releasing its fd. Reuse pin_dir() for the parent checks and execute-only directory support. Add end-to-end coverage for overwrite, creation, directories, pulls, rejected paths, an in-place leaf replacement and a parent rename. Based on the parent-pin approach described by JeanFrancoisGagne in #1107. Fixes #1107. Assisted-by: OpenAI Codex Signed-off-by: Eduard Zateev --- support/rrsync | 20 ++- testsuite/rrsync-receiver-file-arg_test.py | 148 +++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 testsuite/rrsync-receiver-file-arg_test.py diff --git a/support/rrsync b/support/rrsync index ace183fbe..0310a5da1 100755 --- a/support/rrsync +++ b/support/rrsync @@ -376,7 +376,7 @@ def create_pinned_dir(path, orig_arg, mode): pinned_fds.append(fd) return '/proc/self/fd/%d' % fd -# sender_pinned_arg() verdicts that are not a rewritten argument. +# Pinned-argument verdicts that are not a rewritten argument. KEEP_LEAF_PIN = 'keep' # hand rsync the leaf's own /proc/self/fd/N LEAF_PIN_UNUSABLE = 'unusable' # no pin for this shape; keep the plain name @@ -453,6 +453,22 @@ def sender_pinned_arg(fd, arg, orig_arg, has_slash, has_slash_dot): die('post-pin path changed (race?):', orig_arg, check) return pinned +def receiver_pinned_arg(fd, arg, orig_arg): + """Keep existing file destinations writable beneath a pinned parent.""" + leaf_st = os.fstat(fd) + if stat.S_ISDIR(leaf_st.st_mode): + return KEEP_LEAF_PIN + + parent, leaf = os.path.split(arg) + dfd = pin_dir(parent, orig_arg) + try: + st = os.stat(leaf, dir_fd=dfd, follow_symlinks=False) + except OSError as e: + die('post-pin stat failed (race?):', orig_arg, e.strerror) + if (st.st_dev, st.st_ino) != (leaf_st.st_dev, leaf_st.st_ino): + die('post-pin path changed (race?):', orig_arg, leaf) + return '/proc/self/fd/%d/%s' % (dfd, leaf) + def safe_open_logfile(): nofollow = getattr(os, 'O_NOFOLLOW', 0) try: @@ -928,6 +944,8 @@ def validated_arg(opt, arg, typ=3, wild=False): pinned = sender_pinned_arg(fd, logical, orig_arg, arg_has_trailing_slash, arg_has_trailing_slash_dot) + elif opt == 'arg': + pinned = receiver_pinned_arg(fd, real_arg, orig_arg) else: pinned = KEEP_LEAF_PIN if pinned == KEEP_LEAF_PIN: diff --git a/testsuite/rrsync-receiver-file-arg_test.py b/testsuite/rrsync-receiver-file-arg_test.py new file mode 100644 index 000000000..ef757f6d6 --- /dev/null +++ b/testsuite/rrsync-receiver-file-arg_test.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Receiver file arguments must remain beneath a pinned parent.""" + +import os +import shlex +import subprocess +import time + +from rsyncfns import ( + SCRATCHDIR, makepath, patched_rrsync, proc_self_fd_pins, rmtree, + rsync_argv, test_fail, test_skipped, +) + +if not proc_self_fd_pins(): + test_skipped('requires /proc/self/fd directory pins') + +base = SCRATCHDIR / 'rrsync-receiver-file' +rmtree(base) +restricted = base / 'restricted' +outside = base / 'outside' +source = base / 'source' +downloads = base / 'downloads' +makepath(restricted, outside, downloads) +source.write_text('replacement contents\n') + +# The optional gate pauses the real server after rrsync validates its argument. +shim = base / 'rsync-shim' +shim.write_text('''#!/usr/bin/env python3 +import os, pathlib, sys, time +ready = os.environ.get('RRSYNC_TEST_READY') +if ready: + pathlib.Path(ready).touch() + go = pathlib.Path(os.environ['RRSYNC_TEST_GO']) + end = time.monotonic() + 10 + while not go.exists(): + if time.monotonic() >= end: + sys.exit('timed out waiting for test gate') + time.sleep(0.01) +cmd = %r +os.execvp(cmd[0], cmd + sys.argv[1:]) +''' % rsync_argv()) +shim.chmod(0o755) +rrsync = patched_rrsync(base, rsync_path=str(shim)) +rsh = base / 'fake-rsh' +rsh.write_text('#!/bin/sh\nshift\nSSH_ORIGINAL_COMMAND="$*"\n' + 'export SSH_ORIGINAL_COMMAND\nexec %s -no-lock %s\n' + % (shlex.quote(str(rrsync)), shlex.quote(str(restricted)))) +rsh.chmod(0o755) + + +def command(src, dest, *options): + return rsync_argv('-ac', '-e', str(rsh), *options, str(src), str(dest)) + + +def transfer(src, dest, *options, succeeds=True): + proc = subprocess.run(command(src, dest, *options), capture_output=True, + text=True, timeout=20) + if (proc.returncode == 0) != succeeds: + test_fail(f'transfer {src} -> {dest}: rc={proc.returncode}, ' + f'{proc.stdout!r} {proc.stderr!r}') + return proc + + +def check_contents(path): + if path.is_symlink() or not path.is_file() or path.read_text() != source.read_text(): + test_fail(f'wrong file contents or type: {path}') + + +for option in ((), ('--inplace',)): + destination = restricted / 'existing' + destination.write_text('old\n') + transfer(source, 'dummy:existing', *option) + check_contents(destination) + new = restricted / 'new' + new.unlink(missing_ok=True) + transfer(source, 'dummy:new', *option) + check_contents(new) + +makepath(restricted / 'directory') +transfer(source, 'dummy:directory/') +check_contents(restricted / 'directory' / source.name) +transfer('dummy:existing', str(downloads) + '/') +check_contents(downloads / 'existing') + +marker = outside / 'untouched' +marker.write_text('outside fixture\n') +transfer(source, 'dummy:../outside/untouched', succeeds=False) +(restricted / 'outside-link').symlink_to(outside, target_is_directory=True) +transfer(source, 'dummy:outside-link/untouched', succeeds=False) +if marker.read_text() != 'outside fixture\n': + test_fail('a rejected destination changed the outside fixture') + + +def gated_transfer(name, change): + ready = base / 'ready' + go = base / 'go' + ready.unlink(missing_ok=True) + go.unlink(missing_ok=True) + env = {**os.environ, 'RRSYNC_TEST_READY': str(ready), 'RRSYNC_TEST_GO': str(go)} + proc = subprocess.Popen(command(source, f'dummy:{name}', '--inplace'), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, env=env) + try: + end = time.monotonic() + 10 + while not ready.exists(): + if proc.poll() is not None or time.monotonic() >= end: + test_fail('receiver did not reach the post-validation gate') + time.sleep(0.01) + change() + go.touch() + stdout, stderr = proc.communicate(timeout=20) + if marker.read_text() != 'outside fixture\n': + test_fail(f'in-place transfer changed the outside fixture: {stdout} {stderr}') + return proc.returncode + finally: + if proc.poll() is None: + proc.kill() + proc.communicate() + + +leaf = restricted / 'raced-leaf' +leaf.write_text('old\n') + + +def replace_leaf(): + leaf.unlink() + leaf.symlink_to(marker) + + +rc = gated_transfer('raced-leaf', replace_leaf) +if rc == 0: + check_contents(leaf) + +parent = restricted / 'raced-parent' +makepath(parent) +(parent / 'untouched').write_text('old\n') +saved_parent = restricted / 'saved-parent' + + +def replace_parent(): + parent.rename(saved_parent) + parent.symlink_to(outside, target_is_directory=True) + + +if gated_transfer('raced-parent/untouched', replace_parent) == 0: + check_contents(saved_parent / 'untouched') + +print('rrsync receiver file overwrite, creation, directory, pull and path checks passed') From b2fc2e6c28e4bc8761c44b888d14a635e6f440ee Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 22 Oct 2026 18:04:23 +1000 Subject: [PATCH 2/3] testsuite: expect rrsync file-argument skips --- testsuite/skiplist/cygwin.txt | 1 + testsuite/skiplist/macos.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 3e3aa4bb2..fb822fe30 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -58,6 +58,7 @@ pseudo-paths pseudo-paths-daemon read-batch-pipe rename-mixed-parent-transfer +rrsync-receiver-file-arg rrsync-sender-leaf-flip rrsync-sender-parent-pin rrsync-symlink diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index ac8658fdc..a7516cfda 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -26,6 +26,7 @@ pseudo-paths # Linux-only: procfs pseudo-paths pseudo-paths-daemon read-batch-pipe readonly-partial-abort-mode-regression # +rrsync-receiver-file-arg rrsync-sender-leaf-flip rrsync-sender-parent-pin rrsync-symlink From d8370ca4c41429691534cc20755fd73635f9abc3 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Thu, 22 Oct 2026 18:04:34 +1000 Subject: [PATCH 3/3] testsuite: allow slow aligned overflow scans --- testsuite/aligned_overflow_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testsuite/aligned_overflow_test.py b/testsuite/aligned_overflow_test.py index a1c170478..3007341b4 100755 --- a/testsuite/aligned_overflow_test.py +++ b/testsuite/aligned_overflow_test.py @@ -156,7 +156,7 @@ def drain_stream(): ) receiver.send_data(buf) - rc = proc.wait(timeout=120) + rc = proc.wait(timeout=240) t.join(timeout=5) finally: if csock is not None: