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