feat(identity): implement --copy-as USER[:GROUP] safe subset (P7 Wave E)

Force the receiver to apply the requested owner/group to every written
entry through the confined fd-relative identity path instead of switching
the process credentials (unsafe for the multithreaded receiver).  An
unprivileged receiver refuses the transfer up front in server_module_gate,
before STATUS_OK, so no data is written with the wrong ownership.

- new Config fields copy_as_set/copy_as_uid/copy_as_gid + defaults
- identity_parse_copy_as (name/@N/* resolution, primary-gid default,
  gid==uid fallback for numeric ids with no passwd entry); implies -M
- identity snapshot + highest-priority forcing in identity_resolve_targets
- identity_copy_as_refused() helper
- trailing config-frame block (presence int + two int32 ids, >=0 checked)
- PROTOCOL_VERSION 2.17.0 -> 2.18.0; version-sensitive tests updated
- unit tests for parse + wire round-trip/negative-id rejection
- integration TestCopyAs: unprivileged refusal + root chown assertion
- RSYNC_COMPAT.md --copy-as row updated (safe subset + divergence); README
  protocol version refreshed
This commit is contained in:
2026-09-12 11:58:37 +02:00
parent f64d252faf
commit 80dd64aae6
12 changed files with 474 additions and 18 deletions
+112 -4
View File
@@ -27,6 +27,9 @@ typedef struct {
int usermap_count;
IdentityMap* groupmap;
int groupmap_count;
bool copy_as_set;
int32_t copy_as_uid;
int32_t copy_as_gid;
bool set;
} IdentityActive;
@@ -44,6 +47,9 @@ static void identity_active_reset(void) {
g_identity.chown_uid = 0;
g_identity.chown_gid_set = false;
g_identity.chown_gid = 0;
g_identity.copy_as_set = false;
g_identity.copy_as_uid = 0;
g_identity.copy_as_gid = 0;
g_identity.set = false;
}
@@ -60,6 +66,9 @@ void identity_set_active(const Config* config) {
g_identity.chown_uid = config->chown_uid;
g_identity.chown_gid_set = config->chown_gid_set;
g_identity.chown_gid = config->chown_gid;
g_identity.copy_as_set = config->copy_as_set;
g_identity.copy_as_uid = config->copy_as_uid;
g_identity.copy_as_gid = config->copy_as_gid;
if (config->usermap_count > 0) {
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
if (g_identity.usermap) {
@@ -78,9 +87,9 @@ void identity_set_active(const Config* config) {
}
g_identity.set = true;
/* A root receiver would honor any client-supplied ownership request (a
--usermap/--groupmap/--chown, or raw ids under --numeric-ids). Surface
that prominently; a privileged daemon applying arbitrary client ownership
is a deliberate, opt-in choice the operator should be aware of. */
--usermap/--groupmap/--chown/--copy-as, or raw ids under --numeric-ids).
Surface that prominently; a privileged daemon applying arbitrary client
ownership is a deliberate, opt-in choice the operator should be aware of. */
if (geteuid() == 0)
log_message(LOG_LEVEL_WARNING,
"identity mapping active and running as root: client-supplied "
@@ -96,7 +105,11 @@ bool identity_active_enabled(void) {
stays inert; combined with -M it activates raw-id application. */
return g_identity.set &&
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0);
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set);
}
bool identity_copy_as_refused(void) {
return g_identity.copy_as_set && geteuid() != 0;
}
bool identity_wire_valid(const Config* config) {
@@ -358,6 +371,87 @@ done:
return ret;
}
int identity_parse_copy_as(Config* config, const char* value) {
if (!config || !value || *value == '\0') {
log_message(LOG_LEVEL_ERROR, "--copy-as requires USER[:GROUP]");
return -1;
}
/* --copy-as=USER[:GROUP] is the whole grammar: at most one field separator.
* (Unlike --chown there is no escaped-colon form; a name containing ':' is
* simply not expressible, and the extra colon is a clear parse error.) */
int colons = 0;
for (const char* p = value; *p; p++)
if (*p == ':')
colons++;
if (colons > 1) {
log_message(LOG_LEVEL_ERROR, "--copy-as must be USER[:GROUP] (got '%s')", value);
return -1;
}
char* spec = str_dup(value);
if (!spec) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --copy-as");
return -1;
}
char* user_token = spec;
char* group_token = NULL;
char* colon = strchr(spec, ':');
if (colon) {
*colon = '\0';
group_token = colon + 1;
}
int32_t uid;
if (*user_token == '\0') {
log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", value);
free(spec);
return -1;
}
if (strcmp(user_token, "*") == 0) {
/* '*' means the current/root user: the client's euid. */
uid = (int32_t)geteuid();
} else if (identity_resolve_token(user_token, false, &uid) != 0) {
log_message(LOG_LEVEL_ERROR,
"--copy-as could not resolve user '%s' (use a name that exists "
"on the source, '*', or @N)",
value);
free(spec);
return -1;
}
int32_t gid;
if (group_token) {
if (*group_token == '\0') {
log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", value);
free(spec);
return -1;
}
if (strcmp(group_token, "*") == 0) {
gid = (int32_t)getegid();
} else if (identity_resolve_token(group_token, true, &gid) != 0) {
log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group '%s' (got '%s')", group_token,
value);
free(spec);
return -1;
}
} else {
/* Group omitted: use the user's primary gid. A numeric id with no local
* passwd entry has no primary gid to look up, so fall back to gid == uid
* (the rsync-style numeric convention; documented divergence). */
struct passwd* pw = getpwuid((uid_t)uid);
gid = pw ? (int32_t)pw->pw_gid : uid;
}
free(spec);
config->copy_as_set = true;
config->copy_as_uid = uid;
config->copy_as_gid = gid;
/* Ownership application needs the metadata path (the source uid/gid must be
* transmitted); imply it exactly like --chown/--usermap/--groupmap. */
config->use_metadata = true;
return 0;
}
/* ---- Receiver-side ownership application ---- */
static bool identity_map_lookup(const IdentityMap* map, int count, int32_t source_id,
@@ -381,6 +475,20 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
uid_t uid = 0;
gid_t gid = 0;
/* --copy-as (P7 Wave E) has the highest priority: it forces BOTH the owner
* and group of every written entry to the requested ids, beating usermap /
* groupmap / --chown / --numeric-ids and the best-effort name lookup. Only
* skip when the entry already carries exactly those ids. */
if (g_identity.copy_as_set) {
uid = (uid_t)g_identity.copy_as_uid;
gid = (gid_t)g_identity.copy_as_gid;
if (st->st_uid == uid && st->st_gid == gid)
return false;
*out_uid = uid;
*out_gid = gid;
return true;
}
int32_t target;
if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, &target)) {
uid = target == IDENTITY_CURRENT ? geteuid() : (uid_t)target;