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
+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) {