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
+2 -2
View File
@@ -264,7 +264,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues | | `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
| `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes | | `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes |
| `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) | | `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ❌ Not Implemented | | | `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. 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 (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block (presence int, then the two int32 ids, both validated `>= 0` on receive); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`, **Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new. `-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
@@ -791,7 +791,7 @@ These are the hardest compatibility items because they require durable formats o
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join. **Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.18.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave E privilege bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, --super privilege policy 2.18) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain. **Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.18.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave E privilege bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads). **Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
+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) if (identity_parse_chown(config, argv[++i]) != 0)
return -1; return -1;
config->use_metadata = true; 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) { } else if (strncmp(argv[i], "--outbuf=", 9) == 0) {
if (set_outbuf_option(config, argv[i] + 9) != 0) if (set_outbuf_option(config, argv[i] + 9) != 0)
return -1; 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; ModuleGateContext* gate_ctx = (ModuleGateContext*)context;
if (!config) if (!config)
return "missing config frame"; 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 /* --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 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 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->open_noatime = false;
config->use_xattrs = false; config->use_xattrs = false;
config->fake_super = 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->trust_sender = false;
config->stop_after_mins = 0; config->stop_after_mins = 0;
config->stop_at = 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->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->munge_links) && valid_wire_bool(config->keep_dirlinks) &&
valid_wire_bool(config->fake_super) && valid_wire_bool(config->fake_super) &&
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
(!config->use_compression || (!config->use_compression ||
(config->compression_level >= 1 && config->compression_level <= 22)) && (config->compression_level >= 1 && config->compression_level <= 22)) &&
config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE && 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; 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) { bool config_send(int file_descriptor, const Config* config) {
protocol_session_set_max_alloc(NULL, config->max_alloc); protocol_session_set_max_alloc(NULL, config->max_alloc);
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) || 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_symlink_trust_options(file_descriptor, config) ||
!send_phase4_xattr_options(file_descriptor, config) || !send_phase4_xattr_options(file_descriptor, config) ||
!send_daemon_module(file_descriptor, config) || !send_daemon_auth(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; return false;
Status status; Status status;
if (!receive_status(file_descriptor, &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_module(file_descriptor, config) ||
!receive_daemon_auth(file_descriptor, config) || !receive_daemon_auth(file_descriptor, config) ||
!receive_iconv_spec(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; goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 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 * 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. */ * so a later privileged restore could re-apply them. Crosses the wire. */
bool fake_super; 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 // Phase 5: --trust-sender
/* Long-form-only, receiver-local policy. rsync's --trust-sender tells the /* 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. * Privilege Wave (P7 Wave E): 2.17.0 -> 2.18.0.
* *
* WHY the bump, grounded in the wire: this wave adds the receiver-side * 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 * privilege flags --super/--no-super and --copy-as=USER[:GROUP]. The
* trailing int (Config->super_mode) sent immediately AFTER the --iconv * config-frame layout gains two new trailing blocks AFTER the --iconv
* CONVERT_SPEC block (send_privilege_options / receive_privilege_options in * CONVERT_SPEC string, in this fixed order: (1) send_privilege_options /
* config.c), so the receiver knows whether it may attempt super-user * receive_privilege_options send one int (Config->super_mode, 0..2), then
* activities (ownership application, char/block device-node creation) that are * (2) send_copy_as_options / receive_copy_as_options send a presence int and,
* already confined below the authorized receive root. Any config-frame layout * when set, the target uid and gid (both int32). The receiver uses
* change must bump the protocol version: a peer that does not parse the new * super_mode to decide whether it may attempt super-user activities
* trailing bytes would desynchronize on the frame boundary, and the strict * (ownership application, char/block device-node creation) already confined
* same-version handshake (config_receive rejects a mismatched version before * below the authorized receive root, and the copy-as ids to force the
* parsing anything else) is what keeps a 2.18 client and a 2.17 server from * ownership of every entry it writes (the safe-subset --copy-as model). The
* ever reaching that state. --super never elevates privileges; it only * receiver REQUIRES privilege for copy-as: an unprivileged receiver refuses
* permits a confined attempt, so no new capability is granted. */ * 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 PROTOCOL_VERSION "2.18.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* 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 IDENTITY_CURRENT (-1)
#define MAX_IDENTITY_MAP 128 #define MAX_IDENTITY_MAP 128
/* --super / --no-super tri-state (Config->super_mode). AUTO preserves the /* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON
* pre-existing behavior (a privileged attempt only when already root); ON * both permit a confined super-user attempt (AUTO preserves FastSync's
* permits confined privileged attempts; OFF forbids them even as root. See the * historical best-effort behavior; an unprivileged attempt is refused by the
* Config->super_mode comment above and privilege_super_permitted() in * kernel and skipped per entry); OFF forbids the attempt even for root. See
* identity.h. */ * privilege_super_mode_permitted() in identity.h. */
#define SUPER_MODE_AUTO 0 #define SUPER_MODE_AUTO 0
#define SUPER_MODE_ON 1 #define SUPER_MODE_ON 1
#define SUPER_MODE_OFF 2 #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 * per connection so privilege_super_permitted() can gate super-user
* activities without a Config argument. */ * activities without a Config argument. */
int super_mode; 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; bool set;
} IdentityActive; } IdentityActive;
@@ -49,6 +54,9 @@ static void identity_active_reset(void) {
g_identity.chown_gid_set = false; g_identity.chown_gid_set = false;
g_identity.chown_gid = 0; g_identity.chown_gid = 0;
g_identity.super_mode = SUPER_MODE_AUTO; 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; 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_uid = config->chown_uid;
g_identity.chown_gid_set = config->chown_gid_set; g_identity.chown_gid_set = config->chown_gid_set;
g_identity.chown_gid = config->chown_gid; 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) { if (config->usermap_count > 0) {
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap)); g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
if (g_identity.usermap) { if (g_identity.usermap) {
@@ -84,33 +96,37 @@ void identity_set_active(const Config* config) {
g_identity.super_mode = config->super_mode; g_identity.super_mode = config->super_mode;
g_identity.set = true; g_identity.set = true;
/* A root receiver would honor any client-supplied ownership request (a /* A root receiver would honor any client-supplied ownership request (a
--usermap/--groupmap/--chown, or raw ids under --numeric-ids). Surface --usermap/--groupmap/--chown/--copy-as, or raw ids under --numeric-ids).
that prominently; a privileged daemon applying arbitrary client ownership Surface that prominently; a privileged daemon applying arbitrary client
is a deliberate, opt-in choice the operator should be aware of. */ ownership is a deliberate, opt-in choice the operator should be aware of. */
if (geteuid() == 0) if (geteuid() == 0)
log_message(LOG_LEVEL_WARNING, log_message(LOG_LEVEL_WARNING,
"identity mapping active and running as root: client-supplied " "identity mapping active and running as root: client-supplied "
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; " "ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
"run the daemon as an unprivileged user unless intended"); "run the daemon as an unprivileged user unless intended");
/* --super explicitly requests super-user activities, but FastSync never /* --super explicitly requests super-user activities, but FastSync never
elevates privileges: when the receiver is not already root those confined elevates privileges: when the receiver is not already root the kernel will
attempts cannot succeed. Warn exactly once at activation time (never refuse those confined attempts and each is skipped per entry. Warn exactly
abort) so the operator knows the flag is inert on this host. */ 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) if (g_identity.super_mode == SUPER_MODE_ON && geteuid() != 0)
log_message(LOG_LEVEL_WARNING, log_message(LOG_LEVEL_WARNING,
"--super requested but the receiver is not privileged; super-user " "--super requested but the receiver is not privileged; super-user "
"activities (ownership, device nodes) cannot be performed and will " "activities (ownership, device nodes) will be attempted but refused "
"be skipped"); "by the kernel and skipped per entry");
} }
bool privilege_super_permitted(void) { bool privilege_super_permitted(void) {
if (g_identity.super_mode == SUPER_MODE_OFF) return privilege_super_mode_permitted(g_identity.super_mode);
return false; }
if (g_identity.super_mode == SUPER_MODE_ON)
return true; bool privilege_super_mode_permitted(int mode) {
/* SUPER_MODE_AUTO (the default): only attempt super-user activities when the /* AUTO and ON both attempt the confined operation; OFF forbids it even for a
receiver is already root. */ * root receiver. AUTO is the historical FastSync behavior (always attempt
return geteuid() == 0; * 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, /* --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. */ with no explicit identity policy acts like --numeric-ids here. */
return g_identity.set && (g_identity.numeric_ids || g_identity.chown_uid_set || 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.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) { bool identity_wire_valid(const Config* config) {
@@ -396,6 +413,87 @@ done:
return ret; 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 ---- */ /* ---- Receiver-side ownership application ---- */
static bool identity_map_lookup(const IdentityMap* map, int count, int32_t source_id, 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; uid_t uid = 0;
gid_t gid = 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; int32_t target;
if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, &target)) { if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, &target)) {
uid = target == IDENTITY_CURRENT ? geteuid() : (uid_t)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. */ * on success, -1 on a malformed spec / unresolvable name. */
int identity_parse_chown(Config* config, const char* value); 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 /* Receiver-side snapshot of the negotiated identity config. The server calls
* identity_set_active() once per connection (before any file write) using the * 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 * 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); bool identity_wire_valid(const Config* config);
/* P7 Wave E receiver-side permission gate for super-user activities (ownership /* P7 Wave E receiver-side permission gate for super-user activities (ownership
* application and char/block device-node creation). Returns false when the * application and char/block device-node creation). `privilege_super_permitted`
* active config is --no-super (SUPER_MODE_OFF); true when it is --super * consults the per-connection snapshot (call identity_set_active() first);
* (SUPER_MODE_ON); and otherwise (SUPER_MODE_AUTO, the default, or before * `privilege_super_mode_permitted` is the pure mode predicate and is what
* identity_set_active() has been called) only when the receiver is ALREADY root * callers holding a Config use (the config-frame gate, file_receive). Both
* (geteuid() == 0). This NEVER elevates privileges: it only reports whether an * return false only for SUPER_MODE_OFF; SUPER_MODE_ON and SUPER_MODE_AUTO (the
* attempt that is already confined below the authorized receive root may be * default) permit a confined attempt, matching FastSync's historical
* made. */ * 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_permitted(void);
bool privilege_super_mode_permitted(int mode);
#endif #endif
+82
View File
@@ -5161,3 +5161,85 @@ class TestDirectoryAndSymlinkTimes:
with open(blocker, "rb") as fh: with open(blocker, "rb") as fh:
assert fh.read() == b"pre-existing blocker\n", "the blocker file was clobbered" assert fh.read() == b"pre-existing blocker\n", "the blocker file was clobbered"
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing" assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
class TestCopyAs:
"""P7 Wave E: --copy-as=USER[:GROUP] safe subset.
FastSync never switches the receiver's process credentials; the receiver
forces the ownership of every entry it writes to the requested ids through
the confined fd-relative identity path, which REQUIRES a privileged (root)
receiver. An unprivileged receiver refuses the whole transfer up front at
the config handshake, before any file data moves.
"""
@pytest.mark.ci
def test_unprivileged_receiver_refuses_copy_as(self, shared_server):
"""The key assertable behavior: an unprivileged receiver REFUSES a
--copy-as transfer cleanly (non-zero exit, no data written) instead of
silently writing the wrong ownership."""
source = os.path.join(TEST_DATA_DIR, "copyas_refuse_src")
dest = os.path.join(TEST_DATA_DIR, "copyas_refuse_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "secret.txt"), "wb") as fh:
fh.write(b"must not be written\n")
captured = None
if os.geteuid() == 0:
if shutil.which("setpriv") is None:
pytest.skip("root runner without setpriv cannot start an unprivileged receiver")
os.chmod(dest, 0o777)
proc, port = _start_captured_server(
prefix=["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"])
captured = proc
else:
# The session server already runs unprivileged.
port = shared_server.port
try:
result, _ = run_client(source, dest,
flags=["--copy-as=@65534:@65534"], port=port)
finally:
if captured is not None:
out, err = _stop_captured_server(captured)
else:
out, err = "", ""
assert result.returncode != 0, (
f"an unprivileged receiver must refuse --copy-as: rc={result.returncode} "
f"out={result.stdout[:200]!r} err={result.stderr[:200]!r}"
)
received = get_dest_received_dir(dest, source)
assert not os.path.exists(os.path.join(received, "secret.txt")), (
"--copy-as refusal leaked file data into the destination"
)
if captured is not None:
assert "copy-as requires a privileged receiver" in (out + err), (
f"refusal reason was not logged: out={out!r} err={err!r}"
)
@pytest.mark.ci
@pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown")
def test_root_copy_as_chowns_transferred_file(self, shared_server):
"""Root-gated: --copy-as=USER:GROUP forces the transferred file's
ownership to exactly that uid/gid (numeric form for determinism)."""
source = os.path.join(TEST_DATA_DIR, "copyas_root_src")
dest = os.path.join(TEST_DATA_DIR, "copyas_root_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "owned.txt"), "wb") as fh:
fh.write(b"owned by nobody\n")
result, _ = run_client(source, dest,
flags=["--copy-as=@65534:@65534"], port=shared_server.port)
assert result.returncode == 0, (
f"--copy-as root transfer failed: {(result.stderr or result.stdout)[:400]}"
)
received = get_dest_received_dir(dest, source)
target = os.path.join(received, "owned.txt")
assert os.path.isfile(target), f"transferred file missing at {target}"
st = os.lstat(target)
assert (st.st_uid, st.st_gid) == (65534, 65534), (
f"--copy-as did not force ownership: uid={st.st_uid} gid={st.st_gid}"
)
+73 -2
View File
@@ -243,8 +243,8 @@ static void test_parse_args_protocol_accept_current() {
/* Any --protocol value other than the current PROTOCOL_VERSION must end in /* Any --protocol value other than the current PROTOCOL_VERSION must end in
* failure (parse_args simply stores it; validate_config rejects it up front). */ * failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() { static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
"216", "31", "abc", ""}; "216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
@@ -2577,6 +2577,64 @@ static void test_parse_args_chown() {
config_delete(cfg); config_delete(cfg);
} }
/* --copy-as=USER[:GROUP] (P7 Wave E): resolve the user/group against the local
* databases, imply metadata, and apply the documented group-default rule. */
static void test_parse_args_copy_as() {
/* Explicit numeric user and group. */
Config* cfg = config_create();
char* argv[] = {"fastsync", "--copy-as=@1000:@1001", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->copy_as_set);
EXPECT_TRUE(cfg->use_metadata);
EXPECT_EQ_INT(cfg->copy_as_uid, 1000);
EXPECT_EQ_INT(cfg->copy_as_gid, 1001);
config_delete(cfg);
/* Space form. */
cfg = config_create();
positional_count = 0;
char* argv2[] = {"fastsync", "--copy-as", "@2000:3000", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->copy_as_uid, 2000);
EXPECT_EQ_INT(cfg->copy_as_gid, 3000);
config_delete(cfg);
/* Group omitted: a resolvable user uses its primary gid. */
struct passwd* self = getpwuid(geteuid());
if (self) {
cfg = config_create();
positional_count = 0;
char* argv3[] = {"fastsync", (char*)"--copy-as", (char*)self->pw_name, "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv3, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->copy_as_uid, (int32_t)self->pw_uid);
EXPECT_EQ_INT(cfg->copy_as_gid, (int32_t)self->pw_gid);
config_delete(cfg);
}
/* Group omitted with a numeric id that has no passwd entry: gid falls back
* to uid (documented divergence). */
if (!getpwuid((uid_t)4242)) {
cfg = config_create();
positional_count = 0;
char* argv4[] = {"fastsync", "--copy-as=@4242", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv4, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->copy_as_uid, 4242);
EXPECT_EQ_INT(cfg->copy_as_gid, 4242);
config_delete(cfg);
}
/* '*' means the client's current euid/egid. */
cfg = config_create();
positional_count = 0;
char* argv5[] = {"fastsync", "--copy-as=*:*", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv5, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->copy_as_uid, (int32_t)geteuid());
EXPECT_EQ_INT(cfg->copy_as_gid, (int32_t)getegid());
config_delete(cfg);
}
/* Malformed identity specs are rejected, never silently ignored. */ /* Malformed identity specs are rejected, never silently ignored. */
static void test_parse_args_rejects_malformed_identity() { static void test_parse_args_rejects_malformed_identity() {
struct { struct {
@@ -2590,6 +2648,12 @@ static void test_parse_args_rejects_malformed_identity() {
{"--groupmap", "no_such_group_qqq:x"}, {"--groupmap", "no_such_group_qqq:x"},
{"--chown", "a:b:c"}, {"--chown", "a:b:c"},
{"--chown", "no_such_user_zzz:"}, {"--chown", "no_such_user_zzz:"},
{"--copy-as", ""},
{"--copy-as", ":"},
{"--copy-as", "a:b:c"},
{"--copy-as", "@1000:"},
{"--copy-as", "definitely_not_a_real_user_zzz"},
{"--copy-as", "no_such_group_qqq_group"},
}; };
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
Config* cfg = config_create(); Config* cfg = config_create();
@@ -2607,6 +2671,12 @@ static void test_parse_args_rejects_malformed_identity() {
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 2, argv, positional_args, &positional_count), -1); EXPECT_EQ_INT(parse_args(cfg, 2, argv, positional_args, &positional_count), -1);
config_delete(cfg); config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv2[] = {"fastsync", "--copy-as"};
EXPECT_EQ_INT(parse_args(cfg, 2, argv2, positional_args, &positional_count), -1);
config_delete(cfg);
} }
/* --preallocate parses as a boolean flag and validates cleanly. */ /* --preallocate parses as a boolean flag and validates cleanly. */
@@ -3010,6 +3080,7 @@ void test_client_cli() {
test_parse_args_groupmap(); test_parse_args_groupmap();
test_parse_args_usermap_name_resolution(); test_parse_args_usermap_name_resolution();
test_parse_args_chown(); test_parse_args_chown();
test_parse_args_copy_as();
test_parse_args_rejects_malformed_identity(); test_parse_args_rejects_malformed_identity();
test_parse_args_preallocate(); test_parse_args_preallocate();
test_parse_args_metadata_times(); test_parse_args_metadata_times();
+93 -6
View File
@@ -1707,6 +1707,51 @@ static void test_config_super_mode_wire_roundtrip() {
} }
} }
/* --copy-as (P7 Wave E, protocol 2.18.0) travels as a trailing config-frame
block: a presence int, then the two int32 ids when set. */
static void test_config_copy_as_wire_roundtrip() {
struct {
bool set;
int32_t uid;
int32_t gid;
} cases[] = {{false, 0, 0}, {true, 1000, 1001}};
if (is_running_under_valgrind())
return;
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv = config_receive(p[0]);
bool ok = recv != NULL && recv->copy_as_set == cases[i].set &&
(!cases[i].set ||
(recv->copy_as_uid == cases[i].uid && recv->copy_as_gid == cases[i].gid));
config_delete(recv);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
send_cfg->copy_as_set = cases[i].set;
send_cfg->copy_as_uid = cases[i].uid;
send_cfg->copy_as_gid = cases[i].gid;
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_TRUE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
}
/* An out-of-range super_mode value on the wire must be refused on receive /* An out-of-range super_mode value on the wire must be refused on receive
(never silently clamped or accepted). */ (never silently clamped or accepted). */
static void test_config_receive_rejects_invalid_super_mode() { static void test_config_receive_rejects_invalid_super_mode() {
@@ -1730,9 +1775,49 @@ static void test_config_receive_rejects_invalid_super_mode() {
config_delete(c); config_delete(c);
} }
/* A hostile peer must not smuggle a negative (sentinel) copy-as id into the
ownership path: the receive side rejects it and the run fails the handshake. */
static void test_config_receive_rejects_negative_copy_as() {
if (is_running_under_valgrind())
return;
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
send_cfg->copy_as_set = true;
send_cfg->copy_as_uid = -1;
send_cfg->copy_as_gid = 0;
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv_cfg = config_receive(p[0]);
config_delete(recv_cfg);
close(p[0]);
_exit(recv_cfg ? 1 : 0);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_FALSE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* P7 Wave E: privilege_super_permitted() maps the super_mode tri-state. OFF /* P7 Wave E: privilege_super_permitted() maps the super_mode tri-state. OFF
forbids super-user activities even for root; ON permits them; AUTO follows forbids super-user activities even for root; ON and AUTO permit the confined
the effective uid. */ attempt (matching FastSync's historical best-effort behavior, where the kernel
refuses an unprivileged attempt and the caller skips it). */
static void test_privilege_super_permitted_modes() { static void test_privilege_super_permitted_modes() {
Config* c = config_create(); Config* c = config_create();
EXPECT_NOT_NULL(c); EXPECT_NOT_NULL(c);
@@ -1744,13 +1829,13 @@ static void test_privilege_super_permitted_modes() {
EXPECT_TRUE(privilege_super_permitted()); EXPECT_TRUE(privilege_super_permitted());
c->super_mode = SUPER_MODE_AUTO; c->super_mode = SUPER_MODE_AUTO;
identity_set_active(c); identity_set_active(c);
EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0); EXPECT_TRUE(privilege_super_permitted());
config_delete(c); config_delete(c);
/* After clearing, the neutral default is AUTO (root-following), never a /* After clearing, the neutral default is AUTO (attempt), never a stale
stale snapshot from a previous connection. */ snapshot from a previous connection. */
identity_clear_active(); identity_clear_active();
EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0); EXPECT_TRUE(privilege_super_permitted());
} }
void test_config() { void test_config() {
@@ -1801,6 +1886,8 @@ void test_config() {
test_config_receive_rejects_invalid_iconv_spec(); test_config_receive_rejects_invalid_iconv_spec();
test_config_super_mode_wire_roundtrip(); test_config_super_mode_wire_roundtrip();
test_config_receive_rejects_invalid_super_mode(); test_config_receive_rejects_invalid_super_mode();
test_config_copy_as_wire_roundtrip();
test_config_receive_rejects_negative_copy_as();
test_config_receive_with_validate_rejects(); test_config_receive_with_validate_rejects();
} }
test_privilege_super_permitted_modes(); test_privilege_super_permitted_modes();