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
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user