1239 lines
46 KiB
C
1239 lines
46 KiB
C
#include "identity.h"
|
|
#include "log.h"
|
|
#include "utils.h"
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <fnmatch.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>
|
|
|
|
static bool identity_id_fits_int32(unsigned long id);
|
|
|
|
/* 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;
|
|
/* --super / --no-super tri-state (SUPER_MODE_AUTO when unset). Snapshotted
|
|
* per connection so privilege_super_permitted() can gate super-user
|
|
* activities without a Config argument. */
|
|
SuperMode super_mode;
|
|
/* --copy-as=USER[:GROUP]: snapshotted so the ownership resolver can force the
|
|
* target ids without a Config argument. */
|
|
bool copy_as_set;
|
|
int32_t copy_as_uid;
|
|
int32_t copy_as_gid;
|
|
/* -o/--owner and -g/--group: preserve the source owner/group through the
|
|
* normal name/identity resolution path. Split out of the former
|
|
* use_metadata bundle; unlike --numeric-ids/--chown/--usermap/--groupmap/-a
|
|
* these are a preserve-source request, not an arbitrary client-chosen owner,
|
|
* so they are tracked separately from the explicit ownership gate. */
|
|
bool preserve_owner;
|
|
bool preserve_group;
|
|
/* --fake-super: when active the receiver must only RECORD the (resolved)
|
|
* ownership in the reserved xattr, never perform a real chown. Snapshotted
|
|
* so the fd-relative ownership helpers can suppress the chown without a
|
|
* Config argument. */
|
|
bool fake_super;
|
|
bool set;
|
|
} IdentityActive;
|
|
|
|
static IdentityActive g_identity;
|
|
|
|
static void identity_active_reset(void) {
|
|
if (g_identity.usermap) {
|
|
for (int i = 0; i < g_identity.usermap_count; i++)
|
|
free(g_identity.usermap[i].to_name);
|
|
free(g_identity.usermap);
|
|
}
|
|
if (g_identity.groupmap) {
|
|
for (int i = 0; i < g_identity.groupmap_count; i++)
|
|
free(g_identity.groupmap[i].to_name);
|
|
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.super_mode = SUPER_MODE_AUTO;
|
|
g_identity.copy_as_set = false;
|
|
g_identity.copy_as_uid = 0;
|
|
g_identity.copy_as_gid = 0;
|
|
g_identity.preserve_owner = false;
|
|
g_identity.preserve_group = false;
|
|
g_identity.fake_super = false;
|
|
g_identity.set = false;
|
|
}
|
|
|
|
void identity_clear_active(void) {
|
|
identity_active_reset();
|
|
}
|
|
|
|
bool identity_set_active(const Config* config) {
|
|
identity_active_reset();
|
|
if (!config)
|
|
return true;
|
|
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;
|
|
g_identity.super_mode = config->super_mode;
|
|
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;
|
|
g_identity.preserve_owner = config->preserve_owner;
|
|
g_identity.preserve_group = config->preserve_group;
|
|
g_identity.fake_super = config->fake_super;
|
|
if (config->usermap_count > 0) {
|
|
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
|
|
if (!g_identity.usermap)
|
|
goto alloc_failed;
|
|
for (int i = 0; i < config->usermap_count; i++) {
|
|
g_identity.usermap[i] = config->usermap[i];
|
|
g_identity.usermap[i].to_name =
|
|
config->usermap[i].to_name ? str_dup(config->usermap[i].to_name) : NULL;
|
|
if (config->usermap[i].to_name && !g_identity.usermap[i].to_name) {
|
|
g_identity.usermap_count = i; /* free only the entries already duplicated */
|
|
goto alloc_failed;
|
|
}
|
|
}
|
|
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)
|
|
goto alloc_failed;
|
|
for (int i = 0; i < config->groupmap_count; i++) {
|
|
g_identity.groupmap[i] = config->groupmap[i];
|
|
g_identity.groupmap[i].to_name =
|
|
config->groupmap[i].to_name ? str_dup(config->groupmap[i].to_name) : NULL;
|
|
if (config->groupmap[i].to_name && !g_identity.groupmap[i].to_name) {
|
|
g_identity.groupmap_count = i;
|
|
goto alloc_failed;
|
|
}
|
|
}
|
|
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/--copy-as, or raw ids under --numeric-ids)
|
|
ONLY when super-user activities are permitted. --no-super (or a daemon
|
|
veto that forced SUPER_MODE_OFF) forbids the chown even for root, so do
|
|
not claim the ownership will be honored in that case. */
|
|
if (geteuid() == 0) {
|
|
if (privilege_super_mode_permitted(g_identity.super_mode))
|
|
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");
|
|
else
|
|
log_message(LOG_LEVEL_WARNING,
|
|
"identity mapping active and running as root, but super-user activities are "
|
|
"disabled (--no-super): requested ownership will NOT be applied; run the "
|
|
"daemon as an unprivileged user unless intended");
|
|
}
|
|
/* --super explicitly requests super-user activities, but FastSync never
|
|
elevates privileges: when the receiver is not already root the kernel will
|
|
refuse those confined attempts and each is skipped per entry. Warn exactly
|
|
once at activation time (never abort) so the operator knows the flag cannot
|
|
succeed on this host. */
|
|
if (g_identity.super_mode == SUPER_MODE_ON && geteuid() != 0)
|
|
log_message(LOG_LEVEL_WARNING,
|
|
"--super requested but the receiver is not privileged; super-user "
|
|
"activities (ownership, device nodes) will be attempted but refused "
|
|
"by the kernel and skipped per entry");
|
|
return true;
|
|
|
|
alloc_failed:
|
|
/* Never proceed with a partial (count-left-zero) map: that would silently
|
|
apply the WRONG ownership policy. Fail closed and let the caller refuse
|
|
the connection. */
|
|
log_message(LOG_LEVEL_ERROR, "memory allocation failed while activating identity policy");
|
|
identity_active_reset();
|
|
return false;
|
|
}
|
|
|
|
bool privilege_super_permitted(void) {
|
|
return privilege_super_mode_permitted(g_identity.super_mode);
|
|
}
|
|
|
|
bool privilege_super_mode_permitted(SuperMode mode) {
|
|
/* AUTO and ON both attempt the confined operation; OFF forbids it even for a
|
|
* root receiver. AUTO is the historical FastSync behavior (always attempt
|
|
* and let the kernel refuse an unprivileged call, which the caller skips), so
|
|
* it must stay permissive or a group-only chown that a non-root receiver is
|
|
* allowed to make would regress. */
|
|
return mode != SUPER_MODE_OFF;
|
|
}
|
|
|
|
bool identity_active_enabled(void) {
|
|
/* --numeric-ids is deliberately NOT included: it is a mapping MODIFIER (use
|
|
* the transmitted numeric id raw instead of a name lookup), not a request to
|
|
* change ownership. rsync's --numeric-ids on its own never chowns anything;
|
|
* it only changes how an already-requested -o/-g/map resolves. Ownership is
|
|
* activated only by an explicit request: --chown/--usermap/--groupmap/
|
|
* --copy-as or a preserve-source -o/--owner / -g/--group. --super/--no-super
|
|
* likewise does NOT enable ownership: it only permits or forbids the
|
|
* already-requested super-user activities. */
|
|
return g_identity.set &&
|
|
(g_identity.chown_uid_set || g_identity.chown_gid_set || g_identity.usermap_count > 0 ||
|
|
g_identity.groupmap_count > 0 || g_identity.copy_as_set || g_identity.preserve_owner ||
|
|
g_identity.preserve_group);
|
|
}
|
|
|
|
bool identity_owner_requested(void) {
|
|
return g_identity.set && (g_identity.copy_as_set || g_identity.chown_uid_set ||
|
|
g_identity.preserve_owner || g_identity.usermap_count > 0);
|
|
}
|
|
|
|
bool identity_group_requested(void) {
|
|
return g_identity.set && (g_identity.copy_as_set || g_identity.chown_gid_set ||
|
|
g_identity.preserve_group || g_identity.groupmap_count > 0);
|
|
}
|
|
|
|
bool identity_ownership_requested(const Config* config) {
|
|
if (!config)
|
|
return false;
|
|
/* General-awareness predicate: every value that makes the receiver act on a
|
|
* client-chosen owner, plus an explicit --super (super-user device-node
|
|
* activities) and the preserve-source -o/-g requests. Pure config, so callers
|
|
* can evaluate it before identity_set_active(). The daemon module gate uses
|
|
* the narrower identity_explicit_ownership_requested() below, which treats a
|
|
* plain -o/-g/-a as a preserve-source request rather than arbitrary
|
|
* client-chosen ownership. */
|
|
return config->numeric_ids || config->chown_uid_set || config->chown_gid_set ||
|
|
config->usermap_count > 0 || config->groupmap_count > 0 || config->copy_as_set ||
|
|
config->preserve_owner || config->preserve_group || config->fake_super ||
|
|
config->super_mode == SUPER_MODE_ON;
|
|
}
|
|
|
|
bool identity_explicit_ownership_requested(const Config* config) {
|
|
if (!config)
|
|
return false;
|
|
/* The narrow set the daemon gate refuses for a non-opted module: a request
|
|
* that lets the CLIENT choose an arbitrary owner/group (rather than preserve
|
|
* the source's own). Deliberately EXCLUDES preserve_owner/preserve_group so a
|
|
* plain -a/-o/-g push is not refused; for those the gate instead forces
|
|
* super-user ownership activity off (no chown happens) unless the module has
|
|
* `client owner = yes`. */
|
|
return config->numeric_ids || config->chown_uid_set || config->chown_gid_set ||
|
|
config->usermap_count > 0 || config->groupmap_count > 0 || config->copy_as_set ||
|
|
config->fake_super || config->super_mode == SUPER_MODE_ON;
|
|
}
|
|
|
|
bool identity_copy_as_active(void) {
|
|
return g_identity.set && g_identity.copy_as_set;
|
|
}
|
|
|
|
bool identity_copy_as_refused(const Config* config) {
|
|
if (!config || !config->copy_as_set)
|
|
return false;
|
|
/* The safe-subset --copy-as needs a privileged (root) receiver, and an
|
|
* operator/--no-super veto forbids the ownership change even for root. This
|
|
* is deliberately a pure function of the config and the current effective uid
|
|
* (never the active snapshot) because the server evaluates it at the
|
|
* pre-STATUS_OK config gate, before identity_set_active() has run. */
|
|
return geteuid() != 0 || config->super_mode == SUPER_MODE_OFF;
|
|
}
|
|
|
|
/* Validate one received FROM:TO map rule. `from` is a single id, the LOW end
|
|
* of an inclusive range, IDENTITY_MATCH_ANY, or IDENTITY_MATCH_UNNAMED; a
|
|
* sentinel FROM must carry the same value in from_hi. `to` is a non-negative
|
|
* id, IDENTITY_CURRENT, or ignored when a bounded receiver-resolved `to_name`
|
|
* is present. */
|
|
static bool identity_wire_map_valid(const IdentityMap* map) {
|
|
if (!map)
|
|
return false;
|
|
if (map->from < IDENTITY_MATCH_UNNAMED)
|
|
return false;
|
|
if (map->from < 0) {
|
|
if (map->from_hi != map->from)
|
|
return false;
|
|
} else if (map->from_hi < map->from) {
|
|
return false;
|
|
}
|
|
if (map->to < IDENTITY_CURRENT)
|
|
return false;
|
|
if (map->to_name && strlen(map->to_name) > 255)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
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 (!identity_wire_map_valid(&config->usermap[i]))
|
|
return false;
|
|
}
|
|
for (int i = 0; i < config->groupmap_count; i++) {
|
|
if (!identity_wire_map_valid(&config->groupmap[i]))
|
|
return false;
|
|
}
|
|
/* Defense-in-depth: a --copy-as block must never carry a negative (sentinel)
|
|
* id into the ownership path. receive_copy_as_options already rejects them,
|
|
* but identity_wire_valid is the shared validation used by both the receiver
|
|
* and unit tests, so re-assert it here. */
|
|
if (config->copy_as_set && (config->copy_as_uid < 0 || config->copy_as_gid < 0))
|
|
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 bool identity_all_digits(const char* token) {
|
|
if (!token || *token == '\0')
|
|
return false;
|
|
for (const char* p = token; *p; p++)
|
|
if (*p < '0' || *p > '9')
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
static bool identity_token_has_glob(const char* token) {
|
|
return token && (strchr(token, '*') || strchr(token, '?') || strchr(token, '['));
|
|
}
|
|
|
|
/* Parse a --usermap/--groupmap FROM token into a matcher (from/from_hi). rsync
|
|
* accepts a name, a numeric id, an inclusive LOW-HIGH range, '*' (any id), or an
|
|
* empty token (ids with no name on the sender). Returns 0 on success, -1 on a
|
|
* malformed token or an unresolvable sender-side name. */
|
|
static int identity_parse_from(const char* token, bool is_group, int32_t* out_from,
|
|
int32_t* out_hi) {
|
|
if (token[0] == '\0') {
|
|
*out_from = IDENTITY_MATCH_UNNAMED;
|
|
*out_hi = IDENTITY_MATCH_UNNAMED;
|
|
return 0;
|
|
}
|
|
if (strcmp(token, "*") == 0) {
|
|
*out_from = IDENTITY_MATCH_ANY;
|
|
*out_hi = IDENTITY_MATCH_ANY;
|
|
return 0;
|
|
}
|
|
const char* num = token[0] == '@' ? token + 1 : token;
|
|
if (identity_all_digits(num)) {
|
|
int32_t id;
|
|
if (identity_resolve_token(token, is_group, &id) != 0)
|
|
return -1;
|
|
*out_from = id;
|
|
*out_hi = id;
|
|
return 0;
|
|
}
|
|
/* An inclusive LOW-HIGH numeric range. */
|
|
const char* dash = strchr(num, '-');
|
|
if (dash && dash != num && dash[1] != '\0' && strchr(dash + 1, '-') == NULL) {
|
|
size_t lo_len = (size_t)(dash - num);
|
|
size_t hi_len = strlen(dash + 1);
|
|
char low[16];
|
|
char high[16];
|
|
if (lo_len < sizeof(low) && hi_len < sizeof(high)) {
|
|
memcpy(low, num, lo_len);
|
|
low[lo_len] = '\0';
|
|
memcpy(high, dash + 1, hi_len);
|
|
high[hi_len] = '\0';
|
|
if (identity_all_digits(low) && identity_all_digits(high)) {
|
|
char* endptr = NULL;
|
|
errno = 0;
|
|
long lo = strtol(low, &endptr, 10);
|
|
if (errno != 0 || !endptr || *endptr != '\0')
|
|
return -1;
|
|
errno = 0;
|
|
long hi = strtol(high, &endptr, 10);
|
|
if (errno != 0 || !endptr || *endptr != '\0' || hi < lo || hi > INT32_MAX)
|
|
return -1;
|
|
*out_from = (int32_t)lo;
|
|
*out_hi = (int32_t)hi;
|
|
return 0;
|
|
}
|
|
}
|
|
/* Not a numeric LOW-HIGH range: fall through and treat as a name (a
|
|
* hyphenated account name like "wayne-smith" must still resolve). */
|
|
}
|
|
/* A sender-side name. A FROM name wildcard other than the bare '*' is handled
|
|
* by identity_expand_from_glob() in the caller (it expands against the
|
|
* sender's account database at CLI-parse time), so this function only sees the
|
|
* bare '*' or a literal name here. */
|
|
int32_t id;
|
|
if (identity_resolve_token(token, is_group, &id) != 0)
|
|
return -1;
|
|
*out_from = id;
|
|
*out_hi = id;
|
|
return 0;
|
|
}
|
|
|
|
/* Parse a --usermap/--groupmap TO token. '*', a bare numeric id, or an @N id is
|
|
* stored numerically; every other non-empty token is a NAME resolved on the
|
|
* RECEIVER at apply time (rsync resolves TO names against the receiving side).
|
|
* Returns 0 on success, -1 on an empty/malformed token. */
|
|
static int identity_parse_to(const char* token, bool is_group, int32_t* out_to, char** out_name) {
|
|
if (token[0] == '\0') {
|
|
log_message(LOG_LEVEL_ERROR, "%smap TO value is missing", is_group ? "--group" : "--user");
|
|
return -1;
|
|
}
|
|
if (strcmp(token, "*") == 0) {
|
|
*out_to = IDENTITY_CURRENT;
|
|
*out_name = NULL;
|
|
return 0;
|
|
}
|
|
const char* num = token[0] == '@' ? token + 1 : token;
|
|
if (identity_all_digits(num)) {
|
|
int32_t id;
|
|
if (identity_resolve_token(token, is_group, &id) != 0)
|
|
return -1;
|
|
*out_to = id;
|
|
*out_name = NULL;
|
|
return 0;
|
|
}
|
|
if (identity_token_has_glob(token)) {
|
|
log_message(LOG_LEVEL_ERROR, "%smap TO '%s' may not contain a wildcard",
|
|
is_group ? "--group" : "--user", token);
|
|
return -1;
|
|
}
|
|
char* name = str_dup(token);
|
|
if (!name)
|
|
return -1;
|
|
*out_to = 0;
|
|
*out_name = name;
|
|
return 0;
|
|
}
|
|
|
|
static int identity_append_rule(IdentityMap** map, int* count, const IdentityMap* rule) {
|
|
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] = *rule;
|
|
(*count)++;
|
|
return 0;
|
|
}
|
|
|
|
/* True when `lo` and `hi` are adjacent ids (no overflow at INT32_MAX). */
|
|
static bool identity_ids_adjacent(int32_t lo, int32_t hi) {
|
|
return lo < INT32_MAX && hi == lo + 1;
|
|
}
|
|
|
|
static int identity_id_cmp(const void* a, const void* b) {
|
|
int32_t x = *(const int32_t*)a;
|
|
int32_t y = *(const int32_t*)b;
|
|
return (x > y) - (x < y);
|
|
}
|
|
|
|
static bool identity_ids_push(int32_t** ids, size_t* count, size_t* cap, int32_t id) {
|
|
if (*count == *cap) {
|
|
size_t grown_cap = *cap ? *cap * 2 : 16;
|
|
int32_t* grown = realloc(*ids, grown_cap * sizeof(int32_t));
|
|
if (!grown)
|
|
return false;
|
|
*ids = grown;
|
|
*cap = grown_cap;
|
|
}
|
|
(*ids)[(*count)++] = id;
|
|
return true;
|
|
}
|
|
|
|
/* Expand a FROM name wildcard (rsync's match against sender-side account names)
|
|
* into one rule per contiguous run of matching numeric ids, all sharing the same
|
|
* TO side. FastSync transmits numeric ids only, so the wildcard must be
|
|
* resolved here -- at CLI-parse time -- against the SENDER's passwd/group
|
|
* database; the receiver has no sender names to match. Contiguous matched ids
|
|
* are collapsed into a single LOW-HIGH range (a range of adjacent ids contains
|
|
* exactly the ids it spans, so this is semantically exact). Returns 0 on
|
|
* success, -1 on an allocation failure, a wildcard that matches no sender
|
|
* account, or an expansion that would push the map past MAX_IDENTITY_MAP. */
|
|
static int identity_expand_from_glob(Config* config, const char* glob, bool is_group,
|
|
const IdentityMap* to_rule) {
|
|
const char* optname = is_group ? "--groupmap" : "--usermap";
|
|
size_t cap = 0;
|
|
size_t n = 0;
|
|
int32_t* ids = NULL;
|
|
bool alloc_failed = false;
|
|
|
|
if (is_group) {
|
|
setgrent();
|
|
struct group* gr;
|
|
while ((gr = getgrent()) != NULL) {
|
|
if (fnmatch(glob, gr->gr_name, 0) != 0)
|
|
continue;
|
|
if (!identity_id_fits_int32((unsigned long)gr->gr_gid))
|
|
continue;
|
|
if (!identity_ids_push(&ids, &n, &cap, (int32_t)gr->gr_gid)) {
|
|
alloc_failed = true;
|
|
break;
|
|
}
|
|
}
|
|
endgrent();
|
|
} else {
|
|
setpwent();
|
|
struct passwd* pw;
|
|
while ((pw = getpwent()) != NULL) {
|
|
if (fnmatch(glob, pw->pw_name, 0) != 0)
|
|
continue;
|
|
if (!identity_id_fits_int32((unsigned long)pw->pw_uid))
|
|
continue;
|
|
if (!identity_ids_push(&ids, &n, &cap, (int32_t)pw->pw_uid)) {
|
|
alloc_failed = true;
|
|
break;
|
|
}
|
|
}
|
|
endpwent();
|
|
}
|
|
|
|
if (alloc_failed) {
|
|
free(ids);
|
|
log_message(LOG_LEVEL_ERROR, "%s: memory allocation failed expanding FROM '%s'", optname, glob);
|
|
return -1;
|
|
}
|
|
if (n == 0) {
|
|
free(ids);
|
|
log_message(LOG_LEVEL_ERROR, "%s FROM '%s': no source account name matches the wildcard",
|
|
optname, glob);
|
|
return -1;
|
|
}
|
|
|
|
qsort(ids, n, sizeof(int32_t), identity_id_cmp);
|
|
size_t unique = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (unique == 0 || ids[unique - 1] != ids[i])
|
|
ids[unique++] = ids[i];
|
|
}
|
|
n = unique;
|
|
|
|
int runs = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (i == 0 || !identity_ids_adjacent(ids[i - 1], ids[i]))
|
|
runs++;
|
|
}
|
|
|
|
IdentityMap** map = is_group ? &config->groupmap : &config->usermap;
|
|
int* count = is_group ? &config->groupmap_count : &config->usermap_count;
|
|
if (*count > MAX_IDENTITY_MAP - runs) {
|
|
log_message(LOG_LEVEL_ERROR,
|
|
"%s FROM '%s': the name wildcard expands to %d rule(s), which would exceed "
|
|
"the maximum of %d map rules",
|
|
optname, glob, runs, MAX_IDENTITY_MAP);
|
|
free(ids);
|
|
return -1;
|
|
}
|
|
|
|
for (size_t i = 0; i < n;) {
|
|
size_t j = i;
|
|
while (j + 1 < n && identity_ids_adjacent(ids[j], ids[j + 1]))
|
|
j++;
|
|
IdentityMap rule;
|
|
rule.from = ids[i];
|
|
rule.from_hi = ids[j];
|
|
rule.to = to_rule->to;
|
|
rule.to_name = to_rule->to_name ? str_dup(to_rule->to_name) : NULL;
|
|
if (to_rule->to_name && !rule.to_name) {
|
|
free(ids);
|
|
return -1;
|
|
}
|
|
if (identity_append_rule(map, count, &rule) != 0) {
|
|
free(rule.to_name);
|
|
free(ids);
|
|
return -1;
|
|
}
|
|
i = j + 1;
|
|
}
|
|
free(ids);
|
|
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) {
|
|
/* Log before freeing: `rule` points into the str_dup'd list. */
|
|
log_message(LOG_LEVEL_ERROR, "%s rules must be FROM:TO (got '%s')", optname, rule);
|
|
free(list);
|
|
return -1;
|
|
}
|
|
*colon = '\0';
|
|
char* from_token = rule;
|
|
char* to_token = colon + 1;
|
|
IdentityMap parsed;
|
|
memset(&parsed, 0, sizeof(parsed));
|
|
/* A FROM name wildcard (anything with a glob metacharacter other than the
|
|
* bare '*') is expanded against the sender's account database here, while
|
|
* the sender's passwd/group DB is still available; the resulting numeric
|
|
* rules travel on the wire like an explicit list. The TO side is parsed
|
|
* first so every expanded rule shares it. */
|
|
if (strcmp(from_token, "*") != 0 && identity_token_has_glob(from_token)) {
|
|
if (identity_parse_to(to_token, is_group, &parsed.to, &parsed.to_name) != 0) {
|
|
log_message(LOG_LEVEL_ERROR, "%s could not parse TO '%s' in '%s'", optname, to_token,
|
|
value);
|
|
free(list);
|
|
return -1;
|
|
}
|
|
if (identity_expand_from_glob(config, from_token, is_group, &parsed) != 0) {
|
|
free(parsed.to_name);
|
|
free(list);
|
|
return -1;
|
|
}
|
|
continue;
|
|
}
|
|
if (identity_parse_from(from_token, is_group, &parsed.from, &parsed.from_hi) != 0) {
|
|
log_message(LOG_LEVEL_ERROR,
|
|
"%s could not resolve FROM '%s' in '%s' (a name must exist on the "
|
|
"source; use @N for a numeric id)",
|
|
optname, from_token, value);
|
|
free(list);
|
|
return -1;
|
|
}
|
|
if (identity_parse_to(to_token, is_group, &parsed.to, &parsed.to_name) != 0) {
|
|
log_message(LOG_LEVEL_ERROR, "%s could not parse TO '%s' in '%s'", optname, to_token, value);
|
|
free(list);
|
|
return -1;
|
|
}
|
|
if (identity_append_rule(is_group ? &config->groupmap : &config->usermap,
|
|
is_group ? &config->groupmap_count : &config->usermap_count,
|
|
&parsed) != 0) {
|
|
free(parsed.to_name);
|
|
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;
|
|
}
|
|
|
|
/* --chown is rsync's shorthand for "--usermap=*:USER --groupmap=*:GROUP", so a
|
|
* name TO value must be resolved on the RECEIVER, not on the sender. Append the
|
|
* equivalent map rule (FROM matches every id). The numeric/'*' forms are stored
|
|
* numerically exactly as rsync's id_parse/user_to_uid would. Returns 0 on
|
|
* success, -1 on a malformed numeric token or allocation failure. */
|
|
static int identity_append_chown_rule(Config* config, bool is_group, const char* token) {
|
|
IdentityMap rule;
|
|
memset(&rule, 0, sizeof(rule));
|
|
rule.from = IDENTITY_MATCH_ANY;
|
|
rule.from_hi = IDENTITY_MATCH_ANY;
|
|
if (strcmp(token, "*") == 0) {
|
|
rule.to = IDENTITY_CURRENT;
|
|
} else if (identity_all_digits(token[0] == '@' ? token + 1 : token)) {
|
|
if (identity_resolve_token(token, is_group, &rule.to) != 0) {
|
|
log_message(LOG_LEVEL_ERROR, "--chown numeric id is out of range: %s", token);
|
|
return -1;
|
|
}
|
|
} else {
|
|
rule.to = 0;
|
|
rule.to_name = str_dup(token);
|
|
if (!rule.to_name)
|
|
return -1;
|
|
}
|
|
if (identity_append_rule(is_group ? &config->groupmap : &config->usermap,
|
|
is_group ? &config->groupmap_count : &config->usermap_count,
|
|
&rule) != 0) {
|
|
free(rule.to_name);
|
|
log_message(LOG_LEVEL_ERROR, "--chown has too many rules (max %d)", MAX_IDENTITY_MAP);
|
|
return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* Resolve/record one --chown side. The source-side numeric value is kept in
|
|
* chown_uid/chown_gid purely as a fallback (the appended map rule resolves the
|
|
* name on the receiver and wins); a name that does not exist on the sender is
|
|
* accepted and left to receiver-side resolution, matching rsync. */
|
|
static int identity_parse_chown_side(Config* config, bool is_group, const char* token) {
|
|
if (identity_append_chown_rule(config, is_group, token) != 0)
|
|
return -1;
|
|
bool numeric = identity_all_digits(token[0] == '@' ? token + 1 : token);
|
|
int32_t resolved;
|
|
if (identity_resolve_token(token, is_group, &resolved) == 0) {
|
|
if (is_group) {
|
|
config->chown_gid = resolved;
|
|
config->chown_gid_set = true;
|
|
} else {
|
|
config->chown_uid = resolved;
|
|
config->chown_uid_set = true;
|
|
}
|
|
return 0;
|
|
}
|
|
if (numeric) {
|
|
log_message(LOG_LEVEL_ERROR, "--chown could not resolve numeric id '%s'", token);
|
|
return -1;
|
|
}
|
|
/* Unknown sender-side name: rsync accepts it and resolves it (or warns) on
|
|
* the receiver; do the same instead of failing the whole run. */
|
|
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_parse_chown_side(config, false, user) != 0) {
|
|
ret = -1;
|
|
}
|
|
} else {
|
|
/* --chown=USER:GROUP, --chown=:GROUP, --chown=USER: */
|
|
if (*user != '\0' && identity_parse_chown_side(config, false, user) != 0) {
|
|
ret = -1;
|
|
goto done;
|
|
}
|
|
if (*group != '\0' && identity_parse_chown_side(config, true, group) != 0) {
|
|
ret = -1;
|
|
goto done;
|
|
}
|
|
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;
|
|
}
|
|
|
|
/* uid_t/gid_t are unsigned and may hold a value wider than the signed int32 the
|
|
* wire (and the identity policy) uses. Reject such an id instead of truncating
|
|
* it to an out-of-range (possibly negative sentinel) value. */
|
|
static bool identity_id_fits_int32(unsigned long id) {
|
|
return id <= (unsigned long)INT32_MAX;
|
|
}
|
|
|
|
/* Resolve one --copy-as id token. A '*' token means the caller's current
|
|
* effective uid (user) or gid (group). Returns 0 on success. On failure sets
|
|
* *overflow when a '*' id was wider than int32 so the caller can log the
|
|
* specific message; otherwise the token was simply unresolvable. */
|
|
static int identity_resolve_copy_as_id(const char* token, bool is_group, int32_t* out,
|
|
bool* overflow) {
|
|
*overflow = false;
|
|
if (strcmp(token, "*") == 0) {
|
|
unsigned long current = is_group ? (unsigned long)getegid() : (unsigned long)geteuid();
|
|
if (!identity_id_fits_int32(current)) {
|
|
*overflow = true;
|
|
return -1;
|
|
}
|
|
*out = (int32_t)current;
|
|
return 0;
|
|
}
|
|
return identity_resolve_token(token, is_group, out);
|
|
}
|
|
|
|
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) {
|
|
char* escaped = output_escape(value, false);
|
|
log_message(LOG_LEVEL_ERROR, "--copy-as must be USER[:GROUP] (got '%s')",
|
|
escaped ? escaped : "<allocation failed>");
|
|
free(escaped);
|
|
return -1;
|
|
}
|
|
|
|
char* spec = str_dup(value);
|
|
if (!spec) {
|
|
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --copy-as");
|
|
return -1;
|
|
}
|
|
const char* user_token = spec;
|
|
const char* group_token = NULL;
|
|
char* colon = strchr(spec, ':');
|
|
if (colon) {
|
|
*colon = '\0';
|
|
group_token = colon + 1;
|
|
}
|
|
|
|
/* The spec is untrusted user input echoed back in error paths: escape it once
|
|
* (8-bit-safe) so a control byte cannot forge a log line. */
|
|
char* escaped_spec = output_escape(value, false);
|
|
const char* shown = escaped_spec ? escaped_spec : "<allocation failed>";
|
|
int ret = -1;
|
|
|
|
if (*user_token == '\0') {
|
|
log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", shown);
|
|
goto done;
|
|
}
|
|
bool overflow = false;
|
|
int32_t uid;
|
|
if (identity_resolve_copy_as_id(user_token, false, &uid, &overflow) != 0) {
|
|
if (overflow)
|
|
log_message(LOG_LEVEL_ERROR, "--copy-as: current user id %lu exceeds INT32_MAX",
|
|
(unsigned long)geteuid());
|
|
else
|
|
log_message(LOG_LEVEL_ERROR,
|
|
"--copy-as could not resolve user (use a name that exists on the "
|
|
"source, '*', or @N): %s",
|
|
shown);
|
|
goto done;
|
|
}
|
|
|
|
int32_t gid;
|
|
if (group_token) {
|
|
if (*group_token == '\0') {
|
|
log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", shown);
|
|
goto done;
|
|
}
|
|
if (identity_resolve_copy_as_id(group_token, true, &gid, &overflow) != 0) {
|
|
if (overflow)
|
|
log_message(LOG_LEVEL_ERROR, "--copy-as: current group id %lu exceeds INT32_MAX",
|
|
(unsigned long)getegid());
|
|
else
|
|
log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group (got '%s')", shown);
|
|
goto done;
|
|
}
|
|
} 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);
|
|
if (pw) {
|
|
if (!identity_id_fits_int32((unsigned long)pw->pw_gid)) {
|
|
log_message(LOG_LEVEL_ERROR,
|
|
"--copy-as: primary group id %lu for the requested user exceeds INT32_MAX",
|
|
(unsigned long)pw->pw_gid);
|
|
goto done;
|
|
}
|
|
gid = (int32_t)pw->pw_gid;
|
|
} else {
|
|
gid = uid;
|
|
}
|
|
}
|
|
/* The group-default and gid==uid fallbacks must never store a negative
|
|
* (sentinel) value; the explicit numeric path is already capped by
|
|
* identity_resolve_token. */
|
|
if (uid < 0 || gid < 0) {
|
|
log_message(LOG_LEVEL_ERROR, "--copy-as resolved id does not fit in int32 (got '%s')", shown);
|
|
goto done;
|
|
}
|
|
|
|
config->copy_as_set = true;
|
|
config->copy_as_uid = uid;
|
|
config->copy_as_gid = gid;
|
|
ret = 0;
|
|
|
|
done:
|
|
free(escaped_spec);
|
|
free(spec);
|
|
return ret;
|
|
}
|
|
|
|
/* ---- Receiver-side ownership application ---- */
|
|
|
|
/* True when a map rule's FROM matcher accepts `id`. A sentinel FROM never
|
|
* carries a range. IDENTITY_MATCH_UNNAMED mirrors rsync's empty FROM: it
|
|
* matches only ids that have no name in the account database (rsync matches the
|
|
* sender's names; FastSync transmits numeric ids only, so it approximates this
|
|
* with the receiver's database -- documented in RSYNC_COMPAT.md). */
|
|
static bool identity_map_from_matches(const IdentityMap* map, int32_t id, bool is_group) {
|
|
if (map->from == IDENTITY_MATCH_ANY)
|
|
return true;
|
|
if (map->from == IDENTITY_MATCH_UNNAMED)
|
|
return is_group ? (getgrgid((gid_t)id) == NULL) : (getpwuid((uid_t)id) == NULL);
|
|
return id >= map->from && id <= map->from_hi;
|
|
}
|
|
|
|
/* First matching rule wins. A rule whose TO is a receiver-side name resolves it
|
|
* against the receiver's account database here; an unresolvable TO name is
|
|
* skipped with a warning and the next rule is considered (rsync prints "Unknown
|
|
* --usermap name on receiver" and leaves the id unmapped rather than aborting). */
|
|
static bool identity_map_lookup(const IdentityMap* map, int count, int32_t source_id, bool is_group,
|
|
int32_t* out_to) {
|
|
for (int i = 0; i < count; i++) {
|
|
if (!identity_map_from_matches(&map[i], source_id, is_group))
|
|
continue;
|
|
if (map[i].to_name) {
|
|
if (is_group) {
|
|
struct group* gr = getgrnam(map[i].to_name);
|
|
if (!gr) {
|
|
log_message(LOG_LEVEL_WARNING, "Unknown --groupmap name on receiver: %s", map[i].to_name);
|
|
continue;
|
|
}
|
|
*out_to = (int32_t)gr->gr_gid;
|
|
} else {
|
|
struct passwd* pw = getpwnam(map[i].to_name);
|
|
if (!pw) {
|
|
log_message(LOG_LEVEL_WARNING, "Unknown --usermap name on receiver: %s", map[i].to_name);
|
|
continue;
|
|
}
|
|
*out_to = (int32_t)pw->pw_uid;
|
|
}
|
|
} else {
|
|
*out_to = map[i].to;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Resolve the owner side from the negotiated policy. Sets *out and returns
|
|
* true when an owner-affecting request is active (a usermap, --chown USER, or
|
|
* -o/--owner); returns false (leaving *out untouched) when the owner side is
|
|
* not requested, so callers can pass (uid_t)-1 to fchown and leave it as-is.
|
|
* --numeric-ids only changes the RESOLUTION (raw id instead of a name lookup);
|
|
* it never makes the side requested. */
|
|
static bool identity_resolve_owner(int32_t source_uid, uid_t* out) {
|
|
if (!(g_identity.chown_uid_set || g_identity.preserve_owner || g_identity.usermap_count > 0))
|
|
return false;
|
|
int32_t target;
|
|
if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, false,
|
|
&target)) {
|
|
*out = target == IDENTITY_CURRENT ? geteuid() : (uid_t)target;
|
|
} else if (g_identity.chown_uid_set) {
|
|
*out = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
|
} else if (g_identity.numeric_ids) {
|
|
*out = (uid_t)source_uid;
|
|
} else {
|
|
/* Best-effort name mapping against the receiver's own database. When the
|
|
* transmitted (numeric) id has no name here, fall back to the raw numeric id
|
|
* so -o still preserves the source owner. */
|
|
struct passwd* pw = getpwuid((uid_t)source_uid);
|
|
if (pw) {
|
|
const struct passwd* mapped = getpwnam(pw->pw_name);
|
|
*out = mapped ? mapped->pw_uid : (uid_t)source_uid;
|
|
} else {
|
|
*out = (uid_t)source_uid;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* Group-side counterpart of identity_resolve_owner(). */
|
|
static bool identity_resolve_group(int32_t source_gid, gid_t* out) {
|
|
if (!(g_identity.chown_gid_set || g_identity.preserve_group || g_identity.groupmap_count > 0))
|
|
return false;
|
|
int32_t target;
|
|
if (identity_map_lookup(g_identity.groupmap, g_identity.groupmap_count, source_gid, true,
|
|
&target)) {
|
|
*out = target == IDENTITY_CURRENT ? getegid() : (gid_t)target;
|
|
} else if (g_identity.chown_gid_set) {
|
|
*out = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
|
} else if (g_identity.numeric_ids) {
|
|
*out = (gid_t)source_gid;
|
|
} else {
|
|
struct group* gr = getgrgid((gid_t)source_gid);
|
|
if (gr) {
|
|
const struct group* mapped = getgrnam(gr->gr_name);
|
|
*out = mapped ? mapped->gr_gid : (gid_t)source_gid;
|
|
} else {
|
|
*out = (gid_t)source_gid;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* Resolve the target ownership from the negotiated policy against the entry's
|
|
* current stat. Shared by the fd (regular file) and no-follow (symlink) apply
|
|
* paths. Returns false when no side is to be changed. */
|
|
static bool identity_resolve_targets(const struct stat* st, int32_t source_uid, int32_t source_gid,
|
|
uid_t* out_uid, gid_t* out_gid) {
|
|
/* --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_t uid = (uid_t)g_identity.copy_as_uid;
|
|
gid_t 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;
|
|
}
|
|
|
|
/* Each side is resolved independently: -o/-g and the explicit identity flags
|
|
* request the owner/group respectively, and a side that is NOT requested must
|
|
* be left exactly as it is (`-1` to fchown on that side). This is what lets
|
|
* plain -g change only the group, or -o only the owner. */
|
|
uid_t uid = (uid_t)-1;
|
|
gid_t gid = (gid_t)-1;
|
|
bool owner_requested = identity_resolve_owner(source_uid, &uid);
|
|
bool group_requested = identity_resolve_group(source_gid, &gid);
|
|
if (!owner_requested && !group_requested)
|
|
return false;
|
|
|
|
/* Only change ownership when a requested side actually differs (avoid
|
|
* needless syscalls and any chance of clearing setuid/setgid on an
|
|
* already-correct entry). */
|
|
bool changed = (owner_requested && uid != st->st_uid) || (group_requested && gid != st->st_gid);
|
|
if (!changed)
|
|
return false;
|
|
*out_uid = uid;
|
|
*out_gid = gid;
|
|
return true;
|
|
}
|
|
|
|
/* --fake-super storage resolution: the receiver records the ownership it WOULD
|
|
* have applied. A requested side uses the resolved mapping (--copy-as /
|
|
* usermap / --chown / -o/-g, with --numeric-ids as the raw-id modifier); a side
|
|
* that was not requested keeps the source's own id, so a plain --fake-super run
|
|
* records the source owner untouched. */
|
|
void identity_resolve_storage_ids(int32_t source_uid, int32_t source_gid, uint32_t* out_uid,
|
|
uint32_t* out_gid) {
|
|
if (g_identity.copy_as_set) {
|
|
*out_uid = (uint32_t)g_identity.copy_as_uid;
|
|
*out_gid = (uint32_t)g_identity.copy_as_gid;
|
|
return;
|
|
}
|
|
uid_t uid = (uid_t)source_uid;
|
|
gid_t gid = (gid_t)source_gid;
|
|
uid_t resolved_uid;
|
|
gid_t resolved_gid;
|
|
if (identity_resolve_owner(source_uid, &resolved_uid))
|
|
uid = resolved_uid;
|
|
if (identity_resolve_group(source_gid, &resolved_gid))
|
|
gid = resolved_gid;
|
|
*out_uid = (uint32_t)uid;
|
|
*out_gid = (uint32_t)gid;
|
|
}
|
|
|
|
static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
|
/* 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.
|
|
*
|
|
* --copy-as is different: the whole point of the flag is that the target
|
|
* ownership is REQUIRED (the pre-flight gate already refused an unprivileged
|
|
* receiver). If the chown still fails with EPERM/EACCES (a capability-
|
|
* restricted root, root-squash, or a read-only mount) the run would be
|
|
* silently producing the WRONG ownership, so surface it at ERROR. The
|
|
* caller (identity_apply_ownership*) then reports the ENTRY as failed rather
|
|
* than as written, which becomes a FILE_SAVE_ERROR and fails the transfer
|
|
* (fail-fast) instead of reporting overall success with the wrong owner. */
|
|
if (errno == EPERM || errno == EACCES) {
|
|
if (identity_copy_as_active())
|
|
log_message(LOG_LEVEL_ERROR,
|
|
"could not apply --copy-as ownership on %s (uid=%ld gid=%ld): %s; "
|
|
"entry was written with the wrong owner",
|
|
what, (long)uid, (long)gid, strerror(errno));
|
|
else
|
|
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 on %s (uid=%ld gid=%ld): %s", what,
|
|
(long)uid, (long)gid, strerror(errno));
|
|
}
|
|
}
|
|
|
|
bool 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. --no-super
|
|
* additionally forbids it even when the receiver is root. --fake-super never
|
|
* performs a REAL chown: that would defeat the point of the flag (record the
|
|
* source ownership on an unprivileged receiver for a later privileged
|
|
* restore); the resolved ownership is stored in the reserved xattr instead by
|
|
* fake_super_store_fd(). */
|
|
if (!identity_active_enabled() || g_identity.fake_super || !privilege_super_permitted() || fd < 0)
|
|
return true;
|
|
struct stat st;
|
|
if (fstat(fd, &st) != 0)
|
|
return !identity_copy_as_active();
|
|
uid_t uid;
|
|
gid_t gid;
|
|
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
|
return true;
|
|
if (fchown(fd, uid, gid) != 0) {
|
|
identity_log_chown_failure("file", uid, gid);
|
|
/* A required --copy-as ownership that did not land is a per-entry failure;
|
|
* every other policy stays best-effort (rsync parity). */
|
|
return !identity_copy_as_active();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
|
int32_t source_gid) {
|
|
if (!identity_active_enabled() || g_identity.fake_super || !privilege_super_permitted() ||
|
|
parent_fd < 0 || !leaf)
|
|
return true;
|
|
struct stat st;
|
|
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
|
return !identity_copy_as_active();
|
|
uid_t uid;
|
|
gid_t gid;
|
|
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
|
return true;
|
|
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0) {
|
|
identity_log_chown_failure("no-follow entry", uid, gid);
|
|
return !identity_copy_as_active();
|
|
}
|
|
return true;
|
|
}
|