Merge feat/p5-socket: --address, -4/-6, --sockopts, server bind options

# Conflicts:
#	RSYNC_COMPAT.md
#	tests/test_client_cli.c
This commit is contained in:
2026-09-09 14:30:36 +02:00
15 changed files with 711 additions and 49 deletions
+29
View File
@@ -110,6 +110,35 @@ class TestTCPFlags:
assert r["status"] == "Success", r["error"]
class TestTCPSocketOptions:
"""--sockopts, -4/-6 and --address: rsync-compatible socket/bind options.
These are purely local (client-side) socket concerns that never cross the
wire, so each is exercised by a normal transfer succeeding end-to-end."""
@pytest.mark.ci
def test_sockopts_apply(self, shared_server):
r = _run_tcp_test("Sockopts (TCP_NODELAY=1,SO_KEEPALIVE=1)", shared_server.port,
["--sockopts=TCP_NODELAY=1,SO_KEEPALIVE=1"])
assert r["status"] == "Success", r["error"]
def test_sockopts_buffer_sizes(self, shared_server):
r = _run_tcp_test("Sockopts buffer sizes (SO_RCVBUF/SO_SNDBUF)", shared_server.port,
["--sockopts=SO_RCVBUF=131072,SO_SNDBUF=131072"])
assert r["status"] == "Success", r["error"]
def test_ipv4_forced(self, shared_server):
r = _run_tcp_test("Force IPv4 (-4)", shared_server.port, ["-4"])
assert r["status"] == "Success", r["error"]
@pytest.mark.skipif(shutil.which("ip") is None,
reason="requires ip tooling to enumerate a usable local address")
def test_address_source_bind(self, shared_server):
r = _run_tcp_test("Source bind (--address=127.0.0.1)", shared_server.port,
["--address", "127.0.0.1"])
assert r["status"] == "Success", r["error"]
class TestTCPChunkSize:
def test_custom_chunk_size(self, shared_server):
r = _run_tcp_test("Chunk size 5MB", shared_server.port, ["--chunk-size", "5242880"])
+87 -3
View File
@@ -831,10 +831,7 @@ static void test_parse_args_rejects_unimplemented_options() {
"--delete-excluded",
"--max-delete",
"--prune-empty-dirs",
"--address",
"--bind-address",
"--ipv6",
"--ipv4",
"--daemon",
"--config",
"--server"};
@@ -2588,6 +2585,90 @@ static void test_parse_args_devices_specials() {
config_delete(cfg);
}
/* --address binds the outgoing client socket; it is a plain string option. */
static void test_parse_args_address() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--address", "192.0.2.10", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->address, "192.0.2.10");
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* eq_argv[] = {"fastsync", "--address=10.0.0.5", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, eq_argv, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->address, "10.0.0.5");
config_delete(cfg);
}
/* -4/--ipv4 and -6/--ipv6 set the resolution family; both together are
* rejected by validate_config (an address cannot be both v4 and v6). */
static void test_parse_args_ipv4_ipv6() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-4", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->ipv4);
EXPECT_FALSE(cfg->ipv6);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* longv6[] = {"fastsync", "--ipv6", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, longv6, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->ipv4);
EXPECT_TRUE(cfg->ipv6);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* both[] = {"fastsync", "-4", "-6", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, both, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->ipv4);
EXPECT_TRUE(cfg->ipv6);
cfg->send_directory = str_dup("/src");
cfg->receive_root_directory = str_dup("/dst");
EXPECT_FALSE(validate_config(cfg));
config_delete(cfg);
}
/* --sockopts parses and stores the allowlist; unknown options and bad values
* are rejected at the CLI layer (never silently ignored). */
static void test_parse_args_sockopts() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--sockopts=TCP_NODELAY=1,SO_KEEPALIVE=1", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->sockopt_count, 2);
EXPECT_EQ_INT(cfg->sockopts[0].id, SOCKOPT_TCP_NODELAY);
EXPECT_EQ_INT(cfg->sockopts[0].value, 1);
EXPECT_EQ_INT(cfg->sockopts[1].id, SOCKOPT_SO_KEEPALIVE);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* sep_argv[] = {"fastsync", "--sockopts", "SO_RCVBUF=65536", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, sep_argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->sockopt_count, 1);
EXPECT_EQ_INT(cfg->sockopts[0].id, SOCKOPT_SO_RCVBUF);
EXPECT_EQ_INT(cfg->sockopts[0].value, 65536);
config_delete(cfg);
static const char* const bad[] = {"--sockopts=IP_TTL=1", "--sockopts=TCP_NODELAY=2",
"--sockopts=SO_KEEPALIVE"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
cfg = config_create();
positional_count = 0;
char* b[] = {"fastsync", (char*)bad[i], "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, b, positional_args, &positional_count), -1);
config_delete(cfg);
}
}
void test_client_cli() {
test_validate_config_required_paths();
test_parse_args_numeric_ids();
@@ -2601,6 +2682,9 @@ void test_client_cli() {
test_parse_args_devices_specials();
test_parse_args_atimes_long_and_short();
test_parse_args_omit_link_times_long();
test_parse_args_address();
test_parse_args_ipv4_ipv6();
test_parse_args_sockopts();
test_parse_args_append();
test_parse_args_append_verify();
test_parse_args_append_both();
+113 -1
View File
@@ -2,14 +2,120 @@
#include "protocol.h"
#include "test_utils.h"
#include "transport_tcp.h"
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
/* -4/-6 map to a getaddrinfo ai_family hint: -4 -> AF_INET, -6 -> AF_INET6,
* and neither -> AF_UNSPEC. Both flags together are rejected earlier (in
* validate_config), so this helper never needs to prefer one over the other. */
static void test_tcp_connect_family_hints() {
EXPECT_EQ_INT(tcp_connect_family(false, false), AF_UNSPEC);
EXPECT_EQ_INT(tcp_connect_family(true, false), AF_INET);
EXPECT_EQ_INT(tcp_connect_family(false, true), AF_INET6);
}
/* --sockopts parsing+validation: every allowlisted KEY works, OPT=VAL values
* are captured, and an unknown option or a bad value is rejected (never
* silently ignored). */
static void test_sockopts_parse_valid() {
SockOptEntry* out = NULL;
int count = 0;
EXPECT_EQ_INT(config_sockopts_parse("TCP_NODELAY=1,SO_KEEPALIVE=0", &out, &count), 0);
EXPECT_EQ_INT(count, 2);
EXPECT_EQ_INT(out[0].id, SOCKOPT_TCP_NODELAY);
EXPECT_EQ_INT(out[0].value, 1);
EXPECT_EQ_INT(out[1].id, SOCKOPT_SO_KEEPALIVE);
EXPECT_EQ_INT(out[1].value, 0);
free(out);
out = NULL;
count = 0;
EXPECT_EQ_INT(
config_sockopts_parse("SO_RCVBUF=65536,SO_SNDBUF=131072,SO_REUSEADDR=1", &out, &count), 0);
EXPECT_EQ_INT(count, 3);
EXPECT_EQ_INT(out[0].id, SOCKOPT_SO_RCVBUF);
EXPECT_EQ_INT(out[0].value, 65536);
EXPECT_EQ_INT(out[1].id, SOCKOPT_SO_SNDBUF);
EXPECT_EQ_INT(out[1].value, 131072);
EXPECT_EQ_INT(out[2].id, SOCKOPT_SO_REUSEADDR);
EXPECT_EQ_INT(out[2].value, 1);
free(out);
}
static void test_sockopts_parse_rejects() {
static const char* const bad[] = {"IP_TTL=1", /* unknown option name */
"SO_KEEPALIVE", /* missing '=' */
"=1", /* missing option name */
"TCP_NODELAY=", /* missing value */
"TCP_NODELAY=2", /* boolean must be 0/1 */
"TCP_NODELAY=on", /* non-numeric boolean */
"SO_RCVBUF=-1", /* negative buffer */
"SO_SNDBUF=abc", /* non-numeric buffer */
""}; /* empty spec */
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
SockOptEntry* out = NULL;
int count = 0;
EXPECT_EQ_INT(config_sockopts_parse(bad[i], &out, &count), -1);
EXPECT_NULL(out);
}
}
/* Applying a validated allowlist entry must actually set the socket option (a
* real setsockopt on a fresh TCP socket) so the config->wire path is proven. */
static void test_sockopts_apply_sets_option() {
SockOptEntry* entries = NULL;
int count = 0;
EXPECT_EQ_INT(config_sockopts_parse("TCP_NODELAY=1,SO_REUSEADDR=1", &entries, &count), 0);
int fd = socket(AF_INET, SOCK_STREAM, 0);
EXPECT_TRUE(fd >= 0);
for (int i = 0; i < count; i++) {
int value = entries[i].value;
int level = entries[i].id == SOCKOPT_TCP_NODELAY ? IPPROTO_TCP : SOL_SOCKET;
int name = entries[i].id == SOCKOPT_TCP_NODELAY ? TCP_NODELAY : SO_REUSEADDR;
EXPECT_EQ_INT(setsockopt(fd, level, name, &value, sizeof(value)), 0);
}
int got = 0;
socklen_t len = sizeof(got);
EXPECT_EQ_INT(getsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &got, &len), 0);
EXPECT_EQ_INT(got, 1);
close(fd);
free(entries);
}
/* server_create_ex with an explicit --address and family binds to that local
* address; the resulting socket's address family must match. */
static void test_server_create_bind_address() {
ServerBindOptions opts;
opts.bind_address = "127.0.0.1";
opts.family = AF_INET;
Server* s = server_create_ex(0, &opts);
EXPECT_NOT_NULL(s);
EXPECT_EQ_INT(s->address.ss_family, AF_INET);
server_delete(&s);
}
/* An IPv6 bind is honored when the host supports it; on a host with no IPv6 a
* NULL return is acceptable (the feature degrades to unavailable, not wrong). */
static void test_server_create_bind_ipv6() {
ServerBindOptions opts;
opts.bind_address = "::1";
opts.family = AF_INET6;
Server* s = server_create_ex(0, &opts);
if (s) {
EXPECT_EQ_INT(s->address.ss_family, AF_INET6);
server_delete(&s);
}
}
static void test_server_create_ephemeral() {
Server* s = server_create(0);
EXPECT_NOT_NULL(s);
EXPECT_TRUE(s->file_descriptor >= 0);
EXPECT_EQ_INT(s->address.sin_family, AF_INET);
EXPECT_EQ_INT(s->address.ss_family, AF_INET);
server_delete(&s);
EXPECT_NULL(s);
}
@@ -99,4 +205,10 @@ void test_transport_tcp() {
test_server_create_specific_port();
test_server_delete_double();
test_client_disconnect_delete();
test_tcp_connect_family_hints();
test_sockopts_parse_valid();
test_sockopts_parse_rejects();
test_sockopts_apply_sets_option();
test_server_create_bind_address();
test_server_create_bind_ipv6();
}