feat(xattr): --fake-super for directories (#319)

This commit is contained in:
2026-09-24 02:30:02 +02:00
parent a14fbb2c10
commit cc931790e9
7 changed files with 279 additions and 50 deletions
+66
View File
@@ -7042,6 +7042,72 @@ class TestExtendedAttributes:
f"is not interoperable: ours={rec!r} rsync={out_rec!r}"
)
@pytest.mark.ci
def test_fake_super_directory_rsync_interop(self, shared_server):
"""#319: --fake-super fakes DIRECTORIES too. A recursive -a
--fake-super run must write rsync 3.4.1's `user.rsync.%stat` record on
the directory itself (full mode with S_IFDIR + special bits, rdev 0,0,
uid:gid), replay only the permission bits on disk, and real rsync must
read the tree and re-emit the identical record."""
rsync = shutil.which("rsync")
if rsync is None:
pytest.skip("rsync not installed")
source, dest = self._source_and_dest("fakesuper_dir_interop")
sub = os.path.join(source, "subdir")
os.makedirs(sub)
with open(os.path.join(sub, "f.txt"), "wb") as fh:
fh.write(b"dir interop\n")
if not _xattr_supported(sub):
pytest.skip("filesystem does not support user xattrs")
# A special bit (setgid) is exactly what a fake-super record exists to
# carry: rsync only re-emits a directory record when there is something
# it cannot represent on disk (a special bit, or a mode it would widen
# to keep the owner's rwx). Skip cleanly when the filesystem drops it.
os.chmod(sub, 0o2751)
if stat.S_IMODE(os.stat(sub).st_mode) & 0o7000 == 0:
pytest.skip("filesystem drops directory special bits")
uid = os.stat(sub).st_uid
result, _ = run_client(source, dest, flags=["-a", "--fake-super"],
port=shared_server.port)
assert result.returncode == 0, \
f"-a --fake-super dir sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
dst_sub = os.path.join(received, "subdir")
assert os.path.isdir(dst_sub), "the directory entry was not transferred"
rec = os.getxattr(dst_sub, "user.rsync.%stat").decode()
fields = rec.split()
assert len(fields) == 3, f"unexpected rsync fake-super record {rec!r}"
mode_field, rdev_field, owner_field = fields
assert rdev_field == "0,0", f"directory rdev must be 0,0, got {rdev_field!r}"
assert int(mode_field, 8) & 0o170000 == stat.S_IFDIR, (
f"recorded mode {mode_field!r} must carry S_IFDIR"
)
assert int(mode_field, 8) & 0o7777 == 0o2751, (
f"recorded mode {mode_field!r} must carry the full source mode 02751"
)
assert owner_field.split(":")[0] == str(uid), \
f"recorded uid {owner_field!r} != source uid {uid}"
# Permission bits only on disk: the setgid bit stays in the record.
assert stat.S_IMODE(os.stat(dst_sub).st_mode) == 0o751, (
"the directory's special bits must not be installed on disk"
)
# Real rsync reads FastSync's directory record and re-emits it verbatim.
out = os.path.join(TEST_DATA_DIR, "fakesuper_dir_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 directory tree: {rs.stderr[:300]}"
)
out_rec = os.getxattr(os.path.join(out, "subdir"), "user.rsync.%stat").decode()
assert out_rec == rec, (
"rsync re-emitted a different directory fake-super record; FastSync's "
f"grammar 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."""
+110
View File
@@ -892,6 +892,114 @@ static void test_symlink_frame_carries_xattrs() {
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
/* --fake-super for DIRECTORIES: rsync stores a directory's faked mode/uid/gid
* in `user.rsync.%stat` on the directory itself. fake_super_store_fd() and
* fake_super_restore_fd() operate on a directory descriptor exactly like a
* file: the full mode (with S_IFDIR + special bits) is recorded, only the
* permission bits are replayed on disk, and the owner is never real-chowned.
* Guarded on filesystem xattr support. */
static void test_fake_super_directory_fd_roundtrip() {
const char* root = "test_fake_super_dirfd_tmp";
const char* path = "test_fake_super_dirfd_tmp/subdir";
rmdir(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
if (setxattr(root, "user.fastsync-dirprobe", "p", 1, 0) != 0) {
rmdir(root);
return; /* skip silently when the filesystem has no xattr support */
}
removexattr(root, "user.fastsync-dirprobe");
EXPECT_EQ_INT(mkdir(path, 0755), 0);
int fd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
EXPECT_TRUE(fd >= 0);
FileAttrPolicy policy = {true, true, false, false, true};
/* No record yet: restore is a silent no-op on a directory too. */
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
struct stat before;
EXPECT_EQ_INT(fstat(fd, &before), 0);
fake_super_store_fd(fd, 2222, 3333, S_IFDIR | 01777, 0, 0);
char value[64];
ssize_t got = fgetxattr(fd, FAKESUPER_XATTR, value, sizeof(value));
/* S_IFDIR | 01777 == 0041777 -> "41777 0,0 2222:3333" */
EXPECT_EQ_INT((int)got, 19);
EXPECT_TRUE(got == 19 && memcmp(value, "41777 0,0 2222:3333", 19) == 0);
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
struct stat after;
EXPECT_EQ_INT(fstat(fd, &after), 0);
/* The sticky bit is stored in the record but never installed on disk. */
EXPECT_EQ_INT((int)(after.st_mode & 07777), 0777);
EXPECT_EQ_INT((int)(after.st_mode & (S_ISUID | S_ISGID | S_ISVTX)), 0);
EXPECT_EQ_INT((int)after.st_uid, (int)before.st_uid);
EXPECT_EQ_INT((int)after.st_gid, (int)before.st_gid);
close(fd);
removexattr(path, FAKESUPER_XATTR);
rmdir(path);
rmdir(root);
}
/* The deferred directory-metadata pass is where a recursive -a --fake-super
* transfer stamps each directory: dir_metadata_list_apply() must park the
* directory's full stat in the reserved xattr and replay only its permission
* bits on disk. This is the recursive-path counterpart of the explicit
* --dirs store in file_save_directory_to_disk(). Guarded on xattr support. */
static void test_fake_super_directory_deferred_apply() {
const char* root = "test_fake_super_dirdir_tmp";
const char* leaf = "subdir";
const char* path = "test_fake_super_dirdir_tmp/subdir";
rmdir(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
if (setxattr(root, "user.fastsync-dirprobe", "p", 1, 0) != 0) {
rmdir(root);
return; /* skip silently when the filesystem has no xattr support */
}
removexattr(root, "user.fastsync-dirprobe");
EXPECT_EQ_INT(mkdir(path, 0755), 0);
FileMetadata m;
memset(&m, 0, sizeof(m));
m.mode = S_IFDIR | 02751;
m.uid = 1001;
m.gid = 1002;
m.mtime_sec = 1234567890;
Config* config = config_create();
EXPECT_NOT_NULL(config);
config->use_metadata = true;
config->preserve_perms = true;
config->preserve_times = true;
config->fake_super = true;
identity_clear_active();
DirTimeList list;
dir_time_list_init(&list);
EXPECT_TRUE(dir_time_list_add(&list, leaf, &m, NULL));
dir_metadata_list_apply(&list, root, config);
dir_time_list_free(&list);
char value[64];
ssize_t got = getxattr(path, FAKESUPER_XATTR, value, sizeof(value));
/* S_IFDIR | 02751 -> "42751 0,0 1001:1002" (resolved ids == source ids). */
EXPECT_EQ_INT((int)got, 19);
EXPECT_TRUE(got == 19 && memcmp(value, "42751 0,0 1001:1002", 19) == 0);
struct stat st;
EXPECT_EQ_INT(stat(path, &st), 0);
/* Only the permission bits land on disk; setgid stays in the record. */
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0751);
EXPECT_EQ_INT((int)(st.st_mode & (S_ISUID | S_ISGID | S_ISVTX)), 0);
config_delete(config);
removexattr(path, FAKESUPER_XATTR);
rmdir(path);
rmdir(root);
}
void test_xattr() {
test_xattr_list_clone();
test_xattr_capture_symlink_nofollow();
@@ -910,5 +1018,7 @@ void test_xattr() {
test_fake_super_rsync_format();
test_fake_super_no_real_chown();
test_fake_super_storage_resolution();
test_fake_super_directory_fd_roundtrip();
test_fake_super_directory_deferred_apply();
test_file_save_directory_applies_xattrs();
}