feat(protocol): client-message channel and rsync partial exit 23 (2.30.0)

This commit is contained in:
2026-09-23 20:33:27 +02:00
parent 00197102bf
commit cb2979fdf1
25 changed files with 633 additions and 91 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
verify_transfer,
)
PROTOCOL_VERSION = b"2.29.0"
PROTOCOL_VERSION = b"2.30.0"
STATUS_MANIFEST = 5
STATUS_OK = 0
+86 -3
View File
@@ -203,9 +203,9 @@ class TestDeviceSpecial:
flags=["--devices"], port=port)
finally:
out, err = _stop_captured_server(server)
assert result.returncode != 0, (
f"a failed device mknod must be a transfer error like rsync (got exit 0): "
f"{(out + err)[:300]}"
assert result.returncode == 23, (
f"a failed device mknod must exit 23 (rsync partial transfer), got "
f"{result.returncode}: {(out + err)[:300]}"
)
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
assert not os.path.lexists(os.path.join(received, "chardev")), (
@@ -215,6 +215,38 @@ class TestDeviceSpecial:
f"receiver did not log the device creation error: out={out!r} err={err!r}"
)
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes")
def test_devices_nonroot_partial_removes_transferred_sources(self):
"""rsync parity for a partial receiver run under --remove-source-files:
the successfully transferred regular source is still removed, the
un-creatable device source is kept, and the client exits 23 (verified
against rsync 3.4.1: it removes ok.txt/ok2.txt, keeps the device, and
exits 23)."""
if os.geteuid() != 0 or shutil.which("setpriv") is None:
pytest.skip("requires root + setpriv to run the receiver unprivileged")
self._setup()
os.mknod(os.path.join(DEVICE_SOURCE, "chardev"), stat.S_IFCHR | 0o666,
os.makedev(1, 3))
os.makedirs(DEVICE_DEST, exist_ok=True)
os.chmod(DEVICE_DEST, 0o777)
server, port = _start_captured_server(
prefix=["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"])
try:
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["--devices", "--remove-source-files"], port=port)
finally:
out, err = _stop_captured_server(server)
assert result.returncode == 23, (
f"a partial receiver run must exit 23, got {result.returncode}: "
f"{(out + err)[:300]}"
)
assert not os.path.exists(os.path.join(DEVICE_SOURCE, "plain.txt")), (
"a successfully transferred source must be removed even on a partial run"
)
assert os.path.exists(os.path.join(DEVICE_SOURCE, "chardev")), (
"the source device that failed to materialize must be kept"
)
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes")
def test_devices_recreates_real_char_device(self, shared_server):
"""Root-only: a source char device node is recreated on the destination
@@ -334,6 +366,57 @@ def setup_test_data():
shutil.rmtree(DEST_DIR, ignore_errors=True)
class TestClientStderrChannel:
"""--stderr=client: the client's own diagnostics go to the peer's stderr."""
@pytest.mark.ci
def test_client_diagnostic_reaches_server_stderr(self):
"""A client-side warning emitted during the transfer is forwarded over
the STATUS_CLIENT_MSG channel and printed on the server's stderr, not the
client's. A dangling symlink under -L is the deterministic trigger."""
source = os.path.join(TEST_DATA_DIR, "client_msg_src")
dest = os.path.join(TEST_DATA_DIR, "client_msg_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "plain.txt"), "wb") as f:
f.write(b"payload\n")
os.symlink("no-such-referent", os.path.join(source, "dangling"))
server, port = _start_captured_server()
try:
result, _ = run_client(source, dest, flags=["-L", "--stderr=client"],
port=port)
finally:
out, err = _stop_captured_server(server)
assert "symlink has no referent" in (out + err), (
f"client diagnostic did not reach the server stderr: out={out!r} err={err!r}"
)
assert "symlink has no referent" not in (result.stderr or ""), (
f"client diagnostic must not also be written locally: {result.stderr!r}"
)
@pytest.mark.ci
def test_no_msgs2stderr_alias_uses_client_channel(self):
"""--no-msgs2stderr is rsync's spelling of --stderr=client and now
forwards the client's diagnostics to the server too."""
source = os.path.join(TEST_DATA_DIR, "client_msg_alias_src")
dest = os.path.join(TEST_DATA_DIR, "client_msg_alias_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "plain.txt"), "wb") as f:
f.write(b"payload\n")
os.symlink("no-such-referent", os.path.join(source, "dangling"))
server, port = _start_captured_server()
try:
result, _ = run_client(source, dest, flags=["-L", "--no-msgs2stderr"],
port=port)
finally:
out, err = _stop_captured_server(server)
assert "symlink has no referent" in (out + err), (
f"--no-msgs2stderr did not route to the server: out={out!r} err={err!r}"
)
assert "symlink has no referent" not in (result.stderr or "")
class TestDryRun:
def test_trust_sender_transfer_completes(self, shared_server):
"""--trust-sender is a receiver-local policy (never sent to the peer).
+3 -3
View File
@@ -133,14 +133,14 @@ def _seed_protocol_source(source):
class TestProtocol:
@pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.29.0 (the current PROTOCOL_VERSION) is accepted and the
"""--protocol=2.30.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.29.0"],
result, _ = run_client(source, dest, flags=["--protocol=2.30.0"],
port=shared_server.port)
assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -157,7 +157,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
for bad in ("2.28.0", "2.27.0", "2.26.0", "2.25.0", "2.24.0", "2.23.0", "2.22.0", "2.21.0", "2.20.0",
for bad in ("2.29.0", "2.28.0", "2.27.0", "2.26.0", "2.25.0", "2.24.0", "2.23.0", "2.22.0", "2.21.0", "2.20.0",
"2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port)
+32 -10
View File
@@ -352,7 +352,7 @@ 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.29.0"};
"--dest-dir", "/dst", "--protocol=2.30.0"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -362,7 +362,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.29.0"};
"/dst", "--protocol", "2.30.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);
@@ -372,10 +372,10 @@ static void test_parse_args_protocol_accept_current() {
/* 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.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
"2.18.0", "2.19.0", "2.20.0", "2.21.0", "2.22.0",
"2.23.0", "2.24.0", "2.25.0", "2.26.0", "2.27.0",
"2.28.0", "216", "31", "abc", ""};
static const char* const bad_versions[] = {
"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", "2.18.0", "2.19.0",
"2.20.0", "2.21.0", "2.22.0", "2.23.0", "2.24.0", "2.25.0", "2.26.0",
"2.27.0", "2.28.0", "2.29.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);
@@ -2323,9 +2323,9 @@ static void test_parse_args_8_bit_output() {
}
static void test_parse_args_stderr_modes() {
static const char* const modes[] = {"errors", "all", "e", "a"};
static const LogStderrMode expected[] = {LOG_STDERR_ERRORS, LOG_STDERR_ALL, LOG_STDERR_ERRORS,
LOG_STDERR_ALL};
static const char* const modes[] = {"errors", "all", "client", "e", "a", "c"};
static const LogStderrMode expected[] = {LOG_STDERR_ERRORS, LOG_STDERR_ALL, LOG_STDERR_CLIENT,
LOG_STDERR_ERRORS, LOG_STDERR_ALL, LOG_STDERR_CLIENT};
for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) {
Config* cfg = config_create();
char option[32];
@@ -2340,8 +2340,29 @@ static void test_parse_args_stderr_modes() {
log_set_stderr_mode(LOG_STDERR_ERRORS);
}
/* rsync's deprecated --msgs2stderr / --no-msgs2stderr spellings map to
* --stderr=all and --stderr=client respectively; the client-message channel
* that `client` needs now exists (protocol 2.30.0). */
static void test_parse_args_msgs2stderr_aliases() {
Config* cfg = config_create();
char* argv_all[] = {"fastsync", "--msgs2stderr", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv_all, positional_args, &positional_count), 0);
EXPECT_EQ_INT(log_get_stderr_mode(), LOG_STDERR_ALL);
config_delete(cfg);
cfg = config_create();
char* argv_client[] = {"fastsync", "--no-msgs2stderr", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv_client, positional_args, &positional_count), 0);
EXPECT_EQ_INT(log_get_stderr_mode(), LOG_STDERR_CLIENT);
config_delete(cfg);
log_set_stderr_mode(LOG_STDERR_ERRORS);
}
static void test_parse_args_rejects_unsupported_stderr_modes() {
static const char* const modes[] = {"client", "c", "invalid"};
static const char* const modes[] = {"invalid", "x", ""};
for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) {
Config* cfg = config_create();
char option[32];
@@ -5242,6 +5263,7 @@ void test_client_cli() {
test_parse_args_ignore_times();
test_parse_args_8_bit_output();
test_parse_args_stderr_modes();
test_parse_args_msgs2stderr_aliases();
test_parse_args_rejects_unsupported_stderr_modes();
test_parse_args_secluded_args();
test_parse_args_chunk_serialization_long_form();
+8 -5
View File
@@ -2924,7 +2924,7 @@ static void golden_config_populate(Config* c) {
array_list_add(c->filters, str_dup("- /sub/dir/"));
}
/* The pinned golden frame (protocol 2.29.0). The values below are the only
/* The pinned golden frame (protocol 2.30.0). The values below are the only
* thing that ties the generated table to the historical wire format; update
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.24.0
* delete-plan wave changed only the version string; 2.25.0 appended the
@@ -2936,10 +2936,13 @@ static void golden_config_populate(Config* c) {
* (project decision), so the frame grew by one int to 886 bytes. The 2.29.0
* symlink-xattr wave changes only the version string: the config-frame layout
* is unchanged (use_xattrs already crosses the wire); the STATUS_SYMLINK frame
* body grows instead. The byte-exact values are recomputed for the merged
* layout. */
* body grows instead. The 2.30.0 client-message/partial wave changes only the
* version string: the config-frame layout is unchanged (the new
* STATUS_CLIENT_MSG and STATUS_PARTIAL statuses are not part of this frame), so
* the length stays 886 and only the hash moves. The byte-exact values are
* recomputed for the merged layout. */
#define GOLDEN_WIRE_LEN 886
#define GOLDEN_WIRE_HASH 17827864270611927842ULL
#define GOLDEN_WIRE_HASH 4169866417069573876ULL
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
unsigned long long h = 1469598103934665603ULL;
@@ -3021,7 +3024,7 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
return h;
}
/* Byte-for-byte wire compatibility guard (protocol 2.29.0). The expected hash
/* Byte-for-byte wire compatibility guard (protocol 2.30.0). The expected hash
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
static void test_config_wire_golden() {
if (is_running_under_valgrind())
+49
View File
@@ -117,6 +117,54 @@ static void test_log_stderr_mode_all() {
log_set_stderr_mode(LOG_STDERR_ERRORS);
}
/* --stderr=client: an installed sink takes the message body and suppresses the
* local write; a declining sink (or no sink) falls back to stderr. */
static char g_client_msg_capture[256];
static bool client_msg_capture_sink(const char* message) {
snprintf(g_client_msg_capture, sizeof(g_client_msg_capture), "%s", message);
return true;
}
static bool client_msg_decline_sink(const char* message) {
(void)message;
return false;
}
static void test_log_stderr_mode_client() {
int pipe_fds[2];
EXPECT_EQ_INT(pipe(pipe_fds), 0);
int saved_stderr = dup(STDERR_FILENO);
EXPECT_TRUE(saved_stderr >= 0);
EXPECT_TRUE(dup2(pipe_fds[1], STDERR_FILENO) >= 0);
close(pipe_fds[1]);
set_log_level(LOG_LEVEL_WARNING);
log_set_stderr_mode(LOG_STDERR_CLIENT);
g_client_msg_capture[0] = '\0';
log_set_client_msg_sink(client_msg_capture_sink);
log_message(LOG_LEVEL_ERROR, "routed to peer %d", 7);
fflush(stderr);
EXPECT_EQ_STR(g_client_msg_capture, "routed to peer 7");
/* A sink that declines makes the message fall back to local stderr. */
log_set_client_msg_sink(client_msg_decline_sink);
log_message(LOG_LEVEL_ERROR, "fallback local");
fflush(stderr);
log_set_client_msg_sink(NULL);
log_set_stderr_mode(LOG_STDERR_ERRORS);
EXPECT_TRUE(dup2(saved_stderr, STDERR_FILENO) >= 0);
close(saved_stderr);
char output[256] = {0};
ssize_t length = read(pipe_fds[0], output, sizeof(output) - 1);
close(pipe_fds[0]);
EXPECT_TRUE(length > 0);
EXPECT_TRUE(strstr(output, "fallback local") != NULL);
EXPECT_TRUE(strstr(output, "routed to peer") == NULL);
}
/* Test that log_message handles various format strings */
static void test_log_message_formats() {
set_log_level(LOG_LEVEL_DEBUG);
@@ -271,6 +319,7 @@ void test_log() {
test_log_set_level_error();
test_log_filtering();
test_log_stderr_mode_all();
test_log_stderr_mode_client();
test_log_message_formats();
test_log_debug_enabled_matches_gate();
test_log_concurrent_no_torn_lines();
+43 -5
View File
@@ -228,7 +228,7 @@ static void test_send_receive_status() {
/* An unknown wire status outside the enum range must be rejected as a protocol
* error instead of being handed to the caller as an unexpected verdict. The
* last known enumerator (STATUS_STATS) must still be accepted, proving the
* last known enumerator (STATUS_PARTIAL) must still be accepted, proving the
* validation does not reject legitimate statuses. */
static void test_receive_status_rejects_unknown() {
int p[2];
@@ -236,7 +236,7 @@ static void test_receive_status_rejects_unknown() {
ProtocolSession session;
protocol_session_init(&session, p[0], p[1]);
Status bogus = (Status)(STATUS_STATS + 1);
Status bogus = (Status)(STATUS_PARTIAL + 1);
EXPECT_EQ_INT((int)write(p[1], &bogus, sizeof(bogus)), (int)sizeof(bogus));
Status received = STATUS_OK;
EXPECT_FALSE(protocol_receive_status(&session, &received));
@@ -245,12 +245,12 @@ static void test_receive_status_rejects_unknown() {
EXPECT_EQ_INT((int)write(p[1], &negative, sizeof(negative)), (int)sizeof(negative));
EXPECT_FALSE(protocol_receive_status(&session, &received));
Status top = STATUS_STATS;
Status top = STATUS_PARTIAL;
EXPECT_EQ_INT((int)write(p[1], &top, sizeof(top)), (int)sizeof(top));
EXPECT_TRUE(protocol_receive_status(&session, &received));
EXPECT_EQ_INT((int)received, (int)STATUS_STATS);
EXPECT_EQ_INT((int)received, (int)STATUS_PARTIAL);
Status timed_bogus = (Status)(STATUS_STATS + 7);
Status timed_bogus = (Status)(STATUS_PARTIAL + 7);
EXPECT_EQ_INT((int)write(p[1], &timed_bogus, sizeof(timed_bogus)), (int)sizeof(timed_bogus));
EXPECT_FALSE(protocol_receive_status_timed(&session, &received, 5));
@@ -258,6 +258,43 @@ static void test_receive_status_rejects_unknown() {
close(p[1]);
}
/* STATUS_CLIENT_MSG carries a bounded, length-prefixed diagnostic string
* (protocol 2.30.0, --stderr=client). An over-long message must be sliced to
* MAX_CLIENT_MSG_BYTES rather than sent whole. */
static void test_send_client_message_bounded() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
const char message[] = "client diagnostic line";
EXPECT_TRUE(send_client_message(0, message));
Status received = STATUS_OK;
EXPECT_TRUE(receive_status(0, &received));
EXPECT_EQ_INT((int)received, (int)STATUS_CLIENT_MSG);
char* body = receive_str(0);
EXPECT_NOT_NULL(body);
EXPECT_EQ_STR(body, message);
free(body);
size_t big_len = MAX_CLIENT_MSG_BYTES + 100;
char* big = malloc(big_len + 1);
EXPECT_NOT_NULL(big);
memset(big, 'x', big_len);
big[big_len] = '\0';
EXPECT_TRUE(send_client_message(0, big));
EXPECT_TRUE(receive_status(0, &received));
EXPECT_EQ_INT((int)received, (int)STATUS_CLIENT_MSG);
char* big_body = receive_str(0);
EXPECT_NOT_NULL(big_body);
EXPECT_EQ_INT((int)strlen(big_body), (int)MAX_CLIENT_MSG_BYTES);
free(big_body);
free(big);
close(p[0]);
close(p[1]);
}
static void test_receive_n_data_truncated() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
@@ -1250,6 +1287,7 @@ void test_protocol() {
test_send_receive_int();
test_send_receive_status();
test_receive_status_rejects_unknown();
test_send_client_message_bounded();
test_protocol_session_io_timeout();
test_protocol_server_io_timeout_floor();
test_send_receive_status_timed();