Merge branch 'feat/p7-copy-as' into feat/p7-privilege

# Conflicts:
#	RSYNC_COMPAT.md
#	src/shared/config.c
#	src/shared/config.h
#	src/shared/identity.c
#	tests/integration/test_preflight.py
#	tests/test_client_cli.c
#	tests/test_config.c
This commit is contained in:
2026-09-12 12:14:44 +02:00
10 changed files with 510 additions and 52 deletions
+12
View File
@@ -1428,6 +1428,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
+41 -2
View File
@@ -178,6 +178,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;
@@ -242,6 +245,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 &&
@@ -1208,6 +1212,38 @@ static bool receive_privilege_options(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 --super int 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) ||
@@ -1221,7 +1257,9 @@ 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_privilege_options(file_descriptor, config))
!send_iconv_spec(file_descriptor, config) ||
!send_privilege_options(file_descriptor, config) ||
!send_copy_as_options(file_descriptor, config))
return false;
Status status;
if (!receive_status(file_descriptor, &status))
@@ -1265,7 +1303,8 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
!receive_daemon_module(file_descriptor, config) ||
!receive_daemon_auth(file_descriptor, config) ||
!receive_iconv_spec(file_descriptor, config) ||
!receive_privilege_options(file_descriptor, config))
!receive_privilege_options(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) {
+38 -17
View File
@@ -455,6 +455,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
@@ -584,18 +598,25 @@ typedef struct Config {
* Privilege Wave (P7 Wave E): 2.17.0 -> 2.18.0.
*
* WHY the bump, grounded in the wire: this wave adds the receiver-side
* --super / --no-super privilege policy. The config-frame layout gains a new
* trailing int (Config->super_mode) sent immediately AFTER the --iconv
* CONVERT_SPEC block (send_privilege_options / receive_privilege_options in
* config.c), so the receiver knows whether it may attempt super-user
* activities (ownership application, char/block device-node creation) that are
* already confined below the authorized receive root. 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. --super never elevates privileges; it only
* permits a confined attempt, so no new capability is granted. */
* privilege flags --super/--no-super and --copy-as=USER[:GROUP]. The
* config-frame layout gains two new trailing blocks AFTER the --iconv
* CONVERT_SPEC string, in this fixed order: (1) send_privilege_options /
* receive_privilege_options send one int (Config->super_mode, 0..2), then
* (2) send_copy_as_options / receive_copy_as_options send a presence int and,
* when set, the target uid and gid (both int32). The receiver uses
* super_mode to decide whether it may attempt super-user activities
* (ownership application, char/block device-node creation) already confined
* below the authorized receive root, and the copy-as ids to force the
* ownership of every entry it writes (the safe-subset --copy-as model). The
* receiver REQUIRES privilege for copy-as: 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.
* --super never elevates privileges; it only permits a confined attempt, and
* --copy-as never switches process credentials (see RSYNC_COMPAT.md). */
#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). */
@@ -609,11 +630,11 @@ typedef struct Config {
#define IDENTITY_CURRENT (-1)
#define MAX_IDENTITY_MAP 128
/* --super / --no-super tri-state (Config->super_mode). AUTO preserves the
* pre-existing behavior (a privileged attempt only when already root); ON
* permits confined privileged attempts; OFF forbids them even as root. See the
* Config->super_mode comment above and privilege_super_permitted() in
* identity.h. */
/* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON
* both permit a confined super-user attempt (AUTO preserves FastSync's
* historical best-effort behavior; an unprivileged attempt is refused by the
* kernel and skipped per entry); OFF forbids the attempt even for root. See
* privilege_super_mode_permitted() in identity.h. */
#define SUPER_MODE_AUTO 0
#define SUPER_MODE_ON 1
#define SUPER_MODE_OFF 2
+128 -16
View File
@@ -31,6 +31,11 @@ typedef struct {
* per connection so privilege_super_permitted() can gate super-user
* activities without a Config argument. */
int 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;
bool set;
} IdentityActive;
@@ -49,6 +54,9 @@ static void identity_active_reset(void) {
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.set = false;
}
@@ -65,6 +73,10 @@ 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.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;
if (config->usermap_count > 0) {
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
if (g_identity.usermap) {
@@ -84,33 +96,37 @@ void identity_set_active(const Config* config) {
g_identity.super_mode = config->super_mode;
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 "
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
"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 those confined
attempts cannot succeed. Warn exactly once at activation time (never
abort) so the operator knows the flag is inert on this host. */
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) cannot be performed and will "
"be skipped");
"activities (ownership, device nodes) will be attempted but refused "
"by the kernel and skipped per entry");
}
bool privilege_super_permitted(void) {
if (g_identity.super_mode == SUPER_MODE_OFF)
return false;
if (g_identity.super_mode == SUPER_MODE_ON)
return true;
/* SUPER_MODE_AUTO (the default): only attempt super-user activities when the
receiver is already root. */
return geteuid() == 0;
return privilege_super_mode_permitted(g_identity.super_mode);
}
bool privilege_super_mode_permitted(int 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;
}
/* --super with NO explicit identity policy implies raw numeric-id preservation,
@@ -134,7 +150,8 @@ bool identity_active_enabled(void) {
with no explicit identity policy acts like --numeric-ids here. */
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 || identity_super_implies_numeric());
g_identity.groupmap_count > 0 || g_identity.copy_as_set ||
identity_super_implies_numeric());
}
bool identity_wire_valid(const Config* config) {
@@ -396,6 +413,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,
@@ -419,6 +517,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;
+31 -7
View File
@@ -34,6 +34,28 @@ 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 but the receiver is not permitted to
* perform the privileged ownership application it needs. This is the up-front
* refusal predicate: the server rejects the whole transfer at the config
* handshake rather than silently ignoring the requested ownership. It is a
* pure function of the config mode and the current effective uid (it does NOT
* read the active snapshot, so it is valid at the pre-STATUS_OK gate, before
* identity_set_active() has run). `super_mode` is the EFFECTIVE mode after any
* server-side policy veto. */
bool identity_copy_as_refused(const Config* config);
/* 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
@@ -66,13 +88,15 @@ void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t sour
bool identity_wire_valid(const Config* config);
/* P7 Wave E receiver-side permission gate for super-user activities (ownership
* application and char/block device-node creation). Returns false when the
* active config is --no-super (SUPER_MODE_OFF); true when it is --super
* (SUPER_MODE_ON); and otherwise (SUPER_MODE_AUTO, the default, or before
* identity_set_active() has been called) only when the receiver is ALREADY root
* (geteuid() == 0). This NEVER elevates privileges: it only reports whether an
* attempt that is already confined below the authorized receive root may be
* made. */
* application and char/block device-node creation). `privilege_super_permitted`
* consults the per-connection snapshot (call identity_set_active() first);
* `privilege_super_mode_permitted` is the pure mode predicate and is what
* callers holding a Config use (the config-frame gate, file_receive). Both
* return false only for SUPER_MODE_OFF; SUPER_MODE_ON and SUPER_MODE_AUTO (the
* default) permit a confined attempt, matching FastSync's historical
* best-effort behavior where an unprivileged attempt is refused by the kernel
* and skipped. Neither EVER elevates privileges. */
bool privilege_super_permitted(void);
bool privilege_super_mode_permitted(int mode);
#endif