feat(xattr): --fake-super for directories (#319)
This commit is contained in:
+12
-8
File diff suppressed because one or more lines are too long
@@ -102,12 +102,14 @@ bool dir_metadata_should_capture(const Config* config) {
|
||||
/* Directory metadata is captured when a directory attribute is actually
|
||||
* requested: -p/--perms (directory modes), -t/--times (directory mtimes,
|
||||
* unless -O/--omit-dir-times suppresses them), -o/-g (directory ownership),
|
||||
* or -X/-A (directory xattrs/ACLs). --atimes/-U alone does not pull
|
||||
* directory metadata (matching the original dir-time bundle). */
|
||||
* -X/-A (directory xattrs/ACLs), or --fake-super (whose reserved %stat record
|
||||
* is written on the directory itself, so its metadata must travel).
|
||||
* --atimes/-U alone does not pull directory metadata (matching the original
|
||||
* dir-time bundle). */
|
||||
return config && config->use_metadata &&
|
||||
(config->preserve_perms || (config->preserve_times && !config->omit_dir_times) ||
|
||||
config->preserve_owner || config->preserve_group || config->preserve_xattrs ||
|
||||
config->preserve_acls);
|
||||
config->preserve_acls || config->fake_super);
|
||||
}
|
||||
|
||||
void dir_time_list_init(DirTimeList* list) {
|
||||
@@ -205,12 +207,19 @@ void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory
|
||||
bool apply_times = config->preserve_times && !config->omit_dir_times;
|
||||
bool apply_mode = config->preserve_perms;
|
||||
bool apply_xattrs = config->use_xattrs;
|
||||
bool apply_fake_super = config->fake_super;
|
||||
/* Ownership is applied through the active identity snapshot (which no-ops
|
||||
* unless an ownership request is active), and xattrs only when -X/-A was
|
||||
* negotiated. Times/mode keep their own per-attribute gates. */
|
||||
bool have_any = apply_times || apply_mode || apply_xattrs || identity_active_enabled();
|
||||
* unless an ownership request is active), xattrs only when -X/-A was
|
||||
* negotiated, and the --fake-super record whenever the flag is active.
|
||||
* Times/mode keep their own per-attribute gates. */
|
||||
bool have_any =
|
||||
apply_times || apply_mode || apply_xattrs || apply_fake_super || identity_active_enabled();
|
||||
if (!have_any)
|
||||
return;
|
||||
/* Built once: the --fake-super replay uses it to apply only the recorded
|
||||
* permission bits (the special bits stay in the record, exactly like the
|
||||
* regular-file fake-super receiver). */
|
||||
FileAttrPolicy policy = file_attr_policy_from_config(config);
|
||||
for (size_t i = 0; i < list->count; i++) {
|
||||
char* dir_path = path_cat(root_directory, list->paths[i]);
|
||||
if (!dir_path)
|
||||
@@ -260,10 +269,12 @@ void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory
|
||||
free(escaped_path);
|
||||
}
|
||||
}
|
||||
if (apply_mode) {
|
||||
/* The final directory mode (after any --chmod) is computed once so the
|
||||
--fake-super record can carry it even when the on-disk replay is
|
||||
restricted to the permission bits below. */
|
||||
mode_t dir_mode = list->entries[i].mode;
|
||||
bool mode_ready = true;
|
||||
if (config->chmod_spec && *config->chmod_spec &&
|
||||
if (apply_mode && config->chmod_spec && *config->chmod_spec &&
|
||||
!chmod_apply(dir_mode, config->chmod_spec, &dir_mode)) {
|
||||
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "Failed to apply --chmod to directory %s",
|
||||
@@ -271,7 +282,13 @@ void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory
|
||||
free(escaped_path);
|
||||
mode_ready = false;
|
||||
}
|
||||
if (mode_ready) {
|
||||
/* Under --fake-super the normal fchmod below still applies the mode, but
|
||||
the fake-super replay that follows narrows the on-disk result to the
|
||||
recorded permission bits (the full mode, including setuid/setgid/sticky,
|
||||
lives only in the record). Keeping the normal fchmod first means a
|
||||
filesystem without xattr support still gets the directory mode rather than
|
||||
silently losing it. */
|
||||
if (apply_mode && mode_ready) {
|
||||
/* rsync -p copies the source directory mode exactly, including
|
||||
* group/other write and the setgid/sticky bits. Setuid/setgid/sticky
|
||||
* are super-user activities: when the connection forbade them
|
||||
@@ -291,6 +308,19 @@ void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory
|
||||
free(escaped_path);
|
||||
}
|
||||
}
|
||||
/* --fake-super: park the directory's full stat (rsync 3.4.1's exact
|
||||
grammar) on the directory ITSELF, then replay only the recorded
|
||||
permission bits fd-relative. The special bits live only in the record
|
||||
and the recorded ownership is never real-chowned: the resolved ids are
|
||||
stored for a later privileged restore, exactly like the file path. Runs
|
||||
before the xattr apply so a mode change cannot clobber the ACL mask. */
|
||||
if (apply_fake_super && dir_fd >= 0) {
|
||||
uint32_t store_uid = 0;
|
||||
uint32_t store_gid = 0;
|
||||
identity_resolve_storage_ids((int32_t)list->entries[i].uid, (int32_t)list->entries[i].gid,
|
||||
&store_uid, &store_gid);
|
||||
fake_super_store_fd(dir_fd, store_uid, store_gid, (uint32_t)dir_mode, 0, 0);
|
||||
fake_super_restore_fd(dir_fd, policy);
|
||||
}
|
||||
/* xattrs/ACLs last: a mode change can rewrite the ACL mask, so the ACL
|
||||
xattrs must be (re)applied after fchmod. */
|
||||
|
||||
@@ -817,6 +817,21 @@ static FileSaveResult file_save_directory_to_disk(const FileSavePlan* plan, bool
|
||||
} else if (ok && identity_copy_as_active()) {
|
||||
ok = false;
|
||||
}
|
||||
/* --fake-super: park the directory's full stat in rsync's reserved
|
||||
user.rsync.%stat xattr as soon as the directory exists. This makes even a
|
||||
direct file_save_to_disk_full() caller -- which never runs the deferred
|
||||
DirTimeList pass -- produce an rsync-readable fake-super record. The record
|
||||
carries the full mode/uid/gid; the permission bits are replayed by the
|
||||
deferred pass (never inline, so a restrictive mode cannot block child
|
||||
creation) and the recorded ownership is never real-chowned. Best-effort:
|
||||
fake_super_store_fd() logs and skips a failure, never failing the entry. */
|
||||
if (ok && plan->config && plan->config->fake_super && file->metadata && dir_fd >= 0) {
|
||||
uint32_t store_uid = 0;
|
||||
uint32_t store_gid = 0;
|
||||
identity_resolve_storage_ids((int32_t)file->metadata->uid, (int32_t)file->metadata->gid,
|
||||
&store_uid, &store_gid);
|
||||
fake_super_store_fd(dir_fd, store_uid, store_gid, (uint32_t)file->metadata->mode, 0, 0);
|
||||
}
|
||||
/* The final source MODE is deliberately NOT applied inline. A restrictive
|
||||
source mode (for example 0555) would make the directory unwritable before
|
||||
its children are created, so a non-root receiver fails each child with
|
||||
|
||||
+2
-2
@@ -450,7 +450,7 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint
|
||||
if (len <= 0 || (size_t)len >= sizeof(record))
|
||||
return;
|
||||
if (fsetxattr(fd, FAKESUPER_XATTR, record, (size_t)len, 0) != 0) {
|
||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not store %s on destination file: %s",
|
||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not store %s on destination entry: %s",
|
||||
FAKESUPER_XATTR, strerror(errno));
|
||||
}
|
||||
}
|
||||
@@ -550,7 +550,7 @@ bool fake_super_restore_fd(int fd, FileAttrPolicy policy) {
|
||||
} else if (metadata_mode_for_policy((mode_t)(ul_mode & 0777U), cur.st_mode, policy, &want)) {
|
||||
if (fchmod(fd, want) != 0)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"--fake-super: could not restore mode on destination file: %s",
|
||||
"--fake-super: could not restore mode on destination entry: %s",
|
||||
strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -140,21 +140,25 @@ bool xattr_apply_path_nofollow(int parent_fd, const char* leaf, const FileXattrL
|
||||
/* --fake-super: write the source uid/gid/mode/rdev record into the reserved
|
||||
* FAKESUPER_XATTR on `fd`, using rsync 3.4.1's exact grammar (see the key
|
||||
* comment above). `mode` is the full st_mode including its S_IFMT bits.
|
||||
* Best-effort (logged, never fatal). Only meaningful when metadata was
|
||||
* transmitted so the values exist. */
|
||||
* `fd` may be a regular file, a faked char/block device (written as a regular
|
||||
* file), or a DIRECTORY: rsync stores a directory's faked mode/uid/gid in the
|
||||
* reserved xattr on the directory itself. Best-effort (logged, never fatal).
|
||||
* Only meaningful when metadata was transmitted so the values exist. */
|
||||
void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint32_t rdev_major,
|
||||
uint32_t rdev_minor);
|
||||
|
||||
/* --fake-super replay: parse the FAKESUPER_XATTR record previously written on
|
||||
* `fd` by fake_super_store_fd and re-apply the recorded permission bits
|
||||
* fd-relative. The recorded uid/gid are deliberately NOT chowned for real:
|
||||
* --fake-super only RECORDS ownership (the caller stores the resolved mapping
|
||||
* via identity_resolve_storage_ids), it never performs a real chown. The
|
||||
* fd-relative. `fd` may be a regular file, a faked device, or a DIRECTORY;
|
||||
* fgetxattr/fchmod work identically on a directory descriptor. The recorded
|
||||
* uid/gid are deliberately NOT chowned for real: --fake-super only RECORDS
|
||||
* ownership (the caller stores the resolved mapping via
|
||||
* identity_resolve_storage_ids), it never performs a real chown. The
|
||||
* recorded rdev is retained for a later privileged restore but is not acted on
|
||||
* here. Best-effort: absence of the xattr or a malformed record is a silent
|
||||
* no-op that never fails the transfer. The MODE leg is applied only when
|
||||
* policy.perms||policy.executability, and the recorded special bits
|
||||
* (setuid/setgid/sticky) are NOT applied to the real file -- exactly like
|
||||
* (setuid/setgid/sticky) are NOT applied to the real entry -- exactly like
|
||||
* rsync's fake-super receiver, which stores the full mode in the xattr but
|
||||
* strips the special bits on disk. mtime is not part of the record; the normal
|
||||
* metadata path carries it (policy.times) exactly as rsync sets the file's own
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user