identity mapping: --numeric-ids / --usermap / --groupmap / --chown
CI / lint (pull_request) Failing after 3s
CI / build-and-test (pull_request) Skipped
CI / sanitizers (address) (pull_request) Skipped
CI / sanitizers (undefined) (pull_request) Skipped
CI / fuzz-build (pull_request) Skipped
CI / coverage (pull_request) Skipped
CI / valgrind (pull_request) Skipped

Receiver-side ownership application, opt-in and privilege-gated:
- OFF for every existing transfer (plain -M/--preserve still never applies
  ownership); only triggers on an explicit identity flag + receiver permission.
- EPERM/EACCES warn-and-continue (never aborts); other fchown errors escalate.
- fd-relative fchown after the file is written (symlink-safe, confined).
- New src/shared/identity.{c,h}; config fields numeric_ids / chown uid/gid /
  usermap + groupmap id-pair tables cross the wire; PROTOCOL_VERSION 2.10.0
  -> 2.11.0. CLI in client_cli.c; per-connection snapshot in server.c.
- Review fixes: EPERM/EACCES-only warn-and-continue, prominent root-receiver
  notice, identity_clear_active on early server error paths, --numeric-ids
  kept inert standalone (removed from activation trigger set).
This commit is contained in:
2026-09-08 18:23:43 +02:00
parent 829e760086
commit 53ce00b830
12 changed files with 1094 additions and 8 deletions
+75 -2
View File
@@ -3,6 +3,7 @@
#include "delay_updates.h"
#include "delta.h"
#include "file_list.h"
#include "identity.h"
#include "log.h"
#include "protocol.h"
#include "utils.h"
@@ -136,6 +137,15 @@ static void config_set_defaults(Config* config) {
config->skip_compress_suffixes = NULL;
config->skip_compress_count = 0;
config->skip_compress_set = false;
config->numeric_ids = false;
config->chown_uid_set = false;
config->chown_uid = 0;
config->chown_gid_set = false;
config->chown_gid = 0;
config->usermap = NULL;
config->usermap_count = 0;
config->groupmap = NULL;
config->groupmap_count = 0;
config->delay_context = NULL;
}
@@ -178,6 +188,7 @@ static bool validate_received_config(const Config* config) {
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) &&
checksum_algo_valid(config->checksum_algo) && config_has_valid_delete_timing(config) &&
identity_wire_valid(config) &&
!(config->skip_compress_set && config->use_chunk_serialization) &&
/* --append / --append-verify tail resume needs the per-file check,
which chunk serialization -s disables: reject on the receiver too
@@ -379,6 +390,12 @@ void config_delete(Config* config) {
free(config->skip_compress_suffixes[i]);
free(config->skip_compress_suffixes);
}
free(config->usermap);
config->usermap = NULL;
config->usermap_count = 0;
free(config->groupmap);
config->groupmap = NULL;
config->groupmap_count = 0;
if (config->filters) {
array_list_delete(config->filters);
}
@@ -673,6 +690,60 @@ static bool receive_checksum_options(int fd, Config* c) {
return receive_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed));
}
/* --numeric-ids / --usermap / --groupmap / --chown (identity mapping). The
* receiver needs these to apply the ownership the client requested, so they
* cross the config frame. Trailing fields; protocol 2.11.0. */
static bool send_identity_map(int fd, const IdentityMap* map, int count) {
if (!send_int(fd, count))
return false;
for (int i = 0; i < count; i++) {
if (!send_int(fd, map[i].from) || !send_int(fd, map[i].to))
return false;
}
return true;
}
static bool send_identity_options(int fd, const Config* c) {
return send_int(fd, c->numeric_ids) && send_int(fd, c->chown_uid_set) &&
send_int(fd, c->chown_uid) && send_int(fd, c->chown_gid_set) &&
send_int(fd, c->chown_gid) && send_identity_map(fd, c->usermap, c->usermap_count) &&
send_identity_map(fd, c->groupmap, c->groupmap_count);
}
static bool receive_identity_map(int fd, int* pcount, IdentityMap** pmap) {
int count;
if (!receive_int(fd, &count) || count < 0 || count > MAX_IDENTITY_MAP)
return false;
if (count > 0) {
IdentityMap* map = calloc((size_t)count, sizeof(IdentityMap));
if (!map)
return false;
for (int i = 0; i < count; i++) {
if (!receive_int(fd, &map[i].from) || !receive_int(fd, &map[i].to)) {
free(map);
return false;
}
}
*pmap = map;
}
*pcount = count;
return true;
}
static bool receive_identity_options(int fd, Config* c) {
int numeric_ids;
if (!receive_int(fd, &numeric_ids) || !valid_wire_bool(numeric_ids))
return false;
c->numeric_ids = numeric_ids != 0;
if (!receive_wire_bool(fd, &c->chown_uid_set) || !receive_int(fd, &c->chown_uid) ||
!receive_wire_bool(fd, &c->chown_gid_set) || !receive_int(fd, &c->chown_gid))
return false;
if (c->chown_uid < IDENTITY_MATCH_ANY || c->chown_gid < IDENTITY_MATCH_ANY)
return false;
return receive_identity_map(fd, &c->usermap_count, &c->usermap) &&
receive_identity_map(fd, &c->groupmap_count, &c->groupmap);
}
bool config_send(int file_descriptor, const Config* config) {
protocol_session_set_max_alloc(NULL, config->max_alloc);
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
@@ -680,7 +751,8 @@ bool config_send(int file_descriptor, const Config* config) {
!send_selection_options(file_descriptor, config) ||
!send_resume_options(file_descriptor, config) ||
!send_basis_options(file_descriptor, config) || !send_fuzzy_option(file_descriptor, config) ||
!send_checksum_options(file_descriptor, config))
!send_checksum_options(file_descriptor, config) ||
!send_identity_options(file_descriptor, config))
return false;
Status status;
if (!receive_status(file_descriptor, &status))
@@ -715,7 +787,8 @@ Config* config_receive(int file_descriptor) {
!receive_resume_options(file_descriptor, config) ||
!receive_basis_options(file_descriptor, config) ||
!receive_fuzzy_option(file_descriptor, config) ||
!receive_checksum_options(file_descriptor, config))
!receive_checksum_options(file_descriptor, config) ||
!receive_identity_options(file_descriptor, config))
goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 0) {
+40 -1
View File
@@ -29,6 +29,17 @@ typedef struct BasisDest {
char* path; /* relative to the destination root (receiver-confined) */
} BasisDest;
/* One resolved FROM:TO identity-mapping rule (--usermap / --groupmap). Both
* fields are numeric ids. IDENTITY_MATCH_ANY (-1) in `from` is rsync's '*'
* wildcard (matches any transmitted id); IDENTITY_CURRENT (-1) in `to` makes
* the receiver resolve the receiving process's own current euid/egid at apply
* time. Names are resolved to numbers at parse time on the client (see
* identity.h for the exact subset). */
typedef struct {
int32_t from;
int32_t to;
} IdentityMap;
typedef struct Config {
char* version;
char* send_directory;
@@ -252,16 +263,44 @@ typedef struct Config {
int skip_compress_count;
bool skip_compress_set;
// Issue #131: Identity mapping. These configure whether and how the receiver
// applies ownership when it is actually preserved/applied. ALL of them cross
// the wire (protocol 2.11.0) so the receiver resolves and applies ownership
// with the exact policy the client requested. Plain -M/--preserve still does
// NOT apply ownership (FastSync's deliberate conservative default); it is
// only attempted when at least one of these is set (see identity.h).
/* --numeric-ids: no name lookup, use the transmitted numeric ids raw. */
bool numeric_ids;
/* --chown USER (owner) override; IDENTITY_CURRENT = the receiver's euid. */
bool chown_uid_set;
int32_t chown_uid;
/* --chown :GROUP (group) override; IDENTITY_CURRENT = the receiver's egid. */
bool chown_gid_set;
int32_t chown_gid;
/* --usermap / --groupmap entries, in order (first match wins). */
IdentityMap* usermap;
int usermap_count;
IdentityMap* groupmap;
int groupmap_count;
// Receiver-side runtime staging registry for --delay-updates. Never sent
// over the wire and never set on the sender side.
DelayUpdatesContext* delay_context;
} Config;
#define PROTOCOL_VERSION "2.10.0"
#define PROTOCOL_VERSION "2.11.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64
/* Identity-mapping sentinels and bounds (see identity.h for semantics).
* IDENTITY_MATCH_ANY is a usermap/groupmap FROM '*' (matches any id);
* IDENTITY_CURRENT is a chown / map TO '*' (resolve to the receiver's current
* euid/egid at apply time). */
#define IDENTITY_MATCH_ANY (-1)
#define IDENTITY_CURRENT (-1)
#define MAX_IDENTITY_MAP 128
Config* config_create(void);
void config_delete(Config* config);
bool config_send(int file_descriptor, const Config* config);
+457
View File
@@ -0,0 +1,457 @@
#include "identity.h"
#include "log.h"
#include "utils.h"
#include <errno.h>
#include <grp.h>
#include <limits.h>
#include <pwd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* The active identity snapshot lives in a per-process global. The TCP server
* forks one child process per connection, so a connection never shares this
* with another; within a connection the multithreaded receiver reads it without
* mutation. This is what lets the fd-relative metadata path consult the
* negotiated policy without threading a Config through every write helper. */
typedef struct {
bool numeric_ids;
bool chown_uid_set;
int32_t chown_uid;
bool chown_gid_set;
int32_t chown_gid;
IdentityMap* usermap;
int usermap_count;
IdentityMap* groupmap;
int groupmap_count;
bool set;
} IdentityActive;
static IdentityActive g_identity;
static void identity_active_reset(void) {
free(g_identity.usermap);
free(g_identity.groupmap);
g_identity.usermap = NULL;
g_identity.groupmap = NULL;
g_identity.usermap_count = 0;
g_identity.groupmap_count = 0;
g_identity.numeric_ids = false;
g_identity.chown_uid_set = false;
g_identity.chown_uid = 0;
g_identity.chown_gid_set = false;
g_identity.chown_gid = 0;
g_identity.set = false;
}
void identity_clear_active(void) {
identity_active_reset();
}
void identity_set_active(const Config* config) {
identity_active_reset();
if (!config)
return;
g_identity.numeric_ids = config->numeric_ids;
g_identity.chown_uid_set = config->chown_uid_set;
g_identity.chown_uid = config->chown_uid;
g_identity.chown_gid_set = config->chown_gid_set;
g_identity.chown_gid = config->chown_gid;
if (config->usermap_count > 0) {
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
if (g_identity.usermap) {
memcpy(g_identity.usermap, config->usermap,
(size_t)config->usermap_count * sizeof(IdentityMap));
g_identity.usermap_count = config->usermap_count;
}
}
if (config->groupmap_count > 0) {
g_identity.groupmap = calloc((size_t)config->groupmap_count, sizeof(IdentityMap));
if (g_identity.groupmap) {
memcpy(g_identity.groupmap, config->groupmap,
(size_t)config->groupmap_count * sizeof(IdentityMap));
g_identity.groupmap_count = config->groupmap_count;
}
}
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. */
if (geteuid() == 0)
log_message(LOG_LEVEL_WARNING,
"identity mapping active and running as root: client-supplied "
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
"run the daemon as an unprivileged user unless intended");
}
bool identity_active_enabled(void) {
/* --numeric-ids alone is a policy modifier (how ids are resolved WHERE
ownership is otherwise preserved), not itself an ownership-application
trigger, so it is deliberately excluded from this set: standalone it stays
inert, matching its siblings only when combined with -M/--preserve. */
return g_identity.set &&
(g_identity.chown_uid_set || g_identity.chown_gid_set ||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0);
}
bool identity_wire_valid(const Config* config) {
if (!config)
return false;
if (config->usermap_count < 0 || config->usermap_count > MAX_IDENTITY_MAP ||
config->groupmap_count < 0 || config->groupmap_count > MAX_IDENTITY_MAP)
return false;
if (config->chown_uid_set && config->chown_uid < IDENTITY_MATCH_ANY)
return false;
if (config->chown_gid_set && config->chown_gid < IDENTITY_MATCH_ANY)
return false;
for (int i = 0; i < config->usermap_count; i++) {
if (config->usermap[i].from < IDENTITY_MATCH_ANY || config->usermap[i].to < IDENTITY_CURRENT)
return false;
}
for (int i = 0; i < config->groupmap_count; i++) {
if (config->groupmap[i].from < IDENTITY_MATCH_ANY || config->groupmap[i].to < IDENTITY_CURRENT)
return false;
}
return true;
}
/* ---- CLI-time name/number resolution ---- */
/* Parse a single FROM/TO token into an int32 id. Returns 0 on success, -1 on a
* malformed or unresolvable token. When is_group, name lookups use the group
* database; otherwise the user database. A `*` token returns IDENTITY_MATCH_ANY
* / IDENTITY_CURRENT (the same -1 value, disambiguated by the caller's
* position). An `@`-prefixed or bare-decimal token is a numeric id. */
static int identity_resolve_token(const char* token, bool is_group, int32_t* out) {
if (!token || *token == '\0')
return -1;
if (strcmp(token, "*") == 0) {
*out = IDENTITY_MATCH_ANY;
return 0;
}
const char* num = (token[0] == '@') ? token + 1 : token;
if (*num != '\0') {
bool all_digits = true;
for (const char* p = num; *p; p++)
if (*p < '0' || *p > '9')
all_digits = false;
if (all_digits) {
char* endptr = NULL;
errno = 0;
long val = strtol(num, &endptr, 10);
if (errno == 0 && endptr && *endptr == '\0' && val >= 0 && val <= INT32_MAX) {
*out = (int32_t)val;
return 0;
}
return -1;
}
}
/* A name (or a name-like numeric that failed strict numeric parse). */
if (is_group) {
struct group* gr = getgrnam(token);
if (!gr)
return -1;
*out = (int32_t)gr->gr_gid;
return 0;
}
struct passwd* pw = getpwnam(token);
if (!pw)
return -1;
*out = (int32_t)pw->pw_uid;
return 0;
}
static int identity_append_rule(IdentityMap** map, int* count, int32_t from, int32_t to) {
if (*count >= MAX_IDENTITY_MAP)
return -1;
IdentityMap* grown = realloc(*map, (size_t)(*count + 1) * sizeof(IdentityMap));
if (!grown)
return -1;
*map = grown;
(*map)[*count].from = from;
(*map)[*count].to = to;
(*count)++;
return 0;
}
int identity_parse_map(Config* config, const char* value, bool is_group) {
if (!config || !value || *value == '\0') {
log_message(LOG_LEVEL_ERROR, "%smap requires a value", is_group ? "--group" : "--user");
return -1;
}
char* list = str_dup(value);
if (!list)
return -1;
const char* optname = is_group ? "--groupmap" : "--usermap";
char* saveptr = NULL;
for (char* rule = strtok_r(list, ",", &saveptr); rule; rule = strtok_r(NULL, ",", &saveptr)) {
char* colon = strchr(rule, ':');
if (!colon || colon == rule) {
free(list);
log_message(LOG_LEVEL_ERROR, "%s rules must be FROM:TO (got '%s')", optname, rule);
return -1;
}
*colon = '\0';
char* from_token = rule;
char* to_token = colon + 1;
if (*to_token == '\0') {
free(list);
log_message(LOG_LEVEL_ERROR, "%s rule 'FROM:' is missing the TO value (got '%s')", optname,
value);
return -1;
}
int32_t from_id, to_id;
if (identity_resolve_token(from_token, is_group, &from_id) != 0 ||
identity_resolve_token(to_token, is_group, &to_id) != 0) {
free(list);
log_message(LOG_LEVEL_ERROR,
"%s could not resolve '%s' (name must exist on the source; use "
"@N for a numeric id)",
optname, value);
return -1;
}
if (identity_append_rule(is_group ? &config->groupmap : &config->usermap,
is_group ? &config->groupmap_count : &config->usermap_count, from_id,
to_id) != 0) {
free(list);
log_message(LOG_LEVEL_ERROR, "%s has too many rules (max %d)", optname, MAX_IDENTITY_MAP);
return -1;
}
}
free(list);
return 0;
}
/* Split --chown=USER:GROUP on the first UNESCAPED colon, honoring backslash
* escapes (a `\:` is a literal colon inside a name; a lone backslash before any
* other character is kept verbatim). Both sides are returned as malloc'd
* strings (the absent side is NULL). */
static int identity_split_chown(const char* value, char** puser, char** pgroup) {
size_t len = strlen(value);
char* user = malloc(len + 1);
char* group = malloc(len + 1);
if (!user || !group) {
free(user);
free(group);
return -1;
}
const char* p = value;
size_t ui = 0;
bool split_seen = false;
size_t gi = 0;
while (*p) {
if (*p == '\\' && p[1] == ':') {
/* an escaped colon: a literal ':' in the current side's name */
if (split_seen)
group[gi++] = ':';
else
user[ui++] = ':';
p += 2;
continue;
}
if (*p == ':') {
split_seen = true;
p++;
continue;
}
if (split_seen)
group[gi++] = *p;
else
user[ui++] = *p;
p++;
}
user[ui] = '\0';
group[gi] = '\0';
char* u = str_dup(user);
char* g = str_dup(group);
free(user);
free(group);
if (!u || !g) {
free(u);
free(g);
return -1;
}
*puser = u;
*pgroup = g;
return 0;
}
int identity_parse_chown(Config* config, const char* value) {
if (!config || !value || *value == '\0') {
log_message(LOG_LEVEL_ERROR, "--chown requires a value (USER:GROUP, USER, or :GROUP)");
return -1;
}
/* Reject more than one UNESCAPED colon (a name or group may not contain an
* unescaped ':' in the spec). The scan is escape-aware: a `\:` is a literal
* colon inside a name, not a field separator. */
int colons = 0;
bool saw_colon = false;
const char* p = value;
while (*p) {
if (*p == '\\' && p[1] == ':') {
p += 2;
continue;
}
if (*p == ':') {
colons++;
saw_colon = true;
}
p++;
}
if (colons > 1) {
log_message(LOG_LEVEL_ERROR, "--chown must have at most one ':' (got '%s')", value);
return -1;
}
char *user = NULL, *group = NULL;
if (identity_split_chown(value, &user, &group) != 0) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --chown");
return -1;
}
int ret = 0;
if (!saw_colon) {
/* --chown=USER: owner only. */
if (*user == '\0') {
log_message(LOG_LEVEL_ERROR, "--chown requires a user or group (got '%s')", value);
ret = -1;
} else if (identity_resolve_token(user, false, &config->chown_uid) != 0) {
log_message(LOG_LEVEL_ERROR,
"--chown could not resolve user '%s' (use a name that exists "
"on the source, '*', or @N)",
value);
ret = -1;
} else {
config->chown_uid_set = true;
}
} else {
/* --chown=USER:GROUP, --chown=:GROUP, --chown=USER: */
if (*user != '\0') {
if (identity_resolve_token(user, false, &config->chown_uid) != 0) {
log_message(LOG_LEVEL_ERROR, "--chown could not resolve user '%s'", value);
ret = -1;
goto done;
}
config->chown_uid_set = true;
}
if (*group != '\0') {
if (identity_resolve_token(group, true, &config->chown_gid) != 0) {
log_message(LOG_LEVEL_ERROR, "--chown could not resolve group '%s'", value);
ret = -1;
goto done;
}
config->chown_gid_set = true;
}
if (!*user && !*group) {
log_message(LOG_LEVEL_ERROR, "--chown must set a user, a group, or both (got '%s')", value);
ret = -1;
}
}
done:
free(user);
free(group);
return ret;
}
/* ---- Receiver-side ownership application ---- */
static bool identity_map_lookup(const IdentityMap* map, int count, int32_t source_id,
int32_t* out_to) {
for (int i = 0; i < count; i++) {
if (map[i].from == IDENTITY_MATCH_ANY || map[i].from == source_id) {
*out_to = map[i].to;
return true;
}
}
return false;
}
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
/* Ownership application is OFF unless the client requested an identity flag.
* This is the controlled gate: a default (or plain -M) transfer never changes
* ownership, byte-for-byte preserving FastSync's existing behavior. */
if (!identity_active_enabled() || fd < 0)
return;
struct stat st;
if (fstat(fd, &st) != 0)
return;
bool set_uid = false;
bool set_gid = false;
uid_t uid = 0;
gid_t gid = 0;
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;
set_uid = true;
} else if (g_identity.chown_uid_set) {
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
set_uid = true;
} else if (g_identity.numeric_ids) {
uid = (uid_t)source_uid;
set_uid = true;
} else {
/* Best-effort name mapping against the receiver's own database: if the
* transmitted (numeric) id resolves to a name present on this machine,
* re-resolve it. On a shared-account host this is the identity operation;
* when the id has no name here, the user side is left alone. */
struct passwd* pw = getpwuid((uid_t)source_uid);
if (pw) {
const struct passwd* mapped = getpwnam(pw->pw_name);
if (mapped) {
uid = mapped->pw_uid;
set_uid = true;
}
}
}
if (identity_map_lookup(g_identity.groupmap, g_identity.groupmap_count, source_gid, &target)) {
gid = target == IDENTITY_CURRENT ? getegid() : (gid_t)target;
set_gid = true;
} else if (g_identity.chown_gid_set) {
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
set_gid = true;
} else if (g_identity.numeric_ids) {
gid = (gid_t)source_gid;
set_gid = true;
} else {
struct group* gr = getgrgid((gid_t)source_gid);
if (gr) {
const struct group* mapped = getgrnam(gr->gr_name);
if (mapped) {
gid = mapped->gr_gid;
set_gid = true;
}
}
}
if (!set_uid && !set_gid)
return;
/* An unset side keeps the file's current id so the other side can change. */
if (!set_uid)
uid = st.st_uid;
if (!set_gid)
gid = st.st_gid;
/* Only call fchown when the target differs (avoid needless syscalls and any
* chance of clearing setuid/setgid on an already-correct file). */
if (st.st_uid == uid && st.st_gid == gid)
return;
if (fchown(fd, uid, gid) != 0) {
/* EPERM/EACCES are expected when the receiver is not privileged (e.g. the
* CI `nobody` user): warn and continue, never abort the transfer. Any
* other error (EIO/EROFS/ENOSPC/...) is a real failure and must not be
* silently downgraded to a warning. */
if (errno == EPERM || errno == EACCES)
log_message(LOG_LEVEL_WARNING,
"could not apply ownership (uid=%ld gid=%ld): %s; leaving as-is", (long)uid,
(long)gid, strerror(errno));
else
log_message(LOG_LEVEL_ERROR, "failed to apply ownership (uid=%ld gid=%ld): %s", (long)uid,
(long)gid, strerror(errno));
}
}
+61
View File
@@ -0,0 +1,61 @@
#ifndef IDENTITY_H
#define IDENTITY_H
#include "config.h"
#include <stdbool.h>
#include <stdint.h>
#include <sys/types.h>
/*
* Identity mapping: --numeric-ids / --usermap / --groupmap / --chown.
*
* FastSync transmits uid/gid numerically (int32 on the wire) and, by design,
* NEVER applies client-supplied ownership unless a user explicitly opts in with
* an identity flag below. This module is the controlled, opt-in,
* privilege-gated path for applying ownership on the receiver: the wire config
* is snapshotted once per connection via identity_set_active() and applied
* through an fd-relative fchown() in the receiver's metadata-restore path.
*
* Because only numeric ids cross the wire, name-based values are resolved to
* numbers at CLI parse time using the CLIENT (sender) machine's databases. On
* a shared-account source/destination this reproduces rsync's semantics; a
* genuinely different destination database is a documented divergence (see
* RSYNC_COMPAT.md).
*/
/* Parse one --usermap= / --groupmap= value (comma-separated FROM:TO rules,
* first match wins) into config->usermap / config->groupmap. is_group selects
* the group tables and name databases. Returns 0 on success, -1 on a
* malformed spec or an unresolvable name (never a silent no-op). */
int identity_parse_map(Config* config, const char* value, bool is_group);
/* Parse --chown=USER:GROUP. Supports USER:GROUP, USER (owner only), :GROUP
* (group only), '*' (current/root as appropriate) and numeric ids. Returns 0
* on success, -1 on a malformed spec / unresolvable name. */
int identity_parse_chown(Config* config, const char* value);
/* Receiver-side snapshot of the negotiated identity config. The server calls
* identity_set_active() once per connection (before any file write) using the
* config received over the wire; the snapshot is a deep copy so the caller may
* free its Config immediately. identity_clear_active() releases it. */
void identity_set_active(const Config* config);
void identity_clear_active(void);
/* True when any ownership-affecting identity option is present in the active
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
* requests none of them, preserving FastSync's existing behavior. */
bool identity_active_enabled(void);
/* Apply the negotiated ownership to an already-written file descriptor.
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
* a matching usermap/groupmap rule, then --chown, then --numeric-ids (raw),
* then a best-effort name lookup on the receiver's own databases (skipped when
* the transmitted id has no name on this system). Only calls fchown() when the
* result differs from the current value; EPERM/EACCES are logged and ignored,
* never fatal (rsync parity: the transfer must not abort). */
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
/* Receiver-side wire validation of the resolved identity fields. */
bool identity_wire_valid(const Config* config);
#endif
+10 -1
View File
@@ -1,5 +1,6 @@
#include "metadata.h"
#include "file.h"
#include "identity.h"
#include "log.h"
#include "protocol.h"
#include "utils.h"
@@ -249,7 +250,15 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
mode_t safe_mode = metadata_mode(metadata, current.st_mode, preserve_executability);
if (fchmod(fd, safe_mode) != 0)
ok = false;
/* Client uid/gid values are deliberately not authoritative. */
/* Client uid/gid values are deliberately not authoritative UNLESS the client
explicitly opted in with an identity flag (--numeric-ids / --usermap /
--groupmap / --chown). identity_apply_ownership is the controlled,
privilege-gated path: it consults the negotiated policy, resolves the
target ids, and applies them via an fd-relative fchown() that is confined
to the just-written file (EPERM/EACCES are logged, never fatal). With no
identity flag set it is a no-op, so a default or plain -M transfer keeps
FastSync's existing behavior of never applying client ownership. */
identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid);
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
if (futimens(fd, times) != 0)