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
+6 -4
View File
@@ -735,11 +735,13 @@ typedef struct Config {
* --copy-as) imply it. */
/* fake_super */
/* --fake-super: receiver-only. When set, each written file additionally gets
* a reserved user.fastsync.stat xattr recording the RESOLVED uid/gid (the
* rsync's reserved user.rsync.%stat xattr recording the RESOLVED uid/gid (the
* source's own when no ownership request is active, else the --chown/--usermap
* result) plus mode/mtime so a later privileged restore could re-apply them.
* It NEVER real-chowns: the point is to record the source ownership on an
* unprivileged receiver. Crosses the wire. */
* result) plus the full mode and rdev, in rsync 3.4.1's grammar, so the tree is
* interoperable and a later privileged restore could re-apply them. mtime is
* carried by the file's own timestamp, exactly as rsync does it. It NEVER
* real-chowns: the point is to record the source ownership on an unprivileged
* receiver. Crosses the wire. */
/* module */
/* Daemon module selection (Wave A, protocol 2.15.0). Client-composed from a
* host::module/path destination; NULL or "" means "no module" (the ordinary
+5 -4
View File
@@ -1189,14 +1189,15 @@ static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXat
ownership request (--chown/--usermap/--groupmap/--copy-as or -o/-g) is
active, the resolved mapping; otherwise the source's own id. The real
chown is suppressed (identity_apply_ownership early-returns under
--fake-super) so recording never defeats the flag. Mode/mtime are still
replayed (policy-gated) so unprivileged --fake-super keeps working. */
--fake-super) so recording never defeats the flag. The recorded stat is
rsync's format; the permission bits are replayed (policy-gated) so
unprivileged --fake-super keeps working while mtime comes from the
normal metadata path above. */
uint32_t store_uid;
uint32_t store_gid;
identity_resolve_storage_ids((int32_t)metadata->uid, (int32_t)metadata->gid, &store_uid,
&store_gid);
fake_super_store_fd(fd, store_uid, store_gid, (uint32_t)metadata->mode, metadata->mtime_sec,
metadata->mtime_nsec);
fake_super_store_fd(fd, store_uid, store_gid, (uint32_t)metadata->mode, 0, 0);
fake_super_restore_fd(fd, policy);
}
}
+30 -9
View File
@@ -353,11 +353,13 @@ bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode) {
/* ---- Device/special node RECREATION (--devices/--specials), receiver side ----
*
* Privilege gating: making a real device node requires CAP_MKNOD (root); making
* a FIFO works unprivileged (mkfifo). When the receiver lacks the capability,
* mknodat() fails with EPERM and the entry is SKIPPED with a warning -- the
* whole transfer must NOT abort just because the environment cannot make the
* node. CI runs non-root, so device creation is expected to skip there and
* only a FIFO is honestly assertable unprivileged.
* a FIFO works unprivileged (mkfifo). A device node whose mknodat() fails with
* EPERM/EACCES is a genuine transfer error (rsync parity: rsync reports the
* mknod failure and the run exits partial, code 23). Only the unprivileged
* FIFO/socket (--specials) path keeps the best-effort skip, because those are
* normally creatable without privilege and a failure there is environmental.
* CI runs non-root, so device creation is expected to fail there; only a FIFO
* is honestly assertable unprivileged.
*
* Confinement: the parent directory is opened fd-relative below the receive
* root (file_open_secure_parent: O_NOFOLLOW, no "..", root-checked) and the
@@ -495,13 +497,32 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
node_kind, escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
} else if (errno == EPERM || errno == EACCES) {
/* Missing CAP_MKNOD / parent write permission: the environment cannot
create the node, so skip instead of failing the whole run. */
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
const char* shown_path = escaped_path ? escaped_path : "<allocation failed>";
if (is_char || is_blk) {
/* rsync parity: a device node that cannot be created (no CAP_MKNOD, or
* super-user activities not permitted) is a genuine transfer error.
* rsync reports `mknod ".../node" failed: ...` and the run exits
* partial (23); FastSync surfaces it through the outcome aggregation
* instead of silently skipping the entry. FIFO/socket creation
* (--specials) keeps the best-effort skip path below. */
log_message(LOG_LEVEL_ERROR,
"cannot create %s %s: %s\n"
" --devices node creation needs privilege (CAP_MKNOD)",
node_kind, shown_path, strerror(errno));
free(escaped_path);
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_ERROR;
}
/* Missing CAP_MKNOD / parent write permission for a FIFO/socket: the
environment cannot create the node, so skip instead of failing the
whole run. */
log_message(LOG_LEVEL_WARNING,
"skipping %s: cannot create %s node (%s)\n"
" --devices/--specials node creation needs privilege (CAP_MKNOD)",
escaped_path ? escaped_path : "<allocation failed>", node_kind, strerror(errno));
" --specials node creation needs privilege (CAP_MKNOD)",
shown_path, node_kind, strerror(errno));
free(escaped_path);
} else {
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
+35 -33
View File
@@ -363,16 +363,21 @@ bool xattr_apply_fd(int fd, const FileXattrList* list) {
return true;
}
/* ---- --fake-super: park ownership/mode/mtime in a reserved xattr ---- */
/* ---- --fake-super: park ownership/mode/rdev in a reserved xattr ---- */
void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int64_t mtime_sec,
int64_t mtime_nsec) {
void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint32_t rdev_major,
uint32_t rdev_minor) {
if (fd < 0)
return;
char record[128];
int len =
snprintf(record, sizeof(record), "%lu:%lu:%03o:%lld:%ld", (unsigned long)uid,
(unsigned long)gid, (unsigned)mode & 0777U, (long long)mtime_sec, (long)mtime_nsec);
/* rsync 3.4.1's exact grammar: "<octal full st_mode> <rdev_major>,<rdev_minor>
* <uid>:<gid>". The octal mode carries the S_IFMT bits (e.g. 0104711 for a
* setuid regular file, 020644 for a char device); the rdev pair is 0,0 for a
* non-device. No mtime field: rsync leaves the file's own timestamp in
* charge of mtime. This value is what rsync reads back to restore a
* fake-super tree, so the field order and separators must not change. */
char record[96];
int len = snprintf(record, sizeof(record), "%o %u,%u %u:%u", (unsigned)mode, (unsigned)rdev_major,
(unsigned)rdev_minor, (unsigned)uid, (unsigned)gid);
if (len <= 0 || (size_t)len >= sizeof(record))
return;
if (fsetxattr(fd, FAKESUPER_XATTR, record, (size_t)len, 0) != 0) {
@@ -381,11 +386,14 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
}
}
/* --fake-super replay: read the freshly-stored record and re-apply mode/mtime
* fd-relative. The recorded uid/gid are retained for a later privileged
* restore but are NEVER chowned here: --fake-super only RECORDS ownership, it
* must not real-chown the recorded (resolved) owner. Mode/mtime still apply so
* unprivileged --fake-super keeps working. */
/* --fake-super replay: read the freshly-stored record and re-apply its
* permission bits fd-relative. The recorded uid/gid are retained for a later
* privileged restore but are NEVER chowned here: --fake-super only RECORDS
* ownership, it must not real-chown the recorded (resolved) owner. The
* recorded rdev is likewise parsed for grammar compatibility but is not acted
* on (device recreation is a separate, privilege-gated path). mtime is not in
* the record: the normal metadata path applies it (policy.times), exactly as
* rsync relies on the file's own timestamp. */
bool fake_super_restore_fd(int fd, FileAttrPolicy policy) {
if (fd < 0)
return false;
@@ -394,24 +402,25 @@ bool fake_super_restore_fd(int fd, FileAttrPolicy policy) {
if (len < 0)
return false; /* absent or filesystem without xattrs: silent no-op */
record[len] = '\0';
unsigned long ul_uid, ul_gid, ul_mode;
long long mtime_sec;
long mtime_nsec;
if (sscanf(record, "%lu:%lu:%lo:%lld:%ld", &ul_uid, &ul_gid, &ul_mode, &mtime_sec, &mtime_nsec) !=
5)
unsigned ul_mode, rdev_major, rdev_minor, ul_uid, ul_gid;
if (sscanf(record, "%o %u,%u %u:%u", &ul_mode, &rdev_major, &rdev_minor, &ul_uid, &ul_gid) != 5)
return false; /* malformed record: skip, never fatal */
/* --fake-super NEVER performs a real chown: that would defeat the whole
point of the flag (record privileged ownership on an unprivileged receiver
for a later privileged restore). The uid/gid parsed above are retained in
the record for that later restore, but no ownership change happens here. */
/* --fake-super NEVER performs a real chown: that would defeat the whole point
of the flag (record privileged ownership on an unprivileged receiver for a
later privileged restore). The uid/gid parsed above are retained in the
record for that later restore, but no ownership change happens here. The
rdev is retained for the same reason. */
(void)rdev_major;
(void)rdev_minor;
(void)ul_uid;
(void)ul_gid;
/* Mode is applied only when the per-attribute policy asks for it, through the
SAME shared helper the normal metadata path uses (metadata_mode_for_policy):
under --perms the recorded source mode is copied exactly, including
group/other write and setuid/setgid/sticky bits (rsync parity), and the -E
rule derives exec bits from the destination's read bits exactly like
SAME shared helper the normal metadata path uses (metadata_mode_for_policy).
The recorded special bits are stripped first: rsync's fake-super receiver
stores the full mode in the xattr but never installs setuid/setgid/sticky on
the real file, so only the 0777 permission bits may be replayed. The -E
rule then derives exec bits from the destination's read bits exactly like
file_restore_metadata_fd. */
if (policy.perms || policy.executability) {
struct stat cur;
@@ -419,19 +428,12 @@ bool fake_super_restore_fd(int fd, FileAttrPolicy policy) {
if (fstat(fd, &cur) != 0) {
log_message(LOG_LEVEL_WARNING, "--fake-super: could not read destination mode: %s",
strerror(errno));
} else if (metadata_mode_for_policy((mode_t)ul_mode, cur.st_mode, policy, &want)) {
} 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",
strerror(errno));
}
}
if (policy.times) {
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = (time_t)mtime_sec, .tv_nsec = mtime_nsec}};
if (futimens(fd, times) != 0)
log_message(LOG_LEVEL_WARNING,
"--fake-super: could not restore mtime on destination file: %s", strerror(errno));
}
return true;
}
+28 -21
View File
@@ -31,11 +31,14 @@
*/
/* Reserved key used by --fake-super to park the source's privileged ownership
* / mode / mtime on the destination file as an unprivileged user.* xattr, so a
* later privileged restore could re-apply them. Exact documented format:
* uid:gid:mode:mtime_sec:mtime_nsec (decimal, decimal, octal, dec, dec)
* e.g. "1000:1000:644:1765238400:0". */
#define FAKESUPER_XATTR "user.fastsync.stat"
* / mode / rdev on the destination file as an unprivileged user.* xattr, so the
* tree is interoperable with rsync 3.4.1 and a later privileged restore can
* re-apply them. This is rsync's own key and value grammar exactly:
* <octal st_mode with S_IFMT> <rdev_major>,<rdev_minor> <uid>:<gid>
* e.g. "104711 0,0 1234:5678" for a setuid regular file owned by 1234:5678,
* or "20644 1,3 111:222" for a char device. mtime is deliberately NOT part of
* the record: exactly like rsync, the file's own timestamp carries it. */
#define FAKESUPER_XATTR "user.rsync.%stat"
/* --- bounds --- */
#define XATTR_NAME_MAX 255 /* xattr names are limited to 255 bytes */
@@ -91,24 +94,28 @@ FileXattrList* xattr_receive(int fd, int* ok, bool preserve_acls);
* true when apply was attempted (allowing callers to treat it as best-effort). */
bool xattr_apply_fd(int fd, const FileXattrList* list);
/* --fake-super: write the source uid/gid/mode/mtime record into the reserved
* FAKESUPER_XATTR on `fd`. 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, int64_t mtime_sec,
int64_t mtime_nsec);
/* --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. */
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 mode/mtime 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. 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 MTIME leg only when policy.times, so the fake-super
* replay cannot bypass the per-attribute split; the mode follows the normal
* metadata path exactly (under --perms the source mode is copied verbatim,
* special and group/other write bits included).
* Returns true when the xattr was present and parsed. */
* `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
* 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
* 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
* timestamp. Returns true when the xattr was present and parsed. */
bool fake_super_restore_fd(int fd, FileAttrPolicy policy);
#endif