From b514d0a3bbe557026e780d996d7e9625e6dc5a89 Mon Sep 17 00:00:00 2001 From: lichenggang Date: Thu, 13 Aug 2026 19:09:03 +0800 Subject: [PATCH 1/3] rsync-ssl, testsuite: accept --type=SSL_TYPE anywhere in the args The --type=... option was only recognized as the first argument, so a command such as "rsync-ssl --dry-run --type=stunnel host::mod" passed the option through to the underlying rsync, which rejected it as unknown. Scan the full argument list for --type=..., export RSYNC_SSL_TYPE, and drop the option before handing the remaining args to rsync. The manpage no longer says the option must be first. Adds a test that runs rsync-ssl with a fake rsync in PATH and checks that --type= is consumed in first, middle, and last positions. --- rsync-ssl | 13 +++-- rsync-ssl.1.md | 2 +- testsuite/rsync-ssl-type-option_test.py | 65 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 testsuite/rsync-ssl-type-option_test.py diff --git a/rsync-ssl b/rsync-ssl index 8aa2ae642..ca021e39b 100755 --- a/rsync-ssl +++ b/rsync-ssl @@ -226,9 +226,14 @@ if [[ "$1" == --HELPER ]]; then rsync_ssl_helper "${@}" fi -if [[ "$1" == --type=* ]]; then - export RSYNC_SSL_TYPE="${1/--type=/}" - shift -fi +args=() +for arg in "$@"; do + if [[ "$arg" == --type=* ]]; then + export RSYNC_SSL_TYPE="${arg#--type=}" + else + args+=("$arg") + fi +done +set -- "${args[@]}" rsync_ssl_run "${@}" diff --git a/rsync-ssl.1.md b/rsync-ssl.1.md index 6165f349f..33950b2a7 100644 --- a/rsync-ssl.1.md +++ b/rsync-ssl.1.md @@ -25,7 +25,7 @@ rsync version to be at least 3.2.0. ## OPTIONS -If the **first** arg is a `--type=SSL_TYPE` option, the script will only use +If an arg is a `--type=SSL_TYPE` option, the script will only use that particular program to open an ssl connection instead of trying to find an openssl or stunnel executable via a simple heuristic (assuming that the `RSYNC_SSL_TYPE` environment variable is not set as well -- see below). This diff --git a/testsuite/rsync-ssl-type-option_test.py b/testsuite/rsync-ssl-type-option_test.py new file mode 100644 index 000000000..ddd2f1a6c --- /dev/null +++ b/testsuite/rsync-ssl-type-option_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# rsync-ssl only recognized --type=SSL_TYPE as the FIRST argument, so +# "rsync-ssl --dry-run --type=stunnel host::mod" passed the option through to +# the underlying rsync, which rejected it with "--type=stunnel: unknown option". +# Fix: scan the whole argument list for --type=..., export RSYNC_SSL_TYPE, and +# drop the option before handing the remaining args to rsync. +# +# A fake rsync in PATH records the args it receives; rsync-ssl is run in its +# normal (non-HELPER) mode with --type= in various positions and the recorded +# args must contain every other option but never a --type= token. + +import os +import subprocess + +from rsyncfns import SCRATCHDIR, SRCDIR, rmtree, test_fail + +base = SCRATCHDIR / 'rsync-ssl-type-opt' +rmtree(base) +base.mkdir(parents=True) + +args_capture = base / 'rsync_args' +fakebin = base / 'bin' +fakebin.mkdir(parents=True) +fake_rsync = fakebin / 'rsync' +fake_rsync.write_text(f"#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > {args_capture}\nexit 0\n") +fake_rsync.chmod(0o755) + +env = {**os.environ, 'PATH': str(fakebin) + os.pathsep + os.environ.get('PATH', '')} +for v in ('RSYNC_SSL_TYPE', 'RSYNC_SSL_OPENSSL', 'RSYNC_SSL_STUNNEL'): + env.pop(v, None) + + +def run(args): + if args_capture.exists(): + args_capture.unlink() + subprocess.run(['bash', str(SRCDIR / 'rsync-ssl')] + args, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return args_capture.read_text().splitlines() if args_capture.exists() else [] + + +# --- The reported failure: --type= in the middle of the rsync args. +got = run(['--dry-run', '--type=stunnel', 'host::mod']) +if any(a.startswith('--type=') for a in got): + test_fail(f"--type= was passed through to rsync instead of being consumed:\n{got}") +for want in ('--dry-run', 'host::mod'): + if want not in got: + test_fail(f"missing rsync arg {want!r} after --type= handling:\n{got}") +if not any(a.startswith('--rsh=') for a in got): + test_fail(f"missing the --rsh= helper option:\n{got}") + +# --- First, last, and no --type= keep working. +for pos_args in (['--type=stunnel', '--dry-run', 'host::mod'], + ['-av', 'host::mod', '--type=openssl'], + ['-av', 'host::mod']): + got = run(pos_args) + if any(a.startswith('--type=') for a in got): + test_fail(f"--type= was passed through for args {pos_args!r}:\n{got}") + for want in pos_args: + if want.startswith('--type='): + continue + if want not in got: + test_fail(f"missing rsync arg {want!r} for args {pos_args!r}:\n{got}") + +print("rsync-ssl-type-option: --type=SSL_TYPE is consumed in any argument " + "position instead of being passed through to rsync") From 248633b8efe4b5c4d27cf3c172e20fa07cbbf9a4 Mon Sep 17 00:00:00 2001 From: lichenggang Date: Mon, 17 Aug 2026 15:01:28 +0800 Subject: [PATCH 2/3] fix(rsync-ssl): stop interpreting wrapper options at -- rsync-ssl consumed a --type=... operand that appeared after a -- argument, even though -- explicitly protects the rest of the command line from option parsing. Stop scanning for --type=... at a -- argument: preserve the -- and every subsequent argument verbatim, passing them through to rsync unchanged. Add regression coverage for this case and document the behavior in the manpage. --- rsync-ssl | 8 +++++++- rsync-ssl.1.md | 5 ++++- testsuite/rsync-ssl-type-option_test.py | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/rsync-ssl b/rsync-ssl index ca021e39b..a829387df 100755 --- a/rsync-ssl +++ b/rsync-ssl @@ -227,8 +227,14 @@ if [[ "$1" == --HELPER ]]; then fi args=() +dash_dash_seen=false for arg in "$@"; do - if [[ "$arg" == --type=* ]]; then + if [[ $dash_dash_seen == true ]]; then + args+=("$arg") + elif [[ "$arg" == "--" ]]; then + args+=("$arg") + dash_dash_seen=true + elif [[ "$arg" == --type=* ]]; then export RSYNC_SSL_TYPE="${arg#--type=}" else args+=("$arg") diff --git a/rsync-ssl.1.md b/rsync-ssl.1.md index 33950b2a7..030e56f02 100644 --- a/rsync-ssl.1.md +++ b/rsync-ssl.1.md @@ -30,7 +30,10 @@ that particular program to open an ssl connection instead of trying to find an openssl or stunnel executable via a simple heuristic (assuming that the `RSYNC_SSL_TYPE` environment variable is not set as well -- see below). This option must specify one of `openssl` or `stunnel`. The equal sign is -required for this particular option. +required for this particular option. The wrapper's option scan stops at a +`--` argument: the `--` and everything after it are passed through to rsync +unchanged, so a `--type=...` token after a `--` is not consumed by the +wrapper. All the other options are passed through to the rsync command, so consult the **rsync**(1) manpage for more information on how it works. diff --git a/testsuite/rsync-ssl-type-option_test.py b/testsuite/rsync-ssl-type-option_test.py index ddd2f1a6c..6996c19a3 100644 --- a/testsuite/rsync-ssl-type-option_test.py +++ b/testsuite/rsync-ssl-type-option_test.py @@ -4,6 +4,9 @@ # the underlying rsync, which rejected it with "--type=stunnel: unknown option". # Fix: scan the whole argument list for --type=..., export RSYNC_SSL_TYPE, and # drop the option before handing the remaining args to rsync. +# A `--` arg stops the wrapper-option scan: `--` and everything after it are +# passed through to rsync verbatim, so an operand such as `--type=stunnel` +# that was protected from option parsing is not consumed by the wrapper. # # A fake rsync in PATH records the args it receives; rsync-ssl is run in its # normal (non-HELPER) mode with --type= in various positions and the recorded @@ -61,5 +64,17 @@ def run(args): if want not in got: test_fail(f"missing rsync arg {want!r} for args {pos_args!r}:\n{got}") +# --- `--` stops the wrapper-option scan: the protected operand is preserved. +rsh_arg = "--rsh='{}' --HELPER".format(SRCDIR / 'rsync-ssl') +got = run(['--', '--type=stunnel', 'host::mod']) +if got != [rsh_arg, '--', '--type=stunnel', 'host::mod']: + test_fail(f"args after -- must be preserved verbatim (no --type= consumed):\n{got}") + +# --- A wrapper option before `--` is still consumed; the protected one is not. +got = run(['--type=openssl', '--', '--type=stunnel', 'host::mod']) +if got != [rsh_arg, '--', '--type=stunnel', 'host::mod']: + test_fail(f"--type= before -- is consumed, args after -- are preserved:\n{got}") + print("rsync-ssl-type-option: --type=SSL_TYPE is consumed in any argument " - "position instead of being passed through to rsync") + "position instead of being passed through to rsync (until a -- " + "stops the wrapper-option scan)") From 916b75be4d73d8fb9621647353a33a6d9fd3f907 Mon Sep 17 00:00:00 2001 From: lichenggang Date: Wed, 19 Aug 2026 20:55:22 +0800 Subject: [PATCH 3/3] test(rsync-ssl): assert RSYNC_SSL_TYPE and exit status in the type-option test The rsync-ssl-type-option test only checked that --type= tokens were removed from the rsync argv, so it would also pass if the wrapper silently discarded the requested SSL implementation instead of exporting RSYNC_SSL_TYPE. The fake rsync now records both the argv it receives and the RSYNC_SSL_TYPE value it observes; each invocation asserts the expected value (or UNSET when the wrapper must not consume anything), and run() fails the test unless rsync-ssl exits successfully. --- testsuite/rsync-ssl-type-option_test.py | 53 ++++++++++++++++--------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/testsuite/rsync-ssl-type-option_test.py b/testsuite/rsync-ssl-type-option_test.py index 6996c19a3..ae4a7e7b1 100644 --- a/testsuite/rsync-ssl-type-option_test.py +++ b/testsuite/rsync-ssl-type-option_test.py @@ -8,9 +8,11 @@ # passed through to rsync verbatim, so an operand such as `--type=stunnel` # that was protected from option parsing is not consumed by the wrapper. # -# A fake rsync in PATH records the args it receives; rsync-ssl is run in its -# normal (non-HELPER) mode with --type= in various positions and the recorded -# args must contain every other option but never a --type= token. +# A fake rsync in PATH records the args it receives and the RSYNC_SSL_TYPE it +# observes; rsync-ssl is run in its normal (non-HELPER) mode with --type= in +# various positions. The recorded args must contain every other option but +# never a --type= token, RSYNC_SSL_TYPE must match what the wrapper consumed, +# and rsync-ssl must exit successfully. import os import subprocess @@ -22,10 +24,15 @@ base.mkdir(parents=True) args_capture = base / 'rsync_args' +type_capture = base / 'rsync_ssl_type' fakebin = base / 'bin' fakebin.mkdir(parents=True) fake_rsync = fakebin / 'rsync' -fake_rsync.write_text(f"#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > {args_capture}\nexit 0\n") +fake_rsync.write_text( + f"#!/usr/bin/env bash\n" + f"printf '%s\\n' \"$@\" > {args_capture}\n" + f"printf '%s\\n' \"${{RSYNC_SSL_TYPE-UNSET}}\" > {type_capture}\n" + f"exit 0\n") fake_rsync.chmod(0o755) env = {**os.environ, 'PATH': str(fakebin) + os.pathsep + os.environ.get('PATH', '')} @@ -33,16 +40,24 @@ env.pop(v, None) -def run(args): - if args_capture.exists(): - args_capture.unlink() - subprocess.run(['bash', str(SRCDIR / 'rsync-ssl')] + args, env=env, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return args_capture.read_text().splitlines() if args_capture.exists() else [] +def run(args, expect_type): + for capture in (args_capture, type_capture): + if capture.exists(): + capture.unlink() + proc = subprocess.run(['bash', str(SRCDIR / 'rsync-ssl')] + args, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if proc.returncode != 0: + test_fail(f"rsync-ssl exited {proc.returncode} for args {args!r}:\n{proc.stdout}") + got_args = args_capture.read_text().splitlines() if args_capture.exists() else [] + got_type = type_capture.read_text().strip() if type_capture.exists() else 'UNSET' + if got_type != expect_type: + test_fail(f"RSYNC_SSL_TYPE is {got_type!r}, expected {expect_type!r} " + f"for args {args!r}:\n{proc.stdout}") + return got_args # --- The reported failure: --type= in the middle of the rsync args. -got = run(['--dry-run', '--type=stunnel', 'host::mod']) +got = run(['--dry-run', '--type=stunnel', 'host::mod'], 'stunnel') if any(a.startswith('--type=') for a in got): test_fail(f"--type= was passed through to rsync instead of being consumed:\n{got}") for want in ('--dry-run', 'host::mod'): @@ -52,10 +67,10 @@ def run(args): test_fail(f"missing the --rsh= helper option:\n{got}") # --- First, last, and no --type= keep working. -for pos_args in (['--type=stunnel', '--dry-run', 'host::mod'], - ['-av', 'host::mod', '--type=openssl'], - ['-av', 'host::mod']): - got = run(pos_args) +for pos_args, expect_type in ((['--type=stunnel', '--dry-run', 'host::mod'], 'stunnel'), + (['-av', 'host::mod', '--type=openssl'], 'openssl'), + (['-av', 'host::mod'], 'UNSET')): + got = run(pos_args, expect_type) if any(a.startswith('--type=') for a in got): test_fail(f"--type= was passed through for args {pos_args!r}:\n{got}") for want in pos_args: @@ -66,15 +81,15 @@ def run(args): # --- `--` stops the wrapper-option scan: the protected operand is preserved. rsh_arg = "--rsh='{}' --HELPER".format(SRCDIR / 'rsync-ssl') -got = run(['--', '--type=stunnel', 'host::mod']) +got = run(['--', '--type=stunnel', 'host::mod'], 'UNSET') if got != [rsh_arg, '--', '--type=stunnel', 'host::mod']: test_fail(f"args after -- must be preserved verbatim (no --type= consumed):\n{got}") # --- A wrapper option before `--` is still consumed; the protected one is not. -got = run(['--type=openssl', '--', '--type=stunnel', 'host::mod']) +got = run(['--type=openssl', '--', '--type=stunnel', 'host::mod'], 'openssl') if got != [rsh_arg, '--', '--type=stunnel', 'host::mod']: test_fail(f"--type= before -- is consumed, args after -- are preserved:\n{got}") print("rsync-ssl-type-option: --type=SSL_TYPE is consumed in any argument " - "position instead of being passed through to rsync (until a -- " - "stops the wrapper-option scan)") + "position (until a -- stops the wrapper-option scan), exported as " + "RSYNC_SSL_TYPE, and rsync-ssl exits successfully")