feat(xattr): rsync-interoperable --fake-super stat; fix --devices error parity
This commit is contained in:
@@ -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), (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user