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)