fix(p7-privilege): harden copy-as/super gates, own dirs/specials

- fake-super owner replay honors --no-super and an active --copy-as
- copy-as/identity ownership now applied to directories and special nodes
- reject copy_as_set && !use_metadata (receiver + client --no-preserve)
- daemon refuses --copy-as; add server-side --no-super operator veto
- implement identity_copy_as_refused/identity_copy_as_active
- reject copy-as ids that overflow int32; escape spec in log errors
- copy-as chown EPERM/EACCES logged at ERROR (still non-fatal)
- identity_wire_valid copy-as bounds; CLI help and RSYNC_COMPAT docs
- add unit tests and root-gated integration coverage
This commit is contained in:
2026-09-12 12:34:43 +02:00
parent e6a65d0980
commit fdc0f238c9
18 changed files with 500 additions and 34 deletions
+6 -6
View File
@@ -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.
+10
View File
@@ -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;
}
+7
View File
@@ -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 <n> Chunk size in bytes (default: %d)\n", DEFAULT_CHUNK_SIZE);
printf(" --source-dir <path> Source directory\n");
printf(" --dest-dir <path> Destination directory\n");
+28 -1
View File
@@ -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);
+2
View File
@@ -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")) {
+5
View File
@@ -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
+4
View File
@@ -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 &&
+7 -2
View File
@@ -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
+25 -2
View File
@@ -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;
}
+109 -18
View File
@@ -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 : "<allocation failed>");
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 : "<allocation failed>";
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) {
+8
View File
@@ -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
+16 -3
View File
@@ -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
+21
View File
@@ -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;
+98
View File
@@ -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}"
)
+2 -2
View File
@@ -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);
+63
View File
@@ -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();
+20
View File
@@ -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();
}
+69
View File
@@ -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 <fcntl.h>
@@ -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();
}