feat(xattr): rsync-interoperable --fake-super stat; fix --devices error parity

This commit is contained in:
2026-09-22 22:03:41 +02:00
parent d780a4625e
commit 80e8d7f450
11 changed files with 315 additions and 149 deletions
+94 -26
View File
@@ -183,10 +183,11 @@ class TestDeviceSpecial:
)
@pytest.mark.setpriv
def test_devices_nonroot_receiver_skips_safely(self):
"""A receiver without CAP_MKNOD must skip a device entry with a warning
and never abort. A root runner drops the receiver (server) to nobody
via setpriv; on a non-root runner (or without setpriv) the test skips."""
def test_devices_nonroot_receiver_errors_like_rsync(self):
"""A receiver without CAP_MKNOD must report the failed device mknod as a
transfer error (rsync parity, partial failure) instead of silently
succeeding. A root runner drops the receiver (server) to nobody via
setpriv; on a non-root runner (or without setpriv) the test skips."""
if os.geteuid() != 0 or shutil.which("setpriv") is None:
pytest.skip("requires root + setpriv to run the receiver unprivileged")
self._setup()
@@ -202,16 +203,16 @@ class TestDeviceSpecial:
flags=["--devices"], port=port)
finally:
out, err = _stop_captured_server(server)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:300]}"
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
with open(os.path.join(received, "plain.txt")) as f:
assert f.read() == "regular content\n"
assert not os.path.lexists(os.path.join(received, "chardev")), (
"a receiver without CAP_MKNOD must skip the device node, not create it"
assert result.returncode != 0, (
f"a failed device mknod must be a transfer error like rsync (got exit 0): "
f"{(out + err)[:300]}"
)
assert ("cannot create device node" in (out + err)
or "device-node creation is not permitted" in (out + err)), (
f"receiver did not log the documented device skip: out={out!r} err={err!r}"
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
assert not os.path.lexists(os.path.join(received, "chardev")), (
"a receiver without CAP_MKNOD must not create the device node"
)
assert "cannot create device" in (out + err), (
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")
@@ -6599,7 +6600,7 @@ class TestExtendedAttributes:
os.getxattr(os.path.join(received, "data.txt"), "user.foo")
def test_reserved_fake_super_key_not_forwarded(self, shared_server):
"""A source file that already carries the reserved user.fastsync.stat
"""A source file that already carries the reserved user.rsync.%stat
record must NOT have it planted on the receiver during a plain -X run
(it is receiver-only, so it cannot be spoofed for a later privileged
restore)."""
@@ -6609,7 +6610,7 @@ class TestExtendedAttributes:
fh.write(b"reserved\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support user xattrs")
os.setxattr(f, "user.fastsync.stat", b"0:0:644:0:0")
os.setxattr(f, "user.rsync.%stat", b"100644 0,0 0:0")
# A normal user.* attr still travels alongside.
os.setxattr(f, "user.keep", b"yes")
@@ -6619,7 +6620,7 @@ class TestExtendedAttributes:
received = get_dest_received_dir(dest, source)
assert os.getxattr(os.path.join(received, "data.txt"), "user.keep") == b"yes"
with pytest.raises(OSError):
os.getxattr(os.path.join(received, "data.txt"), "user.fastsync.stat")
os.getxattr(os.path.join(received, "data.txt"), "user.rsync.%stat")
@pytest.mark.ci
def test_xattrs_multithreaded(self, shared_server):
@@ -6708,10 +6709,17 @@ class TestExtendedAttributes:
assert result.returncode == 0, \
f"--fake-super sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
record = os.getxattr(os.path.join(received, "data.txt"), "user.fastsync.stat").decode()
fields = record.split(":")
assert len(fields) == 5
assert fields[0] == str(uid), f"reserved uid field {fields[0]} != source uid {uid}"
record = os.getxattr(os.path.join(received, "data.txt"), "user.rsync.%stat").decode()
# rsync 3.4.1 grammar: "<octal st_mode> <rdev_major>,<rdev_minor> <uid>:<gid>".
fields = record.split()
assert len(fields) == 3, f"unexpected rsync fake-super record {record!r}"
mode_field, rdev_field, owner_field = fields
assert rdev_field == "0,0", f"regular file rdev must be 0,0, got {rdev_field!r}"
assert int(mode_field, 8) & 0o170000 == stat.S_IFREG, (
f"recorded mode {mode_field!r} must carry S_IFREG"
)
assert owner_field.split(":")[0] == str(uid), \
f"recorded uid {owner_field!r} != source uid {uid}"
@pytest.mark.ci
def test_fake_super_records_resolved_chown_without_real_chown(self, shared_server):
@@ -6730,12 +6738,71 @@ class TestExtendedAttributes:
assert result.returncode == 0, \
f"--fake-super --chown sync failed: {(result.stderr or result.stdout)[:300]}"
dst = os.path.join(get_dest_received_dir(dest, source), "data.txt")
record = os.getxattr(dst, "user.fastsync.stat").decode().split(":")
assert record[0] == "33333", f"recorded owner {record[0]} != resolved 33333"
assert record[1] == "44444", f"recorded group {record[1]} != resolved 44444"
record = os.getxattr(dst, "user.rsync.%stat").decode().split()
owner = record[2].split(":")
assert owner == ["33333", "44444"], (
f"recorded owner {record[2]!r} != resolved 33333:44444"
)
st = os.stat(dst)
assert st.st_uid != 33333, "--fake-super must not real-chown the recorded owner"
@pytest.mark.ci
def test_fake_super_rsync_interop(self, shared_server):
"""A fake-super tree written by FastSync is readable by rsync 3.4.1:
rsync reads the `user.rsync.%stat` record (mode/rdev/uid:gid) and, when
it re-emits a fake-super tree, reproduces the same record. This pins
the on-disk key and value grammar against the real tool."""
rsync = shutil.which("rsync")
if rsync is None:
pytest.skip("rsync not installed")
source, dest = self._source_and_dest("fakesuper_interop")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"interop\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support user xattrs")
# rsync's fake-super receiver only writes a %stat% record when it has
# something to fake; a root-owned file with a matching root stat is
# a no-op. When privileged, record a non-root owner so the round-trip
# actually exercises the parser (non-root CI already has a non-zero uid).
if os.geteuid() == 0:
try:
os.chown(f, 12345, 12346)
except OSError:
pass
# A setuid bit exercises the full st_mode encoding; set it AFTER any
# chown (chown clears setuid/setgid), and note that neither tool installs
# it on the real destination file.
os.chmod(f, 0o4711)
result, _ = run_client(source, dest, flags=["--fake-super"],
port=shared_server.port)
assert result.returncode == 0, \
f"--fake-super sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
rec = os.getxattr(os.path.join(received, "data.txt"), "user.rsync.%stat").decode()
rec_fields = rec.split()
assert len(rec_fields) == 3 and rec_fields[1] == "0,0", (
f"FastSync did not write rsync's stat grammar: {rec!r}"
)
assert int(rec_fields[0], 8) & 0o7777 == 0o4711, (
f"FastSync did not record the source mode in rsync's grammar: {rec!r}"
)
out = os.path.join(TEST_DATA_DIR, "fakesuper_interop_rsync")
clean_dir(out)
rs = subprocess.run([rsync, "-aX", "--fake-super",
received + "/", out + "/"],
capture_output=True, text=True, timeout=120)
assert rs.returncode == 0, (
f"rsync could not read FastSync's fake-super tree: {rs.stderr[:300]}"
)
out_rec = os.getxattr(os.path.join(out, "data.txt"), "user.rsync.%stat").decode()
assert out_rec == rec, (
"rsync re-emitted a different fake-super record; FastSync's grammar "
f"is not interoperable: ours={rec!r} rsync={out_rec!r}"
)
@pytest.mark.ci
def test_directory_xattrs_preserved(self, shared_server):
"""#286.3: -aX must preserve user.* xattrs on DIRECTORIES, not just files."""
@@ -7255,9 +7322,10 @@ class TestCopyAs:
)
received = get_dest_received_dir(dest, source)
dst = os.path.join(received, "mixed.txt")
record = os.getxattr(dst, "user.fastsync.stat").decode().split(":")
assert (record[0], record[1]) == ("65534", "65534"), (
f"fake-super must record the resolved copy-as ownership: {record[:2]}"
record = os.getxattr(dst, "user.rsync.%stat").decode().split()
owner = record[2].split(":")
assert owner == ["65534", "65534"], (
f"fake-super must record the resolved copy-as ownership: {owner}"
)
st = os.lstat(dst)
assert (st.st_uid, st.st_gid) != (12345, 12346), (
+4 -4
View File
@@ -353,10 +353,10 @@ class TestOwnershipRoot:
st = os.stat(dst)
assert st.st_uid != 12345, \
f"--fake-super -o must NOT real-chown the source owner, got uid={st.st_uid}"
record = os.getxattr(dst, "user.fastsync.stat").decode()
fields = record.split(":")
assert fields[0] == "12345", \
f"--fake-super must record the resolved owner, got {fields[0]}"
record = os.getxattr(dst, "user.rsync.%stat").decode()
owner = record.split()[2].split(":")
assert owner[0] == "12345", \
f"--fake-super must record the resolved owner, got {owner[0]}"
def test_o_applies_directory_owner(self, shared_server):
"""#286.2: -o must apply the source owner to DIRECTORIES too (the