Merge branch 'feat/parity-b4' into feat/parity-next

# Conflicts:
#	RSYNC_COMPAT.md
This commit is contained in:
2026-09-22 22:08:16 +02:00
11 changed files with 318 additions and 152 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
+62 -8
View File
@@ -156,8 +156,8 @@ static void test_xattr_capture_and_appliable() {
EXPECT_TRUE(xattr_name_appliable("user.foo", false));
EXPECT_TRUE(xattr_name_appliable("user.foo", true));
/* The reserved fake-super key is receiver-only and never forwarded/applied. */
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", false));
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", true));
EXPECT_FALSE(xattr_name_appliable("user.rsync.%stat", false));
EXPECT_FALSE(xattr_name_appliable("user.rsync.%stat", true));
/* B4: the ACL names require --acls; -X alone must not authorize them. */
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_access", false));
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_default", false));
@@ -351,9 +351,10 @@ static void test_xattr_capture_filters_acls() {
}
/* --fake-super replay: fake_super_store_fd records the source stat into the
* reserved xattr, and fake_super_restore_fd re-applies mode/mtime (and owner,
* when the process may) fd-relative. Restore must also be a safe no-op with no
* xattr present. Guarded on filesystem xattr support. */
* reserved xattr, and fake_super_restore_fd re-applies the permission bits
* fd-relative (mtime travels through the normal metadata path; the owner is
* never chowned). Restore must also be a safe no-op with no xattr present.
* Guarded on filesystem xattr support. */
static void test_fake_super_restore() {
const char* path = "test_fake_super_restore.txt";
unlink(path);
@@ -373,7 +374,7 @@ static void test_fake_super_restore() {
FileAttrPolicy policy = {true, true, false, false, true};
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
fake_super_store_fd(fd, 1001, 1002, 0751, 1700000000, 123456789);
fake_super_store_fd(fd, 1001, 1002, S_IFREG | 0751, 0, 0);
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
struct stat st;
EXPECT_EQ_INT(fstat(fd, &st), 0);
@@ -381,7 +382,7 @@ static void test_fake_super_restore() {
/* Strict rsync parity: -p restores the recorded mode exactly, including
group/other write (a recorded 0666 restores as 0666). */
fake_super_store_fd(fd, 1001, 1002, 0666, 1700000000, 0);
fake_super_store_fd(fd, 1001, 1002, S_IFREG | 0666, 0, 0);
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0666);
@@ -401,6 +402,58 @@ static void test_fake_super_restore() {
unlink(path);
}
/* The stored record is rsync 3.4.1's exact grammar
* "<octal st_mode with S_IFMT> <rdev_major>,<rdev_minor> <uid>:<gid>"
* so a fake-super tree is readable by rsync. Also pins two rsync parity
* rules: the special bits are stored in the record but NOT applied to the real
* file, and a device record's rdev round-trips through the parser. Guarded on
* filesystem xattr support. */
static void test_fake_super_rsync_format() {
const char* path = "test_fake_super_format.txt";
unlink(path);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
return;
bool has_xattr = setxattr(path, "user.fastsync.xprobe", "p", 1, 0) == 0;
if (has_xattr)
removexattr(path, "user.fastsync.xprobe");
if (!has_xattr) {
close(fd);
unlink(path);
return; /* skip silently when the filesystem has no xattr support */
}
/* A setuid regular file: the full st_mode (with S_IFMT + special bits) is
recorded, rdev is 0,0, and the owner is uid:gid. */
fake_super_store_fd(fd, 1234, 5678, S_IFREG | 04711, 0, 0);
char value[128];
ssize_t got = fgetxattr(fd, FAKESUPER_XATTR, value, sizeof(value));
EXPECT_EQ_INT((int)got, 20);
EXPECT_TRUE(got == 20 && memcmp(value, "104711 0,0 1234:5678", 20) == 0);
/* The special bits in the record are NOT installed on the real file. */
FileAttrPolicy policy = {true, true, false, false, true};
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
struct stat st;
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0711);
EXPECT_EQ_INT((int)(st.st_mode & (S_ISUID | S_ISGID | S_ISVTX)), 0);
/* A device record (char 1,3, uid 111, gid 222) parses without error and
still never real-chowns or installs the device's mode bits verbatim. */
EXPECT_EQ_INT((int)fsetxattr(fd, FAKESUPER_XATTR, "20644 1,3 111:222", 17, 0), 0);
struct stat before;
fstat(fd, &before);
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
fstat(fd, &st);
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
EXPECT_EQ_INT((int)st.st_uid, (int)before.st_uid);
EXPECT_EQ_INT((int)st.st_gid, (int)before.st_gid);
close(fd);
unlink(path);
}
/* --fake-super must NEVER perform a real chown: fake_super_restore_fd applies
* only mode/mtime and leaves the entry's uid/gid exactly as they were, even
* when an explicit ownership policy is active and super_mode permits it. This
@@ -421,7 +474,7 @@ static void test_fake_super_no_real_chown() {
}
struct stat before;
EXPECT_EQ_INT(fstat(fd, &before), 0);
fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0);
fake_super_store_fd(fd, 12345, 12346, S_IFREG | 0755, 0, 0);
Config* c = config_create();
FileAttrPolicy policy = {true, true, false, false, true};
@@ -600,6 +653,7 @@ void test_xattr() {
test_xattr_receive_drops_acl_without_preserve_acls();
test_link_copy_fallback_preserves_xattrs();
test_fake_super_restore();
test_fake_super_rsync_format();
test_fake_super_no_real_chown();
test_fake_super_storage_resolution();
test_file_save_directory_applies_xattrs();