diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 32e7c49..94b6d82 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -257,14 +257,14 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`. | `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) | | `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | | `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | -| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing behavior of attempting them only when already root (`geteuid()==0`). **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. With `--super` and **no** explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`), ownership is treated as raw numeric-id preservation (as if `--numeric-ids`); an explicit identity policy still wins. A non-root receiver given `--super` logs exactly one warning at activation and skips the attempts (never aborts); `--no-super` suppresses ownership and char/block `mknod`, while unprivileged FIFO creation is unaffected. Wire: a trailing `super_mode` int on the config frame (validated 0..2); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates | -| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front | +| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`); `--fake-super`'s owner replay is gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. With `--super` and **no** explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`), ownership is treated as raw numeric-id preservation (as if `--numeric-ids`); an explicit identity policy still wins. A non-root receiver given `--super` logs exactly one warning at activation and skips the attempts (never aborts); `--no-super` suppresses ownership and char/block `mknod`, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates | +| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped when `--no-super` forbids super-user activities (even for root) or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front | | `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path | | `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) | | `--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)`), 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** | +| `--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 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` outright even when root: FastSync has no per-module opt-in for client-chosen ownership, so a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring it 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 but remains non-fatal (the multithreaded receiver is never aborted). 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. @@ -625,7 +625,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved | Flag | Rsync Description | FastSync Status | Notes | |------|-------------------|-----------------|-------| -| `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; no `--super`/`--copy-as`). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding | +| `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; a client `--copy-as` is refused outright and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding | | `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` | | `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` | | `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` | @@ -635,7 +635,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved **Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding. - **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), `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 and there is no `--super`/`--copy-as`. 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. +- **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, a client `--copy-as` is refused outright (no per-module opt-in for client-chosen ownership), 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. - **`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. @@ -826,7 +826,7 @@ These are the last compatibility items and the closing phase toward rsync flag p `--secluded-args` (`🔄 → ⛔ Impossible/Divergence`): a true arg-send protocol would replace the argv-based SSH launch with an in-band channel, and FastSync already builds the remote SSH argv injection-safe (single-quote-escaped shell words), so there is no argument-leak to close; the already-safe behavior is documented in the row and no transport change is made. -**P7 Wave E — Privilege, part 1 (`--super`, ✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: never blind-elevate, never call `setuid`/`seteuid`/`setgid`. `--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` (src/shared/identity.c) returns false for `OFF`, true for `ON`, and `geteuid()==0` for `AUTO`, and gates only the two super-user activities FastSync already confines: ownership application (`identity_apply_ownership`/`_link`) and char/block device-node creation (`file_save_special_to_disk`); 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. A non-root receiver given `--super` logs exactly one warning at activation (`identity_set_active`) and skips the confined attempts, never aborting. `--no-super` suppresses the same activities even for a root receiver. The **confinement floor is unchanged** (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks), so `--super` can never write outside the authorized receive root. **Wire:** one trailing `super_mode` int after the `--iconv` block (`send_privilege_options`/`receive_privilege_options`), validated `0..2`; `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege, whereas FastSync only permits an already-confined attempt and never elevates. +**P7 Wave E — Privilege, part 1 (`--super`, ✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: never blind-elevate, never call `setuid`/`seteuid`/`setgid`. `--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` (src/shared/identity.c) returns 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 for `OFF`, and gates only the two super-user activities FastSync already confines: ownership application (`identity_apply_ownership`/`_link`) and char/block device-node creation (`file_save_special_to_disk`); 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. A non-root receiver given `--super` logs exactly one warning at activation (`identity_set_active`) and skips the confined attempts, never aborting. `--no-super` suppresses the same activities even for a root receiver. The **confinement floor is unchanged** (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks), so `--super` can never write outside the authorized receive root. **Wire:** one trailing `super_mode` int after the `--iconv` block (`send_privilege_options`/`receive_privilege_options`), validated `0..2`; `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege, whereas FastSync only permits an already-confined attempt and never elevates. **Wave E (LAST) — Privilege (deferred decision, `❌`).** `--super`, `--copy-as=USER[:GROUP]`: **deferred by explicit project decision — the privilege model must be decided when this wave starts.** Candidate directions to fix then: a **safe** receiver model — `--copy-as` performs a drop-to-uid/group only when the process is privileged (and a clear refusal otherwise, never blind elevation); `--super` lifts only within the confined receive root — versus a **full setuid/elevation** model (higher security-review burden). Recommended: the safe-subset + clear-refusal direction, consistent with FastSync's confinement philosophy. These are the only remaining `❌` rows. diff --git a/src/client/client_validation.c b/src/client/client_validation.c index 3869b16..67e8060 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -170,5 +170,15 @@ bool validate_config(const Config* config) { PROTOCOL_VERSION); return false; } + /* --copy-as pushes the source ids through the metadata path (it implies + --preserve). A later --no-preserve would clear use_metadata, leaving the + transfer with nothing to chown while the receiver gate would still pass. + Refuse the combination up front rather than silently chowning nothing. */ + if (config->copy_as_set && !config->use_metadata) { + log_message(LOG_LEVEL_ERROR, + "--copy-as requires metadata preservation and cannot be combined with " + "--no-preserve"); + return false; + } return true; } diff --git a/src/client/usage.c b/src/client/usage.c index 714b5d9..12c644c 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -190,6 +190,13 @@ void print_usage(void) { printf(" Names resolve on the source machine; @N for numerics.\n"); printf(" (Metadata is enabled with --preserve; -M now means\n"); printf(" rsync's --remote-option.)\n"); + printf(" --copy-as=USER[:GROUP] Force every written entry (files, dirs, symlinks\n"); + printf(" and special nodes) to USER[:GROUP], resolved on the\n"); + printf(" source machine like --chown. Requires a privileged\n"); + printf(" (root) receiver and implies --preserve; an\n"); + printf(" unprivileged receiver refuses the transfer. Never\n"); + printf(" switches process credentials (safe-subset; see\n"); + printf(" RSYNC_COMPAT.md). A daemon refuses it.\n"); printf(" --chunk-size Chunk size in bytes (default: %d)\n", DEFAULT_CHUNK_SIZE); printf(" --source-dir Source directory\n"); printf(" --dest-dir Destination directory\n"); diff --git a/src/server/server.c b/src/server/server.c index 4be623b..0648660 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -31,6 +31,10 @@ static int authorized_root_fd = -1; static bool allow_delete; static bool trust_sender; static bool allow_unauthenticated; +/* --no-super operator veto: forces SUPER_MODE_OFF for every connection (even + * root), so no super-user activity is attempted and any client --copy-as is + * refused. Set once in main before the accept loop / stdio handler. */ +static bool server_no_super; static const char* required_client_cn; /* --iconv CONVERT_SPEC the server was itself started with (borrowed argv * pointer). Its LOCAL half may override the local charset the client assumed; @@ -181,7 +185,23 @@ static const char* server_module_gate(const Config* config, void* context) { 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) { + /* Daemon divergence (P7 Wave E): a daemon has no per-module opt-in for + client-chosen ownership, so it refuses --copy-as outright even when running + as root -- otherwise any anonymous client could pick an arbitrary owner. + The standalone listener and the SSH-launched --stdio server keep honoring + it (they serve exactly one operator-authorized root). */ + if (g_daemon_conf != NULL && config->copy_as_set) { + log_message(LOG_LEVEL_ERROR, "--copy-as is refused by the daemon (no per-module opt-in for " + "client-chosen ownership); refusing"); + return "--copy-as is not permitted by this daemon"; + } + /* Operator veto: --no-super forces SUPER_MODE_OFF for this connection before + the copy-as gate is evaluated, and the caller clamps the accepted config + again after this returns so the ownership/device gates see it too. */ + Config* effective = (Config*)config; + if (server_no_super) + effective->super_mode = SUPER_MODE_OFF; + if (identity_copy_as_refused(effective)) { log_message(LOG_LEVEL_ERROR, "--copy-as requires a privileged receiver (root); refusing"); return "--copy-as requires a privileged receiver (root)"; } @@ -283,6 +303,12 @@ void handler(int file_descriptor) { protocol_session_unbind(); return; } + /* Operator --no-super veto: clamp the accepted config so every downstream + * gate (identity_apply_ownership via privilege_super_permitted, device-node + * creation) sees SUPER_MODE_OFF even if the gate callback did not already + * mutate a copy of it. */ + if (server_no_super) + config->super_mode = SUPER_MODE_OFF; protocol_set_8_bit_output(config->eight_bit_output); if (!authorized_root) { log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); @@ -671,6 +697,7 @@ int main(int argc, char* argv[]) { allow_delete = opts.allow_delete; trust_sender = opts.trust_sender; allow_unauthenticated = opts.allow_unauthenticated; + server_no_super = opts.no_super; server_iconv_spec = opts.iconv_spec; signal(SIGINT, cleanup); signal(SIGTERM, cleanup); diff --git a/src/server/server_cli.c b/src/server/server_cli.c index e0168c8..120e7bd 100644 --- a/src/server/server_cli.c +++ b/src/server/server_cli.c @@ -142,6 +142,8 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, opts->allow_delete = true; } else if (arg_is(argv[i], "--trust-sender")) { opts->trust_sender = true; + } else if (arg_is(argv[i], "--no-super")) { + opts->no_super = true; } else if (arg_is(argv[i], "--allow-unauthenticated")) { opts->allow_unauthenticated = true; } else if (arg_is(argv[i], "--iconv")) { diff --git a/src/server/server_cli.h b/src/server/server_cli.h index 4bb2351..9994b87 100644 --- a/src/server/server_cli.h +++ b/src/server/server_cli.h @@ -33,6 +33,11 @@ typedef struct ServerCliOptions { bool allow_delete; /* --allow-delete */ bool trust_sender; /* --trust-sender */ bool allow_unauthenticated; /* --allow-unauthenticated */ + /* --no-super: operator veto forcing SUPER_MODE_OFF for every connection, so + * the receiver never attempts super-user activities (ownership application, + * device-node creation) even when running as root. Applies to --stdio and + * --daemon alike; also makes the server refuse any client --copy-as. */ + bool no_super; /* --no-super */ /* --iconv=CONVERT_SPEC: the server's own LOCAL charset declaration. The * client's full spec rides the wire config frame anyway; when the server is * started with its own --iconv, its LOCAL half overrides the local charset diff --git a/src/shared/config.c b/src/shared/config.c index 6b67de2..e0841a6 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -246,6 +246,10 @@ static bool validate_received_config(const Config* config) { valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->fake_super) && (!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) && + /* --copy-as forces ownership through the metadata path; without + metadata it would pass the privilege gate but silently chown + nothing. Refuse the frame instead. */ + (!config->copy_as_set || config->use_metadata) && (!config->use_compression || (config->compression_level >= 1 && config->compression_level <= 22)) && config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE && diff --git a/src/shared/daemon_conf.h b/src/shared/daemon_conf.h index 4f99a49..c25a719 100644 --- a/src/shared/daemon_conf.h +++ b/src/shared/daemon_conf.h @@ -23,8 +23,13 @@ * server's --destination-root: the daemon confines every connection that * selects this module to this path (file_open_secure_parent / * has_path_traversal / path_is_within all keep the existing confinement, just - * per-module). There is never any client-chosen root and no --super / - * --copy-as: a module path always stays confined. + * per-module). There is never any client-chosen root: a module path always + * stays confined. A daemon also REFUSES a client --copy-as outright, because + * there is no per-module opt-in for client-chosen ownership (unlike the + * standalone/SSH server, which honors it for its single operator-authorized + * root); the operator-level --no-super veto additionally forces super-user + * activities off for every daemon connection. See server_module_gate in + * server.c and RSYNC_COMPAT.md. * * `auth_users` is honored by Wave B daemon authentication: a module that * declares auth users accepts a connection only when the presented username is diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 3f459cd..d4ca072 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -456,12 +456,19 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons return FILE_SAVE_SKIPPED; } - /* Apply mtime on the fresh node (utimensat, no-follow). Ownership is not - applied -- identity fchown needs an fd and would require opening the node. */ + /* Apply mtime on the fresh node (utimensat, no-follow). */ struct timespec times[2] = { {.tv_sec = 0, .tv_nsec = UTIME_OMIT}, {.tv_sec = file->metadata->mtime_sec, .tv_nsec = file->metadata->mtime_nsec}}; utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW); + /* P7 Wave E: apply the negotiated ownership to the node ITSELF. A FIFO is + created unprivileged, but --copy-as and explicit identity policies own + every entry (a char/block node path is already privilege-gated above). The + no-follow helper changes the node's own ownership without dereferencing it; + it is a no-op unless an identity policy is active. */ + if (identity_active_enabled()) + identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid, + (int32_t)file->metadata->gid); close(parent_fd); free(leaf); free(destination); @@ -597,6 +604,22 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi if (!dir_path) return FILE_SAVE_ERROR; bool ok = file_ensure_directory_secure(dir_path); + /* P7 Wave E: apply the negotiated ownership to the directory ITSELF (not + just the files inside it). --copy-as and every explicit identity policy + own every entry, so a directory must not keep the receiver's owner while + its children get the policy owner. Applied no-follow on the confined + parent fd after the mkdir; identity_apply_ownership_link() is itself a + no-op unless an identity policy is active. */ + if (ok && file->metadata && identity_active_enabled()) { + char* leaf = NULL; + int parent_fd = file_open_secure_parent(dir_path, &leaf, false); + if (parent_fd >= 0) { + identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid, + (int32_t)file->metadata->gid); + close(parent_fd); + } + free(leaf); + } free(dir_path); return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR; } diff --git a/src/shared/identity.c b/src/shared/identity.c index 0926b9b..3bf6ad6 100644 --- a/src/shared/identity.c +++ b/src/shared/identity.c @@ -148,10 +148,25 @@ bool identity_active_enabled(void) { metadata, never reaches identity_apply_ownership, and therefore correctly stays inert; combined with -M it activates raw-id application. --super with no explicit identity policy acts like --numeric-ids here. */ - return g_identity.set && (g_identity.numeric_ids || g_identity.chown_uid_set || - g_identity.chown_gid_set || g_identity.usermap_count > 0 || - g_identity.groupmap_count > 0 || g_identity.copy_as_set || - identity_super_implies_numeric()); + return g_identity.set && + (g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set || + g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set || + identity_super_implies_numeric()); +} + +bool identity_copy_as_active(void) { + return g_identity.set && g_identity.copy_as_set; +} + +bool identity_copy_as_refused(const Config* config) { + if (!config || !config->copy_as_set) + return false; + /* The safe-subset --copy-as needs a privileged (root) receiver, and an + * operator/--no-super veto forbids the ownership change even for root. This + * is deliberately a pure function of the config and the current effective uid + * (never the active snapshot) because the server evaluates it at the + * pre-STATUS_OK config gate, before identity_set_active() has run. */ + return geteuid() != 0 || config->super_mode == SUPER_MODE_OFF; } bool identity_wire_valid(const Config* config) { @@ -172,6 +187,12 @@ bool identity_wire_valid(const Config* config) { if (config->groupmap[i].from < IDENTITY_MATCH_ANY || config->groupmap[i].to < IDENTITY_CURRENT) return false; } + /* Defense-in-depth: a --copy-as block must never carry a negative (sentinel) + * id into the ownership path. receive_copy_as_options already rejects them, + * but identity_wire_valid is the shared validation used by both the receiver + * and unit tests, so re-assert it here. */ + if (config->copy_as_set && (config->copy_as_uid < 0 || config->copy_as_gid < 0)) + return false; return true; } @@ -413,6 +434,13 @@ done: return ret; } +/* uid_t/gid_t are unsigned and may hold a value wider than the signed int32 the + * wire (and the identity policy) uses. Reject such an id instead of truncating + * it to an out-of-range (possibly negative sentinel) value. */ +static bool identity_id_fits_int32(unsigned long id) { + return id <= (unsigned long)INT32_MAX; +} + 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]"); @@ -426,7 +454,10 @@ int identity_parse_copy_as(Config* config, const char* value) { if (*p == ':') colons++; if (colons > 1) { - log_message(LOG_LEVEL_ERROR, "--copy-as must be USER[:GROUP] (got '%s')", value); + char* escaped = output_escape(value, false); + log_message(LOG_LEVEL_ERROR, "--copy-as must be USER[:GROUP] (got '%s')", + escaped ? escaped : ""); + free(escaped); return -1; } @@ -443,20 +474,34 @@ int identity_parse_copy_as(Config* config, const char* value) { group_token = colon + 1; } + /* The spec is untrusted user input echoed back in error paths: escape it once + * (8-bit-safe) so a control byte cannot forge a log line. */ + char* escaped_spec = output_escape(value, false); + const char* shown = escaped_spec ? escaped_spec : ""; + int32_t uid; if (*user_token == '\0') { - log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", value); + log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", shown); + free(escaped_spec); free(spec); return -1; } if (strcmp(user_token, "*") == 0) { /* '*' means the current/root user: the client's euid. */ + if (!identity_id_fits_int32((unsigned long)geteuid())) { + log_message(LOG_LEVEL_ERROR, "--copy-as: current user id %lu exceeds INT32_MAX", + (unsigned long)geteuid()); + free(escaped_spec); + free(spec); + return -1; + } 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); + "--copy-as could not resolve user (use a name that exists on the " + "source, '*', or @N): %s", + shown); + free(escaped_spec); free(spec); return -1; } @@ -464,15 +509,24 @@ int identity_parse_copy_as(Config* config, const char* value) { int32_t gid; if (group_token) { if (*group_token == '\0') { - log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", value); + log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", shown); + free(escaped_spec); free(spec); return -1; } if (strcmp(group_token, "*") == 0) { + if (!identity_id_fits_int32((unsigned long)getegid())) { + log_message(LOG_LEVEL_ERROR, "--copy-as: current group id %lu exceeds INT32_MAX", + (unsigned long)getegid()); + free(escaped_spec); + free(spec); + return -1; + } 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); + log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group (got '%s'): %s", shown, + shown); + free(escaped_spec); free(spec); return -1; } @@ -481,8 +535,30 @@ int identity_parse_copy_as(Config* config, const char* value) { * 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; + if (pw) { + if (!identity_id_fits_int32((unsigned long)pw->pw_gid)) { + log_message(LOG_LEVEL_ERROR, + "--copy-as: primary group id %lu for the requested user exceeds INT32_MAX", + (unsigned long)pw->pw_gid); + free(escaped_spec); + free(spec); + return -1; + } + gid = (int32_t)pw->pw_gid; + } else { + gid = uid; + } } + /* The group-default and gid==uid fallbacks must never store a negative + * (sentinel) value; the explicit numeric path is already capped by + * identity_resolve_token. */ + if (uid < 0 || gid < 0) { + log_message(LOG_LEVEL_ERROR, "--copy-as resolved id does not fit in int32 (got '%s')", shown); + free(escaped_spec); + free(spec); + return -1; + } + free(escaped_spec); free(spec); config->copy_as_set = true; @@ -596,13 +672,28 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) { /* EPERM/EACCES are expected when the receiver is not privileged (e.g. the CI * `nobody` user): warn and continue, never abort the transfer. Any other * error (EIO/EROFS/ENOSPC/...) is a real failure and must not be silently - * downgraded to a warning. */ - if (errno == EPERM || errno == EACCES) - log_message(LOG_LEVEL_WARNING, "could not apply ownership (uid=%ld gid=%ld): %s; leaving as-is", - (long)uid, (long)gid, strerror(errno)); - else + * downgraded to a warning. + * + * --copy-as is different: the whole point of the flag is that the target + * ownership is REQUIRED (the pre-flight gate already refused an unprivileged + * receiver). If the chown still fails with EPERM/EACCES (a capability- + * restricted root, root-squash, or a read-only mount) the run is silently + * producing the WRONG ownership, so surface it at ERROR. It stays + * non-fatal: never abort the multithreaded receiver mid-transfer. */ + if (errno == EPERM || errno == EACCES) { + if (identity_copy_as_active()) + log_message(LOG_LEVEL_ERROR, + "could not apply --copy-as ownership on %s (uid=%ld gid=%ld): %s; " + "entry was written with the wrong owner", + what, (long)uid, (long)gid, strerror(errno)); + else + log_message(LOG_LEVEL_WARNING, + "could not apply ownership (uid=%ld gid=%ld): %s; leaving as-is", (long)uid, + (long)gid, strerror(errno)); + } else { log_message(LOG_LEVEL_ERROR, "failed to apply ownership on %s (uid=%ld gid=%ld): %s", what, (long)uid, (long)gid, strerror(errno)); + } } void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) { diff --git a/src/shared/identity.h b/src/shared/identity.h index f0a4edf..8973ae8 100644 --- a/src/shared/identity.h +++ b/src/shared/identity.h @@ -56,6 +56,14 @@ int identity_parse_copy_as(Config* config, const char* value); * server-side policy veto. */ bool identity_copy_as_refused(const Config* config); +/* True when the CURRENT per-connection snapshot has a --copy-as active (i.e. + * identity_set_active() has run against a config with copy_as_set). The + * --fake-super owner replay consults this so a copy-as run never lets the + * recorded source owner overwrite the forced target owner. Reads the active + * snapshot, so call identity_set_active() first (the receiver does, before any + * write). */ +bool identity_copy_as_active(void); + /* Receiver-side snapshot of the negotiated identity config. The server calls * identity_set_active() once per connection (before any file write) using the * config received over the wire; the snapshot is a deep copy so the caller may diff --git a/src/shared/xattr.c b/src/shared/xattr.c index 4dd9b51..591d3e7 100644 --- a/src/shared/xattr.c +++ b/src/shared/xattr.c @@ -1,5 +1,6 @@ #define _GNU_SOURCE #include "xattr.h" +#include "identity.h" #include "log.h" #include "protocol.h" #include "utils.h" @@ -337,7 +338,16 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6 * stat fd-relative. A privileged (root) run can actually change the owner; * a non-root run silently skips the fchown on EPERM/EACCES (never fatal, * mirroring the normal metadata identity path; other errors are logged) and - * still applies mode/mtime where permitted. */ + * still applies mode/mtime where permitted. + * + * The OWNER leg additionally honors two policies: + * - --no-super (privilege_super_permitted() false) suppresses it even for a + * root receiver, exactly like the normal metadata identity path. + * - an active --copy-as is AUTHORITATIVE: the identity path already forced the + * target owner, so replaying the recorded source owner here would silently + * override it. The xattr record is still stored/replayed for a later + * privileged restore; only the live chown is skipped. Mode/mtime remain + * applied either way so unprivileged --fake-super still works. */ bool fake_super_restore_fd(int fd) { if (fd < 0) return false; @@ -357,8 +367,11 @@ bool fake_super_restore_fd(int fd) { must not abort the transfer for that reason (FastSync identity philosophy). EPERM/EACCES (expected for a non-root receiver) are skipped silently; a genuine EINVAL (an impossible stored id) is logged so the corruption is - not hidden. */ - if (fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES) + not hidden. --no-super suppresses the owner leg even for root, and an + active --copy-as is authoritative so its forced owner must not be + overwritten by the recorded source owner. */ + if (privilege_super_permitted() && !identity_copy_as_active() && + fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES) log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore owner on destination file: %s", strerror(errno)); /* Mode is applied through the same sanitization the normal metadata path diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index ee8de96..386a3be 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -320,6 +320,27 @@ class TestDaemonRejection: assert result.returncode != 0 assert _tree_file_count(AUTH_MODULE) == 0 + def test_copy_as_refused_by_daemon(self, daemon): + """P7 Wave E: a daemon refuses client-chosen ownership (--copy-as) + outright. There is no per-module opt-in, so even a root daemon must not + honor an arbitrary client-selected owner. The refusal happens at the + config handshake, before any data lands.""" + log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") + before = os.path.getsize(log_path) if os.path.exists(log_path) else 0 + before_files = self._tree_files() + result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=daemon.port, + flags=["--copy-as=@65534:@65534"]) + assert result.returncode != 0, "the daemon must refuse --copy-as" + assert self._tree_files() == before_files, \ + "--copy-as refusal wrote under the module root" + time.sleep(0.3) + with open(log_path, "rb") as f: + f.seek(before) + tail = f.read().decode("utf-8", "replace") + assert "copy-as is refused by the daemon" in tail, ( + f"daemon did not log the copy-as refusal: {tail[-400:]!r}" + ) + @pytest.mark.daemon_detach def test_real_detach_path(self): """--daemon WITHOUT --no-detach double-forks a real background daemon; diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 434c61f..9a18de5 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -4181,6 +4181,24 @@ class TestSuperPrivilege: assert (st.st_uid, st.st_gid) == (12345, 12346), \ f"--super should apply raw ids: uid={st.st_uid} gid={st.st_gid}" + @pytest.mark.ci + @pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership") + def test_fake_super_no_super_does_not_change_owner(self, shared_server): + """--fake-super records the source owner, but --no-super must suppress the + live chown even for root: the destination keeps the receiver's owner + instead of the recorded source owner.""" + source, dest = self._seed("fakesuper_nosuper") + os.chown(os.path.join(source, "f.txt"), 12345, 12346) + result, _ = run_client(source, dest, + flags=["--fake-super", "--preserve", "--no-super"], + port=shared_server.port) + assert result.returncode == 0, \ + f"exit {result.returncode}: {(result.stderr or '')[:300]}" + received = get_dest_received_dir(dest, source) + st = os.lstat(os.path.join(received, "f.txt")) + assert (st.st_uid, st.st_gid) != (12345, 12346), \ + f"--no-super must suppress fake-super's owner replay: uid={st.st_uid} gid={st.st_gid}" + class TestHardLinks: """-H/--hard-links: source files sharing an inode are re-created as hard @@ -5243,3 +5261,83 @@ class TestCopyAs: assert (st.st_uid, st.st_gid) == (65534, 65534), ( f"--copy-as did not force ownership: uid={st.st_uid} gid={st.st_gid}" ) + + @pytest.mark.ci + @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") + def test_root_copy_as_owns_directory(self, shared_server): + """--copy-as must own an explicitly-created directory entry, not just the + files inside it. A listed directory (--files-from + --dirs -R) is sent + as a STATUS_MKDIR entry, exercising the directory ownership path.""" + source = os.path.join(TEST_DATA_DIR, "copyas_dir_src") + dest = os.path.join(TEST_DATA_DIR, "copyas_dir_dst") + clean_dir(source) + clean_dir(dest) + os.makedirs(os.path.join(source, "owned_dir"), exist_ok=True) + lst = os.path.join(TEST_DATA_DIR, "copyas_dir_list.txt") + with open(lst, "wb") as fh: + fh.write(b"owned_dir\n") + + result, _ = run_client( + source, dest, + flags=["--copy-as=@65534:@65534", "--files-from", lst, "--dirs", "-R"], + port=shared_server.port) + assert result.returncode == 0, ( + f"--copy-as directory transfer failed: {(result.stderr or result.stdout)[:400]}" + ) + target = os.path.join(dest, "owned_dir") + assert os.path.isdir(target), f"explicit directory missing at {target}" + st = os.stat(target) + assert (st.st_uid, st.st_gid) == (65534, 65534), ( + f"--copy-as did not own the directory: uid={st.st_uid} gid={st.st_gid}" + ) + + @pytest.mark.ci + @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") + def test_root_copy_as_owns_fifo(self, shared_server): + """--copy-as must own a recreated FIFO special node.""" + source = os.path.join(TEST_DATA_DIR, "copyas_fifo_src") + dest = os.path.join(TEST_DATA_DIR, "copyas_fifo_dst") + clean_dir(source) + clean_dir(dest) + os.mkfifo(os.path.join(source, "pipe.fifo")) + + result, _ = run_client(source, dest, + flags=["--copy-as=@65534:@65534", "--specials"], + port=shared_server.port) + assert result.returncode == 0, ( + f"--copy-as FIFO transfer failed: {(result.stderr or result.stdout)[:400]}" + ) + received = get_dest_received_dir(dest, source) + target = os.path.join(received, "pipe.fifo") + assert stat.S_ISFIFO(os.lstat(target).st_mode), f"FIFO missing at {target}" + st = os.lstat(target) + assert (st.st_uid, st.st_gid) == (65534, 65534), ( + f"--copy-as did not own the FIFO: uid={st.st_uid} gid={st.st_gid}" + ) + + @pytest.mark.ci + @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") + def test_root_copy_as_with_fake_super_keeps_target_owner(self, shared_server): + """--fake-super must not let the recorded source owner override the + --copy-as forced owner (copy-as is authoritative).""" + source = os.path.join(TEST_DATA_DIR, "copyas_fakesuper_src") + dest = os.path.join(TEST_DATA_DIR, "copyas_fakesuper_dst") + clean_dir(source) + clean_dir(dest) + src_file = os.path.join(source, "mixed.txt") + with open(src_file, "wb") as fh: + fh.write(b"copy-as wins over fake-super\n") + os.chown(src_file, 12345, 12346) + + result, _ = run_client(source, dest, + flags=["--copy-as=@65534:@65534", "--fake-super"], + port=shared_server.port) + assert result.returncode == 0, ( + f"--copy-as --fake-super transfer failed: " + f"{(result.stderr or result.stdout)[:400]}" + ) + received = get_dest_received_dir(dest, source) + st = os.lstat(os.path.join(received, "mixed.txt")) + assert (st.st_uid, st.st_gid) == (65534, 65534), ( + f"--fake-super overrode --copy-as: uid={st.st_uid} gid={st.st_gid}" + ) diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 3721d6c..5bbbcef 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -243,8 +243,8 @@ static void test_parse_args_protocol_accept_current() { /* Any --protocol value other than the current PROTOCOL_VERSION must end in * failure (parse_args simply stores it; validate_config rejects it up front). */ static void test_parse_args_protocol_rejects_other_versions() { - static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", - "216", "31", "abc", ""}; + static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", + "216", "31", "abc", ""}; for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { Config* cfg = valid_client_config(); EXPECT_NOT_NULL(cfg); diff --git a/tests/test_config.c b/tests/test_config.c index 3e5007f..efb4c40 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1741,6 +1741,10 @@ static void test_config_copy_as_wire_roundtrip() { send_cfg->copy_as_set = cases[i].set; send_cfg->copy_as_uid = cases[i].uid; send_cfg->copy_as_gid = cases[i].gid; + /* --copy-as requires the metadata path (the receiver chowns from the + transmitted source ids); a raw frame with copy_as_set but no metadata + is now rejected by validate_received_config. */ + send_cfg->use_metadata = cases[i].set; bool sent = config_send(p[1], send_cfg); int status; waitpid(pid, &status, 0); @@ -1814,6 +1818,63 @@ static void test_config_receive_rejects_negative_copy_as() { } } +/* --copy-as forces ownership through the metadata path. A frame that sets + copy_as_set but not use_metadata would pass the receiver's privilege gate + while chowning nothing, so validate_received_config must reject it (and the + sender observes the rejection as a failed config_send). */ +static void test_config_receive_rejects_copy_as_without_metadata() { + if (is_running_under_valgrind()) + return; + Config* c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->copy_as_set = true; + c->copy_as_uid = 1000; + c->copy_as_gid = 1000; + c->use_metadata = false; + EXPECT_FALSE(roundtrip_config_ok(c)); + config_delete(c); + + /* With metadata enabled the same block is accepted. */ + c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->copy_as_set = true; + c->copy_as_uid = 1000; + c->copy_as_gid = 1000; + c->use_metadata = true; + EXPECT_TRUE(roundtrip_config_ok(c)); + config_delete(c); +} + +/* identity_copy_as_refused() is the pure, pre-snapshot refusal predicate: a + --copy-as is refused when the receiver is not root OR the effective super + mode is OFF (an operator veto), and never when --copy-as is unset. */ +static void test_identity_copy_as_refused() { + Config* c = config_create(); + EXPECT_NOT_NULL(c); + EXPECT_FALSE(identity_copy_as_refused(c)); + EXPECT_FALSE(identity_copy_as_refused(NULL)); + + c->copy_as_set = true; + c->super_mode = SUPER_MODE_AUTO; + if (geteuid() == 0) { + EXPECT_FALSE(identity_copy_as_refused(c)); /* AUTO permits as root */ + c->super_mode = SUPER_MODE_ON; + EXPECT_FALSE(identity_copy_as_refused(c)); + c->super_mode = SUPER_MODE_OFF; + EXPECT_TRUE(identity_copy_as_refused(c)); + } else { + /* Unprivileged: refused regardless of the mode. */ + EXPECT_TRUE(identity_copy_as_refused(c)); + c->super_mode = SUPER_MODE_OFF; + EXPECT_TRUE(identity_copy_as_refused(c)); + } + config_delete(c); +} + /* P7 Wave E: privilege_super_permitted() maps the super_mode tri-state. OFF forbids super-user activities even for root; ON and AUTO permit the confined attempt (matching FastSync's historical best-effort behavior, where the kernel @@ -1888,8 +1949,10 @@ void test_config() { test_config_receive_rejects_invalid_super_mode(); test_config_copy_as_wire_roundtrip(); test_config_receive_rejects_negative_copy_as(); + test_config_receive_rejects_copy_as_without_metadata(); test_config_receive_with_validate_rejects(); } + test_identity_copy_as_refused(); test_privilege_super_permitted_modes(); test_config_delete_timing_early_helper(); test_config_is_remote_dest(); diff --git a/tests/test_server_cli.c b/tests/test_server_cli.c index 946b94b..5eea11c 100644 --- a/tests/test_server_cli.c +++ b/tests/test_server_cli.c @@ -31,9 +31,28 @@ static void test_server_cli_defaults() { EXPECT_EQ_INT(opts.bind_family, AF_UNSPEC); EXPECT_FALSE(opts.allow_delete); EXPECT_FALSE(opts.allow_unauthenticated); + EXPECT_FALSE(opts.no_super); server_cli_options_free(&opts); } +/* --no-super is a standalone/SSH operator veto (does not require --daemon): + it forces SUPER_MODE_OFF for every connection and refuses client --copy-as. */ +static void test_server_cli_no_super() { + const char* args[] = {"fastsync-server", "--no-super", "--destination-root", "/srv"}; + ServerCliOptions opts; + EXPECT_EQ_INT(parse_ok(args, 4, &opts), 0); + EXPECT_TRUE(opts.no_super); + EXPECT_EQ_STR(opts.destination_root, "/srv"); + server_cli_options_free(&opts); + + const char* args2[] = {"fastsync-server", "--daemon", "--config=/tmp/x.conf", "--no-super"}; + ServerCliOptions opts2; + EXPECT_EQ_INT(parse_ok(args2, 4, &opts2), 0); + EXPECT_TRUE(opts2.no_super); + EXPECT_TRUE(opts2.daemon_mode); + server_cli_options_free(&opts2); +} + static void test_server_cli_daemon_flags() { const char* args[] = {"fastsync-server", "--daemon", "--no-detach", "--allow-unauthenticated"}; ServerCliOptions opts; @@ -197,5 +216,6 @@ void test_server_cli() { test_server_cli_invalid(); test_server_cli_password_and_early_input(); test_server_cli_password_requires_daemon(); + test_server_cli_no_super(); test_server_cli_help(); } diff --git a/tests/test_xattr.c b/tests/test_xattr.c index 60d430b..24b9e40 100644 --- a/tests/test_xattr.c +++ b/tests/test_xattr.c @@ -1,6 +1,8 @@ #include "test_xattr.h" #include "xattr.h" +#include "config.h" #include "file.h" +#include "identity.h" #include "protocol.h" #include "test_utils.h" #include @@ -273,6 +275,72 @@ static void test_fake_super_restore() { unlink(path); } +/* --fake-super owner replay must honor the super gate and copy-as authority: + --no-super suppresses the recorded-source-owner chown even for root, and an + active --copy-as keeps its forced owner (the recorded source owner must never + override it). Root-gated: only root can observe a chown actually landing. */ +static void test_fake_super_owner_gate() { + if (geteuid() != 0) + return; /* non-root cannot observe ownership changes; skip silently */ + const char* path = "test_fake_super_owner_gate.txt"; + unlink(path); + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) + return; + bool has_xattr = setxattr(path, "user.fastsync.xprobe", "p", 1, 0) == 0; + if (has_xattr) + removexattr(path, "user.fastsync.xprobe"); + if (!has_xattr) { + close(fd); + unlink(path); + return; /* filesystem without xattr support */ + } + if (fchown(fd, 0, 0) != 0) { + close(fd); + unlink(path); + return; + } + fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0); + + Config* c = config_create(); + EXPECT_NOT_NULL(c); + + /* --no-super: the owner leg is skipped even as root. */ + c->super_mode = SUPER_MODE_OFF; + identity_set_active(c); + EXPECT_TRUE(fake_super_restore_fd(fd)); + struct stat st; + EXPECT_EQ_INT(fstat(fd, &st), 0); + EXPECT_EQ_INT((int)st.st_uid, 0); + EXPECT_EQ_INT((int)st.st_gid, 0); + + /* AUTO: the recorded source owner is applied. */ + c->super_mode = SUPER_MODE_AUTO; + identity_set_active(c); + EXPECT_TRUE(fake_super_restore_fd(fd)); + EXPECT_EQ_INT(fstat(fd, &st), 0); + EXPECT_EQ_INT((int)st.st_uid, 12345); + EXPECT_EQ_INT((int)st.st_gid, 12346); + + /* Active --copy-as is authoritative: the recorded source owner must not + override it, even with AUTO/ON. */ + EXPECT_EQ_INT(fchown(fd, 0, 0), 0); + c->super_mode = SUPER_MODE_ON; + c->copy_as_set = true; + c->copy_as_uid = 777; + c->copy_as_gid = 778; + identity_set_active(c); + EXPECT_TRUE(fake_super_restore_fd(fd)); + EXPECT_EQ_INT(fstat(fd, &st), 0); + EXPECT_EQ_INT((int)st.st_uid, 0); + EXPECT_EQ_INT((int)st.st_gid, 0); + + identity_clear_active(); + config_delete(c); + close(fd); + unlink(path); +} + void test_xattr() { test_xattr_wire_roundtrip(); test_xattr_reject_privileged_namespace(); @@ -281,4 +349,5 @@ void test_xattr() { test_xattr_capture_and_appliable(); test_link_copy_fallback_preserves_xattrs(); test_fake_super_restore(); + test_fake_super_owner_gate(); } \ No newline at end of file