diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 178d870..32e7c49 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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 | | `--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) | -| `--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`, `-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 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). diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 79d9b77..b2af76f 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -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; diff --git a/src/server/server.c b/src/server/server.c index bc60b8d..4be623b 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -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 diff --git a/src/shared/config.c b/src/shared/config.c index a5487d1..6b67de2 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -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) { diff --git a/src/shared/config.h b/src/shared/config.h index b206d3b..a7525ab 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -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 diff --git a/src/shared/identity.c b/src/shared/identity.c index d6e0b13..0926b9b 100644 --- a/src/shared/identity.c +++ b/src/shared/identity.c @@ -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; diff --git a/src/shared/identity.h b/src/shared/identity.h index e3a1111..f0a4edf 100644 --- a/src/shared/identity.h +++ b/src/shared/identity.h @@ -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 \ No newline at end of file diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index b18a23c..434c61f 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -5161,3 +5161,85 @@ class TestDirectoryAndSymlinkTimes: with open(blocker, "rb") as fh: 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" + + +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}" + ) diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 1280896..3721d6c 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -243,8 +243,8 @@ static void test_parse_args_protocol_accept_current() { /* Any --protocol value other than the current PROTOCOL_VERSION must end in * failure (parse_args simply stores it; validate_config rejects it up front). */ 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", - "216", "31", "abc", ""}; + static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", + "216", "31", "abc", ""}; for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { Config* cfg = valid_client_config(); EXPECT_NOT_NULL(cfg); @@ -2577,6 +2577,64 @@ static void test_parse_args_chown() { 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. */ static void test_parse_args_rejects_malformed_identity() { struct { @@ -2590,6 +2648,12 @@ static void test_parse_args_rejects_malformed_identity() { {"--groupmap", "no_such_group_qqq:x"}, {"--chown", "a:b:c"}, {"--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++) { Config* cfg = config_create(); @@ -2607,6 +2671,12 @@ static void test_parse_args_rejects_malformed_identity() { int positional_count = 0; EXPECT_EQ_INT(parse_args(cfg, 2, argv, positional_args, &positional_count), -1); 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. */ @@ -3010,6 +3080,7 @@ void test_client_cli() { test_parse_args_groupmap(); test_parse_args_usermap_name_resolution(); test_parse_args_chown(); + test_parse_args_copy_as(); test_parse_args_rejects_malformed_identity(); test_parse_args_preallocate(); test_parse_args_metadata_times(); diff --git a/tests/test_config.c b/tests/test_config.c index d4a9e08..3e5007f 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -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 (never silently clamped or accepted). */ 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); } +/* 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 - forbids super-user activities even for root; ON permits them; AUTO follows - the effective uid. */ + forbids super-user activities even for root; ON and AUTO permit the confined + 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() { Config* c = config_create(); EXPECT_NOT_NULL(c); @@ -1744,13 +1829,13 @@ static void test_privilege_super_permitted_modes() { EXPECT_TRUE(privilege_super_permitted()); c->super_mode = SUPER_MODE_AUTO; identity_set_active(c); - EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0); + EXPECT_TRUE(privilege_super_permitted()); config_delete(c); - /* After clearing, the neutral default is AUTO (root-following), never a - stale snapshot from a previous connection. */ + /* After clearing, the neutral default is AUTO (attempt), never a stale + snapshot from a previous connection. */ identity_clear_active(); - EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0); + EXPECT_TRUE(privilege_super_permitted()); } void test_config() { @@ -1801,6 +1886,8 @@ void test_config() { test_config_receive_rejects_invalid_iconv_spec(); test_config_super_mode_wire_roundtrip(); 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_privilege_super_permitted_modes();