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
+12
View File
@@ -1414,6 +1414,18 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
if (identity_parse_chown(config, argv[++i]) != 0)
return -1;
config->use_metadata = true;
} else if (strncmp(argv[i], "--copy-as=", 10) == 0) {
if (identity_parse_copy_as(config, argv[i] + 10) != 0)
return -1;
config->use_metadata = true;
} else if (opt_is(argv[i], "--copy-as", NULL)) {
if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
return -1;
}
if (identity_parse_copy_as(config, argv[++i]) != 0)
return -1;
config->use_metadata = true;
} else if (strncmp(argv[i], "--outbuf=", 9) == 0) {
if (set_outbuf_option(config, argv[i] + 9) != 0)
return -1;
+10
View File
@@ -175,6 +175,16 @@ static const char* server_module_gate(const Config* config, void* context) {
ModuleGateContext* gate_ctx = (ModuleGateContext*)context;
if (!config)
return "missing config frame";
/* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the
ownership of every written entry to the requested ids, which needs a
privileged (root) receiver. An unprivileged receiver REFUSES the whole
transfer here, at the config handshake and BEFORE the STATUS_OK ack, so no
file data is exchanged and there is never a silent wrong-ownership result.
Placed first so it applies to the standalone server and daemon alike. */
if (config->copy_as_set && geteuid() != 0) {
log_message(LOG_LEVEL_ERROR, "--copy-as requires a privileged receiver (root); refusing");
return "--copy-as requires a privileged receiver (root)";
}
/* --iconv (protocol 2.16.0): the receiver's exact conversion direction (the
client spec's wire charset into this server's local charset, including a
server-side --iconv override) must be usable BEFORE the STATUS_OK ack, so
+40 -2
View File
@@ -177,6 +177,9 @@ static void config_set_defaults(Config* config) {
config->open_noatime = false;
config->use_xattrs = false;
config->fake_super = false;
config->copy_as_set = false;
config->copy_as_uid = 0;
config->copy_as_gid = 0;
config->trust_sender = false;
config->stop_after_mins = 0;
config->stop_at = 0;
@@ -241,6 +244,7 @@ static bool validate_received_config(const Config* config) {
valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) &&
valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) &&
valid_wire_bool(config->fake_super) &&
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
(!config->use_compression ||
(config->compression_level >= 1 && config->compression_level <= 22)) &&
config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE &&
@@ -1185,6 +1189,38 @@ static bool receive_iconv_spec(int fd, Config* c) {
return true;
}
/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Trailing block on the
* config frame, sent after the --iconv CONVERT_SPEC string and before the ack:
* a presence int, then (when set) the target uid and gid as int32. The
* receiver forces the ownership of every entry it writes to these ids through
* the confined fd-relative identity path and requires privilege; both ids are
* validated `>= 0` on receive so a hostile peer cannot smuggle a negative
* (sentinel) value into the ownership path. */
static bool send_copy_as_options(int fd, const Config* c) {
if (!send_int(fd, c->copy_as_set ? 1 : 0))
return false;
if (!c->copy_as_set)
return true;
return send_int(fd, c->copy_as_uid) && send_int(fd, c->copy_as_gid);
}
static bool receive_copy_as_options(int fd, Config* c) {
int present;
if (!receive_int(fd, &present) || !valid_wire_bool(present))
return false;
if (!present) {
c->copy_as_set = false;
return true;
}
int uid, gid;
if (!receive_int(fd, &uid) || !receive_int(fd, &gid) || uid < 0 || gid < 0)
return false;
c->copy_as_set = true;
c->copy_as_uid = uid;
c->copy_as_gid = gid;
return true;
}
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) ||
@@ -1198,7 +1234,7 @@ bool config_send(int file_descriptor, const Config* config) {
!send_symlink_trust_options(file_descriptor, config) ||
!send_phase4_xattr_options(file_descriptor, config) ||
!send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) ||
!send_iconv_spec(file_descriptor, config))
!send_iconv_spec(file_descriptor, config) || !send_copy_as_options(file_descriptor, config))
return false;
Status status;
if (!receive_status(file_descriptor, &status))
@@ -1240,7 +1276,9 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
!receive_symlink_trust_options(file_descriptor, config) ||
!receive_phase4_xattr_options(file_descriptor, config) ||
!receive_daemon_module(file_descriptor, config) ||
!receive_daemon_auth(file_descriptor, config) || !receive_iconv_spec(file_descriptor, config))
!receive_daemon_auth(file_descriptor, config) ||
!receive_iconv_spec(file_descriptor, config) ||
!receive_copy_as_options(file_descriptor, config))
goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 0) {
+32 -2
View File
@@ -439,6 +439,20 @@ typedef struct Config {
* a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime
* so a later privileged restore could re-apply them. Crosses the wire. */
bool fake_super;
/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Safe-subset
* implementation, a documented divergence from rsync's real identity switch:
* the receiver does NOT change its process credentials (FastSync's receiver
* is multithreaded, so a setuid/seteuid drop would be unsafe). Instead the
* receiver FORCES the ownership of every entry it writes to copy_as_uid /
* copy_as_gid through the existing confined, fd-relative identity path
* (fchown/fchownat), which REQUIRES receiver privilege (root); an
* unprivileged receiver REFUSES the whole transfer up front at the config
* handshake (never a silent wrong-ownership result). All three fields CROSS
* the wire as a trailing config-frame block so the receiver learns the
* requested ids; see the PROTOCOL_VERSION note below. */
bool copy_as_set;
int32_t copy_as_uid;
int32_t copy_as_gid;
// Phase 5: --trust-sender
/* Long-form-only, receiver-local policy. rsync's --trust-sender tells the
@@ -563,8 +577,24 @@ typedef struct Config {
* would desynchronize on the unknown frame, and the strict same-version
* handshake (config_receive rejects a mismatched version before parsing
* anything else) is what keeps a 2.17 client and a 2.16 server from ever
* reaching that state. */
#define PROTOCOL_VERSION "2.17.0"
* reaching that state.
*
* Privilege Wave (P7 Wave E): 2.17.0 -> 2.18.0.
*
* WHY the bump, grounded in the wire: --copy-as=USER[:GROUP] adds a serialized
* field to the binary config frame. The client sends, as a new trailing block
* AFTER the --iconv CONVERT_SPEC string (in config_send/config_receive), a
* presence int followed, when set, by the target uid and gid (both int32).
* The receiver needs those ids to force the ownership of every entry it writes
* (the safe-subset --copy-as model; see RSYNC_COMPAT.md), and it REQUIRES
* receiver privilege: an unprivileged receiver refuses the transfer at the
* config handshake (server_module_gate) instead of silently ignoring the flag.
* Any config-frame layout change must bump the protocol version: a peer that
* does not parse the new trailing bytes would desynchronize on the frame
* boundary, and the strict same-version handshake (config_receive rejects a
* mismatched version before parsing anything else) is what keeps a 2.18 client
* and a 2.17 server from ever reaching that state. */
#define PROTOCOL_VERSION "2.18.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
+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;
+18
View File
@@ -34,6 +34,24 @@ int identity_parse_map(Config* config, const char* value, bool is_group);
* on success, -1 on a malformed spec / unresolvable name. */
int identity_parse_chown(Config* config, const char* value);
/* Parse --copy-as=USER[:GROUP] (P7 Wave E). USER is resolved with the same
* user-database rules as --chown (a name, @N/bare N numeric id, or '*' meaning
* the client's current euid); when ':GROUP' is present the group is resolved
* with the group database ('*' meaning the client's egid). When the group is
* omitted, the user's primary gid is used (getpwuid(uid)->pw_gid); if the
* resolved user is a numeric id with no local passwd entry, gid falls back to
* uid. On success sets copy_as_set/copy_as_uid/copy_as_gid and forces
* metadata transmission (ownership application needs the metadata path).
* Returns 0 on success, -1 on a malformed / empty / unresolvable spec (never a
* silent no-op). */
int identity_parse_copy_as(Config* config, const char* value);
/* True when a --copy-as request is active AND this (receiving) process is not
* privileged enough to honor it (euid != 0). This is the up-front refusal
* predicate; the server rejects the whole transfer at the config handshake
* rather than silently ignoring the requested ownership. */
bool identity_copy_as_refused(void);
/* 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