diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index fd53e9d..e8c19ef 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 | ✅ 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)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), 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. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner (the receiver never claims a `--copy-as` success it did not achieve), while a single entry failure does not abort the multithreaded run. 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 **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** | +| `--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)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), 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. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. 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 **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `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. @@ -636,7 +636,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved - **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. -- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. +- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. - **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open". - **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive. @@ -829,7 +829,7 @@ These are the last compatibility items and the closing phase toward rsync flag p **Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root. -`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. With `ON` and no explicit identity policy, ownership falls back to raw numeric-id preservation (as `--numeric-ids`); explicit `--usermap`/`--groupmap`/`--chown`/`--numeric-ids` still win. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection and refuses client `--copy-as`/`--super`. +`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes). `--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor these requests). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner. diff --git a/src/client/usage.c b/src/client/usage.c index 12c644c..9d793d3 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -169,11 +169,11 @@ void print_usage(void) { printf(" re-apply it (fd-relative) on a privileged run; the\n"); printf(" recording format diverges from rsync's user.rsync.%%stat%%\n"); printf(" --super Permit the receiver to attempt super-user activities\n"); - printf(" (ownership application, char/block device-node\n"); - printf(" creation) within the confined receive root. Never\n"); - printf(" elevates privileges and never bypasses confinement;\n"); - printf(" with no explicit identity policy, ownership follows\n"); - printf(" raw numeric ids (as if --numeric-ids)\n"); + printf(" (char/block device-node creation, --write-devices)\n"); + printf(" within the confined receive root. Never elevates\n"); + printf(" privileges and never bypasses confinement; ownership\n"); + printf(" is still applied only with an explicit identity flag\n"); + printf(" (--numeric-ids/--chown/--usermap/--groupmap)\n"); printf(" --no-super Forbid those super-user activities even when the\n"); printf(" receiver is running as root\n"); printf(" --chmod Modify transferred permissions (rsync syntax)\n"); diff --git a/src/server/server.c b/src/server/server.c index a29dfc4..f68615e 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -246,12 +246,25 @@ static const char* server_module_gate(const Config* config, void* context) { client could force arbitrary ownership inside the module root. The standalone/SSH server has a single operator-authorized root and keeps honoring these. */ - if (!module->client_owner && identity_ownership_requested(effective)) { - log_message(LOG_LEVEL_ERROR, - "daemon module '%s' refuses client-chosen ownership/super-user activities " - "(no `client owner = yes` opt-in); refusing", - config->module); - return "client-chosen ownership is not permitted by this daemon module"; + if (!module->client_owner) { + /* Ownership: refuse the whole transfer up front (a clear failure). Uses the + original config so an explicit --super is caught even though super_mode is + clamped to OFF below. */ + if (identity_ownership_requested(config)) { + log_message(LOG_LEVEL_ERROR, + "daemon module '%s' refuses client-chosen ownership/super-user activities " + "(no `client owner = yes` opt-in); refusing", + config->module); + return "client-chosen ownership is not permitted by this daemon module"; + } + /* Super-user DEVICE activities (char/block mknod and --write-devices) are + permitted under the default AUTO mode, so without this clamp a root daemon + would still let a non-opted module create arbitrary device nodes and write + raw devices. Force them off for this connection: those entries are + skipped (never mknod'ed) while an ordinary `-a` push still succeeds + without device nodes, matching the operator's least-privilege choice. + The operator-level --no-super veto is already folded into this. */ + effective->super_mode = SUPER_MODE_OFF; } if (module->auth_user_count > 0) { /* Auth-required module (Wave B): verify the presented credentials against @@ -769,9 +782,10 @@ int main(int argc, char* argv[]) { for (int i = 0; i < g_daemon_conf->module_count; i++) { if (g_daemon_conf->modules[i].client_owner) log_message(LOG_LEVEL_WARNING, - "daemon module '%s' allows client-chosen ownership " - "(`client owner = yes`); clients may request arbitrary owner ids within " - "that module root", + "daemon module '%s' allows client-chosen ownership and super-user device " + "activities (`client owner = yes`); clients may request arbitrary owner ids " + "and device nodes within that module root -- pair it with `auth users` " + "unless the module is intentionally open to the network", g_daemon_conf->modules[i].name); } /* Daemon credential store (Wave B). --password-file and --early-input diff --git a/src/shared/file.c b/src/shared/file.c index f402763..c6bc4ef 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -582,8 +582,16 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) (a pre-existing destination directory is left alone, matching rsync's transferred-entry scope); the helper is a no-op unless an identity policy is active. */ - if (created && identity_copy_as_active()) - identity_apply_ownership_link(fd, component, 0, 0); + if (created && identity_copy_as_active() && + !identity_apply_ownership_link(fd, component, 0, 0)) { + /* A REQUIRED --copy-as ownership that cannot be applied to a + directory this walk just created must fail the entry rather than + leave that implicit parent owned by the receiver. */ + close(fd); + free(copy); + free(leaf); + return -1; + } next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); } } diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 5d23361..9ce4d90 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -689,7 +689,7 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi suppresses the timestamps; ownership stays gated by the identity policy. A symlink has no children, so this can be applied immediately. */ if (ok && config && config->use_metadata) - file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times); + ok = file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times); free(link_path); return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR; } diff --git a/src/shared/identity.c b/src/shared/identity.c index 6f00efd..b30f8d7 100644 --- a/src/shared/identity.c +++ b/src/shared/identity.c @@ -679,8 +679,8 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) { * restricted root, root-squash, or a read-only mount) the run would be * silently producing the WRONG ownership, so surface it at ERROR. The * caller (identity_apply_ownership*) then reports the ENTRY as failed rather - * than as written; the receiver never claims a --copy-as success it did not - * achieve, but a single entry failure does not abort the whole run. */ + * than as written, which becomes a FILE_SAVE_ERROR and fails the transfer + * (fail-fast) instead of reporting overall success with the wrong owner. */ if (errno == EPERM || errno == EACCES) { if (identity_copy_as_active()) log_message(LOG_LEVEL_ERROR, diff --git a/src/shared/identity.h b/src/shared/identity.h index 610988b..8b07e0c 100644 --- a/src/shared/identity.h +++ b/src/shared/identity.h @@ -7,7 +7,7 @@ #include /* - * Identity mapping: --numeric-ids / --usermap / --groupmap / --chown. + * Identity mapping: --numeric-ids / --usermap / --groupmap / --chown / --copy-as. * * FastSync transmits uid/gid numerically (int32 on the wire) and, by design, * NEVER applies client-supplied ownership unless a user explicitly opts in with @@ -87,9 +87,10 @@ bool identity_ownership_requested(const Config* config); /* Apply the negotiated ownership to an already-written file descriptor. * source_uid/source_gid are the transmitted numeric ids. Resolution order: - * a matching usermap/groupmap rule, then --chown, then --numeric-ids (raw), - * then a best-effort name lookup on the receiver's own databases (skipped when - * the transmitted id has no name on this system). Only calls fchown() when the + * --copy-as (highest priority, forces both ids), then a matching + * usermap/groupmap rule, then --chown, then --numeric-ids (raw), then a + * best-effort name lookup on the receiver's own databases (skipped when the + * transmitted id has no name on this system). Only calls fchown() when the * result differs from the current value. * * Returns false ONLY when an active --copy-as ownership application failed: its diff --git a/src/shared/metadata.c b/src/shared/metadata.c index 937e324..949d365 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -357,17 +357,20 @@ void file_restore_metadata(const char* path, const FileMetadata* metadata, } } -void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata, +bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata, bool omit_link_times) { if (path == NULL || metadata == NULL) - return; + return true; char* leaf = NULL; int parent_fd = file_open_secure_parent(path, &leaf, false); if (parent_fd < 0) - return; + return !identity_copy_as_active(); /* Ownership (only when the identity policy is active) via lchown semantics: - fchownat with AT_SYMLINK_NOFOLLOW never dereferences the link. */ - identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid, (int32_t)metadata->gid); + fchownat with AT_SYMLINK_NOFOLLOW never dereferences the link. A failed + REQUIRED --copy-as ownership marks the entry failed; every other policy is + best-effort. */ + bool owned = identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid, + (int32_t)metadata->gid); /* Symlink mode: not settable on Linux (fchmodat AT_SYMLINK_NOFOLLOW returns EOPNOTSUPP/ENOTSUP); attempt it for platforms that support it and quietly ignore the unsupported case so the transfer never fails over it. */ @@ -392,6 +395,7 @@ void file_restore_symlink_metadata(const char* path, const FileMetadata* metadat } close(parent_fd); free(leaf); + return owned; } bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability) { diff --git a/src/shared/metadata.h b/src/shared/metadata.h index 28fe5f9..faf5194 100644 --- a/src/shared/metadata.h +++ b/src/shared/metadata.h @@ -44,8 +44,10 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv * under the authorized root. `omit_link_times` (-J/--omit-link-times) * suppresses the timestamps; the link's mode/ownership are still attempted * (ownership stays gated by the identity policy and by default is not applied). - * A null metadata or an unfollowable parent is a harmless no-op. */ -void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata, + * A null metadata or an unfollowable parent is a harmless no-op. Returns false + * only when a REQUIRED --copy-as ownership application failed, so the caller can + * report the entry as failed instead of claiming a wrong-owner success. */ +bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata, bool omit_link_times); /* Compare timestamps using rsync's whole-second modification window. */ diff --git a/src/shared/xattr.h b/src/shared/xattr.h index f615fbe..55f22dd 100644 --- a/src/shared/xattr.h +++ b/src/shared/xattr.h @@ -89,11 +89,13 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6 /* --fake-super replay: parse the FAKESUPER_XATTR record previously written on * `fd` by fake_super_store_fd and re-apply uid/gid/mode/mtime fd-relative. * Best-effort: absence of the xattr or a malformed record is a silent no-op - * that never fails the transfer; fchown is applied only when permitted (a - * non-root EPERM/EACCES is skipped silently, matching FastSync's identity - * philosophy), and the mode is sanitized exactly like the normal metadata path - * (group/other write bits never granted). Returns true when the xattr was - * present and parsed. */ + * that never fails the transfer. The OWNER leg is applied only when an explicit + * ownership identity policy is active (numeric-ids/chown/usermap/groupmap/ + * copy-as), when super-user activities are permitted, and when --copy-as is not + * authoritative; a non-root EPERM/EACCES is skipped silently, matching + * FastSync's identity philosophy. The mode is sanitized exactly like the normal + * metadata path (group/other write bits never granted). Returns true when the + * xattr was present and parsed. */ bool fake_super_restore_fd(int fd); #endif \ No newline at end of file diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index 0091c4a..d0ea7ca 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -16,8 +16,10 @@ import hashlib import os import shutil import signal +import stat import subprocess import sys +import tempfile import time import pytest @@ -244,6 +246,21 @@ def _tree_file_count(root): return sum(len(files) for _, _, files in os.walk(root)) if os.path.exists(root) else 0 +def _can_mknod(): + """True when this process may create a char device (needs root/CAP_MKNOD).""" + probe = os.path.join(tempfile.gettempdir(), "._fastsync_mknod_probe_%d" % os.getpid()) + try: + os.mknod(probe, stat.S_IFCHR | 0o600, os.makedev(1, 3)) + os.unlink(probe) + return True + except (OSError, AttributeError): + try: + os.unlink(probe) + except OSError: + pass + return False + + class TestDaemonModuleSelection: @pytest.mark.ci def test_module_transfer(self, daemon): @@ -382,6 +399,43 @@ class TestDaemonRejection: assert not missing, f"missing: {missing[:5]}" assert not mismatches, f"mismatch: {mismatches[:5]}" + def _device_source(self, name): + src = os.path.join(TEST_DATA_DIR, name) + shutil.rmtree(src, ignore_errors=True) + os.makedirs(src) + with open(os.path.join(src, "f.txt"), "wb") as fh: + fh.write(b"device gate\n") + os.mknod(os.path.join(src, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3)) + return src + + @pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD") + def test_devices_skipped_without_owner_opt_in(self, daemon): + """H3: a non-opted daemon module must not create device nodes even under + the default AUTO super mode (a root daemon would otherwise let any client + mknod arbitrary devices). An ordinary -a push still succeeds; the device + entry is skipped.""" + src = self._device_source("devsrc_noowner") + os.makedirs(os.path.join(FILES_MODULE, "devskip"), exist_ok=True) + result, _ = run_client(src, "127.0.0.1::files/devskip", port=daemon.port, flags=["-a"]) + assert result.returncode == 0, result.stderr or result.stdout + received = get_dest_received_dir(os.path.join(FILES_MODULE, "devskip"), src) + node = os.path.join(received, "null") + assert not os.path.exists(node) or not stat.S_ISCHR(os.stat(node).st_mode), \ + "non-opted daemon module created a device node" + + @pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD") + def test_devices_created_with_owner_opt_in(self, daemon): + """Control: an opted-in module (`client owner = yes`) may create device + nodes under -a, proving the clamp is specific to non-opted modules.""" + src = self._device_source("devsrc_owner") + os.makedirs(os.path.join(OWNER_MODULE, "devok"), exist_ok=True) + result, _ = run_client(src, "127.0.0.1::owner/devok", port=daemon.port, flags=["-a"]) + assert result.returncode == 0, result.stderr or result.stdout + received = get_dest_received_dir(os.path.join(OWNER_MODULE, "devok"), src) + node = os.path.join(received, "null") + assert os.path.exists(node) and stat.S_ISCHR(os.stat(node).st_mode), \ + "opted-in daemon module did not create the device node" + @pytest.mark.daemon_detach def test_real_detach_path(self): """--daemon WITHOUT --no-detach double-forks a real background daemon;