From 80dd64aae64e0597d7fab846c9305c87781dae29 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 11:58:37 +0200 Subject: [PATCH] feat(identity): implement --copy-as USER[:GROUP] safe subset (P7 Wave E) Force the receiver to apply the requested owner/group to every written entry through the confined fd-relative identity path instead of switching the process credentials (unsafe for the multithreaded receiver). An unprivileged receiver refuses the transfer up front in server_module_gate, before STATUS_OK, so no data is written with the wrong ownership. - new Config fields copy_as_set/copy_as_uid/copy_as_gid + defaults - identity_parse_copy_as (name/@N/* resolution, primary-gid default, gid==uid fallback for numeric ids with no passwd entry); implies -M - identity snapshot + highest-priority forcing in identity_resolve_targets - identity_copy_as_refused() helper - trailing config-frame block (presence int + two int32 ids, >=0 checked) - PROTOCOL_VERSION 2.17.0 -> 2.18.0; version-sensitive tests updated - unit tests for parse + wire round-trip/negative-id rejection - integration TestCopyAs: unprivileged refusal + root chown assertion - RSYNC_COMPAT.md --copy-as row updated (safe subset + divergence); README protocol version refreshed --- README.md | 2 +- RSYNC_COMPAT.md | 6 +- src/client/client_cli.c | 12 +++ src/server/server.c | 10 +++ src/shared/config.c | 42 +++++++++- src/shared/config.h | 34 +++++++- src/shared/identity.c | 116 +++++++++++++++++++++++++++- src/shared/identity.h | 18 +++++ tests/integration/test_features.py | 82 ++++++++++++++++++++ tests/integration/test_preflight.py | 6 +- tests/test_client_cli.c | 78 ++++++++++++++++++- tests/test_config.c | 86 +++++++++++++++++++++ 12 files changed, 474 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1ee8ab1..9929be9 100644 --- a/README.md +++ b/README.md @@ -507,7 +507,7 @@ defaults to the current directory. | ## Protocol and Security -FastSync protocol version `2.5.0` is shared by the client and server. The +FastSync protocol version `2.18.0` is shared by the client and server. The current protocol is sender-driven and includes configuration negotiation, including the maximum allocation limit, incremental checks, checksums, manifests, keep-alives, abort handling, per-file remove-source results, and diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 30cac50..1662c59 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. @@ -675,7 +675,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved | `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below | | `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes | | `--fsync` | Fsync every written file before publication | ✅ Implemented | | -| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.17.0) with no downgrade/backward-compat code paths, so `--protocol=2.17.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.17`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below | +| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.18.0) with no downgrade/backward-compat code paths, so `--protocol=2.18.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.18`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below | | `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below | | `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior | | `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. | @@ -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.17.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave D times bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.17`, `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) 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 copy-as 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, 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 78cbb3f..07a7fbd 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -1414,6 +1414,18 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, if (identity_parse_chown(config, argv[++i]) != 0) return -1; config->use_metadata = true; + } else if (strncmp(argv[i], "--copy-as=", 10) == 0) { + if (identity_parse_copy_as(config, argv[i] + 10) != 0) + return -1; + config->use_metadata = true; + } else if (opt_is(argv[i], "--copy-as", NULL)) { + if (i + 1 >= argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); + return -1; + } + if (identity_parse_copy_as(config, argv[++i]) != 0) + return -1; + config->use_metadata = true; } else if (strncmp(argv[i], "--outbuf=", 9) == 0) { if (set_outbuf_option(config, argv[i] + 9) != 0) return -1; 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 896f07c..697e8a2 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -177,6 +177,9 @@ static void config_set_defaults(Config* config) { config->open_noatime = false; config->use_xattrs = false; config->fake_super = false; + config->copy_as_set = false; + config->copy_as_uid = 0; + config->copy_as_gid = 0; config->trust_sender = false; config->stop_after_mins = 0; config->stop_at = 0; @@ -241,6 +244,7 @@ static bool validate_received_config(const Config* config) { valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->fake_super) && + (!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) && (!config->use_compression || (config->compression_level >= 1 && config->compression_level <= 22)) && config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE && @@ -1185,6 +1189,38 @@ static bool receive_iconv_spec(int fd, Config* c) { return true; } +/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Trailing block on the + * config frame, sent after the --iconv CONVERT_SPEC string and before the ack: + * a presence int, then (when set) the target uid and gid as int32. The + * receiver forces the ownership of every entry it writes to these ids through + * the confined fd-relative identity path and requires privilege; both ids are + * validated `>= 0` on receive so a hostile peer cannot smuggle a negative + * (sentinel) value into the ownership path. */ +static bool send_copy_as_options(int fd, const Config* c) { + if (!send_int(fd, c->copy_as_set ? 1 : 0)) + return false; + if (!c->copy_as_set) + return true; + return send_int(fd, c->copy_as_uid) && send_int(fd, c->copy_as_gid); +} + +static bool receive_copy_as_options(int fd, Config* c) { + int present; + if (!receive_int(fd, &present) || !valid_wire_bool(present)) + return false; + if (!present) { + c->copy_as_set = false; + return true; + } + int uid, gid; + if (!receive_int(fd, &uid) || !receive_int(fd, &gid) || uid < 0 || gid < 0) + return false; + c->copy_as_set = true; + c->copy_as_uid = uid; + c->copy_as_gid = gid; + return true; +} + bool config_send(int file_descriptor, const Config* config) { protocol_session_set_max_alloc(NULL, config->max_alloc); if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) || @@ -1198,7 +1234,7 @@ bool config_send(int file_descriptor, const Config* config) { !send_symlink_trust_options(file_descriptor, config) || !send_phase4_xattr_options(file_descriptor, config) || !send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) || - !send_iconv_spec(file_descriptor, config)) + !send_iconv_spec(file_descriptor, config) || !send_copy_as_options(file_descriptor, config)) return false; Status status; if (!receive_status(file_descriptor, &status)) @@ -1240,7 +1276,9 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val !receive_symlink_trust_options(file_descriptor, config) || !receive_phase4_xattr_options(file_descriptor, config) || !receive_daemon_module(file_descriptor, config) || - !receive_daemon_auth(file_descriptor, config) || !receive_iconv_spec(file_descriptor, config)) + !receive_daemon_auth(file_descriptor, config) || + !receive_iconv_spec(file_descriptor, config) || + !receive_copy_as_options(file_descriptor, config)) goto error; if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && strcmp(config->compress_choice, "none") != 0) { diff --git a/src/shared/config.h b/src/shared/config.h index c47f410..ea043c8 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -439,6 +439,20 @@ typedef struct Config { * a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime * so a later privileged restore could re-apply them. Crosses the wire. */ bool fake_super; + /* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Safe-subset + * implementation, a documented divergence from rsync's real identity switch: + * the receiver does NOT change its process credentials (FastSync's receiver + * is multithreaded, so a setuid/seteuid drop would be unsafe). Instead the + * receiver FORCES the ownership of every entry it writes to copy_as_uid / + * copy_as_gid through the existing confined, fd-relative identity path + * (fchown/fchownat), which REQUIRES receiver privilege (root); an + * unprivileged receiver REFUSES the whole transfer up front at the config + * handshake (never a silent wrong-ownership result). All three fields CROSS + * the wire as a trailing config-frame block so the receiver learns the + * requested ids; see the PROTOCOL_VERSION note below. */ + bool copy_as_set; + int32_t copy_as_uid; + int32_t copy_as_gid; // Phase 5: --trust-sender /* Long-form-only, receiver-local policy. rsync's --trust-sender tells the @@ -563,8 +577,24 @@ typedef struct Config { * would desynchronize on the unknown frame, and the strict same-version * handshake (config_receive rejects a mismatched version before parsing * anything else) is what keeps a 2.17 client and a 2.16 server from ever - * reaching that state. */ -#define PROTOCOL_VERSION "2.17.0" + * reaching that state. + * + * Privilege Wave (P7 Wave E): 2.17.0 -> 2.18.0. + * + * WHY the bump, grounded in the wire: --copy-as=USER[:GROUP] adds a serialized + * field to the binary config frame. The client sends, as a new trailing block + * AFTER the --iconv CONVERT_SPEC string (in config_send/config_receive), a + * presence int followed, when set, by the target uid and gid (both int32). + * The receiver needs those ids to force the ownership of every entry it writes + * (the safe-subset --copy-as model; see RSYNC_COMPAT.md), and it REQUIRES + * receiver privilege: an unprivileged receiver refuses the transfer at the + * config handshake (server_module_gate) instead of silently ignoring the flag. + * Any config-frame layout change must bump the protocol version: a peer that + * does not parse the new trailing bytes would desynchronize on the frame + * boundary, and the strict same-version handshake (config_receive rejects a + * mismatched version before parsing anything else) is what keeps a 2.18 client + * and a 2.17 server from ever reaching that state. */ +#define PROTOCOL_VERSION "2.18.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ #define MAX_BASIS_DIRS 64 diff --git a/src/shared/identity.c b/src/shared/identity.c index d641de3..c60d977 100644 --- a/src/shared/identity.c +++ b/src/shared/identity.c @@ -27,6 +27,9 @@ typedef struct { int usermap_count; IdentityMap* groupmap; int groupmap_count; + bool copy_as_set; + int32_t copy_as_uid; + int32_t copy_as_gid; bool set; } IdentityActive; @@ -44,6 +47,9 @@ static void identity_active_reset(void) { g_identity.chown_uid = 0; g_identity.chown_gid_set = false; g_identity.chown_gid = 0; + g_identity.copy_as_set = false; + g_identity.copy_as_uid = 0; + g_identity.copy_as_gid = 0; g_identity.set = false; } @@ -60,6 +66,9 @@ void identity_set_active(const Config* config) { g_identity.chown_uid = config->chown_uid; g_identity.chown_gid_set = config->chown_gid_set; g_identity.chown_gid = config->chown_gid; + g_identity.copy_as_set = config->copy_as_set; + g_identity.copy_as_uid = config->copy_as_uid; + g_identity.copy_as_gid = config->copy_as_gid; if (config->usermap_count > 0) { g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap)); if (g_identity.usermap) { @@ -78,9 +87,9 @@ void identity_set_active(const Config* config) { } g_identity.set = true; /* A root receiver would honor any client-supplied ownership request (a - --usermap/--groupmap/--chown, or raw ids under --numeric-ids). Surface - that prominently; a privileged daemon applying arbitrary client ownership - is a deliberate, opt-in choice the operator should be aware of. */ + --usermap/--groupmap/--chown/--copy-as, or raw ids under --numeric-ids). + Surface that prominently; a privileged daemon applying arbitrary client + ownership is a deliberate, opt-in choice the operator should be aware of. */ if (geteuid() == 0) log_message(LOG_LEVEL_WARNING, "identity mapping active and running as root: client-supplied " @@ -96,7 +105,11 @@ bool identity_active_enabled(void) { stays inert; combined with -M it activates raw-id application. */ return g_identity.set && (g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set || - g_identity.usermap_count > 0 || g_identity.groupmap_count > 0); + g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set); +} + +bool identity_copy_as_refused(void) { + return g_identity.copy_as_set && geteuid() != 0; } bool identity_wire_valid(const Config* config) { @@ -358,6 +371,87 @@ done: return ret; } +int identity_parse_copy_as(Config* config, const char* value) { + if (!config || !value || *value == '\0') { + log_message(LOG_LEVEL_ERROR, "--copy-as requires USER[:GROUP]"); + return -1; + } + /* --copy-as=USER[:GROUP] is the whole grammar: at most one field separator. + * (Unlike --chown there is no escaped-colon form; a name containing ':' is + * simply not expressible, and the extra colon is a clear parse error.) */ + int colons = 0; + for (const char* p = value; *p; p++) + if (*p == ':') + colons++; + if (colons > 1) { + log_message(LOG_LEVEL_ERROR, "--copy-as must be USER[:GROUP] (got '%s')", value); + return -1; + } + + char* spec = str_dup(value); + if (!spec) { + log_message(LOG_LEVEL_ERROR, "memory allocation failed for --copy-as"); + return -1; + } + char* user_token = spec; + char* group_token = NULL; + char* colon = strchr(spec, ':'); + if (colon) { + *colon = '\0'; + group_token = colon + 1; + } + + int32_t uid; + if (*user_token == '\0') { + log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", value); + free(spec); + return -1; + } + if (strcmp(user_token, "*") == 0) { + /* '*' means the current/root user: the client's euid. */ + uid = (int32_t)geteuid(); + } else if (identity_resolve_token(user_token, false, &uid) != 0) { + log_message(LOG_LEVEL_ERROR, + "--copy-as could not resolve user '%s' (use a name that exists " + "on the source, '*', or @N)", + value); + free(spec); + return -1; + } + + int32_t gid; + if (group_token) { + if (*group_token == '\0') { + log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", value); + free(spec); + return -1; + } + if (strcmp(group_token, "*") == 0) { + gid = (int32_t)getegid(); + } else if (identity_resolve_token(group_token, true, &gid) != 0) { + log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group '%s' (got '%s')", group_token, + value); + free(spec); + return -1; + } + } else { + /* Group omitted: use the user's primary gid. A numeric id with no local + * passwd entry has no primary gid to look up, so fall back to gid == uid + * (the rsync-style numeric convention; documented divergence). */ + struct passwd* pw = getpwuid((uid_t)uid); + gid = pw ? (int32_t)pw->pw_gid : uid; + } + free(spec); + + config->copy_as_set = true; + config->copy_as_uid = uid; + config->copy_as_gid = gid; + /* Ownership application needs the metadata path (the source uid/gid must be + * transmitted); imply it exactly like --chown/--usermap/--groupmap. */ + config->use_metadata = true; + return 0; +} + /* ---- Receiver-side ownership application ---- */ static bool identity_map_lookup(const IdentityMap* map, int count, int32_t source_id, @@ -381,6 +475,20 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid, uid_t uid = 0; gid_t gid = 0; + /* --copy-as (P7 Wave E) has the highest priority: it forces BOTH the owner + * and group of every written entry to the requested ids, beating usermap / + * groupmap / --chown / --numeric-ids and the best-effort name lookup. Only + * skip when the entry already carries exactly those ids. */ + if (g_identity.copy_as_set) { + uid = (uid_t)g_identity.copy_as_uid; + gid = (gid_t)g_identity.copy_as_gid; + if (st->st_uid == uid && st->st_gid == gid) + return false; + *out_uid = uid; + *out_gid = gid; + return true; + } + int32_t target; if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, &target)) { uid = target == IDENTITY_CURRENT ? geteuid() : (uid_t)target; diff --git a/src/shared/identity.h b/src/shared/identity.h index 8d74c29..ea8342e 100644 --- a/src/shared/identity.h +++ b/src/shared/identity.h @@ -34,6 +34,24 @@ int identity_parse_map(Config* config, const char* value, bool is_group); * on success, -1 on a malformed spec / unresolvable name. */ int identity_parse_chown(Config* config, const char* value); +/* Parse --copy-as=USER[:GROUP] (P7 Wave E). USER is resolved with the same + * user-database rules as --chown (a name, @N/bare N numeric id, or '*' meaning + * the client's current euid); when ':GROUP' is present the group is resolved + * with the group database ('*' meaning the client's egid). When the group is + * omitted, the user's primary gid is used (getpwuid(uid)->pw_gid); if the + * resolved user is a numeric id with no local passwd entry, gid falls back to + * uid. On success sets copy_as_set/copy_as_uid/copy_as_gid and forces + * metadata transmission (ownership application needs the metadata path). + * Returns 0 on success, -1 on a malformed / empty / unresolvable spec (never a + * silent no-op). */ +int identity_parse_copy_as(Config* config, const char* value); + +/* True when a --copy-as request is active AND this (receiving) process is not + * privileged enough to honor it (euid != 0). This is the up-front refusal + * predicate; the server rejects the whole transfer at the config handshake + * rather than silently ignoring the requested ownership. */ +bool identity_copy_as_refused(void); + /* Receiver-side snapshot of the negotiated identity config. The server calls * identity_set_active() once per connection (before any file write) using the * config received over the wire; the snapshot is a deep copy so the caller may diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index c3518c3..1b79437 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -5098,3 +5098,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/integration/test_preflight.py b/tests/integration/test_preflight.py index 354f604..12f7b5e 100644 --- a/tests/integration/test_preflight.py +++ b/tests/integration/test_preflight.py @@ -94,14 +94,14 @@ def _seed_protocol_source(source): class TestProtocol: @pytest.mark.ci def test_protocol_current_version_accepted(self, shared_server): - """--protocol=2.17.0 (the current PROTOCOL_VERSION) is accepted and the + """--protocol=2.18.0 (the current PROTOCOL_VERSION) is accepted and the transfer completes normally.""" source = os.path.join(TEST_DATA_DIR, "proto_ok_src") dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst") shutil.rmtree(dest, ignore_errors=True) os.makedirs(dest) _seed_protocol_source(source) - result, _ = run_client(source, dest, flags=["--protocol=2.17.0"], + result, _ = run_client(source, dest, flags=["--protocol=2.18.0"], port=shared_server.port) assert result.returncode == 0, \ f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}" @@ -118,7 +118,7 @@ class TestProtocol: shutil.rmtree(dest, ignore_errors=True) os.makedirs(dest) _seed_protocol_source(source) - for bad in ("2.15.0", "2.16.0", "216", "31"): + for bad in ("2.15.0", "2.16.0", "2.17.0", "216", "31"): result, _ = run_client(source, dest, flags=[f"--protocol={bad}"], port=shared_server.port) assert result.returncode != 0, f"--protocol={bad} should be rejected" diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index a567b0f..08a1b7a 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -223,7 +223,7 @@ static void test_parse_args_protocol_accept_current() { Config* cfg = valid_client_config(); EXPECT_NOT_NULL(cfg); char* argv_equals[] = {"fastsync", "--source-dir", "/src", - "--dest-dir", "/dst", "--protocol=2.17.0"}; + "--dest-dir", "/dst", "--protocol=2.18.0"}; int positional_args[2]; int positional_count = 0; EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0); @@ -233,7 +233,7 @@ static void test_parse_args_protocol_accept_current() { cfg = valid_client_config(); EXPECT_NOT_NULL(cfg); char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir", - "/dst", "--protocol", "2.17.0"}; + "/dst", "--protocol", "2.18.0"}; positional_count = 0; EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0); EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); @@ -243,7 +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.16", "2.15.0", "2.16.0", "216", "31", "abc", ""}; + static const char* const bad_versions[] = {"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); @@ -2539,6 +2540,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 { @@ -2552,6 +2611,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(); @@ -2569,6 +2634,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. */ @@ -2972,6 +3043,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 d5b8c4e..88f6d74 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1669,6 +1669,90 @@ static void test_config_receive_rejects_invalid_iconv_spec() { } } +/* --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); + } + } +} + +/* 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); + } +} + void test_config() { test_config_lifecycle(); test_config_ssh_dest(); @@ -1715,6 +1799,8 @@ void test_config() { test_config_iconv_spec_wire_roundtrip(); test_config_iconv_spec_empty_canonicalizes_to_null(); test_config_receive_rejects_invalid_iconv_spec(); + test_config_copy_as_wire_roundtrip(); + test_config_receive_rejects_negative_copy_as(); test_config_receive_with_validate_rejects(); } test_config_delete_timing_early_helper();