From 2b7bb2d523f05e0ab0072a35192dccec39fd4b8e Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 10 Sep 2026 20:36:05 +0200 Subject: [PATCH 1/3] feat(p6-protocol): --protocol version-force flag (client-only) --- src/client/client_cli.c | 6 ++++++ src/client/client_validation.c | 11 +++++++++++ src/client/usage.c | 3 +++ 3 files changed, 20 insertions(+) diff --git a/src/client/client_cli.c b/src/client/client_cli.c index c51b508..20bebfd 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -598,6 +598,12 @@ static const OptionEntry OPTION_TABLE[] = { * charsets at startup (client_validation.c) and the full spec rides the * config frame so the receiver derives the wire charset symmetrically. */ {"--iconv", NULL, OPT_STRING, offsetof(Config, iconv_spec)}, + /* --protocol=NUM: rsync-compatible flag that forces the wire protocol + * version to the current value. FastSync has exactly one wire format, so + * any value other than PROTOCOL_VERSION is rejected at validation, before + * any network I/O. Client-only: the server does not negotiate, it just + * enforces an exact match. */ + {"--protocol", NULL, OPT_STRING, offsetof(Config, version)}, {"--delete-before", NULL, OPT_FLAG, offsetof(Config, delete_before)}, {"--delete-during", "--del", OPT_FLAG, offsetof(Config, delete_during)}, {"--delete-delay", NULL, OPT_FLAG, offsetof(Config, delete_delay)}, diff --git a/src/client/client_validation.c b/src/client/client_validation.c index d42da2e..baa6fba 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -3,6 +3,7 @@ #include "delay_updates.h" #include "log.h" #include "usage.h" +#include #include /* Validate config after parsing. Returns true if valid. */ @@ -132,5 +133,15 @@ bool validate_config(const Config* config) { "--iconv requires LOCAL[,REMOTE] charset names supported by iconv"); return false; } + /* --protocol: FastSync has exactly one wire format, so the forced version + must equal the current PROTOCOL_VERSION exactly. Rejected here, before any + network I/O, rather than letting the server hit its own mismatch check. */ + if (strcmp(config->version, PROTOCOL_VERSION) != 0) { + log_message(LOG_LEVEL_ERROR, + "--protocol must be %s (FastSync supports only its current wire " + "protocol version and cannot speak an older or virtual one)", + PROTOCOL_VERSION); + return false; + } return true; } diff --git a/src/client/usage.c b/src/client/usage.c index 5814f93..3c6759b 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -41,6 +41,9 @@ void print_usage(void) { printf(" converted before transmission and back on receipt; a\n"); printf(" name that cannot be represented in the target charset\n"); printf(" fails that transfer cleanly (rsync-compatible)\n"); + printf(" --protocol=NUM Force the wire protocol version (must equal the current\n"); + printf(" PROTOCOL_VERSION; FastSync cannot speak older/virtual\n"); + printf(" wire formats)\n"); printf(" --delete Delete files on receiver not in source\n"); printf(" (default timing: delete only after the whole\n"); printf(" transfer has succeeded)\n"); From 9fe6d6c748ff6182a40e3d2f4bb44b5972434c61 Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 10 Sep 2026 20:36:05 +0200 Subject: [PATCH 2/3] test(p6-protocol): unit + integration coverage for --protocol --- tests/integration/test_preflight.py | 58 +++++++++++++++++++++++++- tests/test_client_cli.c | 63 +++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_preflight.py b/tests/integration/test_preflight.py index 6acf868..7f333ae 100644 --- a/tests/integration/test_preflight.py +++ b/tests/integration/test_preflight.py @@ -2,10 +2,11 @@ import subprocess import sys import os +import shutil import pytest sys.path.insert(0, os.path.dirname(__file__)) -from common import BUILD_DIR, CLIENT_CMD, SERVER_CMD +from common import BUILD_DIR, CLIENT_CMD, SERVER_CMD, TEST_DATA_DIR, run_client, verify_transfer class TestHelp: @@ -79,3 +80,58 @@ class TestServerPort: finally: proc.terminate() proc.wait(timeout=5) + + +def _seed_protocol_source(source): + os.makedirs(source, exist_ok=True) + with open(os.path.join(source, "p.txt"), "w") as fh: + fh.write("protocol test\n") + os.makedirs(os.path.join(source, "nested"), exist_ok=True) + with open(os.path.join(source, "nested", "deep.txt"), "w") as fh: + fh.write("deep file\n") + + +class TestProtocol: + @pytest.mark.ci + def test_protocol_current_version_accepted(self, shared_server): + """--protocol=2.16.0 (the current PROTOCOL_VERSION) is accepted and the + transfer completes normally.""" + source = os.path.join(TEST_DATA_DIR, "proto_ok_src") + dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst") + shutil.rmtree(dest, ignore_errors=True) + os.makedirs(dest) + _seed_protocol_source(source) + result, _ = run_client(source, dest, flags=["--protocol=2.16.0"], + port=shared_server.port) + assert result.returncode == 0, \ + f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}" + received = os.path.join(dest, os.path.abspath(source).lstrip(os.sep)) + mismatches, missing = verify_transfer(source, received) + assert not mismatches and not missing, \ + f"transfer mismatch: missing={missing} mismatches={mismatches}" + + @pytest.mark.ci + def test_protocol_rejects_other_versions(self, shared_server): + """Other versions are rejected up front, before connecting.""" + source = os.path.join(TEST_DATA_DIR, "proto_reject_src") + dest = os.path.join(TEST_DATA_DIR, "proto_reject_dst") + shutil.rmtree(dest, ignore_errors=True) + os.makedirs(dest) + _seed_protocol_source(source) + for bad in ("2.15.0", "2.17.0", "216", "31"): + result, _ = run_client(source, dest, flags=[f"--protocol={bad}"], + port=shared_server.port) + assert result.returncode != 0, f"--protocol={bad} should be rejected" + + @pytest.mark.ci + def test_protocol_rejects_garbage(self, shared_server): + """Garbage/empty --protocol values are rejected up front.""" + source = os.path.join(TEST_DATA_DIR, "proto_garbage_src") + dest = os.path.join(TEST_DATA_DIR, "proto_garbage_dst") + shutil.rmtree(dest, ignore_errors=True) + os.makedirs(dest) + _seed_protocol_source(source) + for bad in ("abc", ""): + result, _ = run_client(source, dest, flags=[f"--protocol={bad}"], + port=shared_server.port) + assert result.returncode != 0, f"--protocol={bad} should be rejected" diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 9bbe79d..a022e89 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -210,6 +210,66 @@ static void test_parse_args_version() { config_delete(cfg); } +/* --protocol=NUM forces the wire protocol version: the current PROTOCOL_VERSION + * is accepted (stored into config->version, which the config frame transmits), + * and any other value is rejected. Client-only: no server-side flag exists. */ +static void test_parse_args_protocol_accept_current() { + Config* cfg = valid_client_config(); + EXPECT_NOT_NULL(cfg); + char* argv_equals[] = {"fastsync", "--source-dir", "/src", + "--dest-dir", "/dst", "--protocol=2.16.0"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); + config_delete(cfg); + + cfg = valid_client_config(); + EXPECT_NOT_NULL(cfg); + char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir", + "/dst", "--protocol", "2.16.0"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); + config_delete(cfg); +} + +/* Any --protocol value other than the current PROTOCOL_VERSION must end in + * failure (parse_args simply stores it; validate_config rejects it up front). */ +static void test_parse_args_protocol_rejects_other_versions() { + static const char* const bad_versions[] = {"2.16", "2.15.0", "2.17.0", "216", "31", "abc", ""}; + for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { + Config* cfg = valid_client_config(); + EXPECT_NOT_NULL(cfg); + char arg[64]; + snprintf(arg, sizeof(arg), "--protocol=%s", bad_versions[i]); + char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", arg}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(strcmp(cfg->version, PROTOCOL_VERSION) != 0); + EXPECT_FALSE(validate_config(cfg)); + config_delete(cfg); + } +} + +/* validate_config accepts the current PROTOCOL_VERSION (the default) and rejects + * a version that does not equal it -- the honest post-parse enforcement. */ +static void test_validate_config_protocol_version() { + Config* cfg = valid_client_config(); + EXPECT_NOT_NULL(cfg); + EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); + EXPECT_TRUE(validate_config(cfg)); + config_delete(cfg); + + cfg = valid_client_config(); + EXPECT_NOT_NULL(cfg); + free(cfg->version); + cfg->version = str_dup("2.15.0"); + EXPECT_FALSE(validate_config(cfg)); + config_delete(cfg); +} + /* --xattrs/-X and --acls/-A preserve per-file xattrs and both imply metadata * transmission (the xattr block rides the metadata/per-file frame); each is * individually negatable and the derived use_xattrs follows the flags. */ @@ -2850,6 +2910,9 @@ void test_client_cli() { test_cli_exclude_patterns(); test_parse_args_help(); test_parse_args_version(); + test_parse_args_protocol_accept_current(); + test_parse_args_protocol_rejects_other_versions(); + test_validate_config_protocol_version(); test_parse_args_valid_port(); test_parse_args_size_only(); test_parse_args_ignore_existing(); From 5262cc2597c2e95f9b9215d736c587a332771ed6 Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 10 Sep 2026 20:44:33 +0200 Subject: [PATCH 3/3] test(p6-protocol): harden validation null-check; cover missing-arg --protocol --- tests/test_client_cli.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index a022e89..270e25b 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -266,6 +266,7 @@ static void test_validate_config_protocol_version() { EXPECT_NOT_NULL(cfg); free(cfg->version); cfg->version = str_dup("2.15.0"); + EXPECT_NOT_NULL(cfg->version); EXPECT_FALSE(validate_config(cfg)); config_delete(cfg); } @@ -1854,7 +1855,8 @@ static void test_parse_args_table_equals_string_and_int_options() { * generic "Unknown option", when they are the final argv entry. */ static void test_parse_args_missing_argument_diagnostic() { static const char* const options[] = {"--exclude", "--server-port", "--skip-compress", - "-T", "--out-format", "--log-file-format"}; + "-T", "--out-format", "--log-file-format", + "--protocol"}; for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { Config* cfg = config_create();