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
+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).