feat(p7-super): implement --super/--no-super safe-subset privilege gate (protocol 2.18.0)
Add the receiver-side --super / --no-super tri-state (Config->super_mode) under the safe-subset + clear-refusal privilege model: FastSync never elevates privileges, it only permits super-user attempts that are already confined fd-relative below the authorized receive root. - identity: privilege_super_permitted() gate (OFF=false, ON=true, AUTO follows geteuid()==0); identity_apply_ownership/_link become no-ops when not permitted; --super with no explicit identity policy implies raw numeric-id preservation (explicit usermap/groupmap/chown/numeric-ids still win); warn exactly once when --super is requested by a non-root receiver. - file_receive: gate char/block device-node creation on the gate; FIFO/socket handling is unchanged. - wire: trailing super_mode int after the --iconv spec, validated 0..2 in receive_privilege_options and validate_received_config; PROTOCOL_VERSION 2.17.0 -> 2.18.0; version-sensitive tests and docs updated. - CLI: --super/--no-super parsed explicitly before the generic --no-* branch (malformed --super=x rejected); usage text added. - tests: config wire round-trip + invalid-value rejection, privilege-gate mode unit test, CLI parse test, integration transfer + root-gated ownership suppression/appliance tests. - docs: RSYNC_COMPAT --super row + Wave E note, protocol mentions, README.
This commit is contained in:
@@ -507,7 +507,7 @@ defaults to the current directory. |
|
|||||||
|
|
||||||
## Protocol and Security
|
## Protocol and Security
|
||||||
|
|
||||||
FastSync protocol version `2.5.0` is shared by the client and server. The
|
FastSync protocol version `2.18.0` is shared by the client and server. The
|
||||||
current protocol is sender-driven and includes configuration negotiation,
|
current protocol is sender-driven and includes configuration negotiation,
|
||||||
including the maximum allocation limit, incremental checks, checksums,
|
including the maximum allocation limit, incremental checks, checksums,
|
||||||
manifests, keep-alives, abort handling, per-file remove-source results, and
|
manifests, keep-alives, abort handling, per-file remove-source results, and
|
||||||
|
|||||||
+5
-3
@@ -257,7 +257,7 @@ 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) |
|
| `-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** |
|
| `-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** |
|
| `-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 | ❌ Not Implemented | |
|
| `--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 |
|
| `--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 |
|
||||||
| `--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 |
|
| `--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) |
|
| `--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) |
|
||||||
@@ -675,7 +675,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
|||||||
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
|
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
|
||||||
| `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
|
| `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
|
||||||
| `--fsync` | Fsync every written file before publication | ✅ Implemented | |
|
| `--fsync` | Fsync every written file before publication | ✅ Implemented | |
|
||||||
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.17.0) with no downgrade/backward-compat code paths, so `--protocol=2.17.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.17`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
|
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.18.0) with no downgrade/backward-compat code paths, so `--protocol=2.18.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.18`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
|
||||||
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
|
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
|
||||||
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
|
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
|
||||||
| `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
|
| `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
|
||||||
@@ -791,7 +791,7 @@ These are the hardest compatibility items because they require durable formats o
|
|||||||
|
|
||||||
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
|
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
|
||||||
|
|
||||||
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.17.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave D times bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.17`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.18.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave E privilege bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, --super privilege policy 2.18) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
||||||
|
|
||||||
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
|
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
|
||||||
|
|
||||||
@@ -826,6 +826,8 @@ 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.
|
`--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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
**Post-Phase-7 Summary (after Waves A–D).** ✅141 / 🔀0 / ⛔4 / ⚠️0 / 🔄0 / ❌2 = 147. The 3 `🔀 Alt Arg` rows (`-a`, `-p`, `-z`) are ✅ (Wave A). All 10 prior `⚠️ Partial` rows are resolved to ✅ (`-S`, `-P`, `--block-size`, `--fake-super`, `--devices`, `--copy-devices`, `--write-devices`) or ⛔ (`--stderr=client`, `-N/--crtimes`, `--specials` for the impossible socket case). The 3 `🔄 Compatibility No-op` rows are resolved: `-O`/`-J` are now real ✅ (Wave D), `--secluded-args` is ⛔. The **Impossible/Divergence** bucket holds the 4 physically-impossible/divergent flags: `--stderr=client`, `-N/--crtimes`, `--specials` (sockets), `--secluded-args`. The only remaining `❌ Not Implemented` rows are `--super` and `--copy-as=USER[:GROUP]`, deferred to **Wave E** pending an explicit privilege-model decision (see that paragraph).
|
**Post-Phase-7 Summary (after Waves A–D).** ✅141 / 🔀0 / ⛔4 / ⚠️0 / 🔄0 / ❌2 = 147. The 3 `🔀 Alt Arg` rows (`-a`, `-p`, `-z`) are ✅ (Wave A). All 10 prior `⚠️ Partial` rows are resolved to ✅ (`-S`, `-P`, `--block-size`, `--fake-super`, `--devices`, `--copy-devices`, `--write-devices`) or ⛔ (`--stderr=client`, `-N/--crtimes`, `--specials` for the impossible socket case). The 3 `🔄 Compatibility No-op` rows are resolved: `-O`/`-J` are now real ✅ (Wave D), `--secluded-args` is ⛔. The **Impossible/Divergence** bucket holds the 4 physically-impossible/divergent flags: `--stderr=client`, `-N/--crtimes`, `--specials` (sockets), `--secluded-args`. The only remaining `❌ Not Implemented` rows are `--super` and `--copy-as=USER[:GROUP]`, deferred to **Wave E** pending an explicit privilege-model decision (see that paragraph).
|
||||||
|
|||||||
@@ -848,6 +848,20 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
|
|||||||
config->no_motd = true;
|
config->no_motd = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
/* "--super" / "--no-super" are real rsync option names controlling the
|
||||||
|
* receiver's super-user activity policy (ownership, device nodes), not a
|
||||||
|
* Boolean pair for the generic --no-* negation branch: both map onto the
|
||||||
|
* Config->super_mode tri-state. Handle them explicitly (exact match only,
|
||||||
|
* so a malformed "--super=x" still falls through to the unknown-option
|
||||||
|
* error) before the generic negation branch would mis-reject "--no-super". */
|
||||||
|
if (strcmp(argv[i], "--super") == 0) {
|
||||||
|
config->super_mode = SUPER_MODE_ON;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strcmp(argv[i], "--no-super") == 0) {
|
||||||
|
config->super_mode = SUPER_MODE_OFF;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (strncmp(argv[i], "--no-", strlen("--no-")) == 0) {
|
if (strncmp(argv[i], "--no-", strlen("--no-")) == 0) {
|
||||||
if (strcmp(argv[i], "--no-delta") == 0)
|
if (strcmp(argv[i], "--no-delta") == 0)
|
||||||
no_delta = true;
|
no_delta = true;
|
||||||
|
|||||||
@@ -168,6 +168,14 @@ void print_usage(void) {
|
|||||||
printf(" user.fastsync.stat xattr on each written file and\n");
|
printf(" user.fastsync.stat xattr on each written file and\n");
|
||||||
printf(" re-apply it (fd-relative) on a privileged run; the\n");
|
printf(" re-apply it (fd-relative) on a privileged run; the\n");
|
||||||
printf(" recording format diverges from rsync's user.rsync.%%stat%%\n");
|
printf(" recording format diverges from rsync's user.rsync.%%stat%%\n");
|
||||||
|
printf(" --super Permit the receiver to attempt super-user activities\n");
|
||||||
|
printf(" (ownership application, char/block device-node\n");
|
||||||
|
printf(" creation) within the confined receive root. Never\n");
|
||||||
|
printf(" elevates privileges and never bypasses confinement;\n");
|
||||||
|
printf(" with no explicit identity policy, ownership follows\n");
|
||||||
|
printf(" raw numeric ids (as if --numeric-ids)\n");
|
||||||
|
printf(" --no-super Forbid those super-user activities even when the\n");
|
||||||
|
printf(" receiver is running as root\n");
|
||||||
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
|
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
|
||||||
printf(" --numeric-ids Do not map uid/gid by name: use the source numeric\n");
|
printf(" --numeric-ids Do not map uid/gid by name: use the source numeric\n");
|
||||||
printf(" ids directly when applying ownership\n");
|
printf(" ids directly when applying ownership\n");
|
||||||
|
|||||||
+28
-3
@@ -169,6 +169,7 @@ static void config_set_defaults(Config* config) {
|
|||||||
config->usermap_count = 0;
|
config->usermap_count = 0;
|
||||||
config->groupmap = NULL;
|
config->groupmap = NULL;
|
||||||
config->groupmap_count = 0;
|
config->groupmap_count = 0;
|
||||||
|
config->super_mode = SUPER_MODE_AUTO;
|
||||||
config->delay_context = NULL;
|
config->delay_context = NULL;
|
||||||
config->preserve_atimes = false;
|
config->preserve_atimes = false;
|
||||||
config->preserve_crtimes = false;
|
config->preserve_crtimes = false;
|
||||||
@@ -256,7 +257,10 @@ static bool validate_received_config(const Config* config) {
|
|||||||
unsupported charset name so the run is refused up front instead of
|
unsupported charset name so the run is refused up front instead of
|
||||||
every received file name failing mid-transfer. A NULL spec (iconv
|
every received file name failing mid-transfer. A NULL spec (iconv
|
||||||
disabled) is always accepted. */
|
disabled) is always accepted. */
|
||||||
(!config->iconv_spec || charset_spec_valid(config->iconv_spec));
|
(!config->iconv_spec || charset_spec_valid(config->iconv_spec)) &&
|
||||||
|
/* --super / --no-super: the received tri-state must be one of the
|
||||||
|
defined values (AUTO/ON/OFF); anything else is a malformed frame. */
|
||||||
|
config->super_mode >= SUPER_MODE_AUTO && config->super_mode <= SUPER_MODE_OFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
Config* config_create(void) {
|
Config* config_create(void) {
|
||||||
@@ -1185,6 +1189,25 @@ static bool receive_iconv_spec(int fd, Config* c) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --super / --no-super privilege policy (P7 Wave E, protocol 2.18.0). One
|
||||||
|
* trailing int on the config frame, sent after the --iconv spec and before the
|
||||||
|
* STATUS_OK ack, so the receiver knows whether it may attempt super-user
|
||||||
|
* activities (ownership application, char/block device-node creation) that are
|
||||||
|
* already confined below the authorized receive root. The received value is
|
||||||
|
* validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by
|
||||||
|
* validate_received_config). */
|
||||||
|
static bool send_privilege_options(int fd, const Config* c) {
|
||||||
|
return send_int(fd, c->super_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool receive_privilege_options(int fd, Config* c) {
|
||||||
|
int mode;
|
||||||
|
if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF)
|
||||||
|
return false;
|
||||||
|
c->super_mode = mode;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool config_send(int file_descriptor, const Config* config) {
|
bool config_send(int file_descriptor, const Config* config) {
|
||||||
protocol_session_set_max_alloc(NULL, config->max_alloc);
|
protocol_session_set_max_alloc(NULL, config->max_alloc);
|
||||||
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
|
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
|
||||||
@@ -1198,7 +1221,7 @@ bool config_send(int file_descriptor, const Config* config) {
|
|||||||
!send_symlink_trust_options(file_descriptor, config) ||
|
!send_symlink_trust_options(file_descriptor, config) ||
|
||||||
!send_phase4_xattr_options(file_descriptor, config) ||
|
!send_phase4_xattr_options(file_descriptor, config) ||
|
||||||
!send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) ||
|
!send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) ||
|
||||||
!send_iconv_spec(file_descriptor, config))
|
!send_iconv_spec(file_descriptor, config) || !send_privilege_options(file_descriptor, config))
|
||||||
return false;
|
return false;
|
||||||
Status status;
|
Status status;
|
||||||
if (!receive_status(file_descriptor, &status))
|
if (!receive_status(file_descriptor, &status))
|
||||||
@@ -1240,7 +1263,9 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
|||||||
!receive_symlink_trust_options(file_descriptor, config) ||
|
!receive_symlink_trust_options(file_descriptor, config) ||
|
||||||
!receive_phase4_xattr_options(file_descriptor, config) ||
|
!receive_phase4_xattr_options(file_descriptor, config) ||
|
||||||
!receive_daemon_module(file_descriptor, config) ||
|
!receive_daemon_module(file_descriptor, config) ||
|
||||||
!receive_daemon_auth(file_descriptor, config) || !receive_iconv_spec(file_descriptor, config))
|
!receive_daemon_auth(file_descriptor, config) ||
|
||||||
|
!receive_iconv_spec(file_descriptor, config) ||
|
||||||
|
!receive_privilege_options(file_descriptor, config))
|
||||||
goto error;
|
goto error;
|
||||||
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
|
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
|
||||||
strcmp(config->compress_choice, "none") != 0) {
|
strcmp(config->compress_choice, "none") != 0) {
|
||||||
|
|||||||
+43
-2
@@ -402,6 +402,22 @@ typedef struct Config {
|
|||||||
IdentityMap* groupmap;
|
IdentityMap* groupmap;
|
||||||
int groupmap_count;
|
int groupmap_count;
|
||||||
|
|
||||||
|
/* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege
|
||||||
|
* policy for super-user activities confined below the authorized receive
|
||||||
|
* root. SUPER_MODE_AUTO (default) preserves the pre-existing behavior: a
|
||||||
|
* privileged operation is only attempted when the receiver is ALREADY root
|
||||||
|
* (geteuid() == 0). SUPER_MODE_ON (--super) PERMITS the receiver to attempt
|
||||||
|
* those activities (ownership application, char/block device-node creation)
|
||||||
|
* even when it is not root -- the attempt is then confined exactly as before
|
||||||
|
* and simply fails/skips if the kernel refuses it. SUPER_MODE_OFF
|
||||||
|
* (--no-super) FORBIDS them even when running as root. FastSync NEVER
|
||||||
|
* elevates privileges (no setuid/seteuid/setgid) and never bypasses the
|
||||||
|
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
||||||
|
* --super only permits an attempt that is already confined. Crosses the wire
|
||||||
|
* as a trailing int so the receiver can enforce the policy. See
|
||||||
|
* privilege_super_permitted() in identity.h. */
|
||||||
|
int super_mode;
|
||||||
|
|
||||||
// Receiver-side runtime staging registry for --delay-updates. Never sent
|
// Receiver-side runtime staging registry for --delay-updates. Never sent
|
||||||
// over the wire and never set on the sender side.
|
// over the wire and never set on the sender side.
|
||||||
DelayUpdatesContext* delay_context;
|
DelayUpdatesContext* delay_context;
|
||||||
@@ -563,8 +579,24 @@ typedef struct Config {
|
|||||||
* would desynchronize on the unknown frame, and the strict same-version
|
* would desynchronize on the unknown frame, and the strict same-version
|
||||||
* handshake (config_receive rejects a mismatched version before parsing
|
* handshake (config_receive rejects a mismatched version before parsing
|
||||||
* anything else) is what keeps a 2.17 client and a 2.16 server from ever
|
* anything else) is what keeps a 2.17 client and a 2.16 server from ever
|
||||||
* reaching that state. */
|
* reaching that state.
|
||||||
#define PROTOCOL_VERSION "2.17.0"
|
*
|
||||||
|
* Privilege Wave (P7 Wave E): 2.17.0 -> 2.18.0.
|
||||||
|
*
|
||||||
|
* WHY the bump, grounded in the wire: this wave adds the receiver-side
|
||||||
|
* --super / --no-super privilege policy. The config-frame layout gains a new
|
||||||
|
* trailing int (Config->super_mode) sent immediately AFTER the --iconv
|
||||||
|
* CONVERT_SPEC block (send_privilege_options / receive_privilege_options in
|
||||||
|
* config.c), so the receiver knows whether it may attempt super-user
|
||||||
|
* activities (ownership application, char/block device-node creation) that are
|
||||||
|
* already confined below the authorized receive root. Any config-frame layout
|
||||||
|
* change must bump the protocol version: a peer that does not parse the new
|
||||||
|
* trailing bytes would desynchronize on the frame boundary, and the strict
|
||||||
|
* same-version handshake (config_receive rejects a mismatched version before
|
||||||
|
* parsing anything else) is what keeps a 2.18 client and a 2.17 server from
|
||||||
|
* ever reaching that state. --super never elevates privileges; it only
|
||||||
|
* permits a confined attempt, so no new capability is granted. */
|
||||||
|
#define PROTOCOL_VERSION "2.18.0"
|
||||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||||
#define MAX_BASIS_DIRS 64
|
#define MAX_BASIS_DIRS 64
|
||||||
@@ -577,6 +609,15 @@ typedef struct Config {
|
|||||||
#define IDENTITY_CURRENT (-1)
|
#define IDENTITY_CURRENT (-1)
|
||||||
#define MAX_IDENTITY_MAP 128
|
#define MAX_IDENTITY_MAP 128
|
||||||
|
|
||||||
|
/* --super / --no-super tri-state (Config->super_mode). AUTO preserves the
|
||||||
|
* pre-existing behavior (a privileged attempt only when already root); ON
|
||||||
|
* permits confined privileged attempts; OFF forbids them even as root. See the
|
||||||
|
* Config->super_mode comment above and privilege_super_permitted() in
|
||||||
|
* identity.h. */
|
||||||
|
#define SUPER_MODE_AUTO 0
|
||||||
|
#define SUPER_MODE_ON 1
|
||||||
|
#define SUPER_MODE_OFF 2
|
||||||
|
|
||||||
Config* config_create(void);
|
Config* config_create(void);
|
||||||
void config_delete(Config* config);
|
void config_delete(Config* config);
|
||||||
bool config_send(int file_descriptor, const Config* config);
|
bool config_send(int file_descriptor, const Config* config);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "delay_updates.h"
|
#include "delay_updates.h"
|
||||||
#include "delta.h"
|
#include "delta.h"
|
||||||
#include "file.h"
|
#include "file.h"
|
||||||
|
#include "identity.h"
|
||||||
#include "log.h"
|
#include "log.h"
|
||||||
#include "metadata.h"
|
#include "metadata.h"
|
||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
@@ -355,6 +356,17 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
|||||||
if (is_char || is_blk) {
|
if (is_char || is_blk) {
|
||||||
if (!config || !config->preserve_devices)
|
if (!config || !config->preserve_devices)
|
||||||
return FILE_SAVE_SKIPPED;
|
return FILE_SAVE_SKIPPED;
|
||||||
|
/* --super / --no-super (P7 Wave E): char/block device-node creation is a
|
||||||
|
super-user activity. --no-super forbids it even for a root receiver; the
|
||||||
|
default AUTO only attempts it when already root. Pure FIFO creation is
|
||||||
|
unprivileged and deliberately NOT gated here. */
|
||||||
|
if (!privilege_super_permitted()) {
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"skipping %s: super-user device-node creation is not permitted "
|
||||||
|
"(--no-super, or the receiver is not privileged)",
|
||||||
|
file->path);
|
||||||
|
return FILE_SAVE_SKIPPED;
|
||||||
|
}
|
||||||
} else if (is_fifo) {
|
} else if (is_fifo) {
|
||||||
if (!config || !config->preserve_specials)
|
if (!config || !config->preserve_specials)
|
||||||
return FILE_SAVE_SKIPPED;
|
return FILE_SAVE_SKIPPED;
|
||||||
|
|||||||
+48
-9
@@ -27,6 +27,10 @@ typedef struct {
|
|||||||
int usermap_count;
|
int usermap_count;
|
||||||
IdentityMap* groupmap;
|
IdentityMap* groupmap;
|
||||||
int groupmap_count;
|
int groupmap_count;
|
||||||
|
/* --super / --no-super tri-state (SUPER_MODE_AUTO when unset). Snapshotted
|
||||||
|
* per connection so privilege_super_permitted() can gate super-user
|
||||||
|
* activities without a Config argument. */
|
||||||
|
int super_mode;
|
||||||
bool set;
|
bool set;
|
||||||
} IdentityActive;
|
} IdentityActive;
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@ static void identity_active_reset(void) {
|
|||||||
g_identity.chown_uid = 0;
|
g_identity.chown_uid = 0;
|
||||||
g_identity.chown_gid_set = false;
|
g_identity.chown_gid_set = false;
|
||||||
g_identity.chown_gid = 0;
|
g_identity.chown_gid = 0;
|
||||||
|
g_identity.super_mode = SUPER_MODE_AUTO;
|
||||||
g_identity.set = false;
|
g_identity.set = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +81,7 @@ void identity_set_active(const Config* config) {
|
|||||||
g_identity.groupmap_count = config->groupmap_count;
|
g_identity.groupmap_count = config->groupmap_count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
g_identity.super_mode = config->super_mode;
|
||||||
g_identity.set = true;
|
g_identity.set = true;
|
||||||
/* A root receiver would honor any client-supplied ownership request (a
|
/* A root receiver would honor any client-supplied ownership request (a
|
||||||
--usermap/--groupmap/--chown, or raw ids under --numeric-ids). Surface
|
--usermap/--groupmap/--chown, or raw ids under --numeric-ids). Surface
|
||||||
@@ -86,6 +92,37 @@ void identity_set_active(const Config* config) {
|
|||||||
"identity mapping active and running as root: client-supplied "
|
"identity mapping active and running as root: client-supplied "
|
||||||
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
|
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
|
||||||
"run the daemon as an unprivileged user unless intended");
|
"run the daemon as an unprivileged user unless intended");
|
||||||
|
/* --super explicitly requests super-user activities, but FastSync never
|
||||||
|
elevates privileges: when the receiver is not already root those confined
|
||||||
|
attempts cannot succeed. Warn exactly once at activation time (never
|
||||||
|
abort) so the operator knows the flag is inert on this host. */
|
||||||
|
if (g_identity.super_mode == SUPER_MODE_ON && geteuid() != 0)
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"--super requested but the receiver is not privileged; super-user "
|
||||||
|
"activities (ownership, device nodes) cannot be performed and will "
|
||||||
|
"be skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool privilege_super_permitted(void) {
|
||||||
|
if (g_identity.super_mode == SUPER_MODE_OFF)
|
||||||
|
return false;
|
||||||
|
if (g_identity.super_mode == SUPER_MODE_ON)
|
||||||
|
return true;
|
||||||
|
/* SUPER_MODE_AUTO (the default): only attempt super-user activities when the
|
||||||
|
receiver is already root. */
|
||||||
|
return geteuid() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --super with NO explicit identity policy implies raw numeric-id preservation,
|
||||||
|
* exactly as if --numeric-ids had been given. An explicit usermap/groupmap/
|
||||||
|
* --chown/--numeric-ids always wins: identity_resolve_targets() checks those
|
||||||
|
* before the numeric fallback, and this predicate is false whenever any of them
|
||||||
|
* is present. In AUTO (the default) no implication is made, preserving the
|
||||||
|
* opt-in-only behavior. */
|
||||||
|
static bool identity_super_implies_numeric(void) {
|
||||||
|
return g_identity.super_mode == SUPER_MODE_ON && !g_identity.numeric_ids &&
|
||||||
|
!g_identity.chown_uid_set && !g_identity.chown_gid_set && g_identity.usermap_count == 0 &&
|
||||||
|
g_identity.groupmap_count == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool identity_active_enabled(void) {
|
bool identity_active_enabled(void) {
|
||||||
@@ -93,10 +130,11 @@ bool identity_active_enabled(void) {
|
|||||||
which runs only when metadata is present (a -M/--preserve transfer). A
|
which runs only when metadata is present (a -M/--preserve transfer). A
|
||||||
standalone --numeric-ids (no ownership-affecting flag) carries no
|
standalone --numeric-ids (no ownership-affecting flag) carries no
|
||||||
metadata, never reaches identity_apply_ownership, and therefore correctly
|
metadata, never reaches identity_apply_ownership, and therefore correctly
|
||||||
stays inert; combined with -M it activates raw-id application. */
|
stays inert; combined with -M it activates raw-id application. --super
|
||||||
return g_identity.set &&
|
with no explicit identity policy acts like --numeric-ids here. */
|
||||||
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
|
return g_identity.set && (g_identity.numeric_ids || g_identity.chown_uid_set ||
|
||||||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0);
|
g_identity.chown_gid_set || g_identity.usermap_count > 0 ||
|
||||||
|
g_identity.groupmap_count > 0 || identity_super_implies_numeric());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool identity_wire_valid(const Config* config) {
|
bool identity_wire_valid(const Config* config) {
|
||||||
@@ -388,7 +426,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
|||||||
} else if (g_identity.chown_uid_set) {
|
} else if (g_identity.chown_uid_set) {
|
||||||
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
||||||
set_uid = true;
|
set_uid = true;
|
||||||
} else if (g_identity.numeric_ids) {
|
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
||||||
uid = (uid_t)source_uid;
|
uid = (uid_t)source_uid;
|
||||||
set_uid = true;
|
set_uid = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -412,7 +450,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
|||||||
} else if (g_identity.chown_gid_set) {
|
} else if (g_identity.chown_gid_set) {
|
||||||
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
||||||
set_gid = true;
|
set_gid = true;
|
||||||
} else if (g_identity.numeric_ids) {
|
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
||||||
gid = (gid_t)source_gid;
|
gid = (gid_t)source_gid;
|
||||||
set_gid = true;
|
set_gid = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -458,8 +496,9 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
|||||||
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
||||||
/* Ownership application is OFF unless the client requested an identity flag.
|
/* Ownership application is OFF unless the client requested an identity flag.
|
||||||
* This is the controlled gate: a default (or plain -M) transfer never changes
|
* This is the controlled gate: a default (or plain -M) transfer never changes
|
||||||
* ownership, byte-for-byte preserving FastSync's existing behavior. */
|
* ownership, byte-for-byte preserving FastSync's existing behavior. --no-super
|
||||||
if (!identity_active_enabled() || fd < 0)
|
* additionally forbids it even when the receiver is root. */
|
||||||
|
if (!identity_active_enabled() || !privilege_super_permitted() || fd < 0)
|
||||||
return;
|
return;
|
||||||
struct stat st;
|
struct stat st;
|
||||||
if (fstat(fd, &st) != 0)
|
if (fstat(fd, &st) != 0)
|
||||||
@@ -474,7 +513,7 @@ void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
|||||||
|
|
||||||
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||||
int32_t source_gid) {
|
int32_t source_gid) {
|
||||||
if (!identity_active_enabled() || parent_fd < 0 || !leaf)
|
if (!identity_active_enabled() || !privilege_super_permitted() || parent_fd < 0 || !leaf)
|
||||||
return;
|
return;
|
||||||
struct stat st;
|
struct stat st;
|
||||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
||||||
|
|||||||
@@ -65,4 +65,14 @@ void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t sour
|
|||||||
/* Receiver-side wire validation of the resolved identity fields. */
|
/* Receiver-side wire validation of the resolved identity fields. */
|
||||||
bool identity_wire_valid(const Config* config);
|
bool identity_wire_valid(const Config* config);
|
||||||
|
|
||||||
|
/* P7 Wave E receiver-side permission gate for super-user activities (ownership
|
||||||
|
* application and char/block device-node creation). Returns false when the
|
||||||
|
* active config is --no-super (SUPER_MODE_OFF); true when it is --super
|
||||||
|
* (SUPER_MODE_ON); and otherwise (SUPER_MODE_AUTO, the default, or before
|
||||||
|
* identity_set_active() has been called) only when the receiver is ALREADY root
|
||||||
|
* (geteuid() == 0). This NEVER elevates privileges: it only reports whether an
|
||||||
|
* attempt that is already confined below the authorized receive root may be
|
||||||
|
* made. */
|
||||||
|
bool privilege_super_permitted(void);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -200,8 +200,9 @@ class TestDeviceSpecial:
|
|||||||
assert not os.path.lexists(os.path.join(received, "chardev")), (
|
assert not os.path.lexists(os.path.join(received, "chardev")), (
|
||||||
"a receiver without CAP_MKNOD must skip the device node, not create it"
|
"a receiver without CAP_MKNOD must skip the device node, not create it"
|
||||||
)
|
)
|
||||||
assert "cannot create device node" in (out + err), (
|
assert ("cannot create device node" in (out + err)
|
||||||
f"receiver did not log the documented CAP_MKNOD skip: out={out!r} err={err!r}"
|
or "device-node creation is not permitted" in (out + err)), (
|
||||||
|
f"receiver did not log the documented device skip: out={out!r} err={err!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes")
|
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes")
|
||||||
@@ -4119,6 +4120,68 @@ class TestIdentityMapping:
|
|||||||
f"--chown not applied: uid={st.st_uid} gid={st.st_gid}"
|
f"--chown not applied: uid={st.st_uid} gid={st.st_gid}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuperPrivilege:
|
||||||
|
"""P7 Wave E: --super / --no-super control the receiver's already-confined
|
||||||
|
super-user activities (ownership application, char/block device nodes).
|
||||||
|
FastSync never elevates, so on an unprivileged receiver --super only
|
||||||
|
permits a confined attempt (which then skips); --no-super forbids the
|
||||||
|
activity even for root."""
|
||||||
|
|
||||||
|
def _seed(self, tag):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, f"super_{tag}_source")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, f"super_{tag}_dest")
|
||||||
|
clean_dir(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
with open(os.path.join(source, "f.txt"), "wb") as f:
|
||||||
|
f.write(b"super privilege\n")
|
||||||
|
return source, dest
|
||||||
|
|
||||||
|
def test_super_and_no_super_transfer_successfully(self, shared_server):
|
||||||
|
"""Both flags parse and the transfer completes normally regardless of
|
||||||
|
the receiver's privilege level."""
|
||||||
|
for flag in ("--super", "--no-super"):
|
||||||
|
source, dest = self._seed(flag.strip("-"))
|
||||||
|
result, _ = run_client(source, dest, flags=[flag], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, \
|
||||||
|
f"{flag} exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
with open(os.path.join(received, "f.txt"), "rb") as f:
|
||||||
|
assert f.read() == b"super privilege\n"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||||
|
def test_no_super_suppresses_ownership_as_root(self, shared_server):
|
||||||
|
"""As root the default gate would apply a raw numeric id; --no-super
|
||||||
|
must suppress that ownership application entirely."""
|
||||||
|
source, dest = self._seed("nosuper")
|
||||||
|
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||||
|
result, _ = run_client(source, dest,
|
||||||
|
flags=["--preserve", "--numeric-ids", "--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.stat(os.path.join(received, "f.txt"))
|
||||||
|
assert (st.st_uid, st.st_gid) != (12345, 12346), \
|
||||||
|
f"--no-super must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||||
|
def test_super_applies_ownership_as_root(self, shared_server):
|
||||||
|
"""Control/proof the flag is not inert for root: --super with no explicit
|
||||||
|
identity policy treats ownership as raw numeric ids (as --numeric-ids),
|
||||||
|
applying the very ownership --no-super suppressed."""
|
||||||
|
source, dest = self._seed("super")
|
||||||
|
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||||
|
result, _ = run_client(source, dest,
|
||||||
|
flags=["--preserve", "--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.stat(os.path.join(received, "f.txt"))
|
||||||
|
assert (st.st_uid, st.st_gid) == (12345, 12346), \
|
||||||
|
f"--super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
||||||
|
|
||||||
|
|
||||||
class TestHardLinks:
|
class TestHardLinks:
|
||||||
"""-H/--hard-links: source files sharing an inode are re-created as hard
|
"""-H/--hard-links: source files sharing an inode are re-created as hard
|
||||||
links to one another on the destination (dedup preserved, first copy
|
links to one another on the destination (dedup preserved, first copy
|
||||||
|
|||||||
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
|
|||||||
class TestProtocol:
|
class TestProtocol:
|
||||||
@pytest.mark.ci
|
@pytest.mark.ci
|
||||||
def test_protocol_current_version_accepted(self, shared_server):
|
def test_protocol_current_version_accepted(self, shared_server):
|
||||||
"""--protocol=2.17.0 (the current PROTOCOL_VERSION) is accepted and the
|
"""--protocol=2.18.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||||
transfer completes normally."""
|
transfer completes normally."""
|
||||||
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
||||||
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
os.makedirs(dest)
|
os.makedirs(dest)
|
||||||
_seed_protocol_source(source)
|
_seed_protocol_source(source)
|
||||||
result, _ = run_client(source, dest, flags=["--protocol=2.17.0"],
|
result, _ = run_client(source, dest, flags=["--protocol=2.18.0"],
|
||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode == 0, \
|
assert result.returncode == 0, \
|
||||||
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
||||||
@@ -118,7 +118,7 @@ class TestProtocol:
|
|||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
os.makedirs(dest)
|
os.makedirs(dest)
|
||||||
_seed_protocol_source(source)
|
_seed_protocol_source(source)
|
||||||
for bad in ("2.15.0", "2.16.0", "216", "31"):
|
for bad in ("2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
||||||
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
||||||
|
|||||||
+42
-3
@@ -223,7 +223,7 @@ static void test_parse_args_protocol_accept_current() {
|
|||||||
Config* cfg = valid_client_config();
|
Config* cfg = valid_client_config();
|
||||||
EXPECT_NOT_NULL(cfg);
|
EXPECT_NOT_NULL(cfg);
|
||||||
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
|
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
|
||||||
"--dest-dir", "/dst", "--protocol=2.17.0"};
|
"--dest-dir", "/dst", "--protocol=2.18.0"};
|
||||||
int positional_args[2];
|
int positional_args[2];
|
||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
||||||
@@ -233,7 +233,7 @@ static void test_parse_args_protocol_accept_current() {
|
|||||||
cfg = valid_client_config();
|
cfg = valid_client_config();
|
||||||
EXPECT_NOT_NULL(cfg);
|
EXPECT_NOT_NULL(cfg);
|
||||||
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
||||||
"/dst", "--protocol", "2.17.0"};
|
"/dst", "--protocol", "2.18.0"};
|
||||||
positional_count = 0;
|
positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
|
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
|
||||||
@@ -243,7 +243,8 @@ static void test_parse_args_protocol_accept_current() {
|
|||||||
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
|
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
|
||||||
* failure (parse_args simply stores it; validate_config rejects it up front). */
|
* failure (parse_args simply stores it; validate_config rejects it up front). */
|
||||||
static void test_parse_args_protocol_rejects_other_versions() {
|
static void test_parse_args_protocol_rejects_other_versions() {
|
||||||
static const char* const bad_versions[] = {"2.16", "2.15.0", "2.16.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++) {
|
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
|
||||||
Config* cfg = valid_client_config();
|
Config* cfg = valid_client_config();
|
||||||
EXPECT_NOT_NULL(cfg);
|
EXPECT_NOT_NULL(cfg);
|
||||||
@@ -331,6 +332,43 @@ static void test_parse_args_fake_super() {
|
|||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P7 Wave E: --super / --no-super set the receiver-side privilege tri-state
|
||||||
|
* (they take no argument). The default is AUTO, the last of either flag wins,
|
||||||
|
* and a malformed inline value ("--super=x") is rejected rather than silently
|
||||||
|
* treated as --super. */
|
||||||
|
static void test_parse_args_super() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_AUTO);
|
||||||
|
char* argv_on[] = {"fastsync", "--super", "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_on, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_ON);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
cfg = config_create();
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv_off[] = {"fastsync", "--no-super", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_off, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_OFF);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
/* Tri-state, not a boolean pair: the last flag wins. */
|
||||||
|
cfg = config_create();
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv_both[] = {"fastsync", "--super", "--no-super", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv_both, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_OFF);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
/* A malformed inline value is a hard unknown-option error. */
|
||||||
|
cfg = config_create();
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv_bad[] = {"fastsync", "--super=x", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_bad, positional_args, &positional_count), -1);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
/* Test parse_args with valid SSH port (long form; -p is now rsync --perms) */
|
/* Test parse_args with valid SSH port (long form; -p is now rsync --perms) */
|
||||||
static void test_parse_args_valid_port() {
|
static void test_parse_args_valid_port() {
|
||||||
Config* cfg = config_create();
|
Config* cfg = config_create();
|
||||||
@@ -3081,6 +3119,7 @@ void test_client_cli() {
|
|||||||
test_parse_args_missing_argument_diagnostic();
|
test_parse_args_missing_argument_diagnostic();
|
||||||
test_parse_args_xattrs_acls();
|
test_parse_args_xattrs_acls();
|
||||||
test_parse_args_fake_super();
|
test_parse_args_fake_super();
|
||||||
|
test_parse_args_super();
|
||||||
test_parse_args_partial_progress();
|
test_parse_args_partial_progress();
|
||||||
test_parse_args_itemize_changes();
|
test_parse_args_itemize_changes();
|
||||||
test_parse_args_list_only();
|
test_parse_args_list_only();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "test_config.h"
|
#include "test_config.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "identity.h"
|
||||||
#include "multiprocessing.h"
|
#include "multiprocessing.h"
|
||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
#include "queue.h"
|
#include "queue.h"
|
||||||
@@ -1669,6 +1670,89 @@ static void test_config_receive_rejects_invalid_iconv_spec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P7 Wave E: the --super / --no-super tri-state crosses the config wire
|
||||||
|
unchanged (AUTO/ON/OFF), so the receiver can enforce the privilege policy. */
|
||||||
|
static void test_config_super_mode_wire_roundtrip() {
|
||||||
|
if (is_running_under_valgrind())
|
||||||
|
return;
|
||||||
|
int modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF};
|
||||||
|
for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) {
|
||||||
|
int p[2];
|
||||||
|
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
close(p[1]);
|
||||||
|
io_set_fds(p[0], p[0]);
|
||||||
|
Config* recv = config_receive(p[0]);
|
||||||
|
bool ok = recv != NULL && recv->super_mode == modes[i];
|
||||||
|
config_delete(recv);
|
||||||
|
close(p[0]);
|
||||||
|
_exit(ok ? 0 : 1);
|
||||||
|
} else {
|
||||||
|
close(p[0]);
|
||||||
|
io_set_fds(p[1], p[1]);
|
||||||
|
Config* send_cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(send_cfg);
|
||||||
|
send_cfg->send_directory = str_dup("/src");
|
||||||
|
send_cfg->receive_root_directory = str_dup("/dst");
|
||||||
|
send_cfg->super_mode = modes[i];
|
||||||
|
bool sent = config_send(p[1], send_cfg);
|
||||||
|
int status;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
close(p[1]);
|
||||||
|
config_delete(send_cfg);
|
||||||
|
EXPECT_TRUE(sent);
|
||||||
|
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An out-of-range super_mode value on the wire must be refused on receive
|
||||||
|
(never silently clamped or accepted). */
|
||||||
|
static void test_config_receive_rejects_invalid_super_mode() {
|
||||||
|
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->super_mode = 99;
|
||||||
|
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||||
|
config_delete(c);
|
||||||
|
|
||||||
|
/* A negative value is equally invalid. */
|
||||||
|
c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
c->send_directory = str_dup("/src");
|
||||||
|
c->receive_root_directory = str_dup("/dst");
|
||||||
|
c->super_mode = -1;
|
||||||
|
EXPECT_FALSE(roundtrip_config_ok(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 permits them; AUTO follows
|
||||||
|
the effective uid. */
|
||||||
|
static void test_privilege_super_permitted_modes() {
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
c->super_mode = SUPER_MODE_OFF;
|
||||||
|
identity_set_active(c);
|
||||||
|
EXPECT_FALSE(privilege_super_permitted());
|
||||||
|
c->super_mode = SUPER_MODE_ON;
|
||||||
|
identity_set_active(c);
|
||||||
|
EXPECT_TRUE(privilege_super_permitted());
|
||||||
|
c->super_mode = SUPER_MODE_AUTO;
|
||||||
|
identity_set_active(c);
|
||||||
|
EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0);
|
||||||
|
config_delete(c);
|
||||||
|
|
||||||
|
/* After clearing, the neutral default is AUTO (root-following), never a
|
||||||
|
stale snapshot from a previous connection. */
|
||||||
|
identity_clear_active();
|
||||||
|
EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
void test_config() {
|
void test_config() {
|
||||||
test_config_lifecycle();
|
test_config_lifecycle();
|
||||||
test_config_ssh_dest();
|
test_config_ssh_dest();
|
||||||
@@ -1715,8 +1799,11 @@ void test_config() {
|
|||||||
test_config_iconv_spec_wire_roundtrip();
|
test_config_iconv_spec_wire_roundtrip();
|
||||||
test_config_iconv_spec_empty_canonicalizes_to_null();
|
test_config_iconv_spec_empty_canonicalizes_to_null();
|
||||||
test_config_receive_rejects_invalid_iconv_spec();
|
test_config_receive_rejects_invalid_iconv_spec();
|
||||||
|
test_config_super_mode_wire_roundtrip();
|
||||||
|
test_config_receive_rejects_invalid_super_mode();
|
||||||
test_config_receive_with_validate_rejects();
|
test_config_receive_with_validate_rejects();
|
||||||
}
|
}
|
||||||
|
test_privilege_super_permitted_modes();
|
||||||
test_config_delete_timing_early_helper();
|
test_config_delete_timing_early_helper();
|
||||||
test_config_is_remote_dest();
|
test_config_is_remote_dest();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user