Merge security hardening wave: parser/compression, receiver confinement, server/transport/TLS
CI / lint (push) Successful in 1m26s
CI / sanitizers (undefined) (push) Successful in 1m2s
CI / sanitizers (address) (push) Successful in 1m7s
CI / fuzz-build (push) Successful in 35s
CI / coverage (push) Successful in 56s
CI / valgrind (push) Failing after 3m19s
CI / build-and-test (push) Successful in 5m26s

This commit is contained in:
2026-09-14 17:40:29 +02:00
46 changed files with 1998 additions and 219 deletions
+9 -1
View File
@@ -178,6 +178,7 @@ transfer is never aborted.
| `--ca <path>` | TLS CA certificate file for verification (PEM) | | `--ca <path>` | TLS CA certificate file for verification (PEM) |
| `--destination-root <path>` | Authorized destination root (default: `.`) | | `--destination-root <path>` | Authorized destination root (default: `.`) |
| `--allow-delete` | Permit manifest deletion | | `--allow-delete` | Permit manifest deletion |
| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. **Rejected with `--stdio`** (the SSH remote argv is client-composed, so a client could otherwise pass it and defeat the secure default; operators exposing `fastsync-server --stdio` over SSH must use a forced command if the default must hold). No effect when not root. |
| `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. | | `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. |
| `-v, --verbose` | Enable debug logging | | `-v, --verbose` | Enable debug logging |
| `--help` | Show help | | `--help` | Show help |
@@ -304,6 +305,12 @@ The remote host must have `fastsync-server` available in `PATH`, or use
working directory, so use a destination below that directory unless the working directory, so use a destination below that directory unless the
remote server is otherwise configured with a matching authorized root. remote server is otherwise configured with a matching authorized root.
The remote `--stdio` server argv is composed by the client, so it must never
be trusted to opt a root receiver into super-user activities: `--allow-super`
is rejected with `--stdio` and super stays off on that path. Operators
exposing `fastsync-server --stdio` over SSH must use a forced command (e.g. an
`authorized_keys` `command=` entry) if the default must hold.
```bash ```bash
ssh user@host 'mkdir -p destination' ssh user@host 'mkdir -p destination'
./build/client /path/to/source user@host:destination ./build/client /path/to/source user@host:destination
@@ -498,7 +505,8 @@ link-target transfer remains incomplete. |
| `--ca <path>` | CA file for peer verification. | | `--ca <path>` | CA file for peer verification. |
| `--destination-root <path>` | Confine received files to this server-side root; | `--destination-root <path>` | Confine received files to this server-side root;
defaults to the current directory. | defaults to the current directory. |
| `--allow-delete` | Permit client delete manifests. Deletion is refused by default. | | `--allow-delete` | Permit client delete manifests. Deletion is refused by default. This also gates `--force` (which can recursively replace/remove a destination directory tree). |
| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. Rejected with `--stdio` (the SSH remote argv is client-composed; use a forced command if the default must hold). No effect when not root. Daemon modules opt in per module with `client owner = yes`. |
| `-v`, `--verbose` | Enable debug logging. | | `-v`, `--verbose` | Enable debug logging. |
| `--help` | Print server usage. | | `--help` | Print server usage. |
+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) | | `-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 | ✅ 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`/`--super`); the `--fake-super` owner replay and the `--write-devices` write path are 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. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, 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 | | `--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`/`--super`); a **privileged (root) standalone TCP listener now also defaults to `OFF`** unless the operator opts in with the new server-only `--allow-super` flag (the flag is **rejected with `--stdio`**, whose remote argv is composed by the client and must never defeat the secure default; operators exposing `fastsync-server --stdio` over SSH need a forced command if the default must hold. An unprivileged receiver is unchanged, since the kernel refuses the confined attempts anyway; the `--daemon` path keeps its per-module `client owner = yes` opt-in); the `--fake-super` owner replay and the `--write-devices` write path are 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. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, 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 unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — 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 | | `--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 unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — 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 | | `--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) |
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues | | `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
| `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes | | `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes |
| `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) | | `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** | | `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it; a privileged (root) standalone TCP listener refuses it by default too and only honors it after the operator passes `--allow-super` (the flag is rejected with `--stdio`, where the client-composed remote argv could otherwise defeat the default; a forced command is required if the default must hold), and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (a root standalone listener honors these for its single operator-authorized root only when started with `--allow-super`). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`, **Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new. `-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
@@ -639,7 +639,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
- **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time. - **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time.
- **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start. - **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start.
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (a root standalone TCP listener honors them for its single operator-authorized root only when started with `--allow-super`; the flag is rejected with `--stdio`, whose client-composed remote argv must never opt back into super mode). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. - **`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` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. 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". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `<store_path>.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay. - **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. 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". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `<store_path>.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay.
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). 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 legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `<store_path>.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there. - **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). 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 legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `<store_path>.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there.
@@ -662,7 +662,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) | | `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) |
| `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way | | `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way |
| `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation | | `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation |
| `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump | | `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. `--force` is deletion authority and is therefore gated by the server `--allow-delete` policy exactly like `--delete`/`--delete-missing-args`: without it the receiver clears the flag, so a client cannot use `--force` to recursively replace or remove a destination directory tree. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump |
## 16. Batch Operations ## 16. Batch Operations
@@ -832,9 +832,9 @@ These are the last compatibility items and the closing phase toward rsync flag p
**Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root. **Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root.
`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes). `--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). A privileged (root) standalone TCP listener instead defaults to `OFF` and requires the server-only `--allow-super` opt-in to attempt any super-user activity (the flag is rejected with `--stdio`, whose client-composed remote argv must never defeat the default; use a forced command if the default must hold); a non-root server is unchanged. On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes).
`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor these requests). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner. `--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (a root standalone TCP listener, which serves one operator-authorized root, honors these requests only when started with `--allow-super`; the flag is rejected with `--stdio`). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner.
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity. **Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
+59 -9
View File
@@ -19,6 +19,7 @@
#include "usage.h" #include "usage.h"
#include "utils.h" #include "utils.h"
#include <errno.h> #include <errno.h>
#include <fcntl.h>
#include <limits.h> #include <limits.h>
#include <time.h> #include <time.h>
#include <signal.h> #include <signal.h>
@@ -27,6 +28,7 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h>
/* Async-signal-safe abort flag set by the SIGINT/SIGTERM handler. Exposed via /* Async-signal-safe abort flag set by the SIGINT/SIGTERM handler. Exposed via
* client_send.h so the send loops can poll it. Defined here (not in * client_send.h so the send loops can poll it. Defined here (not in
@@ -429,8 +431,14 @@ static int parse_info_flags(const char* value, Config* config) {
return 0; return 0;
} }
/* Parse a string as an unsigned long long. Returns 0 on success, -1 on error. */ /* Parse a string as an unsigned long long. Returns 0 on success, -1 on error.
* A leading '-'/'+' (or whitespace) is rejected outright: strtoull would
* otherwise silently wrap a negative value to a huge unsigned one. */
static int parse_ull_arg(const char* val, unsigned long long* out, const char* optname) { static int parse_ull_arg(const char* val, unsigned long long* out, const char* optname) {
if (!val || val[0] < '0' || val[0] > '9') {
log_message(LOG_LEVEL_ERROR, "%s must be a non-negative integer", optname);
return -1;
}
char* end; char* end;
errno = 0; errno = 0;
unsigned long long v = strtoull(val, &end, 10); unsigned long long v = strtoull(val, &end, 10);
@@ -537,7 +545,10 @@ static int config_add_filter(Config* config, const char* rule) {
char err[160]; char err[160];
FilterRule* parsed = filter_rule_parse(rule, err, sizeof(err)); FilterRule* parsed = filter_rule_parse(rule, err, sizeof(err));
if (!parsed) { if (!parsed) {
log_message(LOG_LEVEL_ERROR, "invalid --filter rule '%s': %s", rule, err); char* escaped = output_escape(rule, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "invalid --filter rule '%s': %s",
escaped ? escaped : "<allocation failed>", err);
free(escaped);
return -1; return -1;
} }
filter_rule_free(parsed); filter_rule_free(parsed);
@@ -1303,10 +1314,15 @@ static bool cli_handle_ssh_and_pattern_options(CliParseCtx* ctx) {
ctx->exit_code = -1; ctx->exit_code = -1;
return true; return true;
} }
if (val >= DELTA_MIN_FILE_SIZE) if (val >= DELTA_MIN_FILE_SIZE && val <= DELTA_MAX_FILE_SIZE) {
config->delta_max_file_size = val; config->delta_max_file_size = val;
else } else if (val < DELTA_MIN_FILE_SIZE) {
log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val); log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val);
} else {
log_message(LOG_LEVEL_ERROR, "--delta-max must not exceed %llu bytes",
(unsigned long long)DELTA_MAX_FILE_SIZE);
ctx->exit_code = -1;
}
return true; return true;
} }
return false; return false;
@@ -1463,6 +1479,12 @@ static bool cli_handle_io_options(CliParseCtx* ctx) {
ctx->exit_code = -1; ctx->exit_code = -1;
return true; return true;
} }
if (val > MAX_CHUNK_SIZE) {
log_message(LOG_LEVEL_ERROR, "--chunk-size must be between 1 and %llu",
(unsigned long long)MAX_CHUNK_SIZE);
ctx->exit_code = -1;
return true;
}
config->chunk_size = val; config->chunk_size = val;
return true; return true;
} }
@@ -1479,11 +1501,20 @@ static bool cli_handle_io_options(CliParseCtx* ctx) {
fclose(config->log_file); fclose(config->log_file);
config->log_file = NULL; config->log_file = NULL;
} }
FILE* lf = fopen(ctx->argv[++ctx->i], "a"); const char* log_path = ctx->argv[++ctx->i];
/* Refuse a symlinked target and never leak the descriptor across exec: an
* attacker who can plant a symlink in the working directory must not be
* able to redirect (or truncate) an arbitrary file via --log-file. The log
* is created with owner-only permissions. */
int log_fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0600);
FILE* lf = log_fd >= 0 ? fdopen(log_fd, "a") : NULL;
if (!lf) { if (!lf) {
char* escaped = output_escape(ctx->argv[ctx->i], false); int open_errno = errno;
if (log_fd >= 0)
close(log_fd);
char* escaped = output_escape(log_path, false);
log_message(LOG_LEVEL_ERROR, "could not open log file '%s': %s", log_message(LOG_LEVEL_ERROR, "could not open log file '%s': %s",
escaped ? escaped : "<allocation failed>", strerror(errno)); escaped ? escaped : "<allocation failed>", strerror(open_errno));
free(escaped); free(escaped);
ctx->exit_code = -1; ctx->exit_code = -1;
return true; return true;
@@ -2011,8 +2042,27 @@ static int read_patterns_from_file(const char* filepath, char*** patterns, int*
} }
char* line = NULL; char* line = NULL;
size_t line_size = 0; size_t line_size = 0;
ssize_t n; while (true) {
while ((n = getline(&line, &line_size, fp)) != -1) { ssize_t n = utils_getdelim_bounded(fp, &line, &line_size, '\n', UTILS_MAX_LINE_LEN);
if (n < 0) {
/* output_escape() may allocate (and clobber errno): capture the reader's
* errno first so an over-long line is still reported as EFBIG. */
int saved_errno = errno;
char* escaped = output_escape(filepath, false);
if (saved_errno == EFBIG) {
log_message(LOG_LEVEL_ERROR, "pattern file '%s' has a line exceeding %d bytes",
escaped ? escaped : "<allocation failed>", (int)UTILS_MAX_LINE_LEN);
} else {
log_message(LOG_LEVEL_ERROR, "could not read pattern file '%s': %s",
escaped ? escaped : "<allocation failed>", strerror(saved_errno));
}
free(escaped);
free(line);
fclose(fp);
return -1;
}
if (n == 0)
break;
char* p = line; char* p = line;
while (*p == ' ' || *p == '\t') while (*p == ' ' || *p == '\t')
p++; p++;
+25 -8
View File
@@ -310,8 +310,11 @@ static bool files_from_list_check(const Config* config, ArrayList* missing_dest,
return false; return false;
} }
if (set->count == 0) { if (set->count == 0) {
char* escaped_list =
output_escape(config->files_from ? config->files_from : "", log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "--files-from file '%s' contains no entries; nothing to transfer", log_message(LOG_LEVEL_ERROR, "--files-from file '%s' contains no entries; nothing to transfer",
config->files_from ? config->files_from : ""); escaped_list ? escaped_list : "<allocation failed>");
free(escaped_list);
return false; return false;
} }
bool ignore = config->ignore_missing_args || config->delete_missing_args; bool ignore = config->ignore_missing_args || config->delete_missing_args;
@@ -329,7 +332,10 @@ static bool files_from_list_check(const Config* config, ArrayList* missing_dest,
free(full); free(full);
if (ignore) { if (ignore) {
(*skipped_out)++; (*skipped_out)++;
log_info_message(LOG_INFO_MISC, "skipping missing --files-from entry '%s'", entry); char* escaped_entry = output_escape(entry, log_get_8_bit_output());
log_info_message(LOG_INFO_MISC, "skipping missing --files-from entry '%s'",
escaped_entry ? escaped_entry : "<allocation failed>");
free(escaped_entry);
if (config->delete_missing_args && missing_dest) { if (config->delete_missing_args && missing_dest) {
char* mirror = files_from_missing_dest_path(config, entry); char* mirror = files_from_missing_dest_path(config, entry);
if (!mirror || !array_list_add(missing_dest, mirror)) { if (!mirror || !array_list_add(missing_dest, mirror)) {
@@ -340,8 +346,13 @@ static bool files_from_list_check(const Config* config, ArrayList* missing_dest,
} }
continue; continue;
} }
log_message(LOG_LEVEL_ERROR, "--files-from entry '%s' not found in source '%s'", entry, char* escaped_entry = output_escape(entry, log_get_8_bit_output());
config->send_directory); char* escaped_src = output_escape(config->send_directory, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "--files-from entry '%s' not found in source '%s'",
escaped_entry ? escaped_entry : "<allocation failed>",
escaped_src ? escaped_src : "<allocation failed>");
free(escaped_entry);
free(escaped_src);
return false; return false;
} }
free(full); free(full);
@@ -588,8 +599,12 @@ static void remove_transferred_sources(const Config* config, ArrayList* paths) {
close(dirfd); close(dirfd);
continue; continue;
} }
if (unlinkat(dirfd, leaf, 0) != 0) if (unlinkat(dirfd, leaf, 0) != 0) {
log_message(LOG_LEVEL_WARNING, "Could not remove source file %s", source->path); char* escaped_path = output_escape(source->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Could not remove source file %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
}
close(dirfd); close(dirfd);
} }
} }
@@ -2120,7 +2135,7 @@ int write_batch_from_source(const Config* config, const char* batch_path) {
prepared_scanner_destroy(&prepared); prepared_scanner_destroy(&prepared);
return 1; return 1;
} }
int fd = open(batch_path, O_WRONLY | O_CREAT | O_TRUNC, 0644); int fd = open(batch_path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
if (fd < 0) { if (fd < 0) {
log_perror("could not create batch file"); log_perror("could not create batch file");
directory_scanner_destroy(scanner); directory_scanner_destroy(scanner);
@@ -2135,8 +2150,10 @@ int write_batch_from_source(const Config* config, const char* batch_path) {
if (f == NULL || f->data == NULL) if (f == NULL || f->data == NULL)
continue; continue;
if (f->data->size > 0 && f->data->data == NULL && !file_load_data(f)) { if (f->data->size > 0 && f->data->data == NULL && !file_load_data(f)) {
char* escaped_path = output_escape(f->path ? f->path : "", log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "batch: failed to load data for %s", log_message(LOG_LEVEL_ERROR, "batch: failed to load data for %s",
f->path ? f->path : "<no path>"); escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
ok = false; ok = false;
break; break;
} }
+6 -4
View File
@@ -25,12 +25,14 @@ bool validate_config(const Config* config) {
/* A dry-run of a local batch apply is not meaningful: --read-batch bypasses /* A dry-run of a local batch apply is not meaningful: --read-batch bypasses
the client-side scan/server decision entirely, so dry-run would have no the client-side scan/server decision entirely, so dry-run would have no
wire state to report (and must not be used as a mutation escape hatch). wire state to report (and must not be used as a mutation escape hatch).
--only-write-batch likewise never contacts a receiver. Reject both up --only-write-batch likewise never contacts a receiver. --write-batch DOES
run a live transfer but additionally mutates the filesystem by emitting the
batch file, so a dry-run must not write it either. Reject all three up
front instead of silently ignoring --dry-run. */ front instead of silently ignoring --dry-run. */
if (config->dry_run && (read_batch || only_write_batch)) { if (config->dry_run && (read_batch || only_write_batch || write_batch)) {
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
"--dry-run cannot be combined with --read-batch or --only-write-batch; " "--dry-run cannot be combined with --read-batch, --only-write-batch, or "
"a dry-run of a local batch apply is not meaningful"); "--write-batch; a dry-run must not mutate anything, including batch files");
return false; return false;
} }
if (read_batch) { if (read_batch) {
+16 -5
View File
@@ -179,7 +179,7 @@ static bool entry_passes_selection(const FileListSet* file_list, const FilterRul
static void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) { static void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) {
if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls)) if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls))
return; return;
file->xattrs = xattr_capture_path(file->path); file->xattrs = xattr_capture_path(file->path, scanner->options.preserve_acls);
} }
/* Apply --hard-links (-H) detection to one regular File. On a sibling (a /* Apply --hard-links (-H) detection to one regular File. On a sibling (a
@@ -284,7 +284,10 @@ static int open_directory_filter_context(DirectoryScanner* scanner, const Filter
filter_file_read(scanner->current_path, scanner->current_rel ? scanner->current_rel : "", filter_file_read(scanner->current_path, scanner->current_rel ? scanner->current_rel : "",
&exists, err, sizeof(err)); &exists, err, sizeof(err));
if (!own) { if (!own) {
log_message(LOG_LEVEL_ERROR, "invalid .rsync-filter in %s: %s", scanner->current_path, err); char* escaped_path = output_escape(scanner->current_path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "invalid .rsync-filter in %s: %s",
escaped_path ? escaped_path : "<allocation failed>", err);
free(escaped_path);
scanner->failed = true; scanner->failed = true;
return -1; return -1;
} }
@@ -777,11 +780,19 @@ static File* dirs_file_for_entry(DirectoryScanner* scanner, const char* entry) {
nothing (missing entries never appear there). Without the flags it stays nothing (missing entries never appear there). Without the flags it stays
a hard pre-transfer error. */ a hard pre-transfer error. */
if (scanner->options.ignore_missing_args) { if (scanner->options.ignore_missing_args) {
log_info_message(LOG_INFO_MISC, "skipping missing --files-from entry '%s'", entry); char* escaped_entry = output_escape(entry, log_get_8_bit_output());
log_info_message(LOG_INFO_MISC, "skipping missing --files-from entry '%s'",
escaped_entry ? escaped_entry : "<allocation failed>");
free(escaped_entry);
free(abs_path); free(abs_path);
return NULL; return NULL;
} }
log_message(LOG_LEVEL_ERROR, "--dirs listed entry is not present under the source: %s", entry); {
char* escaped_entry = output_escape(entry, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "--dirs listed entry is not present under the source: %s",
escaped_entry ? escaped_entry : "<allocation failed>");
free(escaped_entry);
}
free(abs_path); free(abs_path);
scanner->failed = true; scanner->failed = true;
return NULL; return NULL;
@@ -1434,7 +1445,7 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
} }
if ((options->preserve_xattrs || options->preserve_acls) && if ((options->preserve_xattrs || options->preserve_acls) &&
!(file->link_group != 0 && !file->link_first)) !(file->link_group != 0 && !file->link_first))
file->xattrs = xattr_capture_path(file->path); file->xattrs = xattr_capture_path(file->path, options->preserve_acls);
if (!array_list_add(root_files, file)) { if (!array_list_add(root_files, file)) {
free(rel); free(rel);
file_destroy(file); file_destroy(file);
+60 -6
View File
@@ -37,6 +37,15 @@ static bool allow_unauthenticated;
* root), so no super-user activity is attempted and any client --copy-as is * 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. */ * refused. Set once in main before the accept loop / stdio handler. */
static bool server_no_super; static bool server_no_super;
/* --allow-super: locally-launched standalone TCP opt-in that preserves the
* historical permissive super mode for a root receiver. When false, a
* privileged standalone receiver forces SUPER_MODE_OFF for every connection
* (C3), so a client cannot make it create device nodes / write raw devices /
* apply client-chosen ownership. It is REJECTED for --stdio (the SSH remote
* argv is composed by the client, so it must never be able to opt a root
* receiver back into super mode); the --stdio path always keeps the secure
* default. */
static bool server_allow_super;
static const char* required_client_cn; static const char* required_client_cn;
/* --iconv CONVERT_SPEC the server was itself started with (borrowed argv /* --iconv CONVERT_SPEC the server was itself started with (borrowed argv
* pointer). Its LOCAL half may override the local charset the client assumed; * pointer). Its LOCAL half may override the local charset the client assumed;
@@ -187,13 +196,25 @@ static bool tls_client_identity_allowed(SSL* ssl) {
X509* certificate = SSL_get1_peer_certificate(ssl); X509* certificate = SSL_get1_peer_certificate(ssl);
if (!certificate) if (!certificate)
return false; return false;
char common_name[256];
int length = X509_NAME_get_text_by_NID(X509_get_subject_name(certificate), NID_commonName,
common_name, sizeof(common_name));
size_t required_length = strlen(required_client_cn); size_t required_length = strlen(required_client_cn);
bool allowed = length >= 0 && (size_t)length == required_length && bool allowed = false;
required_length < sizeof(common_name) && X509_NAME* subject = X509_get_subject_name(certificate);
credentials_secure_equal(common_name, required_client_cn, required_length); int index = subject ? X509_NAME_get_index_by_NID(subject, NID_commonName, -1) : -1;
if (index >= 0) {
X509_NAME_ENTRY* entry = X509_NAME_get_entry(subject, index);
ASN1_STRING* data = entry ? X509_NAME_ENTRY_get_data(entry) : NULL;
/* Convert the CN to UTF-8 to get its FULL byte length: unlike
* X509_NAME_get_text_by_NID (which truncates an over-long CN to the buffer
* and reports the truncated length), ASN1_STRING_to_UTF8 never truncates, so
* an exactly-required-length CN is accepted while an over-long one cannot be
* prefix-matched by a shorter required name. */
unsigned char* utf8 = NULL;
int cn_length = data ? ASN1_STRING_to_UTF8(&utf8, data) : -1;
if (cn_length >= 0 && (size_t)cn_length == required_length)
allowed = credentials_secure_equal((const char*)utf8, required_client_cn, required_length);
if (utf8)
OPENSSL_free(utf8);
}
X509_free(certificate); X509_free(certificate);
return allowed; return allowed;
} }
@@ -592,6 +613,22 @@ static const char* server_module_gate(const Config* config, void* context) {
if (gate_ctx) if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF; gate_ctx->super_mode_override = SUPER_MODE_OFF;
} }
/* C3: a privileged (root) STANDALONE receiver defaults to SUPER_MODE_OFF.
* Without this a client --devices/--write-devices/--super would let a root
* server create arbitrary device nodes and write raw devices, and
* client-chosen ownership (--numeric-ids/--chown/--usermap/--groupmap) would
* be applied, with no operator opt-in. The operator must pass --allow-super
* to restore the historical permissive behavior; the flag is rejected for
* --stdio, whose client-composed argv must never defeat this default (an
* operator exposing `fastsync-server --stdio` over SSH needs a forced command
* to keep the permissive behavior). An unprivileged receiver is unaffected
* (the kernel refuses the confined attempts) and the daemon path keeps its
* per-module `client owner = yes` gate. */
if (g_daemon_conf == NULL && geteuid() == 0 && !server_allow_super) {
effective.super_mode = SUPER_MODE_OFF;
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
/* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the /* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the
ownership of every written entry to the requested ids, which needs a ownership of every written entry to the requested ids, which needs a
privileged (root) receiver. An unprivileged receiver REFUSES the whole privileged (root) receiver. An unprivileged receiver REFUSES the whole
@@ -751,6 +788,13 @@ void handler(int file_descriptor) {
goto done; goto done;
} }
config->use_delete = config->use_delete && allow_delete; config->use_delete = config->use_delete && allow_delete;
/* --force (receiver-side) is deletion authority too: it lets an incoming
* regular file recursively remove a non-empty destination directory tree, and
* lets --delete-missing-args remove a non-empty directory mirror. Without
* the operator's --allow-delete it must be inert, exactly like --delete and
* --delete-missing-args, so a client cannot use --force to bypass the delete
* policy. */
config->force_delete = config->force_delete && allow_delete;
/* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion /* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion
now that the client's full CONVERT_SPEC has been received and validated, now that the client's full CONVERT_SPEC has been received and validated,
before any received file name is decoded. The server's own --iconv (if before any received file name is decoded. The server's own --iconv (if
@@ -1010,6 +1054,13 @@ static void print_server_usage(void) {
printf(" --no-super Operator veto: never attempt super-user activities\n"); printf(" --no-super Operator veto: never attempt super-user activities\n");
printf(" (ownership, device nodes) even as root, and refuse\n"); printf(" (ownership, device nodes) even as root, and refuse\n");
printf(" any client --copy-as/--super request\n"); printf(" any client --copy-as/--super request\n");
printf(" --allow-super Standalone TCP listener only: keep super-user\n");
printf(" activities enabled for a root receiver. Without it a\n");
printf(" root standalone server forces SUPER_MODE_OFF, so client\n");
printf(" --devices/--write-devices/--super and ownership\n");
printf(" requests are refused/skipped. Never honored with\n");
printf(" --stdio (the SSH remote argv is client-composed, so\n");
printf(" super stays off there); no effect when not root\n");
printf(" --iconv=LOCAL[,REMOTE] Declare this server's LOCAL charset for file-name\n"); printf(" --iconv=LOCAL[,REMOTE] Declare this server's LOCAL charset for file-name\n");
printf(" conversion: received names are translated to this\n"); printf(" conversion: received names are translated to this\n");
printf(" charset (the wire charset still comes from the\n"); printf(" charset (the wire charset still comes from the\n");
@@ -1134,6 +1185,9 @@ int main(int argc, char* argv[]) {
trust_sender = opts.trust_sender; trust_sender = opts.trust_sender;
allow_unauthenticated = opts.allow_unauthenticated; allow_unauthenticated = opts.allow_unauthenticated;
server_no_super = opts.no_super; server_no_super = opts.no_super;
/* --stdio rejects --allow-super at parse time; force it off here as well so
* this process-global policy cannot be re-enabled by a future caller. */
server_allow_super = opts.allow_super && !opts.stdio_mode;
server_iconv_spec = opts.iconv_spec; server_iconv_spec = opts.iconv_spec;
signal(SIGINT, cleanup); signal(SIGINT, cleanup);
signal(SIGTERM, cleanup); signal(SIGTERM, cleanup);
+24
View File
@@ -179,6 +179,8 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
opts->trust_sender = true; opts->trust_sender = true;
} else if (arg_is(argv[i], "--no-super")) { } else if (arg_is(argv[i], "--no-super")) {
opts->no_super = true; opts->no_super = true;
} else if (arg_is(argv[i], "--allow-super")) {
opts->allow_super = true;
} else if (arg_is(argv[i], "--allow-unauthenticated")) { } else if (arg_is(argv[i], "--allow-unauthenticated")) {
opts->allow_unauthenticated = true; opts->allow_unauthenticated = true;
} else if (arg_has_value(argv[i], "--iconv", &inline_value)) { } else if (arg_has_value(argv[i], "--iconv", &inline_value)) {
@@ -259,6 +261,28 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio"); set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio");
return -1; return -1;
} }
if (opts->allow_super && opts->no_super) {
set_error(err, err_size, "--allow-super and --no-super are mutually exclusive");
return -1;
}
if (opts->allow_super && opts->daemon_mode) {
set_error(err, err_size,
"--allow-super is for a locally-launched standalone TCP server; daemon modules opt "
"in per module with 'client owner = yes'");
return -1;
}
/* --stdio is the SSH transport: the remote server argv is composed by the
* CLIENT (directly and via --remote-option), so a client could otherwise pass
* --allow-super to a root --stdio receiver and defeat the C3 secure default.
* Never honor it there; the super mode stays forced OFF. An operator who
* must keep the historical permissive behavior over SSH has to launch the
* receiver through a forced command, not via client-composed argv. */
if (opts->allow_super && opts->stdio_mode) {
set_error(err, err_size,
"--allow-super is not accepted with --stdio (the remote argv is client-composed; "
"use a forced command if the default must hold)");
return -1;
}
if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) { if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) {
set_error(err, err_size, "--iterations requires --hash-credentials"); set_error(err, err_size, "--iterations requires --hash-credentials");
return -1; return -1;
+11
View File
@@ -45,6 +45,17 @@ typedef struct ServerCliOptions {
* device-node creation) even when running as root. Applies to --stdio and * device-node creation) even when running as root. Applies to --stdio and
* --daemon alike; also makes the server refuse any client --copy-as. */ * --daemon alike; also makes the server refuse any client --copy-as. */
bool no_super; /* --no-super */ bool no_super; /* --no-super */
/* --allow-super: locally-launched standalone TCP listener opt-in that keeps
* the historical permissive behavior for a PRIVILEGED (root) receiver.
* Without it a root standalone server forces SUPER_MODE_OFF, so a client
* --devices / --write-devices / --super / ownership request cannot make it
* create device nodes, write raw devices, or apply client-chosen ownership.
* It is rejected for --stdio: that path's remote argv is composed by the
* client (directly and via --remote-option), so it must never opt a root
* receiver back into super mode. Non-root receivers are unaffected (the
* kernel refuses the confined attempts). The daemon path instead uses the
* per-module `client owner = yes` opt-in. */
bool allow_super; /* --allow-super */
/* --iconv=CONVERT_SPEC: the server's own LOCAL charset declaration. The /* --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 * 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 * started with its own --iconv, its LOCAL half overrides the local charset
+46
View File
@@ -1,6 +1,7 @@
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include <limits.h> #include <limits.h>
#include <stdatomic.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -20,6 +21,33 @@
#define MAX_FILE_DATA_SIZE (64ULL * 1024 * 1024) #define MAX_FILE_DATA_SIZE (64ULL * 1024 * 1024)
#define MAX_FILES_PER_CHUNK 65536U #define MAX_FILES_PER_CHUNK 65536U
/* Reserve `charge` against `session`'s connection budget. This mirrors the
static protocol_reserve_memory() in protocol.c: the receive-side call sites
only have the Data.owner pointer (a ProtocolSession*), and protocol.c is out
of scope for this fix, so the same atomic CAS accounting is reproduced here.
The matching release always goes through data_destroy()'s Data.owner path. */
static bool chunk_session_reserve(ProtocolSession* session, size_t charge) {
unsigned long long allocated = atomic_load(&session->total_allocated_bytes);
while (true) {
if (allocated > MAX_CONNECTION_MEMORY ||
(unsigned long long)charge > MAX_CONNECTION_MEMORY - allocated)
return false;
if (atomic_compare_exchange_weak(&session->total_allocated_bytes, &allocated,
allocated + (unsigned long long)charge))
return true;
}
}
bool data_charge_session(Data* data, ProtocolSession* session, size_t charge) {
if (!data || charge == 0 || session == NULL)
return true;
if (!chunk_session_reserve(session, charge))
return false;
data->owner = session;
data->protocol_charge = charge;
return true;
}
Chunk* chunk_create(File** items, int element_count) { Chunk* chunk_create(File** items, int element_count) {
if (element_count < 0 || (element_count > 0 && items == NULL)) if (element_count < 0 || (element_count > 0 && items == NULL))
return NULL; return NULL;
@@ -370,6 +398,15 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) {
Data* replacement = data_create(file_data, file_data_size); Data* replacement = data_create(file_data, file_data_size);
if (replacement == NULL) if (replacement == NULL)
goto error; goto error;
/* Charge the retained per-file copy to the connection budget (when the
inbound chunk carries an owning session) so the queued copies are not
held outside MAX_CONNECTION_MEMORY (B6). A NULL owner (e.g. a local
batch apply) leaves the copy uncharged. */
if (!data_charge_session(replacement, data->owner, allocation_size)) {
log_message(LOG_LEVEL_ERROR, "Per-connection memory limit exceeded for chunk file data");
data_destroy(replacement);
goto error;
}
data_destroy(file->data); data_destroy(file->data);
file->data = replacement; file->data = replacement;
data_pointer += file_data_size; data_pointer += file_data_size;
@@ -466,12 +503,21 @@ Chunk* receive_chunk_data(int fd, const Config* config) {
} }
Data* data_to_process = chunk_data; Data* data_to_process = chunk_data;
if (config->use_compression) { if (config->use_compression) {
/* Preserve the inbound session across decompression so the (larger)
decompressed chunk is charged to the same connection budget; the
compressed buffer's own charge is released by data_destroy below. */
ProtocolSession* owner = chunk_data->owner;
data_to_process = data_decompress_limited(chunk_data, MAX_CHUNK_SIZE); data_to_process = data_decompress_limited(chunk_data, MAX_CHUNK_SIZE);
data_destroy(chunk_data); data_destroy(chunk_data);
if (data_to_process == NULL) { if (data_to_process == NULL) {
log_message(LOG_LEVEL_ERROR, "Failed to decompress chunk"); log_message(LOG_LEVEL_ERROR, "Failed to decompress chunk");
return NULL; return NULL;
} }
if (!data_charge_session(data_to_process, owner, data_to_process->size)) {
log_message(LOG_LEVEL_ERROR, "Per-connection memory limit exceeded for decompressed chunk");
data_destroy(data_to_process);
return NULL;
}
} }
// Reject chunks larger than the maximum allowed size to prevent OOM. // Reject chunks larger than the maximum allowed size to prevent OOM.
+11
View File
@@ -23,4 +23,15 @@ Data* chunk_compress_with_threads(Chunk* chunk, int compression_level, bool use_
int compression_threads); int compression_threads);
Chunk* receive_chunk_data(int fd, const Config* config); Chunk* receive_chunk_data(int fd, const Config* config);
/* Charge `charge` retained bytes of `data` against `session`'s per-connection
* budget (MAX_CONNECTION_MEMORY), mirroring the protocol layer's accounting, and
* record them on `data` so data_destroy() returns the charge through the
* Data.owner path. Returns false (leaving `data` uncharged) when the ceiling
* would be exceeded. A NULL/zero-size charge or a NULL session is a no-op
* success. The receive-side decompression and chunk-copy paths know the owning
* session only through the Data.owner of the buffer they are processing, so
* this is the entry point that lets them participate in the connection budget
* without a session handle (B6). */
bool data_charge_session(Data* data, ProtocolSession* session, size_t charge);
#endif #endif
+21 -3
View File
@@ -243,9 +243,13 @@ Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
log_debug_message(LOG_DEBUG_UTIL, "Start to decompress data"); log_debug_message(LOG_DEBUG_UTIL, "Start to decompress data");
unsigned long long dst_size = unsigned long long dst_size =
ZSTD_getFrameContentSize(compressed_data->data, compressed_data->size); ZSTD_getFrameContentSize(compressed_data->data, compressed_data->size);
if (ZSTD_isError(dst_size)) { /* ZSTD_isError() is also true for ZSTD_CONTENTSIZE_ERROR and
log_message(LOG_LEVEL_ERROR, "Failed to get decompressed size: %s", * ZSTD_CONTENTSIZE_UNKNOWN (both are encoded near (size_t)-1), so test the
ZSTD_getErrorName(dst_size)); * sentinels explicitly instead of blanket-rejecting every error-ish value:
* only CONTENTSIZE_ERROR means an unreadable header, while CONTENTSIZE_UNKNOWN
* must reach the estimate fallback below. */
if (dst_size == ZSTD_CONTENTSIZE_ERROR) {
log_message(LOG_LEVEL_ERROR, "Failed to get decompressed size: invalid zstd frame");
return NULL; return NULL;
} }
@@ -324,6 +328,20 @@ Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
uncompressed_data->data = new_data; uncompressed_data->data = new_data;
output.dst = new_data; output.dst = new_data;
output.size = buf_size; output.size = buf_size;
/* Re-attempt with the larger output buffer; the truncated-frame check
* below must not reject a complete frame that merely filled the previous
* buffer exactly. */
continue;
}
/* A positive hint with all input consumed means the frame is incomplete: a
* truncated stream would otherwise spin here forever (ZSTD_decompressStream
* keeps returning the same hint). Fail instead of burning CPU. */
if (ret != 0 && input.pos == input.size) {
log_message(LOG_LEVEL_ERROR,
"Truncated zstd frame: input exhausted with %zu bytes still expected", ret);
data_destroy(uncompressed_data);
uncompressed_data = NULL;
goto cleanup;
} }
} while (ret > 0); } while (ret > 0);
+23 -6
View File
@@ -613,19 +613,36 @@ int config_parse_transport_dest(Config* config) {
int daemon_ret = config_parse_daemon_dest(config); int daemon_ret = config_parse_daemon_dest(config);
if (daemon_ret != 0) if (daemon_ret != 0)
return daemon_ret; return daemon_ret;
config_parse_ssh_dest(config); /* 0 for a local destination (nothing parsed) or a valid SSH destination;
return 0; * -1 (already logged) for an injection-shaped user@host. */
return config_parse_ssh_dest(config);
} }
void config_parse_ssh_dest(Config* config) { int config_parse_ssh_dest(Config* config) {
if (!config || !config->receive_root_directory)
return 0;
if (!config_is_remote_dest(config->receive_root_directory)) if (!config_is_remote_dest(config->receive_root_directory))
return; return 0;
const char* dest = config->receive_root_directory;
const char* colon = strchr(dest, ':');
/* The user@host token is passed to ssh in option position, so a user or host
* beginning with '-' would be consumed by ssh as an option (argument
* injection: e.g. "-oProxyCommand=..."). An empty host is likewise not a
* valid destination. Validate before any wire/argv construction. */
const char* at = memchr(dest, '@', (size_t)(colon - dest));
const char* host = at ? at + 1 : dest;
size_t host_len = (size_t)(colon - host);
size_t user_len = at ? (size_t)(at - dest) : 0;
if (host_len == 0 || host[0] == '-' || (user_len > 0 && dest[0] == '-'))
return daemon_dest_parse_error("invalid remote destination user@host (must not be empty or "
"start with '-')",
dest);
config->transport = TRANSPORT_SSH; config->transport = TRANSPORT_SSH;
config->ssh_destination = str_dup(config->receive_root_directory); config->ssh_destination = str_dup(dest);
const char* colon = strchr(config->receive_root_directory, ':');
char* path = str_dup(colon + 1); char* path = str_dup(colon + 1);
free(config->receive_root_directory); free(config->receive_root_directory);
config->receive_root_directory = path; config->receive_root_directory = path;
return 0;
} }
void config_burn_auth(Config* config) { void config_burn_auth(Config* config) {
+4 -1
View File
@@ -851,7 +851,10 @@ bool config_send(int file_descriptor, const Config* config);
bool config_send_wire_block(int file_descriptor, const Config* config); bool config_send_wire_block(int file_descriptor, const Config* config);
Config* config_receive(int file_descriptor); Config* config_receive(int file_descriptor);
bool config_is_remote_dest(const char* s); bool config_is_remote_dest(const char* s);
void config_parse_ssh_dest(Config* config); /* Parse a single-colon host:path SSH destination (0 = not an SSH destination or
* parsed successfully, -1 = rejected, e.g. a user@host beginning with '-'; the
* reason is logged). */
int config_parse_ssh_dest(Config* config);
/* A ConfigValidateFunc may return this sentinel to tell /* A ConfigValidateFunc may return this sentinel to tell
* config_receive_with_validate that the callback ALREADY sent a terminal status * config_receive_with_validate that the callback ALREADY sent a terminal status
+2 -2
View File
@@ -158,13 +158,13 @@ static int hex_value(char c) {
* digits. Such a line is refused loudly (and never accepted) so an operator * digits. Such a line is refused loudly (and never accepted) so an operator
* cannot keep a replayable bearer digest in place after the protocol bump. */ * cannot keep a replayable bearer digest in place after the protocol bump. */
static bool secret_is_legacy_hex(const char* s) { static bool secret_is_legacy_hex(const char* s) {
if (!s) if (!s || strlen(s) != 64)
return false; return false;
for (int i = 0; i < 64; i++) { for (int i = 0; i < 64; i++) {
if (hex_value(s[i]) < 0) if (hex_value(s[i]) < 0)
return false; return false;
} }
return s[64] == '\0'; return true;
} }
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) { bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) {
+24 -2
View File
@@ -133,6 +133,7 @@ static bool store_host_list(char*** list, int* count, const char* value, const c
return false; return false;
} }
char* save = NULL; char* save = NULL;
int added = 0;
for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) { for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) {
if (!host_pattern_valid(token)) { if (!host_pattern_valid(token)) {
if (module_name) if (module_name)
@@ -163,8 +164,20 @@ static bool store_host_list(char*** list, int* count, const char* value, const c
return false; return false;
} }
(*list)[(*count)++] = dup; (*list)[(*count)++] = dup;
added++;
} }
free(copy); free(copy);
/* A present key with an empty (or separator-only) value would otherwise
* install a zero-length list, i.e. no ACL at all: a strict-parse config must
* never silently turn a restrictive directive into "allow everyone". */
if (added == 0) {
if (module_name)
set_error(err, err_size, "module '%s': '%s' must list at least one host pattern", module_name,
key);
else
set_error(err, err_size, "'%s' must list at least one host pattern", key);
return false;
}
return true; return true;
} }
@@ -403,6 +416,7 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
return false; return false;
} }
char* save = NULL; char* save = NULL;
int added = 0;
for (char* token = strtok_r(list, ",", &save); token; token = strtok_r(NULL, ",", &save)) { for (char* token = strtok_r(list, ",", &save); token; token = strtok_r(NULL, ",", &save)) {
const char* user = trim_ws(token); const char* user = trim_ws(token);
if (*user == '\0') if (*user == '\0')
@@ -430,8 +444,16 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
return false; return false;
} }
module->auth_users[module->auth_user_count++] = dup; module->auth_users[module->auth_user_count++] = dup;
added++;
} }
free(list); free(list);
/* An empty/separator-only value must not silently disable authentication:
* the key's presence is an explicit request for an allow-list. */
if (added == 0) {
set_error(err, err_size, "module '%s': 'auth users' must list at least one user",
module->name);
return false;
}
return true; return true;
} }
if (key_equals(key, "max connections")) if (key_equals(key, "max connections"))
@@ -439,10 +461,10 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
"max connections", module->name, err, err_size); "max connections", module->name, err, err_size);
if (key_equals(key, "hosts allow")) if (key_equals(key, "hosts allow"))
return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow", return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow",
false, module->name, err, err_size); module->name, false, err, err_size);
if (key_equals(key, "hosts deny")) if (key_equals(key, "hosts deny"))
return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny", return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny",
false, module->name, err, err_size); module->name, false, err, err_size);
set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name); set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name);
return false; return false;
} }
+37 -7
View File
@@ -890,13 +890,35 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
if (inplace) { if (inplace) {
/* --inplace writes directly into the destination; a scratch --temp-dir /* --inplace writes directly into the destination; a scratch --temp-dir
does not apply and must never redirect these writes. */ does not apply and must never redirect these writes. */
fd = openat(dirfd, leaf, O_WRONLY | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0644); /* Type gate BEFORE opening: an existing destination entry that is not a
regular file (FIFO, socket, char/block device, directory) must never be
opened for writing. Opening a FIFO would block the receive thread
forever and writing into a device would bypass the --write-devices /
super-mode gate (a client-controlled device write). fstatat with
AT_SYMLINK_NOFOLLOW does not follow a symlink and does not block. */
struct stat pre_stat;
if (fstatat(dirfd, leaf, &pre_stat, AT_SYMLINK_NOFOLLOW) == 0 && !S_ISREG(pre_stat.st_mode)) {
close(dirfd);
free(leaf);
return false;
}
/* O_NONBLOCK: a no-op for a regular file, but a raced-in FIFO cannot block
the open before the post-open S_ISREG re-check rejects it. */
fd = openat(dirfd, leaf, O_WRONLY | O_CREAT | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, 0644);
if (fd >= 0) { if (fd >= 0) {
struct stat destination_stat; struct stat destination_stat;
/* Re-check the opened descriptor: a concurrent replacement between the
fstatat probe and the open (or a device/FIFO raced in) must never be
written through. */
if (fstat(fd, &destination_stat) != 0 || !S_ISREG(destination_stat.st_mode)) {
close(fd);
close(dirfd);
free(leaf);
return false;
}
bool newer = false; bool newer = false;
if (update && metadata && fstat(fd, &destination_stat) == 0 && if (update && metadata && stat_is_newer(&destination_stat, metadata)) {
S_ISREG(destination_stat.st_mode)) { newer = true;
newer = stat_is_newer(&destination_stat, metadata);
} }
if (newer) { if (newer) {
ok = true; ok = true;
@@ -1226,10 +1248,18 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
if (linked) { if (linked) {
int target_dirfd = scratch_dirfd >= 0 ? scratch_dirfd : dirfd; int target_dirfd = scratch_dirfd >= 0 ? scratch_dirfd : dirfd;
if (use_fsync) { if (use_fsync) {
int tfd = openat(target_dirfd, tmp, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); /* O_NONBLOCK: the freshly linked temp is normally the basis's regular
if (tfd < 0 || fsync(tfd) != 0) { file, but a raced-in FIFO at the name must not block this reopen
forever. With O_NONBLOCK such an open fails with ENXIO instead of
blocking, which is treated as a benign fsync-skip (the link itself
is still installed); any other open/fsync failure falls back to the
byte-copy path as before. */
int tfd = openat(target_dirfd, tmp, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK);
if (tfd < 0) {
if (errno != ENXIO)
linked = false;
} else if (fsync(tfd) != 0) {
linked = false; linked = false;
if (tfd >= 0)
close(tfd); close(tfd);
} else { } else {
close(tfd); close(tfd);
+22 -4
View File
@@ -23,6 +23,8 @@ static void string_list_destroy(StringList* list) {
static bool string_list_add(StringList* list, const char* text) { static bool string_list_add(StringList* list, const char* text) {
if (list->count == list->capacity) { if (list->count == list->capacity) {
if (list->capacity > INT_MAX / 2)
return false;
int new_cap = list->capacity > 0 ? list->capacity * 2 : 16; int new_cap = list->capacity > 0 ? list->capacity * 2 : 16;
char** grown = realloc(list->items, (size_t)new_cap * sizeof(char*)); char** grown = realloc(list->items, (size_t)new_cap * sizeof(char*));
if (!grown) if (!grown)
@@ -56,8 +58,14 @@ static int normalize_entry(const char* raw, size_t len, bool strip_line_endings,
snprintf(err, err_size, "absolute path entries are not allowed: '%.*s'", print_len, raw); snprintf(err, err_size, "absolute path entries are not allowed: '%.*s'", print_len, raw);
return -1; return -1;
} }
/* Reject NUL bytes inside a token defensively (NUL-delimited mode splits on /* Reject NUL bytes inside a token defensively. In NUL-delimited mode the
* them, so this only guards against embedded garbage). */ * delimiter itself is the final byte and is expected; in line mode any NUL is
* embedded garbage (strlen-based parsing would otherwise silently truncate). */
size_t scan_len = strip_line_endings ? len : len - 1;
if (memchr(raw, '\0', scan_len)) {
snprintf(err, err_size, "entry contains an embedded NUL byte");
return -1;
}
char* dup = malloc(len + 1); char* dup = malloc(len + 1);
if (!dup) { if (!dup) {
snprintf(err, err_size, "memory allocation failed"); snprintf(err, err_size, "memory allocation failed");
@@ -158,10 +166,20 @@ FileListSet* file_list_load(const char* path, bool null_separated, char* err, si
StringList raw = {0}; StringList raw = {0};
char* line = NULL; char* line = NULL;
size_t line_cap = 0; size_t line_cap = 0;
ssize_t n;
bool ok = true; bool ok = true;
char delim = null_separated ? '\0' : '\n'; char delim = null_separated ? '\0' : '\n';
while (ok && (n = getdelim(&line, &line_cap, delim, fp)) != -1) { while (ok) {
ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, delim, UTILS_MAX_LINE_LEN);
if (n < 0) {
if (errno == EFBIG)
snprintf(err, err_size, "entry in file list exceeds %d bytes", (int)UTILS_MAX_LINE_LEN);
else
snprintf(err, err_size, "error reading file list: %s", strerror(errno));
ok = false;
break;
}
if (n == 0)
break;
int r = normalize_entry(line, (size_t)n, !null_separated, &raw, err, err_size); int r = normalize_entry(line, (size_t)n, !null_separated, &raw, err, err_size);
if (r < 0) { if (r < 0) {
ok = false; ok = false;
+110 -23
View File
@@ -12,6 +12,7 @@
#include "array_list.h" #include "array_list.h"
#include "charset.h" #include "charset.h"
#include "chmod.h" #include "chmod.h"
#include "chunk.h"
#include "compression.h" #include "compression.h"
#include "config.h" #include "config.h"
#include "data.h" #include "data.h"
@@ -27,6 +28,11 @@
#define MAX_SERVER_DELETE_COUNT 100000U #define MAX_SERVER_DELETE_COUNT 100000U
#define MAX_FILE_DATA_SIZE MAX_RECEIVE_WHOLE_FILE_SIZE #define MAX_FILE_DATA_SIZE MAX_RECEIVE_WHOLE_FILE_SIZE
/* Retained cost of one delete-manifest entry beyond its path bytes: the
ArrayList pointer slot plus an approximate malloc header/rounding for the
heap copy. Charged against MAX_MANIFEST_BYTES so a frame full of tiny paths
cannot retain far more than the byte budget (B5). */
#define MANIFEST_ENTRY_OVERHEAD (sizeof(char*) + 16)
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) { bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) {
return file_save_to_disk_full(root_directory, file, config) != FILE_SAVE_ERROR; return file_save_to_disk_full(root_directory, file, config) != FILE_SAVE_ERROR;
@@ -127,7 +133,10 @@ static bool hardlink_read_source(const char* path, void** out_buf, unsigned long
*source_absent = errno == ENOENT || errno == ENOTDIR; *source_absent = errno == ENOENT || errno == ENOTDIR;
return false; return false;
} }
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); /* O_NONBLOCK is a no-op for a regular file but makes openat() fail/succeed
immediately for a client-planted FIFO instead of blocking the receive
thread forever; the post-open S_ISREG gate below is the actual type check. */
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
int saved_errno = errno; int saved_errno = errno;
free(leaf); free(leaf);
close(parent_fd); close(parent_fd);
@@ -254,7 +263,8 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
free(destination_path); free(destination_path);
return absent_result; return absent_result;
} }
FileXattrList* sibling_xattrs = cfg->use_xattrs ? xattr_capture_path(staged_first) : NULL; FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(staged_first, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs( bool ok = file_to_disk_secure_link_attrs(
staged_sibling, staged_first, content, content_size, preallocate, file->metadata, staged_sibling, staged_first, content, content_size, preallocate, file->metadata,
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, NULL); preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
@@ -286,7 +296,8 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
return absent_result; return absent_result;
} }
const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL; const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL;
FileXattrList* sibling_xattrs = cfg->use_xattrs ? xattr_capture_path(first_disk) : NULL; FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs( bool ok = file_to_disk_secure_link_attrs(
destination_path, first_disk, content, content_size, preallocate, file->metadata, destination_path, first_disk, content, content_size, preallocate, file->metadata,
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir); preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir);
@@ -944,7 +955,7 @@ static bool receive_file_xattrs(File* file, int fd, const Config* config) {
if (!config->use_xattrs) if (!config->use_xattrs)
return true; return true;
int xok = 0; int xok = 0;
FileXattrList* list = xattr_receive(fd, &xok); FileXattrList* list = xattr_receive(fd, &xok, config->preserve_acls);
if (!xok) { if (!xok) {
xattr_list_free(list); xattr_list_free(list);
return false; return false;
@@ -1009,6 +1020,7 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
!compression_should_skip_with_suffixes( !compression_should_skip_with_suffixes(
check_path, config->skip_compress_suffixes, check_path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count : -1)) { config->skip_compress_set ? config->skip_compress_count : -1)) {
ProtocolSession* owner = delta_data->owner;
raw_delta = data_decompress_limited(delta_data, MAX_RECEIVE_WHOLE_FILE_SIZE); raw_delta = data_decompress_limited(delta_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
data_destroy(delta_data); data_destroy(delta_data);
if (!raw_delta) { if (!raw_delta) {
@@ -1017,6 +1029,15 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
*failed = true; *failed = true;
return NULL; return NULL;
} }
/* Charge the decompressed delta to the connection budget (the paired
wire buffer's charge was just released). */
if (!data_charge_session(raw_delta, owner, raw_delta->size)) {
data_destroy(raw_delta);
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
} }
Delta* delta = delta_deserialize(raw_delta); Delta* delta = delta_deserialize(raw_delta);
@@ -1131,12 +1152,20 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
file->path, config->skip_compress_suffixes, file->path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count : -1)) { config->skip_compress_set ? config->skip_compress_count : -1)) {
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data); data_destroy(file_data);
if (uncompressed == NULL) { if (uncompressed == NULL) {
file_destroy(file); file_destroy(file);
*failed = true; *failed = true;
return NULL; return NULL;
} }
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
file_destroy(file);
send_status(fd, STATUS_ERROR);
*failed = true;
return NULL;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) { if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed); data_destroy(uncompressed);
file_destroy(file); file_destroy(file);
@@ -1193,7 +1222,9 @@ static bool basis_open_regular(const char* path, unsigned long long expected_siz
int parent_fd = file_open_secure_parent(path, &leaf, false); int parent_fd = file_open_secure_parent(path, &leaf, false);
if (parent_fd < 0) if (parent_fd < 0)
return false; return false;
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); /* O_NONBLOCK: a client-planted FIFO must not block the receiver's openat()
forever; the fstat()/S_ISREG gate below rejects it immediately. */
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
free(leaf); free(leaf);
close(parent_fd); close(parent_fd);
if (fd < 0) if (fd < 0)
@@ -1247,14 +1278,27 @@ static bool basis_quick_matches(const Config* config, const struct stat* st, tim
/* Search the basis-dir list in command-line order and return the first exact /* Search the basis-dir list in command-line order and return the first exact
match. When load_content is true the matched bytes are kept in out->content match. When load_content is true the matched bytes are kept in out->content
so the caller can materialize the file without re-reading it. */ so the caller can materialize the file without re-reading it.
An exact match ALSO requires the basis bytes' digest to equal the source's,
so `hash_content` gates the content read/hash itself. A server-contacting
--dry-run passes hash_content=false: no basis file may be read or hashed
(that would be a 1-bit content oracle against a client-supplied digest), so a
metadata-only pass can never confirm a hit and declines it. The real path
always passes hash_content=true, keeping its behavior byte-for-byte. */
static bool basis_match_find(const Config* config, const char* check_path, static bool basis_match_find(const Config* config, const char* check_path,
unsigned long long check_size, time_t check_mtime, unsigned long long check_size, time_t check_mtime,
long check_mtime_nsec, const uint8_t* check_digest, long check_mtime_nsec, const uint8_t* check_digest,
size_t check_digest_len, bool load_content, BasisMatch* out) { size_t check_digest_len, bool load_content, bool hash_content,
BasisMatch* out) {
memset(out, 0, sizeof(*out)); memset(out, 0, sizeof(*out));
if (!config || !config_has_basis(config) || config->ignore_times) if (!config || !config_has_basis(config) || config->ignore_times)
return false; return false;
/* Dry-run: never read/hash basis content. A hit cannot be decided from
metadata alone, so report no match (the caller treats it as would-transfer)
without touching the file's contents. */
if (!hash_content)
return false;
for (int i = 0; i < config->basis_count; i++) { for (int i = 0; i < config->basis_count; i++) {
const BasisDest* entry = &config->basis_dirs[i]; const BasisDest* entry = &config->basis_dirs[i];
char* basis_dir = path_cat(config->receive_root_directory, entry->path); char* basis_dir = path_cat(config->receive_root_directory, entry->path);
@@ -1637,11 +1681,17 @@ static File* receive_full_file(int fd, const Config* config, const char* path) {
config->skip_compress_set ? config->skip_compress_count config->skip_compress_set ? config->skip_compress_count
: -1)) { : -1)) {
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data); data_destroy(file_data);
if (uncompressed == NULL) { if (uncompressed == NULL) {
file_destroy(file); file_destroy(file);
return NULL; return NULL;
} }
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
file_destroy(file);
return NULL;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) { if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed); data_destroy(uncompressed);
file_destroy(file); file_destroy(file);
@@ -1776,7 +1826,9 @@ static IncrementalCheckOutcome incremental_check_open_destination(IncrementalChe
char* leaf = NULL; char* leaf = NULL;
int parent_fd = file_open_secure_parent(full_path, &leaf, false); int parent_fd = file_open_secure_parent(full_path, &leaf, false);
if (parent_fd >= 0) { if (parent_fd >= 0) {
state->old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); /* O_NONBLOCK: an existing FIFO at the destination must not block this
openat(); the S_ISREG gate below rejects the non-regular entry. */
state->old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
free(leaf); free(leaf);
close(parent_fd); close(parent_fd);
state->has_old_file = state->old_fd >= 0 && fstat(state->old_fd, &state->old_st) == 0 && state->has_old_file = state->old_fd >= 0 && fstat(state->old_fd, &state->old_st) == 0 &&
@@ -1815,7 +1867,15 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
bool try_delta = config->use_delta && !config->whole_file && has_old_file && bool try_delta = config->use_delta && !config->whole_file && has_old_file &&
delta_should_attempt(old_size, state->check_size, config->delta_max_file_size); delta_should_attempt(old_size, state->check_size, config->delta_max_file_size);
bool checksum_needs_read = size_equal && !config->ignore_times && config->checksum; bool checksum_needs_read = size_equal && !config->ignore_times && config->checksum;
bool need_old_data = checksum_needs_read || try_delta; /* --dry-run must never read the destination file's CONTENTS: a client could
otherwise use `--dry-run --checksum` against a read-only module as a
1-bit content oracle (hash match / mismatch) and force arbitrary reads.
Decide from metadata alone; when metadata is inconclusive (checksum or
delta would have required the body) report would-transfer. The real
(non-dry-run) behavior below is unchanged. */
bool need_old_data = !config->dry_run && (checksum_needs_read || try_delta);
if (config->dry_run)
try_delta = false;
*out_try_delta = try_delta; *out_try_delta = try_delta;
if (need_old_data && has_old_file && old_size > 0 && old_size <= MAX_RECEIVE_WHOLE_FILE_SIZE && if (need_old_data && has_old_file && old_size > 0 && old_size <= MAX_RECEIVE_WHOLE_FILE_SIZE &&
@@ -1836,7 +1896,12 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
} }
bool match = false; bool match = false;
if (checksum_needs_read) { if (config->dry_run) {
/* Metadata-only decision: a size match plus a matching mtime is treated as
up to date; --checksum/--delta cannot be verified without reading, so an
otherwise inconclusive comparison is a would-transfer. */
match = size_equal && !config->ignore_times && (config->size_only || match_by_metadata);
} else if (checksum_needs_read) {
uint8_t old_digest[CHECKSUM_MAX_DIGEST_LEN]; uint8_t old_digest[CHECKSUM_MAX_DIGEST_LEN];
size_t old_len = 0; size_t old_len = 0;
bool hashed = checksum_digest((ChecksumAlgo)config->checksum_algo, config->checksum_seed, bool hashed = checksum_digest((ChecksumAlgo)config->checksum_algo, config->checksum_seed,
@@ -1861,10 +1926,13 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
When dry_run is set and the file is not already up to date the receiver must When dry_run is set and the file is not already up to date the receiver must
materialize nothing (no basis link/copy, no append/delta/full transfer) and materialize nothing (no basis link/copy, no append/delta/full transfer) and
the sender must send no data, so answer STATUS_DRY_RUN_TRANSFER and stop. the sender must send no data, so answer STATUS_DRY_RUN_TRANSFER and stop.
The one exception is a --compare-dest exact hit with no destination copy: a
real run would suppress the data without changing the destination, so it The basis lookup is deliberately content-blind: a real run would only accept
reports as a skip (STATUS_OK) exactly as the full basis path below would. a --compare-dest exact hit after hashing the basis file and comparing it with
Everything read here (destination file, basis candidates) is read-only. */ the client-supplied digest, which in a dry-run is a 1-bit content oracle.
Under dry_run no basis bytes may be read, so an otherwise-matching entry is
treated as would-transfer instead of a skip. Everything read here (the
destination file's metadata, basis candidates' metadata) is read-only. */
static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalCheckState* state, static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalCheckState* state,
bool* skipped, bool* skipped,
bool* would_transfer) { bool* would_transfer) {
@@ -1875,9 +1943,12 @@ static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalChe
bool skip_via_compare = false; bool skip_via_compare = false;
if (config_has_basis(config) && !config->ignore_times) { if (config_has_basis(config) && !config->ignore_times) {
BasisMatch basis; BasisMatch basis;
/* hash_content=false: a dry-run must not read or hash the basis file. No
content comparison is possible, so no compare-dest hit can be confirmed
and an otherwise-matching file is reported as would-transfer. */
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime, basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len, (long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
false, &basis); false, false, &basis);
if (basis.hit && basis.type == BASIS_DEST_COMPARE && !state->has_old_file) if (basis.hit && basis.type == BASIS_DEST_COMPARE && !state->has_old_file)
skip_via_compare = true; skip_via_compare = true;
basis_match_free(&basis); basis_match_free(&basis);
@@ -1905,7 +1976,7 @@ static IncrementalCheckOutcome incremental_check_try_basis(IncrementalCheckState
BasisMatch basis; BasisMatch basis;
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime, basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len, (long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
true, &basis); true, true, &basis);
if (basis.hit) { if (basis.hit) {
if (basis.type == BASIS_DEST_COMPARE) { if (basis.type == BASIS_DEST_COMPARE) {
basis_match_free(&basis); basis_match_free(&basis);
@@ -2050,7 +2121,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
} }
if (config->use_xattrs) { if (config->use_xattrs) {
int xok = 0; int xok = 0;
append_xattrs = xattr_receive(fd, &xok); append_xattrs = xattr_receive(fd, &xok, config->preserve_acls);
if (!xok) { if (!xok) {
xattr_list_free(append_xattrs); xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
@@ -2066,11 +2137,17 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
config->skip_compress_set ? config->skip_compress_count config->skip_compress_set ? config->skip_compress_count
: -1)) { : -1)) {
Data* uncompressed = data_decompress_limited(tail, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* uncompressed = data_decompress_limited(tail, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = tail->owner;
data_destroy(tail); data_destroy(tail);
if (uncompressed == NULL) { if (uncompressed == NULL) {
xattr_list_free(append_xattrs); xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
} }
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) { if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed); data_destroy(uncompressed);
xattr_list_free(append_xattrs); xattr_list_free(append_xattrs);
@@ -2301,11 +2378,17 @@ File* file_receive(const Config* config, int file_descriptor) {
config->skip_compress_set ? config->skip_compress_count config->skip_compress_set ? config->skip_compress_count
: -1)) { : -1)) {
Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data); data_destroy(file_data);
if (file_data_uncompressed == NULL) { if (file_data_uncompressed == NULL) {
file_destroy(file); file_destroy(file);
return NULL; return NULL;
} }
if (!data_charge_session(file_data_uncompressed, owner, file_data_uncompressed->size)) {
data_destroy(file_data_uncompressed);
file_destroy(file);
return NULL;
}
if (file_data_uncompressed->size > MAX_FILE_DATA_SIZE) { if (file_data_uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(file_data_uncompressed); data_destroy(file_data_uncompressed);
file_destroy(file); file_destroy(file);
@@ -2694,19 +2777,21 @@ File* file_receive_special(int file_descriptor) {
manifest). Returns an owned DeleteManifest, or NULL after sending STATUS_ERROR manifest). Returns an owned DeleteManifest, or NULL after sending STATUS_ERROR
when the frame is malformed (bad count, empty/absolute path, path traversal, when the frame is malformed (bad count, empty/absolute path, path traversal,
or an aggregate size beyond MAX_MANIFEST_BYTES). */ or an aggregate size beyond MAX_MANIFEST_BYTES). */
static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_bytes) { static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_bytes,
size_t* manifest_entries) {
int count; int count;
if (!receive_int(fd, &count)) { if (!receive_int(fd, &count)) {
send_status(fd, STATUS_ERROR); send_status(fd, STATUS_ERROR);
return false; return false;
} }
if (count < 0 || count > MAX_MANIFEST_ENTRIES) { if (count < 0 || count > MAX_MANIFEST_ENTRIES ||
(size_t)count > MAX_MANIFEST_ENTRIES - *manifest_entries) {
send_status(fd, STATUS_ERROR); send_status(fd, STATUS_ERROR);
return false; return false;
} }
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
char* s = receive_wire_str(fd); char* s = receive_wire_str(fd);
size_t entry_size = s ? strlen(s) : 0; size_t entry_size = s ? strlen(s) + MANIFEST_ENTRY_OVERHEAD : 0;
if (!s || s[0] == '\0' || s[0] == '/' || has_path_traversal(s) || if (!s || s[0] == '\0' || s[0] == '/' || has_path_traversal(s) ||
entry_size > MAX_MANIFEST_BYTES - *manifest_bytes || entry_size > MAX_MANIFEST_BYTES - *manifest_bytes ||
(*manifest_bytes += entry_size) > MAX_MANIFEST_BYTES || !array_list_add(list, s)) { (*manifest_bytes += entry_size) > MAX_MANIFEST_BYTES || !array_list_add(list, s)) {
@@ -2715,6 +2800,7 @@ static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_b
return false; return false;
} }
} }
*manifest_entries += (size_t)count;
return true; return true;
} }
@@ -2733,9 +2819,10 @@ DeleteManifest* receive_manifest_entries(int fd) {
return NULL; return NULL;
} }
size_t manifest_bytes = 0; size_t manifest_bytes = 0;
if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes) || size_t manifest_entries = 0;
!receive_manifest_section(fd, manifest->protected, &manifest_bytes) || if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes, &manifest_entries) ||
!receive_manifest_section(fd, manifest->missing, &manifest_bytes)) { !receive_manifest_section(fd, manifest->protected, &manifest_bytes, &manifest_entries) ||
!receive_manifest_section(fd, manifest->missing, &manifest_bytes, &manifest_entries)) {
delete_manifest_free(manifest); delete_manifest_free(manifest);
return NULL; return NULL;
} }
+20 -4
View File
@@ -2,6 +2,7 @@
#include "log.h" #include "log.h"
#include "utils.h" #include "utils.h"
#include <errno.h> #include <errno.h>
#include <limits.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -176,6 +177,8 @@ bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule) {
if (!list || !rule) if (!list || !rule)
return false; return false;
if (list->count == list->capacity) { if (list->count == list->capacity) {
if (list->capacity > INT_MAX / 2)
return false;
int new_cap = list->capacity > 0 ? list->capacity * 2 : 8; int new_cap = list->capacity > 0 ? list->capacity * 2 : 8;
FilterRule** grown = realloc(list->items, (size_t)new_cap * sizeof(FilterRule*)); FilterRule** grown = realloc(list->items, (size_t)new_cap * sizeof(FilterRule*));
if (!grown) if (!grown)
@@ -322,8 +325,10 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
if (!fp) { if (!fp) {
if (errno == ENOENT || errno == ENOTDIR) if (errno == ENOENT || errno == ENOTDIR)
return filter_rule_list_create(); return filter_rule_list_create();
log_message(LOG_LEVEL_WARNING, "Could not read .rsync-filter in %s: %s", dir_path, char* escaped_dir = output_escape(dir_path, log_get_8_bit_output());
strerror(errno)); log_message(LOG_LEVEL_WARNING, "Could not read .rsync-filter in %s: %s",
escaped_dir ? escaped_dir : "<allocation failed>", strerror(errno));
free(escaped_dir);
return filter_rule_list_create(); return filter_rule_list_create();
} }
if (exists) if (exists)
@@ -336,9 +341,20 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
} }
char* line = NULL; char* line = NULL;
size_t line_cap = 0; size_t line_cap = 0;
ssize_t n;
bool ok = true; bool ok = true;
while ((n = getline(&line, &line_cap, fp)) != -1) { while (true) {
ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, '\n', UTILS_MAX_LINE_LEN);
if (n < 0) {
if (errno == EFBIG) {
snprintf(err, err_size, "line in .rsync-filter exceeds %d bytes", (int)UTILS_MAX_LINE_LEN);
} else {
snprintf(err, err_size, "error reading .rsync-filter: %s", strerror(errno));
}
ok = false;
break;
}
if (n == 0)
break;
const char* p = line; const char* p = line;
while (*p == ' ' || *p == '\t') while (*p == ' ' || *p == '\t')
p++; p++;
+14 -3
View File
@@ -302,10 +302,14 @@ bool protocol_send_n_data(ProtocolSession* session, const void* data, size_t dat
if (pfd.revents & (POLLERR | POLLNVAL)) if (pfd.revents & (POLLERR | POLLNVAL))
return false; return false;
ssize_t bytes_send; ssize_t bytes_send;
if (session->ssl) if (session->ssl) {
bytes_send = SSL_write(session->ssl, (const char*)data + total_bytes_send, chunk); /* SSL_write takes an int length; clamp a >INT_MAX request into chunks so
else * the size_t downcast can never truncate into a negative/partial write. */
size_t ssl_chunk = chunk > (size_t)INT_MAX ? (size_t)INT_MAX : chunk;
bytes_send = SSL_write(session->ssl, (const char*)data + total_bytes_send, (int)ssl_chunk);
} else {
bytes_send = write(fd, (const char*)data + total_bytes_send, chunk); bytes_send = write(fd, (const char*)data + total_bytes_send, chunk);
}
if (bytes_send <= 0) { if (bytes_send <= 0) {
if (session->ssl) { if (session->ssl) {
int ssl_err = SSL_get_error(session->ssl, (int)bytes_send); int ssl_err = SSL_get_error(session->ssl, (int)bytes_send);
@@ -387,6 +391,13 @@ static bool protocol_receive_n_data_until(ProtocolSession* session, void* data,
wait_events = ssl_err == SSL_ERROR_WANT_WRITE ? POLLOUT : POLLIN; wait_events = ssl_err == SSL_ERROR_WANT_WRITE ? POLLOUT : POLLIN;
continue; continue;
} }
/* A signal interrupts the blocking TLS read: retry (mirrors the send
path and protocol_read_status_until) so the loop reaches its next
abort/deadline checkpoint instead of failing spuriously. */
if (ssl_err == SSL_ERROR_SYSCALL && errno == EINTR)
continue;
} else if (errno == EINTR) {
continue;
} }
if (bytes_received == 0) if (bytes_received == 0)
log_message(LOG_LEVEL_ERROR, "Connection closed while receiving data"); log_message(LOG_LEVEL_ERROR, "Connection closed while receiving data");
+19 -3
View File
@@ -75,6 +75,15 @@ static int parse_remote_dest(const char* dest, RemoteDest* r) {
memcpy(r->host, dest, host_len); memcpy(r->host, dest, host_len);
r->host[host_len] = '\0'; r->host[host_len] = '\0';
} }
/* The user@host token is handed to ssh in option position. Reject anything
* that ssh would consume as an option (a leading '-') or an empty host, so a
* crafted destination can never inject an ssh option such as
* -oProxyCommand=... . This mirrors config_parse_ssh_dest's validation and
* is defense-in-depth for callers that bypass it. */
if (r->host[0] == '\0' || r->host[0] == '-' || r->user[0] == '-') {
remote_dest_destroy(r);
return -1;
}
return 0; return 0;
} }
@@ -216,10 +225,10 @@ char** ssh_build_client_argv(const char* rsh_command, int port, const char* user
nwords = 1; nwords = 1;
} }
/* Fixed tail: three -o pairs (6) + optional -p/value (2) + user@host + /* Fixed tail: three -o pairs (6) + optional -p/value (2) + the "--" end of
* remote command + terminating NULL. */ * options marker + user@host + remote command + terminating NULL. */
int port_extra = (port > 0 && port != 22) ? 2 : 0; int port_extra = (port > 0 && port != 22) ? 2 : 0;
size_t total = (size_t)nwords + 6 + (size_t)port_extra + 3; size_t total = (size_t)nwords + 6 + (size_t)port_extra + 4;
char** argv = calloc(total, sizeof(char*)); char** argv = calloc(total, sizeof(char*));
if (!argv) { if (!argv) {
for (int i = 0; i < nwords; i++) for (int i = 0; i < nwords; i++)
@@ -253,6 +262,13 @@ char** ssh_build_client_argv(const char* rsh_command, int port, const char* user
goto fail_argv; goto fail_argv;
ac++; ac++;
} }
/* End of options: guarantees the user@host token that follows is treated as
* the destination and never re-interpreted as an ssh option, even if every
* caller-side validation were bypassed. */
argv[ac] = str_dup("--");
if (!argv[ac])
goto fail_argv;
ac++;
argv[ac] = str_dup(userhost); argv[ac] = str_dup(userhost);
if (!argv[ac]) if (!argv[ac])
goto fail_argv; goto fail_argv;
+76 -15
View File
@@ -4,7 +4,9 @@
#include "transport_tcp.h" #include "transport_tcp.h"
#include "utils.h" #include "utils.h"
#include <arpa/inet.h> #include <arpa/inet.h>
#include <fcntl.h>
#include <openssl/err.h> #include <openssl/err.h>
#include <openssl/pem.h>
#include <openssl/ssl.h> #include <openssl/ssl.h>
#include <signal.h> #include <signal.h>
#include <stdio.h> #include <stdio.h>
@@ -34,6 +36,59 @@ static void log_ssl_errors(void) {
} }
} }
/* Load the TLS private key through an already-opened, no-follow descriptor so
* the owner/mode policy is checked on the SAME file object that is loaded: an
* attacker cannot swap the path between a stat() and a later open() (TOCTOU).
* The exact-owner / 0600 policy is preserved and group/other execute bits are
* rejected as well. Ownership of the descriptor passes to the BIO and is
* released exactly once by BIO_free() (BIO_CLOSE). */
static bool load_private_key_secure(SSL_CTX* ctx, const char* key) {
int fd = open(key, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to open private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
return false;
}
struct stat key_stat;
if (fstat(fd, &key_stat) != 0 || !S_ISREG(key_stat.st_mode) || key_stat.st_uid != geteuid() ||
(key_stat.st_mode & (S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH | S_IXGRP | S_IXOTH))) {
log_message(LOG_LEVEL_ERROR,
"TLS private key must be a regular file owned by the current user and private "
"(mode 0600)");
close(fd);
return false;
}
BIO* bio = BIO_new_fd(fd, BIO_CLOSE);
if (!bio) {
close(fd);
log_message(LOG_LEVEL_ERROR, "Failed to read private key");
return false;
}
EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
BIO_free(bio); /* releases fd via BIO_CLOSE */
if (!pkey) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to load private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
return false;
}
int use_ok = SSL_CTX_use_PrivateKey(ctx, pkey);
EVP_PKEY_free(pkey);
if (use_ok != 1) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to use private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
return false;
}
return true;
}
static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key, static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key,
const char* ca_path) { const char* ca_path) {
if (!is_server && !ca_path) { if (!is_server && !ca_path) {
@@ -56,12 +111,21 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
#ifdef SSL_OP_NO_RENEGOTIATION #ifdef SSL_OP_NO_RENEGOTIATION
SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION); SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION);
#endif #endif
/* Let the server's own preference order decide the negotiated cipher rather
* than the client's, so a client cannot steer both peers into a weaker (but
* still offered) suite. */
SSL_CTX_set_options(ctx, SSL_OP_CIPHER_SERVER_PREFERENCE);
if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) { if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) {
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
if (SSL_CTX_set_cipher_list(ctx, "HIGH:!aNULL:!eNULL:!MD5:!RC4:!3DES") != 1) { /* TLS 1.2 and below: an AEAD-only suite list. "HIGH" still includes CBC
* suites (Lucky13/POODLE-adjacent MAC-then-encrypt constructions), so restrict
* the list to ECDHE key agreement with an AEAD record cipher (AES-GCM or
* ChaCha20-Poly1305). A NULL/weak/3DES cipher is never selectable. */
if (SSL_CTX_set_cipher_list(ctx, "ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!eNULL:!MD5:!RC4:!3DES") !=
1) {
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
@@ -79,13 +143,6 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
#endif #endif
if (cert && key) { if (cert && key) {
struct stat key_stat;
if (stat(key, &key_stat) != 0 || !S_ISREG(key_stat.st_mode) || key_stat.st_uid != geteuid() ||
(key_stat.st_mode & (S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH))) {
log_message(LOG_LEVEL_ERROR, "TLS private key must be owned by the current user and private");
SSL_CTX_free(ctx);
return NULL;
}
if (SSL_CTX_use_certificate_file(ctx, cert, SSL_FILETYPE_PEM) <= 0) { if (SSL_CTX_use_certificate_file(ctx, cert, SSL_FILETYPE_PEM) <= 0) {
char* escaped = output_escape(cert, false); char* escaped = output_escape(cert, false);
log_message(LOG_LEVEL_ERROR, "Failed to load certificate: %s", log_message(LOG_LEVEL_ERROR, "Failed to load certificate: %s",
@@ -95,12 +152,7 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
if (SSL_CTX_use_PrivateKey_file(ctx, key, SSL_FILETYPE_PEM) <= 0) { if (!load_private_key_secure(ctx, key)) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to load private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
@@ -144,7 +196,16 @@ static SSL* wrap_fd_with_ssl(int fd, SSL_CTX* ctx, bool is_server, const char* h
// Enable hostname verification for client connections when a hostname is provided. // Enable hostname verification for client connections when a hostname is provided.
// Must be done before SSL_connect to take effect during the handshake. // Must be done before SSL_connect to take effect during the handshake.
if (!is_server && hostname) { if (!is_server && hostname) {
if (SSL_set1_host(ssl, hostname) != 1) { /* An IP-literal host must be verified against the certificate's IP SAN
* (X509_check_ip_asc), not as a DNS name: SSL_set1_host would look for a
* DNS SAN that a legitimate IP-SAN certificate never carries. */
struct in_addr ipv4;
struct in6_addr ipv6;
bool is_ip_literal =
inet_pton(AF_INET, hostname, &ipv4) == 1 || inet_pton(AF_INET6, hostname, &ipv6) == 1;
int set_ok = is_ip_literal ? X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl), hostname)
: SSL_set1_host(ssl, hostname);
if (set_ok != 1) {
SSL_free(ssl); SSL_free(ssl);
return NULL; return NULL;
} }
+137 -41
View File
@@ -378,59 +378,155 @@ char* output_escape(const char* string, bool eight_bit_output) {
return escaped; return escaped;
} }
ssize_t utils_getdelim_bounded(FILE* stream, char** line, size_t* cap, int delim, size_t max_len) {
if (!stream || !line || !cap || max_len == 0) {
errno = EINVAL;
return -1;
}
size_t limit = max_len + 1; /* content bytes plus the terminating NUL */
if (*line == NULL || *cap < 2) {
size_t initial = limit < 256 ? limit : 256;
char* buf = malloc(initial);
if (!buf)
return -1;
free(*line);
*line = buf;
*cap = initial;
}
size_t len = 0;
int c;
while ((c = getc_unlocked(stream)) != EOF) {
if (len >= max_len) {
errno = EFBIG;
return -1;
}
if (len + 2 > *cap) {
size_t new_cap = *cap * 2;
if (new_cap < len + 2)
new_cap = len + 2;
if (new_cap > limit)
new_cap = limit;
char* grown = realloc(*line, new_cap);
if (!grown)
return -1;
*line = grown;
*cap = new_cap;
}
(*line)[len++] = (char)c;
if (c == delim)
break;
}
if (c == EOF && len == 0)
return 0;
(*line)[len] = '\0';
return (ssize_t)len;
}
/* Match a glob pattern against a string. Supported wildcards: /* Match a glob pattern against a string. Supported wildcards:
* ? matches any single character except '/'. * ? matches any single character except '/'.
* * matches any sequence of characters within one path component (no '/'). * * matches any sequence of characters within one path component (no '/').
* ** matches any sequence of characters, including '/' (cross-directory). * ** matches any sequence of characters, including '/' (cross-directory).
* slash-star-star-slash is treated as a cross-directory wildcard when it appears between * slash-star-star-slash is treated as a cross-directory wildcard when it appears between
* literals. * literals.
*/ *
* The matcher is an iterative O(pattern * string) dynamic program rather than the
* original backtracking recursion: overlapping `*`/`**` wildcards made a pattern
* like `*a*a*a*...*b` run in exponential time against a long run of `a`, a CPU
* denial-of-service vector reachable from a hostile --exclude/--include pattern
* or `.rsync-filter`. The DP reasons over (pattern position, string position)
* so every state is visited once; the transitions below mirror the original
* recursion exactly. */
bool glob_match(const char* pattern, const char* str) { bool glob_match(const char* pattern, const char* str) {
while (*pattern) { if (!pattern || !str)
if (*pattern == '*') {
if (*(pattern + 1) == '*') {
/* globstar: match across directories */
pattern += 2;
if (*pattern == '\0')
return true;
if (*pattern == '/')
pattern++;
while (*str) {
if (glob_match(pattern, str))
return true;
str++;
}
return glob_match(pattern, str);
}
/* single *: match within one path component */
pattern++;
while (*str && *str != '/') {
if (glob_match(pattern, str))
return true;
str++;
}
return glob_match(pattern, str);
} else if (*pattern == '?') {
if (!*str || *str == '/')
return false; return false;
pattern++; size_t pattern_len = strlen(pattern);
str++; size_t str_len = strlen(str);
} else { if (pattern_len == 0)
if (*pattern != *str) { return str_len == 0;
/* allow literal / ** / rest to match any number of directories */ /* Defensive work cap: the DP is bounded by pattern*string states, but a
if (*pattern == '/' && *(pattern + 1) == '*' && *(pattern + 2) == '*') { * 64 KiB pattern against a 64 KiB path would still cost billions of steps.
const char* rest = pattern + 3; * Treat the pattern as non-matching above the cap instead of burning CPU. */
if (*rest == '/') if (str_len > (SIZE_MAX / (pattern_len + 1)) - 1)
return false;
if ((pattern_len + 1) * (str_len + 1) > 64u * 1024u * 1024u)
return false;
size_t row_bytes = str_len + 1;
/* Rows for pattern positions i, i+1, i+2 and i+3 are live at once (the
* globstar transition can skip up to three pattern bytes). Four rotating
* rows keep memory at O(string length); a stack buffer avoids an allocation
* for the common short-leaf case. */
enum { STACK_ROW = 257 };
uint8_t stack_rows[4 * STACK_ROW];
uint8_t* rows = stack_rows;
if (row_bytes > STACK_ROW) {
rows = malloc(4 * row_bytes);
if (!rows)
return false;
}
#define GLOB_ROW(i) (rows + ((pattern_len - (i)) & 3) * row_bytes)
/* Base row: pattern position `pattern_len` matches only the string's end. */
for (size_t j = 0; j <= str_len; j++)
GLOB_ROW(pattern_len)[j] = (j == str_len) ? 1 : 0;
for (size_t i = pattern_len; i-- > 0;) {
const char pc = pattern[i];
uint8_t* cur = GLOB_ROW(i);
const uint8_t* next = GLOB_ROW(i + 1);
if (pc == '*') {
if (i + 1 < pattern_len && pattern[i + 1] == '*') {
/* Globstar: skip `**` and an optional following '/', then consume any
* (possibly empty) run of characters -- including '/'. */
size_t rest = i + 2;
if (rest < pattern_len && pattern[rest] == '/')
rest++; rest++;
return glob_match(rest, str); const uint8_t* rest_row = GLOB_ROW(rest);
for (size_t j = str_len + 1; j-- > 0;) {
bool v = rest_row[j] != 0;
if (!v && j < str_len)
v = cur[j + 1] != 0;
cur[j] = v ? 1 : 0;
} }
return false; } else {
} /* Single `*`: zero characters, or one non-'/' character. */
pattern++; for (size_t j = str_len + 1; j-- > 0;) {
str++; bool v = next[j] != 0;
if (!v && j < str_len && str[j] != '/')
v = cur[j + 1] != 0;
cur[j] = v ? 1 : 0;
} }
} }
return *str == '\0'; } else if (pc == '?') {
for (size_t j = str_len + 1; j-- > 0;) {
bool v = j < str_len && str[j] != '/' && next[j + 1] != 0;
cur[j] = v ? 1 : 0;
}
} else {
/* Literal: consume an equal character, or -- for a '/' immediately before
* a globstar -- let the '/' match zero directories and continue at `**`. */
for (size_t j = str_len + 1; j-- > 0;) {
bool v = false;
if (j < str_len && str[j] == pc) {
v = next[j + 1] != 0;
} else if (pc == '/' && i + 2 < pattern_len && pattern[i + 1] == '*' &&
pattern[i + 2] == '*') {
size_t rest = i + 3;
if (rest < pattern_len && pattern[rest] == '/')
rest++;
v = GLOB_ROW(rest)[j] != 0;
}
cur[j] = v ? 1 : 0;
}
}
}
bool matched = GLOB_ROW(0)[0] != 0;
#undef GLOB_ROW
if (rows != stack_rows)
free(rows);
return matched;
} }
bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_size) { bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_size) {
+19
View File
@@ -4,7 +4,9 @@
#include "array_list.h" #include "array_list.h"
#include <stddef.h> #include <stddef.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/types.h>
/* Small open-addressing string hash set used to turn quadratic membership /* Small open-addressing string hash set used to turn quadratic membership
* scans into O(path length) exact-match lookups (the --delete keep-set and the * scans into O(path length) exact-match lookups (the --delete keep-set and the
@@ -79,6 +81,17 @@ bool path_index_has_descendant(const PathIndex* index, const char* path);
char* str_dup(const char* string); char* str_dup(const char* string);
char* output_escape(const char* string, bool eight_bit_output); char* output_escape(const char* string, bool eight_bit_output);
/* Upper bound on one line/token read from a local list file (--files-from,
* --exclude-from/--include-from, .rsync-filter). Mirrors MAX_STRING_SIZE and
* stops a hostile multi-gigabyte line from forcing unbounded allocation. */
#define UTILS_MAX_LINE_LEN (64 * 1024)
/* Read one `delim`-terminated record from `stream` into *line (grown as needed
* and NUL-terminated), refusing to consume/allocate more than `max_len` bytes
* of content. Returns the number of bytes stored (delimiter included, matching
* getdelim), 0 at end of file, or -1 on error (errno is EFBIG when the record
* exceeds `max_len`, ENOMEM on allocation failure). *line and *cap are updated
* as the buffer grows and the caller owns *line. */
ssize_t utils_getdelim_bounded(FILE* stream, char** line, size_t* cap, int delim, size_t max_len);
char* path_cat(const char* path1, const char* path2); char* path_cat(const char* path1, const char* path2);
bool glob_match(const char* pattern, const char* str); bool glob_match(const char* pattern, const char* str);
/* Result of a bounded extra-file deletion run. */ /* Result of a bounded extra-file deletion run. */
@@ -148,6 +161,12 @@ const char* utils_get_authorized_root_path(void);
* callers guarantee this); this is containment by string, not by resolved * callers guarantee this); this is containment by string, not by resolved
* symlinks. Shared by the utils and file secure-walk root confinement. */ * symlinks. Shared by the utils and file secure-walk root confinement. */
bool path_is_within_root(const char* root, const char* path); bool path_is_within_root(const char* root, const char* path);
/* True when `path` contains a ".." component. This is a purely lexical
* dot-dot check: an absolute path is NOT rejected here, because default
* (non-relative) transfers legitimately put the sender's absolute source path
* on the wire and the receiver re-roots it under the destination with
* path_cat(). Callers that accept a strictly relative path (e.g. batch paths)
* must reject a leading '/' themselves (see utils_valid_batch_path). */
bool has_path_traversal(const char* path); bool has_path_traversal(const char* path);
bool utils_valid_batch_path(const char* path); bool utils_valid_batch_path(const char* path);
bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_size); bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_size);
+43 -13
View File
@@ -73,13 +73,17 @@ bool xattr_list_append(FileXattrList* list, const char* name, const void* value,
/* A Linux xattr name is "namespace.name" with an optional leading "trusted.", /* A Linux xattr name is "namespace.name" with an optional leading "trusted.",
* "system.", "security.", "user.", or "trusted." prefix. We only ever touch * "system.", "security.", "user.", or "trusted." prefix. We only ever touch
* the unprivileged "user.*" namespace and the two POSIX ACL xattrs carried in * the unprivileged "user.*" namespace and, only when --acls/-A was negotiated,
* the "system." namespace. Everything else -- especially "security.*" (ACLs, * the two POSIX ACL xattrs carried in the "system." namespace. Everything else
* capabilities, SELinux labels) and "trusted.*" -- is refused so a client can * -- especially "security.*" (ACLs, capabilities, SELinux labels) and
* never compel the receiver to apply a privileged attribute it would not * "trusted.*" -- is refused so a client can never compel the receiver to apply a
* otherwise be able to set (and which would be a local privilege escalation if * privileged attribute it would not otherwise be able to set (and which would be
* it could). */ * a local privilege escalation if it could).
bool xattr_name_appliable(const char* name) { *
* The ACL gate is deliberate: --xattrs/-X alone derives use_xattrs but must NOT
* authorize the ACL names, otherwise a -X client could plant an ACL the
* receiver never opted into (B4). */
bool xattr_name_appliable(const char* name, bool preserve_acls) {
if (!name || name[0] == '\0') if (!name || name[0] == '\0')
return false; return false;
size_t len = strlen(name); size_t len = strlen(name);
@@ -95,15 +99,24 @@ bool xattr_name_appliable(const char* name) {
if (strncmp(name, "user.", 5) == 0) if (strncmp(name, "user.", 5) == 0)
return name[5] != '\0'; return name[5] != '\0';
if (strcmp(name, "system.posix_acl_access") == 0) if (strcmp(name, "system.posix_acl_access") == 0)
return true; return preserve_acls;
if (strcmp(name, "system.posix_acl_default") == 0) if (strcmp(name, "system.posix_acl_default") == 0)
return true; return preserve_acls;
return false; return false;
} }
/* The two POSIX ACL xattr names: the only names whose applicablity is
* conditional (they require --acls). Used by the receiver to distinguish "not
* negotiated" (drop the entry, keep user.* working for -X) from a genuinely
* disallowed namespace (hard reject). */
static bool xattr_name_is_posix_acl(const char* name) {
return name != NULL && (strcmp(name, "system.posix_acl_access") == 0 ||
strcmp(name, "system.posix_acl_default") == 0);
}
/* ---- SENDER: capture ---- */ /* ---- SENDER: capture ---- */
FileXattrList* xattr_capture_path(const char* path) { FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) {
if (!path) if (!path)
return NULL; return NULL;
ssize_t list_size = listxattr(path, NULL, 0); ssize_t list_size = listxattr(path, NULL, 0);
@@ -130,7 +143,10 @@ FileXattrList* xattr_capture_path(const char* path) {
if (name_len == 0) if (name_len == 0)
break; /* trailing double NUL not expected; stop */ break; /* trailing double NUL not expected; stop */
offset += (ssize_t)name_len + 1; offset += (ssize_t)name_len + 1;
if (!xattr_name_appliable(name)) /* Capture is sender-side: the scanner has already gated on -X/-A, so the
per-name whitelist here allows the ACL names only when --acls was
negotiated. Without it a plain -X capture never carries an ACL. */
if (!xattr_name_appliable(name, preserve_acls))
continue; continue;
ssize_t value_size = getxattr(path, name, NULL, 0); ssize_t value_size = getxattr(path, name, NULL, 0);
if (value_size < 0) if (value_size < 0)
@@ -190,7 +206,7 @@ bool xattr_send(int fd, const FileXattrList* list) {
return true; return true;
} }
FileXattrList* xattr_receive(int fd, int* ok) { FileXattrList* xattr_receive(int fd, int* ok, bool preserve_acls) {
if (ok) if (ok)
*ok = 0; *ok = 0;
int count; int count;
@@ -232,12 +248,20 @@ FileXattrList* xattr_receive(int fd, int* ok) {
xattr_list_free(list); xattr_list_free(list);
return NULL; return NULL;
} }
if (!xattr_name_appliable(name)) { bool skip = false;
if (!xattr_name_appliable(name, preserve_acls)) {
if (!preserve_acls && xattr_name_is_posix_acl(name)) {
/* -X without -A: the sender may still carry ACLs, but the receiver must
never apply an ACL it was not asked to preserve. Consume and drop the
entry (keeping -X compatibility) rather than failing the transfer. */
skip = true;
} else {
log_message(LOG_LEVEL_ERROR, "rejected xattr block: disallowed namespace for '%s'", name); log_message(LOG_LEVEL_ERROR, "rejected xattr block: disallowed namespace for '%s'", name);
free(name); free(name);
xattr_list_free(list); xattr_list_free(list);
return NULL; return NULL;
} }
}
int32_t value_len32; int32_t value_len32;
if (!receive_n_data(fd, &value_len32, sizeof(value_len32))) { if (!receive_n_data(fd, &value_len32, sizeof(value_len32))) {
free(name); free(name);
@@ -273,6 +297,12 @@ FileXattrList* xattr_receive(int fd, int* ok) {
return NULL; return NULL;
} }
} }
if (skip) {
free(value);
free(name);
budget += (size_t)name_len32 + (size_t)value_len32;
continue;
}
if (!xattr_list_append(list, name, value, (size_t)value_len32)) { if (!xattr_list_append(list, name, value, (size_t)value_len32)) {
free(value); free(value);
free(name); free(name);
+17 -9
View File
@@ -59,20 +59,28 @@ void xattr_list_free(FileXattrList* list);
bool xattr_list_append(FileXattrList* list, const char* name, const void* value, size_t value_len); bool xattr_list_append(FileXattrList* list, const char* name, const void* value, size_t value_len);
/* True when `name` is a well-formed xattr name AND belongs to a namespace this /* True when `name` is a well-formed xattr name AND belongs to a namespace this
* build is authorized to apply (user.* or the two POSIX ACL xattrs). Used for * build is authorized to apply. `user.*` is always accepted for -X; the two
* both capture and receiver-side validation. */ * POSIX ACL xattrs are accepted only when `preserve_acls` (--acls/-A) is set, so
bool xattr_name_appliable(const char* name); * a plain -X run can never carry or apply an ACL the receiver did not ask for.
* Used for both capture and receiver-side validation. */
bool xattr_name_appliable(const char* name, bool preserve_acls);
/* Sender: read the whitelisted xattrs of `path` into a new list. Returns NULL /* Sender: read the whitelisted xattrs of `path` into a new list. The POSIX ACL
* when the path has no appliable xattrs (or the filesystem has no xattr * names are captured only when `preserve_acls` (--acls/-A) is set, so a plain
* support); an empty-but-valid list is never returned distinct from NULL. */ * -X run never carries an ACL it was not asked to preserve; `user.*` is
FileXattrList* xattr_capture_path(const char* path); * unaffected. Returns NULL when the path has no appliable xattrs (or the
* filesystem has no xattr support); an empty-but-valid list is never returned
* distinct from NULL. */
FileXattrList* xattr_capture_path(const char* path, bool preserve_acls);
/* Wire: bounded serialization. xattr_send returns false on write failure; an /* Wire: bounded serialization. xattr_send returns false on write failure; an
* empty/NULL list transmits a zero-count block. xattr_receive returns NULL and * empty/NULL list transmits a zero-count block. xattr_receive returns NULL and
* sets *ok = 0 on any malformed / oversized / non-whitelisted entry. */ * sets *ok = 0 on any malformed / oversized / non-whitelisted entry. When
* `preserve_acls` is false, any POSIX ACL entries are consumed and DROPPED (so
* a -X transfer still succeeds and never applies an ACL it did not negotiate);
* a genuinely disallowed namespace is still rejected. */
bool xattr_send(int fd, const FileXattrList* list); bool xattr_send(int fd, const FileXattrList* list);
FileXattrList* xattr_receive(int fd, int* ok); FileXattrList* xattr_receive(int fd, int* ok, bool preserve_acls);
/* Receiver: apply every entry fd-relative (fsetxattr) to the just-written file /* Receiver: apply every entry fd-relative (fsetxattr) to the just-written file
* descriptor. A per-attribute failure (e.g. ACL set refused for non-root on a * descriptor. A per-attribute failure (e.g. ACL set refused for non-root on a
+7 -1
View File
@@ -16,7 +16,13 @@ def shared_server():
Under pytest-xdist this session fixture is instantiated once per worker Under pytest-xdist this session fixture is instantiated once per worker
process, so each worker gets its own server on an ephemeral port.""" process, so each worker gets its own server on an ephemeral port."""
server = ServerManager() server = ServerManager()
server.start() # --allow-super keeps the historical permissive super mode for a root
# receiver: the integration suite's root-only ownership/device/copy-as tests
# exercise that opted-in configuration. The secure default (a root
# standalone server without --allow-super forces SUPER_MODE_OFF) is covered
# explicitly by TestStandaloneSuperDefault in test_features.py. Non-root
# runs are unaffected by the flag.
server.start(extra_args=["--allow-super"])
yield server yield server
server.stop() server.stop()
+11 -7
View File
@@ -75,7 +75,7 @@ static void write_best_effort(int fd, const void* data, size_t size) {
} }
static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data, static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data,
size_t size) { size_t size, bool preserve_acls) {
int sv[2]; int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
return; return;
@@ -91,7 +91,7 @@ static void receive_stream(const unsigned char* prefix, size_t prefix_len, const
shutdown(sv[0], SHUT_WR); shutdown(sv[0], SHUT_WR);
int ok = 0; int ok = 0;
FileXattrList* list = xattr_receive(sv[1], &ok); FileXattrList* list = xattr_receive(sv[1], &ok, preserve_acls);
xattr_list_free(list); xattr_list_free(list);
close(sv[0]); close(sv[0]);
@@ -102,14 +102,18 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (!g_block_ready) if (!g_block_ready)
build_canonical_block(); build_canonical_block();
/* Raw bytes as the whole block. */ /* Raw bytes as the whole block. Exercise both the -X-only (no ACLs) and the
receive_stream(NULL, 0, data, size); * -A (ACL names accepted) receiver gates. */
for (int acls = 0; acls < 2; acls++) {
bool preserve_acls = acls != 0;
receive_stream(NULL, 0, data, size, preserve_acls);
/* Valid framing so the fuzzer mutates the entry list, the first value and /* Valid framing so the fuzzer mutates the entry list, the first value and
* the second entry respectively instead of stopping at the count. */ * the second entry respectively instead of stopping at the count. */
receive_stream(g_block, g_off_after_entry0, data, size); receive_stream(g_block, g_off_after_entry0, data, size, preserve_acls);
receive_stream(g_block, g_off_value0, data, size); receive_stream(g_block, g_off_value0, data, size, preserve_acls);
receive_stream(g_block, g_off_after_count, data, size); receive_stream(g_block, g_off_after_count, data, size, preserve_acls);
}
return 0; return 0;
} }
+12
View File
@@ -118,3 +118,15 @@ def test_batch_modes_conflict():
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
assert result.returncode != 0, \ assert result.returncode != 0, \
f"expected conflict failure for {flags}: {result.stderr}" f"expected conflict failure for {flags}: {result.stderr}"
@pytest.mark.ci
def test_dry_run_rejects_write_batch():
"""--dry-run must not emit a batch file (it must not mutate anything)."""
if os.path.exists(BATCH_FILE):
os.unlink(BATCH_FILE)
cmd = _run(["--source-dir", SOURCE_DIR, "--dest-dir", DEST1,
"--dry-run", "--write-batch", BATCH_FILE])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
assert result.returncode != 0, result.stderr
assert not os.path.exists(BATCH_FILE), "dry-run must not create a batch file"
+140 -8
View File
@@ -444,9 +444,10 @@ class TestRemoteDryRun:
self._seed(source) self._seed(source)
clean_dir(dest) clean_dir(dest)
# Populate the destination with a real transfer, then make exactly one # Populate the destination with a real transfer that preserves mtimes
# file differ (content+size) and add a brand-new file. # (--preserve), then make exactly one file differ (content+size) and add
result, _ = run_client(source, dest, port=shared_server.port) # a brand-new file.
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -456,9 +457,11 @@ class TestRemoteDryRun:
f.write(b"newly added\n") f.write(b"newly added\n")
before = _snapshot_tree(received) before = _snapshot_tree(received)
# --checksum makes the up-to-date decision content-based (the seed # --checksum must NOT read destination contents in a dry-run (B3), so
# transfer did not preserve mtimes), so keep.txt/deep.txt report skip. # the up-to-date decision is metadata-only. The --preserve seed made
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum"], # keep.txt and deep.txt size+mtime-identical; the dry-run must also
# transmit metadata (--preserve) for that metadata to be comparable.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}" assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}"
assert "Dry run:" in result.stdout, result.stdout[:200] assert "Dry run:" in result.stdout, result.stdout[:200]
@@ -470,6 +473,34 @@ class TestRemoteDryRun:
assert "deep.txt" not in result.stdout, result.stdout assert "deep.txt" not in result.stdout, result.stdout
assert _snapshot_tree(received) == before, "remote dry-run mutated the destination" assert _snapshot_tree(received) == before, "remote dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_checksum_does_not_read_destination(self, shared_server):
"""B3: --dry-run --checksum against a read-only module must not read the
destination file's content (a 1-bit hash oracle). A same-size/same-content
file whose mtime differs is therefore reported as would-transfer because
the metadata-only decision is inconclusive, instead of being hashed and
silently skipped."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
target = os.path.join(received, "keep.txt")
# Identical size and content, but a deliberately different mtime.
os.utime(target, (1000000000, 1000000000))
before = _snapshot_tree(received)
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "keep.txt" in result.stdout, (
f"dry-run --checksum must not read the destination to prove equality: {result.stdout}"
)
assert _snapshot_tree(received) == before, "dry-run mutated the destination"
@pytest.mark.ci @pytest.mark.ci
def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server): def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src") source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src")
@@ -1561,8 +1592,33 @@ class TestDelete:
assert not missing, f"Missing: {missing}" assert not missing, f"Missing: {missing}"
assert not mismatches, f"Mismatch: {mismatches}" assert not mismatches, f"Mismatch: {mismatches}"
@pytest.mark.ci
class TestProgress: def test_force_cannot_replace_directory_without_allow_delete(self):
"""C2: --force is deletion authority (an incoming file may recursively
remove a non-empty destination directory tree). A server started without
--allow-delete must clear it, so the operator's delete policy cannot be
bypassed with --force."""
source = os.path.join(TEST_DATA_DIR, "force_src")
dest = os.path.join(TEST_DATA_DIR, "force_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "blocker"), "wb") as f:
f.write(b"incoming file\n")
received = get_dest_received_dir(dest, source)
blocker = os.path.join(received, "blocker")
os.makedirs(blocker)
nested = os.path.join(blocker, "nested.txt")
with open(nested, "w") as f:
f.write("survivor")
# Deliberately NO --allow-delete.
server = ServerManager()
server.start()
try:
run_client(source, dest, flags=["--force"], port=server.port)
finally:
server.stop()
assert os.path.isdir(blocker), "unauthorized --force removed a destination directory"
assert os.path.exists(nested), "unauthorized --force removed a nested file"
def test_progress_output(self, shared_server): def test_progress_output(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, dur = run_client( result, dur = run_client(
@@ -3782,6 +3838,27 @@ class TestBasisDestDirs:
assert _read_file(os.path.join(received, self.ADDED)) == \ assert _read_file(os.path.join(received, self.ADDED)) == \
self._source_tree("c")[self.ADDED], "added file not transferred" self._source_tree("c")[self.ADDED], "added file not transferred"
@pytest.mark.ci
def test_dry_run_compare_dest_does_not_read_basis(self, shared_server):
# A dry-run --compare-dest must never read/hash the basis file: doing so
# is a 1-bit content oracle against the client-supplied digest. Even a
# byte-identical basis with a matching size+mtime is therefore reported
# as would-transfer, and nothing is created.
source = self._make_source("basis_dry_src", {self.UNCHANGED: b"stable content v1\n"})
dest = os.path.join(TEST_DATA_DIR, "basis_dry_dst")
clean_dir(dest)
self._seed_basis(dest, source, "drybasis", {self.UNCHANGED: b"stable content v1\n"})
before = _snapshot_tree(dest)
result, _ = run_client(source, dest,
flags=["--compare-dest=drybasis", "--dry-run"],
port=shared_server.port)
assert result.returncode == 0, \
f"dry-run compare-dest failed: {result.stderr[:300]}"
assert self.UNCHANGED in result.stdout, (
"dry-run compare-dest silently skipped: receiver read the basis content"
)
assert _snapshot_tree(dest) == before, "dry-run compare-dest mutated the destination"
def test_compare_dest_content_mismatch_forces_transfer(self, shared_server): def test_compare_dest_content_mismatch_forces_transfer(self, shared_server):
# The basis holds a file with a DIFFERENT body: even though it shares # The basis holds a file with a DIFFERENT body: even though it shares
# the mtime pin, the xxHash check fails and the data must be sent. # the mtime pin, the xxHash check fails and the data must be sent.
@@ -4578,6 +4655,61 @@ class TestSuperPrivilege:
f"--no-super must suppress fake-super's owner replay: uid={st.st_uid} gid={st.st_gid}" f"--no-super must suppress fake-super's owner replay: uid={st.st_uid} gid={st.st_gid}"
class TestStandaloneSuperDefault:
"""C3: a privileged (root) STANDALONE server without --allow-super forces
SUPER_MODE_OFF, so a client cannot make it create device nodes, write raw
devices, apply ownership, or use --copy-as. The shared_server fixture opts in
with --allow-super to keep the historical behavior available to the existing
root-only tests; these tests start their own un-opted server."""
@pytest.mark.ci
def test_copy_as_refused_without_allow_super(self):
"""--copy-as is a client-chosen-ownership request and must be refused by
a standalone server that did not opt in with --allow-super (on a non-root
receiver it is refused for lack of privilege either way)."""
source = os.path.join(TEST_DATA_DIR, "super_default_src")
dest = os.path.join(TEST_DATA_DIR, "super_default_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as f:
f.write(b"no copy-as\n")
server = ServerManager()
server.start() # deliberately no --allow-super
try:
result, _ = run_client(source, dest,
flags=["--preserve", "--copy-as=@65534:@65534"],
port=server.port)
finally:
server.stop()
assert result.returncode != 0, (
"standalone server accepted --copy-as without --allow-super"
)
@pytest.mark.skipif(os.geteuid() != 0, reason="root can create the source device node")
def test_devices_skipped_without_allow_super(self):
"""Root standalone server without --allow-super must skip device-node
creation even for a client --devices request (the run still succeeds and
the regular file transfers)."""
source = os.path.join(TEST_DATA_DIR, "super_default_dev_src")
dest = os.path.join(TEST_DATA_DIR, "super_default_dev_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "plain.txt"), "wb") as f:
f.write(b"regular\n")
os.mknod(os.path.join(source, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3))
server = ServerManager()
server.start() # deliberately no --allow-super
try:
result, _ = run_client(source, dest, flags=["--devices"], port=server.port)
finally:
server.stop()
assert result.returncode == 0, f"exit {result.returncode}: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source)
assert not os.path.lexists(os.path.join(received, "null")), (
"root standalone server created a device node without --allow-super"
)
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
+48
View File
@@ -1,8 +1,10 @@
#include "chunk.h" #include "chunk.h"
#include "protocol.h"
#include "test_utils.h" #include "test_utils.h"
#include "utils.h" #include "utils.h"
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
@@ -279,6 +281,51 @@ static void test_chunk_special_rdev_out_of_range_rejected() {
chunk_destroy(chunk); chunk_destroy(chunk);
} }
/* B6: chunk_deserialize() charges each retained per-file copy to the owning
* session's connection budget (MAX_CONNECTION_MEMORY) so queued chunk payloads
* are not held outside the per-connection ceiling; destroying the chunk returns
* the charge through the Data.owner path. */
static void test_chunk_deserialize_charges_session_budget() {
const char* path = "temp_chunk_charge.txt";
const char* content = "charge me to the connection budget";
unlink(path);
file_write_to_disk(path, content, strlen(content), false, false);
struct stat st;
EXPECT_EQ_INT(stat(path, &st), 0);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
ProtocolSession session;
protocol_session_init(&session, p[0], p[1]);
protocol_session_set_max_alloc(&session, 4ULL * 1024 * 1024);
File* f = file_create(path);
EXPECT_NOT_NULL(f);
f->data->size = (unsigned long long)st.st_size;
EXPECT_TRUE(file_load_data(f));
File* files[1] = {f};
Chunk* chunk = chunk_create(files, 1);
EXPECT_NOT_NULL(chunk);
Data* serialized = chunk_serialize(chunk, false);
EXPECT_NOT_NULL(serialized);
/* Simulate a received buffer carrying its owning session. */
serialized->owner = &session;
Chunk* deserialized = chunk_deserialize(serialized, false);
EXPECT_NOT_NULL(deserialized);
unsigned long long charged = atomic_load(&session.total_allocated_bytes);
EXPECT_EQ_INT((int)charged, (int)strlen(content));
chunk_destroy(deserialized);
/* The copy's charge is released with the File/Data on destroy. */
EXPECT_EQ_INT((int)atomic_load(&session.total_allocated_bytes), 0);
data_destroy(serialized);
chunk_destroy(chunk);
close(p[0]);
close(p[1]);
unlink(path);
}
void test_chunk() { void test_chunk() {
test_file_operations(); test_file_operations();
test_chunk_operations(); test_chunk_operations();
@@ -286,4 +333,5 @@ void test_chunk() {
test_chunk_symlink_roundtrip(); test_chunk_symlink_roundtrip();
test_chunk_special_rdev_roundtrip(); test_chunk_special_rdev_roundtrip();
test_chunk_special_rdev_out_of_range_rejected(); test_chunk_special_rdev_out_of_range_rejected();
test_chunk_deserialize_charges_session_budget();
} }
+55
View File
@@ -3275,6 +3275,58 @@ static void test_parse_args_block_size() {
config_delete(cfg); config_delete(cfg);
} }
/* An over-long --exclude-from/--include-from line is rejected at parse time
* rather than being read without a bound. */
static void test_parse_args_pattern_file_oversized_rejected() {
const char* list_path = "cli_pattern_oversized.txt";
size_t len = UTILS_MAX_LINE_LEN + 4096;
char* big = malloc(len);
EXPECT_NOT_NULL(big);
memset(big, 'a', len);
write_file_bytes(list_path, big, len);
free(big);
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* argv[] = {"fastsync", "--exclude-from", (char*)list_path, "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
remove(list_path);
}
/* A leading '-'/'+' must be rejected for every unsigned numeric option so
* strtoull can never silently wrap (e.g. -1 -> ULLONG_MAX). */
static void test_parse_args_unsigned_options_reject_sign() {
static const char* const opts[] = {"--chunk-size", "--bwlimit", "--delta-max"};
for (size_t i = 0; i < sizeof(opts) / sizeof(opts[0]); i++) {
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* argv[] = {"fastsync", (char*)opts[i], "-1", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* An over-cap --chunk-size is rejected at parse time (max 64 MiB). */
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* big_argv[] = {"fastsync", "--chunk-size", "67108865", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, big_argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* --dry-run must not emit a batch file, so it is rejected alongside
* --read-batch/--only-write-batch. */
static void test_validate_config_dry_run_rejects_write_batch() {
Config* cfg = valid_client_config();
cfg->dry_run = true;
cfg->write_batch = str_dup("batch.dat");
EXPECT_FALSE(validate_config(cfg));
config_delete(cfg);
}
void test_client_cli() { void test_client_cli() {
test_validate_config_required_paths(); test_validate_config_required_paths();
test_parse_args_numeric_ids(); test_parse_args_numeric_ids();
@@ -3432,4 +3484,7 @@ void test_client_cli() {
test_parse_args_remote_option_short_M(); test_parse_args_remote_option_short_M();
test_parse_args_no_motd(); test_parse_args_no_motd();
test_parse_args_password_file(); test_parse_args_password_file();
test_parse_args_pattern_file_oversized_rejected();
test_parse_args_unsigned_options_reject_sign();
test_validate_config_dry_run_rejects_write_batch();
} }
+98
View File
@@ -6,8 +6,10 @@
#include "utils.h" #include "utils.h"
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/wait.h>
#include <threads.h> #include <threads.h>
#include <unistd.h> #include <unistd.h>
#include <zstd.h>
static void test_data_compress_decompress_roundtrip() { static void test_data_compress_decompress_roundtrip() {
const char original[] = "Hello, World! This is test data for compression round-trip!"; const char original[] = "Hello, World! This is test data for compression round-trip!";
@@ -138,6 +140,63 @@ static void test_chunk_compress_decompress_roundtrip() {
unlink(path2); unlink(path2);
} }
/* Build a zstd frame whose header omits the content size (the content size
* flag is cleared), which ZSTD_getFrameContentSize reports as
* ZSTD_CONTENTSIZE_UNKNOWN. */
static Data* make_unknown_size_frame(const void* src, size_t len) {
ZSTD_CCtx* cctx = ZSTD_createCCtx();
if (!cctx)
return NULL;
ZSTD_CCtx_setParameter(cctx, ZSTD_c_contentSizeFlag, 0);
size_t cap = ZSTD_compressBound(len);
Data* out = data_create_empty(cap);
if (!out) {
ZSTD_freeCCtx(cctx);
return NULL;
}
ZSTD_inBuffer in = {src, len, 0};
ZSTD_outBuffer ob = {out->data, cap, 0};
size_t ret;
do {
ret = ZSTD_compressStream2(cctx, &ob, &in, ZSTD_e_end);
if (ZSTD_isError(ret)) {
data_destroy(out);
ZSTD_freeCCtx(cctx);
return NULL;
}
} while (ret > 0);
out->size = ob.pos;
ZSTD_freeCCtx(cctx);
return out;
}
/* ZSTD_CONTENTSIZE_UNKNOWN is flagged by ZSTD_isError(), so a naive
* ZSTD_isError() check rejects every unknown-size frame. Such a frame must
* instead reach the 3x estimate fallback and decompress correctly. */
static void test_data_decompress_unknown_size_frame() {
const char original[] = "unknown-content-size frame: the decompressor must use the 3x estimate, "
"not reject the frame as an error.";
size_t len = strlen(original);
char* buf = malloc(len);
EXPECT_NOT_NULL(buf);
memcpy(buf, original, len);
Data* frame = make_unknown_size_frame(buf, len);
free(buf);
EXPECT_NOT_NULL(frame);
/* Guard the premise of the test: the frame really has no stored size. */
EXPECT_EQ_INT((int)ZSTD_getFrameContentSize(frame->data, frame->size),
(int)ZSTD_CONTENTSIZE_UNKNOWN);
Data* decompressed = data_decompress(frame);
EXPECT_NOT_NULL(decompressed);
EXPECT_EQ_INT((int)decompressed->size, (int)len);
EXPECT_EQ_INT(memcmp(decompressed->data, original, len), 0);
data_destroy(decompressed);
data_destroy(frame);
}
typedef struct { typedef struct {
int id; int id;
int iterations; int iterations;
@@ -211,9 +270,48 @@ static void test_data_compress_reused_contexts_multithreaded() {
compression_free_thread_contexts(); compression_free_thread_contexts();
} }
/* A truncated zstd frame used to make the decompressor spin forever: the
* stream call keeps returning a positive hint with all input consumed. Run the
* decompression in a child with an alarm so a regression (infinite loop) is
* caught as a timeout failure instead of hanging the whole unit suite. */
static void test_data_decompress_truncated_frame_fails() {
const char* original =
"The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.";
size_t len = strlen(original);
char* buf = malloc(len);
EXPECT_NOT_NULL(buf);
memcpy(buf, original, len);
Data* input = data_create(buf, len);
EXPECT_NOT_NULL(input);
pid_t pid = fork();
EXPECT_TRUE(pid >= 0);
if (pid == 0) {
alarm(10); /* kills the child if the decompressor hangs */
Data* compressed = data_compress(input, 3);
if (compressed && compressed->size > 1) {
compressed->size -= 1; /* drop the final byte: frame is now incomplete */
Data* out = data_decompress(compressed);
bool failed_cleanly = (out == NULL);
data_destroy(out);
data_destroy(compressed);
_exit(failed_cleanly ? 0 : 1);
}
data_destroy(compressed);
_exit(2);
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
data_destroy(input);
}
void test_compression() { void test_compression() {
test_data_compress_decompress_roundtrip(); test_data_compress_decompress_roundtrip();
test_data_compress_decompress_large(); test_data_compress_decompress_large();
test_data_decompress_unknown_size_frame();
test_data_decompress_truncated_frame_fails();
test_skip_compress_suffix_matching(); test_skip_compress_suffix_matching();
test_data_compress_with_threads_roundtrip(); test_data_compress_with_threads_roundtrip();
test_data_compress_reused_contexts_multithreaded(); test_data_compress_reused_contexts_multithreaded();
+29
View File
@@ -87,6 +87,34 @@ static void test_config_ssh_dest_no_user() {
config_delete(cfg); config_delete(cfg);
} }
/* C1: the user@host token is passed to ssh in option position, so a host or user
* beginning with '-' (e.g. "-oProxyCommand=...") must be rejected before any
* argv is built, and an empty host must be rejected too. */
static void test_config_ssh_dest_rejects_option_injection() {
Config* cfg = make_config("1.0", "/src", "-oProxyCommand=id:/dst", true, false, false, false,
false, 1, false, 0);
EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1);
EXPECT_EQ_INT(cfg->transport, TRANSPORT_TCP);
EXPECT_NULL(cfg->ssh_destination);
config_delete(cfg);
cfg =
make_config("1.0", "/src", "-user@host:/dst", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1);
config_delete(cfg);
cfg = make_config("1.0", "/src", "user@:/dst", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1);
config_delete(cfg);
/* config_parse_transport_dest propagates the rejection (and still returns 1
* for daemon syntax first). */
cfg = make_config("1.0", "/src", "-oProxyCommand=id:/dst", true, false, false, false, false, 1,
false, 0);
EXPECT_EQ_INT(config_parse_transport_dest(cfg), -1);
config_delete(cfg);
}
static void test_config_daemon_dest_parse() { static void test_config_daemon_dest_parse() {
Config* cfg = make_config("1.0", "/src", "dahost::files/sub/dir", true, false, false, false, Config* cfg = make_config("1.0", "/src", "dahost::files/sub/dir", true, false, false, false,
false, 1, false, 0); false, 1, false, 0);
@@ -2789,6 +2817,7 @@ void test_config() {
test_config_ssh_dest(); test_config_ssh_dest();
test_config_ssh_dest_local_path(); test_config_ssh_dest_local_path();
test_config_ssh_dest_no_user(); test_config_ssh_dest_no_user();
test_config_ssh_dest_rejects_option_injection();
test_config_daemon_dest_parse(); test_config_daemon_dest_parse();
test_config_daemon_dest_no_path(); test_config_daemon_dest_no_path();
test_config_daemon_dest_double_slash_normalized(); test_config_daemon_dest_double_slash_normalized();
+29
View File
@@ -473,6 +473,34 @@ static void test_credentials_store_rejects_legacy_hex() {
free(path); free(path);
} }
/* C9: the legacy-hex detector must check the length before indexing 64 bytes, so
* a short secret is never read out of bounds. Such a line is rejected for the
* ordinary "expected verifier" reason, never as legacy. */
static void test_credentials_store_rejects_short_secret() {
char contents[CREDENTIAL_MAX_LINE];
snprintf(contents, sizeof(contents), "alice:%s\n", "abc");
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(strstr(err, "legacy unsalted") == NULL);
rm_temp(path);
free(path);
char short_hex[64];
memset(short_hex, 'a', 63);
short_hex[63] = '\0';
snprintf(contents, sizeof(contents), "alice:%s\n", short_hex);
path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(strstr(err, "legacy unsalted") == NULL);
rm_temp(path);
free(path);
}
static void test_credentials_store_duplicate_rejected() { static void test_credentials_store_duplicate_rejected() {
char line[CREDENTIAL_MAX_LINE]; char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line))); EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
@@ -1066,6 +1094,7 @@ void test_credentials(void) {
test_credentials_store_parse_valid(); test_credentials_store_parse_valid();
test_credentials_store_parse_rejects_malformed(); test_credentials_store_parse_rejects_malformed();
test_credentials_store_rejects_legacy_hex(); test_credentials_store_rejects_legacy_hex();
test_credentials_store_rejects_short_secret();
test_credentials_store_duplicate_rejected(); test_credentials_store_duplicate_rejected();
test_credentials_store_rejects_nonuniform_iters(); test_credentials_store_rejects_nonuniform_iters();
test_credentials_store_parse_missing_file(); test_credentials_store_parse_missing_file();
+62 -3
View File
@@ -391,6 +391,22 @@ static void test_daemon_conf_auth_users_validated() {
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[0], "alice"); EXPECT_EQ_STR(ok_conf->modules[0].auth_users[0], "alice");
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[1], "bob"); EXPECT_EQ_STR(ok_conf->modules[0].auth_users[1], "bob");
daemon_conf_free(ok_conf); daemon_conf_free(ok_conf);
/* C4: an empty or separator-only `auth users` value is a parse error. It
* would otherwise leave the module with a zero-length allow-list, silently
* disabling the authentication the operator asked for. */
const char* empty_auth[] = {
"[m]\npath = /x\nauth users = \n",
"[m]\npath = /x\nauth users = , ,\n",
"[m]\npath = /x\nauth users = \t\n",
};
for (size_t i = 0; i < sizeof(empty_auth) / sizeof(empty_auth[0]); i++) {
EXPECT_EQ_INT(write_conf(empty_auth[i], &path), 0);
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(rejected);
EXPECT_TRUE(strstr(err, "'auth users' must list at least one user") != NULL);
}
} }
/* Wave 3 daemon hardening: configurable global/per-module connection caps, /* Wave 3 daemon hardening: configurable global/per-module connection caps,
@@ -475,12 +491,55 @@ static void test_daemon_conf_limits_and_hosts_parse() {
EXPECT_EQ_INT(conf->modules[0].max_connections, 0); EXPECT_EQ_INT(conf->modules[0].max_connections, 0);
daemon_conf_free(conf); daemon_conf_free(conf);
/* An empty hosts list is not an error (no patterns are added). */ /* C4: a present hosts key with an empty/separator-only value must not silently
EXPECT_EQ_INT(write_conf("hosts allow = \n[m]\npath = /x\n", &path), 0); * install a zero-length (allow-everyone) list. */
const char* empty_hosts[] = {
"hosts allow = \n[m]\npath = /x\n",
"hosts deny = \n[m]\npath = /x\n",
"hosts allow = , ,\n[m]\npath = /x\n",
"hosts deny = \t\n[m]\npath = /x\n",
};
for (size_t i = 0; i < sizeof(empty_hosts) / sizeof(empty_hosts[0]); i++) {
EXPECT_EQ_INT(write_conf(empty_hosts[i], &path), 0);
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(rejected);
EXPECT_TRUE(strstr(err, "must list at least one host pattern") != NULL);
}
const char* empty_module_hosts[] = {
"[m]\npath = /x\nhosts allow = \n",
"[m]\npath = /x\nhosts deny = ,\n",
};
for (size_t i = 0; i < sizeof(empty_module_hosts) / sizeof(empty_module_hosts[0]); i++) {
EXPECT_EQ_INT(write_conf(empty_module_hosts[i], &path), 0);
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(rejected);
EXPECT_TRUE(strstr(err, "must list at least one host pattern") != NULL);
/* The diagnostic must name the offending module. */
EXPECT_TRUE(strstr(err, "module 'm'") != NULL);
}
/* Per-module host lists APPEND across lines like the global ones. (A swapped
* store_host_list call passed the module name as `replace`, so each line
* silently replaced the previous one and only the last survived.) */
EXPECT_EQ_INT(write_conf("[m]\npath = /x\n"
"hosts allow = 127.0.0.1\n"
"hosts allow = 10.0.0.0/8\n"
"hosts deny = 192.168.0.1\n"
"hosts deny = 2001:db8::/32\n",
&path),
0);
conf = daemon_conf_load(path, err, sizeof(err)); conf = daemon_conf_load(path, err, sizeof(err));
free(path); free(path);
EXPECT_NOT_NULL(conf); EXPECT_NOT_NULL(conf);
EXPECT_EQ_INT(conf->global.hosts_allow_count, 0); EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 2);
EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "127.0.0.1");
EXPECT_EQ_STR(conf->modules[0].hosts_allow[1], "10.0.0.0/8");
EXPECT_EQ_INT(conf->modules[0].hosts_deny_count, 2);
EXPECT_EQ_STR(conf->modules[0].hosts_deny[0], "192.168.0.1");
EXPECT_EQ_STR(conf->modules[0].hosts_deny[1], "2001:db8::/32");
daemon_conf_free(conf); daemon_conf_free(conf);
} }
+91
View File
@@ -14,6 +14,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/sysmacros.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
@@ -994,6 +995,94 @@ static void test_inplace_overwrite_truncates_shorter_payload() {
rmdir(root); rmdir(root);
} }
/* B2: --inplace must refuse an existing non-regular destination entry. A FIFO
would block open(O_WRONLY) forever and a device node would be written
directly, bypassing the --write-devices/super gate. Forked with an alarm so
a regression is a prompt failure instead of a hung suite. */
static void test_inplace_refuses_fifo_destination() {
const char* root = "test_inplace_fifo_tmp";
const char* path = "test_inplace_fifo_tmp/fifo";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
EXPECT_EQ_INT(mkfifo(path, 0600), 0);
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("fifo");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISFIFO(st.st_mode)); /* left untouched */
unlink(path);
rmdir(root);
}
/* B2: an existing char device must not be written by --inplace. mknod needs
privilege, so a non-root run skips gracefully. /dev/null's (1:3) rdev makes
the negative case harmless if it ever regresses. */
static void test_inplace_refuses_device_destination() {
const char* root = "test_inplace_dev_tmp";
const char* path = "test_inplace_dev_tmp/dev";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
if (mknod(path, S_IFCHR | 0600, makedev(1, 3)) != 0) {
rmdir(root);
return; /* no privilege to create a device node: skip */
}
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("dev");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISCHR(st.st_mode)); /* still a device, not replaced */
unlink(path);
rmdir(root);
}
/* Explicit directory entries (--dirs) create the directory under the receive /* Explicit directory entries (--dirs) create the directory under the receive
root through the same save funnel, creating parents as needed, and reject root through the same save funnel, creating parents as needed, and reject
traversal the same way a file path does. */ traversal the same way a file path does. */
@@ -1606,4 +1695,6 @@ void test_file() {
test_inplace_overwrite_clears_special_mode_bits(); test_inplace_overwrite_clears_special_mode_bits();
test_inplace_overwrite_metadata_strips_special_bits(); test_inplace_overwrite_metadata_strips_special_bits();
test_inplace_overwrite_truncates_shorter_payload(); test_inplace_overwrite_truncates_shorter_payload();
test_inplace_refuses_fifo_destination();
test_inplace_refuses_device_destination();
} }
+28
View File
@@ -1,5 +1,6 @@
#include "test_file_list.h" #include "test_file_list.h"
#include "file_list.h" #include "file_list.h"
#include "utils.h"
#include "test_utils.h" #include "test_utils.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -189,8 +190,35 @@ static void test_deep_paths_are_bounded() {
free(entry); free(entry);
} }
/* An over-long list entry must be rejected cleanly instead of being read
without a bound (the reader never allocates beyond UTILS_MAX_LINE_LEN). */
static void test_oversized_entry_rejected() {
const char* path = "test_file_list_oversized.txt";
FILE* fp = fopen(path, "wb");
EXPECT_NOT_NULL(fp);
char chunk[4096];
memset(chunk, 'a', sizeof(chunk));
size_t total = 0;
while (total <= UTILS_MAX_LINE_LEN) {
EXPECT_EQ_INT((int)fwrite(chunk, 1, sizeof(chunk), fp), (int)sizeof(chunk));
total += sizeof(chunk);
}
EXPECT_EQ_INT(fputc('\n', fp), '\n');
fclose(fp);
char err[160];
FileListSet* set = file_list_load(path, false, err, sizeof(err));
if (set) {
file_list_destroy(set);
EXPECT_FAIL("over-long entry was accepted");
}
EXPECT_TRUE(strstr(err, "exceeds") != NULL);
remove(path);
}
void test_file_list() { void test_file_list() {
test_membership_matches_reference(); test_membership_matches_reference();
test_ancestor_and_descendant_queries(); test_ancestor_and_descendant_queries();
test_deep_paths_are_bounded(); test_deep_paths_are_bounded();
test_oversized_entry_rejected();
} }
+31
View File
@@ -1,7 +1,9 @@
#include "test_glob.h" #include "test_glob.h"
#include "utils.h" #include "utils.h"
#include "test_utils.h" #include "test_utils.h"
#include <stdlib.h>
#include <string.h> #include <string.h>
#include <time.h>
static void test_glob_exact_match() { static void test_glob_exact_match() {
EXPECT_TRUE(glob_match("foo", "foo")); EXPECT_TRUE(glob_match("foo", "foo"));
@@ -76,6 +78,34 @@ static void test_glob_doublestar_mid() {
EXPECT_FALSE(glob_match("a/**/b", "a/x/bad")); EXPECT_FALSE(glob_match("a/**/b", "a/x/bad"));
} }
/* The old backtracking matcher explored an exponential number of paths for a
* pattern with many `*` wildcards against a long run that never matches the
* trailing literal. The iterative matcher must stay bounded: 30 `*a` groups
* followed by `b` against ten thousand `a`s is a few hundred thousand states,
* not 2^30 recursion nodes. */
static void test_glob_pathological_is_bounded() {
char pattern[128];
size_t pos = 0;
for (int i = 0; i < 30; i++) {
pattern[pos++] = '*';
pattern[pos++] = 'a';
}
pattern[pos++] = 'b';
pattern[pos] = '\0';
char* text = malloc(10001);
EXPECT_NOT_NULL(text);
memset(text, 'a', 10000);
text[10000] = '\0';
clock_t start = clock();
EXPECT_FALSE(glob_match(pattern, text));
double elapsed = (double)(clock() - start) / CLOCKS_PER_SEC;
EXPECT_TRUE(elapsed < 5.0);
free(text);
}
void test_glob() { void test_glob() {
test_glob_exact_match(); test_glob_exact_match();
test_glob_question_mark(); test_glob_question_mark();
@@ -91,4 +121,5 @@ void test_glob() {
test_glob_doublestar_prefix(); test_glob_doublestar_prefix();
test_glob_doublestar_suffix(); test_glob_doublestar_suffix();
test_glob_doublestar_mid(); test_glob_doublestar_mid();
test_glob_pathological_is_bounded();
} }
+257
View File
@@ -1,4 +1,5 @@
#include "test_server.h" #include "test_server.h"
#include "checksum.h"
#include "config.h" #include "config.h"
#include "delta.h" #include "delta.h"
#include "file.h" #include "file.h"
@@ -844,6 +845,258 @@ static void test_special_socket_path_log_escaped() {
EXPECT_NOT_NULL(strstr(output, "socket not recreated: evil\\#012path")); EXPECT_NOT_NULL(strstr(output, "socket not recreated: evil\\#012path"));
} }
/* B1: a client-planted FIFO at the destination must not block the receiver's
* incremental-check open. With the O_NONBLOCK open plus the post-open S_ISREG
* gate the FIFO is simply "no existing regular file", so the receiver proceeds
* to a full transfer; without O_NONBLOCK the child blocks in openat() and the
* alarm(30) kills it. */
static void test_incremental_check_fifo_destination_does_not_hang() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
char* root = make_check_root("qffo");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
char path[1024];
snprintf(path, sizeof(path), "%s/file.txt", root);
EXPECT_EQ_INT(mkfifo(path, 0600), 0);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
File* file = receive_incremental_check(p[0], cfg, &skipped);
bool ok = file != NULL && !skipped;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = 4;
long long mtime = 42;
long long mtime_nsec = 0;
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_NEXT);
Data* body = data_create_reserve(4);
EXPECT_NOT_NULL(body);
body->data = malloc(4);
EXPECT_NOT_NULL(body->data);
memcpy(body->data, "data", 4);
body->size = 4;
EXPECT_TRUE(send_data(p[1], body));
data_destroy(body);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
unlink(path);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* A server-contacting --dry-run with an alternate basis dir must never read or
hash the basis file. An exact (size+mtime+content) basis match would
otherwise let a client probe the basis bytes against its own supplied digest
(a 1-bit content oracle). The dry-run decision is metadata-only, so even a
byte-identical basis is reported as would-transfer, not a compare-dest skip. */
static void test_incremental_check_dry_run_basis_does_not_read_content() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->dry_run = true;
char* root = make_check_root("dryb");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
char basis_dir[1024];
char basis_path[2048];
snprintf(basis_dir, sizeof(basis_dir), "%s/basis", root);
EXPECT_EQ_INT(mkdir(basis_dir, 0700), 0);
const char* content = "basis content that matches\n";
write_check_file(basis_dir, "file.txt", content);
snprintf(basis_path, sizeof(basis_path), "%s/file.txt", basis_dir);
struct stat bst;
EXPECT_EQ_INT(stat(basis_path, &bst), 0);
EXPECT_EQ_INT(config_basis_append(cfg, BASIS_DEST_COMPARE, "basis"), 0);
/* The (correct) source digest for the basis bytes: an unfixed dry-run would
read+hash the basis and treat this as an exact compare-dest hit. */
uint8_t digest[CHECKSUM_MAX_DIGEST_LEN];
size_t digest_len = 0;
EXPECT_TRUE(checksum_digest((ChecksumAlgo)cfg->checksum_algo, cfg->checksum_seed, content,
strlen(content), digest, sizeof(digest), &digest_len));
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
bool would_transfer = false;
File* file = receive_incremental_check_ex(p[0], cfg, &skipped, &would_transfer);
bool ok = file == NULL && !skipped && would_transfer;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = (unsigned long long)bst.st_size;
long long mtime = (long long)bst.st_mtime;
long long mtime_nsec = 0;
#ifdef __linux__
mtime_nsec = (long long)bst.st_mtim.tv_nsec;
#endif
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
uint8_t wire_len = (uint8_t)digest_len;
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
EXPECT_TRUE(send_n_data(p[1], digest, digest_len));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
/* A skip here would mean the receiver read+hashed the basis file. */
EXPECT_EQ_INT(s, STATUS_DRY_RUN_TRANSFER);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
/* The dry-run must not have materialized anything in the receive root. */
char dest_path[2048];
snprintf(dest_path, sizeof(dest_path), "%s/file.txt", root);
EXPECT_FALSE(file_path_exists_secure(dest_path));
unlink(basis_path);
rmdir(basis_dir);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* B1: a FIFO planted in a --link-dest basis directory must not block
* basis_open_regular() either; the basis match is simply declined. */
static void test_incremental_check_basis_fifo_does_not_hang() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
char* root = make_check_root("qbfi");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
char basis_dir[1024];
char basis_path[2048];
snprintf(basis_dir, sizeof(basis_dir), "%s/basis", root);
EXPECT_EQ_INT(mkdir(basis_dir, 0700), 0);
snprintf(basis_path, sizeof(basis_path), "%s/file.txt", basis_dir);
EXPECT_EQ_INT(mkfifo(basis_path, 0600), 0);
EXPECT_EQ_INT(config_basis_append(cfg, BASIS_DEST_LINK, "basis"), 0);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
File* file = receive_incremental_check(p[0], cfg, &skipped);
bool ok = file != NULL && !skipped;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = 4;
long long mtime = 42;
long long mtime_nsec = 0;
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
/* config_has_basis() makes the request carry the source digest. */
uint8_t wire_len = 8;
uint8_t digest[8] = {0};
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
EXPECT_TRUE(send_n_data(p[1], digest, sizeof(digest)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_NEXT);
Data* body = data_create_reserve(4);
EXPECT_NOT_NULL(body);
body->data = malloc(4);
EXPECT_NOT_NULL(body->data);
memcpy(body->data, "data", 4);
body->size = 4;
EXPECT_TRUE(send_data(p[1], body));
data_destroy(body);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
unlink(basis_path);
rmdir(basis_dir);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* B5: the aggregate entry count across the three manifest sections is capped at
* MAX_MANIFEST_ENTRIES, and a section that would push the total over the cap is
* rejected before its entries are read (so a tiny first section followed by a
* huge claimed second section fails fast). */
static void test_receive_manifest_total_entry_cap() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->receive_root_directory = str_dup("/tmp/dst");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
EXPECT_TRUE(send_int(p[1], 1));
EXPECT_TRUE(send_str(p[1], "keep.txt"));
/* The second section alone is within its per-section cap, but 1 + it exceeds
the cross-section cap; the receiver must reject at the count. */
EXPECT_TRUE(send_int(p[1], MAX_MANIFEST_ENTRIES));
EXPECT_NULL(receive_manifest_entries(p[0]));
Status status;
EXPECT_TRUE(receive_status(p[1], &status));
EXPECT_EQ_INT(status, STATUS_ERROR);
close(p[0]);
close(p[1]);
config_delete(cfg);
}
void test_server() { void test_server() {
test_special_socket_path_log_escaped(); test_special_socket_path_log_escaped();
if (!is_running_under_valgrind()) { if (!is_running_under_valgrind()) {
@@ -856,6 +1109,10 @@ void test_server() {
test_incremental_check_size_mismatch_full_transfer(); test_incremental_check_size_mismatch_full_transfer();
test_incremental_check_dry_run_reports_transfer_without_writing(); test_incremental_check_dry_run_reports_transfer_without_writing();
test_incremental_check_delta_oversize_reports_failure(); test_incremental_check_delta_oversize_reports_failure();
test_incremental_check_fifo_destination_does_not_hang();
test_incremental_check_basis_fifo_does_not_hang();
test_incremental_check_dry_run_basis_does_not_read_content();
test_receive_manifest_total_entry_cap();
test_late_manifest_abort_frees_keepset(); test_late_manifest_abort_frees_keepset();
test_late_manifest_eof_frees_keepset(); test_late_manifest_eof_frees_keepset();
test_late_second_manifest_frees_both(); test_late_second_manifest_frees_both();
+30
View File
@@ -32,6 +32,35 @@ static void test_server_cli_defaults() {
EXPECT_FALSE(opts.allow_delete); EXPECT_FALSE(opts.allow_delete);
EXPECT_FALSE(opts.allow_unauthenticated); EXPECT_FALSE(opts.allow_unauthenticated);
EXPECT_FALSE(opts.no_super); EXPECT_FALSE(opts.no_super);
EXPECT_FALSE(opts.allow_super);
server_cli_options_free(&opts);
}
/* C3: --allow-super is the locally-launched standalone TCP opt-in for a
* privileged receiver; it never combines with --no-super, is refused with
* --stdio (whose client-composed remote argv must not defeat the default), and
* daemon modules use their own per-module `client owner = yes` opt-in instead. */
static void test_server_cli_allow_super() {
const char* args[] = {"fastsync-server", "--allow-super", "--destination-root", "/srv"};
ServerCliOptions opts;
EXPECT_EQ_INT(parse_ok(args, 4, &opts), 0);
EXPECT_TRUE(opts.allow_super);
server_cli_options_free(&opts);
char err[256];
const char* a1[] = {"s", "--allow-super", "--no-super"};
EXPECT_EQ_INT(server_cli_parse(3, (char**)a1, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "mutually exclusive") != NULL);
const char* a2[] = {"s", "--daemon", "--config=/tmp/x.conf", "--allow-super"};
EXPECT_EQ_INT(server_cli_parse(4, (char**)a2, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "client owner") != NULL);
/* The SSH/--stdio receiver argv is composed by the client, so --allow-super
* must be rejected there and the C3 secure default stays in force. */
const char* a3[] = {"s", "--stdio", "--allow-super", "--destination-root", "/srv"};
EXPECT_EQ_INT(server_cli_parse(5, (char**)a3, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "--stdio") != NULL);
server_cli_options_free(&opts); server_cli_options_free(&opts);
} }
@@ -224,5 +253,6 @@ void test_server_cli() {
test_server_cli_password_and_early_input(); test_server_cli_password_and_early_input();
test_server_cli_password_requires_daemon(); test_server_cli_password_requires_daemon();
test_server_cli_no_super(); test_server_cli_no_super();
test_server_cli_allow_super();
test_server_cli_help(); test_server_cli_help();
} }
+29
View File
@@ -519,9 +519,38 @@ static void test_path_index_semantics() {
path_index_free(&empty); path_index_free(&empty);
} }
/* utils_getdelim_bounded must return normal short lines unchanged and refuse an
* over-long record with EFBIG rather than allocating without bound. */
static void test_getdelim_bounded() {
FILE* fp = tmpfile();
EXPECT_NOT_NULL(fp);
const char* short_line = "short\n";
EXPECT_EQ_INT((int)fwrite(short_line, 1, strlen(short_line), fp), (int)strlen(short_line));
char big[32];
memset(big, 'x', 20);
big[20] = '\n';
EXPECT_EQ_INT((int)fwrite(big, 1, 21, fp), 21);
rewind(fp);
char* line = NULL;
size_t cap = 0;
ssize_t n = utils_getdelim_bounded(fp, &line, &cap, '\n', 64);
EXPECT_EQ_INT((int)n, 6);
EXPECT_EQ_STR(line, "short\n");
errno = 0;
n = utils_getdelim_bounded(fp, &line, &cap, '\n', 10);
EXPECT_EQ_INT((int)n, -1);
EXPECT_EQ_INT(errno, EFBIG);
free(line);
fclose(fp);
}
void test_shared_utils() { void test_shared_utils() {
test_path_index_bounded(); test_path_index_bounded();
test_path_index_semantics(); test_path_index_semantics();
test_getdelim_bounded();
test_walker_removes_extras_keeps_manifest_and_protected(); test_walker_removes_extras_keeps_manifest_and_protected();
test_walker_keeps_nested_manifest_dirs(); test_walker_keeps_nested_manifest_dirs();
test_walker_max_delete_exceeded_deletes_nothing(); test_walker_max_delete_exceeded_deletes_nothing();
+29 -10
View File
@@ -66,16 +66,18 @@ static void test_ssh_remote_command_argument_modes() {
free(command); free(command);
} }
/* The build for a single-word argv is [prog, six -o args, user, command]. */ /* The build for a single-word argv is [prog, six -o args, "--", user, command]. */
static void test_ssh_build_client_argv_default_is_ssh() { static void test_ssh_build_client_argv_default_is_ssh() {
char** argv = ssh_build_client_argv(NULL, 0, "u@h", "'srv' --stdio"); char** argv = ssh_build_client_argv(NULL, 0, "u@h", "'srv' --stdio");
EXPECT_NOT_NULL(argv); EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[0], "ssh");
EXPECT_EQ_STR(argv[1], "-o"); EXPECT_EQ_STR(argv[1], "-o");
EXPECT_EQ_STR(argv[7], "u@h"); /* The "--" end-of-options marker precedes the destination token. */
EXPECT_EQ_STR(argv[8], "'srv' --stdio"); EXPECT_EQ_STR(argv[7], "--");
EXPECT_NULL(argv[9]); EXPECT_EQ_STR(argv[8], "u@h");
EXPECT_EQ_STR(argv[9], "'srv' --stdio");
EXPECT_NULL(argv[10]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
} }
@@ -84,7 +86,7 @@ static void test_ssh_build_client_argv_uses_custom_rsh() {
char** argv = ssh_build_client_argv("myrsh", 0, "u@h", "rc"); char** argv = ssh_build_client_argv("myrsh", 0, "u@h", "rc");
EXPECT_NOT_NULL(argv); EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "myrsh"); EXPECT_EQ_STR(argv[0], "myrsh");
EXPECT_NULL(argv[9]); EXPECT_NULL(argv[10]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
} }
@@ -96,21 +98,37 @@ static void test_ssh_build_client_argv_whitespace_command_and_port() {
EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[0], "ssh");
EXPECT_EQ_STR(argv[1], "-p"); EXPECT_EQ_STR(argv[1], "-p");
EXPECT_EQ_STR(argv[2], "2222"); EXPECT_EQ_STR(argv[2], "2222");
EXPECT_NULL(argv[11]); EXPECT_NULL(argv[12]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
argv = ssh_build_client_argv("ssh", 2222, "u@h", "rc"); argv = ssh_build_client_argv("ssh", 2222, "u@h", "rc");
EXPECT_NOT_NULL(argv); EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[0], "ssh");
/* Flat [prog, -o x6, -p, port, user, command]. */ /* Flat [prog, -o x6, -p, port, "--", user, command]. */
EXPECT_EQ_STR(argv[7], "-p"); EXPECT_EQ_STR(argv[7], "-p");
EXPECT_EQ_STR(argv[8], "2222"); EXPECT_EQ_STR(argv[8], "2222");
EXPECT_EQ_STR(argv[9], "u@h"); EXPECT_EQ_STR(argv[9], "--");
EXPECT_EQ_STR(argv[10], "rc"); EXPECT_EQ_STR(argv[10], "u@h");
EXPECT_NULL(argv[11]); EXPECT_EQ_STR(argv[11], "rc");
EXPECT_NULL(argv[12]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
} }
/* C1: a destination host/user beginning with '-' would be parsed by ssh as an
* option (argument injection: -oProxyCommand=...), and an empty host is never
* valid. These are refused before any child is forked, so no Client is
* returned and no command can run. */
static void test_ssh_connect_rejects_option_host() {
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh("-oProxyCommand=touch /tmp/pwned:/remote", 22, NULL, false,
NULL, false, NULL, 0);
EXPECT_NULL(client);
client = client_connect_ssh("-evil:/remote", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
client = client_connect_ssh("user@:/remote", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
}
/* --remote-option=OPT appends OPT to the remote command line after " --stdio", /* --remote-option=OPT appends OPT to the remote command line after " --stdio",
* each escaped as its own single-quoted shell word. Metacharacters that could * each escaped as its own single-quoted shell word. Metacharacters that could
* break out of the quoting are neutralized (never injected), matching the * break out of the quoting are neutralized (never injected), matching the
@@ -162,6 +180,7 @@ void test_transport_ssh() {
test_ssh_connect_invalid_dest_empty(); test_ssh_connect_invalid_dest_empty();
test_ssh_connect_malformed(); test_ssh_connect_malformed();
test_ssh_connect_unreachable(); test_ssh_connect_unreachable();
test_ssh_connect_rejects_option_host();
test_ssh_remote_command_argument_modes(); test_ssh_remote_command_argument_modes();
test_ssh_build_client_argv_default_is_ssh(); test_ssh_build_client_argv_default_is_ssh();
test_ssh_build_client_argv_uses_custom_rsh(); test_ssh_build_client_argv_uses_custom_rsh();
+11
View File
@@ -24,6 +24,17 @@ static void test_server_create_tls_without_certs() {
#ifdef SSL_OP_NO_RENEGOTIATION #ifdef SSL_OP_NO_RENEGOTIATION
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_RENEGOTIATION) != 0); EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_RENEGOTIATION) != 0);
#endif #endif
/* C5: the server's preference order decides the cipher and the TLS 1.2 list is
* AEAD-only (no CBC/RC4/3DES legacy suites). */
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_CIPHER_SERVER_PREFERENCE) != 0);
STACK_OF(SSL_CIPHER)* ciphers = SSL_CTX_get_ciphers(ctx);
EXPECT_NOT_NULL(ciphers);
for (int i = 0; i < sk_SSL_CIPHER_num(ciphers); i++) {
const char* name = SSL_CIPHER_get_name(sk_SSL_CIPHER_value(ciphers, i));
EXPECT_TRUE(name != NULL && strstr(name, "CBC") == NULL);
EXPECT_TRUE(name != NULL && strstr(name, "RC4") == NULL);
EXPECT_TRUE(name != NULL && strstr(name, "3DES") == NULL);
}
server_delete(&s); server_delete(&s);
EXPECT_NULL(s); EXPECT_NULL(s);
} }
+135 -10
View File
@@ -17,7 +17,7 @@
static void run_recv_helper(int fd) { static void run_recv_helper(int fd) {
int ok = 0; int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok); FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok) if (!ok)
_exit(1); _exit(1);
if (!list) { if (!list) {
@@ -64,7 +64,7 @@ static void test_xattr_wire_roundtrip() {
static void run_recv_must_fail(int fd) { static void run_recv_must_fail(int fd) {
int ok = 0; int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok); FileXattrList* list = xattr_receive(fd, &ok, false);
/* A NULL list with ok==0 is the expected rejection. */ /* A NULL list with ok==0 is the expected rejection. */
if (ok == 0 && list == NULL) if (ok == 0 && list == NULL)
_exit(0); _exit(0);
@@ -148,15 +148,64 @@ static void test_xattr_count_bound() {
/* The captured list on a plain file reflects only whitelisted namespaces /* The captured list on a plain file reflects only whitelisted namespaces
* (Linux only; skipped when the filesystem has no xattr support). */ * (Linux only; skipped when the filesystem has no xattr support). */
static void test_xattr_capture_and_appliable() { static void test_xattr_capture_and_appliable() {
EXPECT_FALSE(xattr_name_appliable(NULL)); EXPECT_FALSE(xattr_name_appliable(NULL, false));
EXPECT_FALSE(xattr_name_appliable("")); EXPECT_FALSE(xattr_name_appliable("", false));
EXPECT_FALSE(xattr_name_appliable("security.selinux")); EXPECT_FALSE(xattr_name_appliable("security.selinux", false));
EXPECT_FALSE(xattr_name_appliable("trusted.blob")); EXPECT_FALSE(xattr_name_appliable("trusted.blob", false));
EXPECT_TRUE(xattr_name_appliable("user.foo")); EXPECT_TRUE(xattr_name_appliable("user.foo", false));
EXPECT_TRUE(xattr_name_appliable("user.foo", true));
/* The reserved fake-super key is receiver-only and never forwarded/applied. */ /* The reserved fake-super key is receiver-only and never forwarded/applied. */
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat")); EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", false));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access")); EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", true));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default")); /* B4: the ACL names require --acls; -X alone must not authorize them. */
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_access", false));
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_default", false));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access", true));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default", true));
}
/* B4: a `-X`-only receiver (preserve_acls false) must NOT apply an incoming
* ACL xattr, while a user.* attribute in the same block still survives. The
* ACL entry is dropped, not applied (and the -X transfer is not failed). */
static void run_recv_drops_acl_keeps_user(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok || list == NULL)
_exit(1);
bool saw_user = false;
for (int i = 0; i < list->count; i++) {
if (strcmp(list->items[i].name, "system.posix_acl_access") == 0)
_exit(1); /* ACL must have been dropped */
if (strcmp(list->items[i].name, "user.keep") == 0)
saw_user = true;
}
xattr_list_free(list);
_exit(saw_user ? 0 : 1);
}
static void test_xattr_receive_drops_acl_without_preserve_acls() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
run_recv_drops_acl_keeps_user(p[0]);
}
close(p[0]);
io_set_fds(p[1], p[1]);
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
EXPECT_TRUE(xattr_list_append(list, "system.posix_acl_access", "\x02\x00\x00\x00", 4));
EXPECT_TRUE(xattr_list_append(list, "user.keep", "yes", 3));
xattr_send(p[1], list);
xattr_list_free(list);
int status;
waitpid(pid, &status, 0);
close(p[1]);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
} }
/* MINOR-2: a --link-dest / -H copy fallback (linkat refused) must still apply /* MINOR-2: a --link-dest / -H copy fallback (linkat refused) must still apply
@@ -224,6 +273,80 @@ static void test_link_copy_fallback_preserves_xattrs() {
rmdir(basis_dir); rmdir(basis_dir);
} }
/* Capture must honor --acls: xattr_capture_path(path, false) (plain -X) must
* never return the POSIX ACL names, while xattr_capture_path(path, true) (-A)
* does; user.* is captured either way. This is the capture-side counterpart of
* the receiver's --acls gate and must not depend on the caller having checked
* the flag. Guarded on filesystem/ACL support. */
static void test_xattr_capture_filters_acls() {
const char* path = "test_xattr_capture_acls.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 (setxattr(path, "user.keep", "yes", 3, 0) != 0) {
close(fd);
unlink(path);
return;
}
/* Synthesize a valid non-trivial POSIX access ACL blob (little-endian):
version 2 followed by USER_OBJ/USER/GROUP_OBJ/MASK/OTHER entries. */
uint32_t acl_uid = geteuid() == 0 ? 65534u : (uint32_t)geteuid();
unsigned char blob[4 + 5 * 8];
uint32_t version = 2;
memcpy(blob, &version, 4);
const uint16_t tags[5] = {0x01, 0x02, 0x04, 0x10, 0x20}; /* OBJ/USER/GROUP/MASK/OTHER */
const uint16_t perms[5] = {0x04, 0x04, 0x04, 0x04, 0x00};
const uint32_t ids[5] = {0xFFFFFFFFu, acl_uid, 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu};
size_t off = 4;
for (int i = 0; i < 5; i++) {
memcpy(blob + off, &tags[i], sizeof(tags[i]));
off += sizeof(tags[i]);
memcpy(blob + off, &perms[i], sizeof(perms[i]));
off += sizeof(perms[i]);
memcpy(blob + off, &ids[i], sizeof(ids[i]));
off += sizeof(ids[i]);
}
if (setxattr(path, "system.posix_acl_access", blob, off, 0) != 0) {
close(fd);
unlink(path);
return; /* no unprivileged ACL support: skip silently */
}
close(fd);
FileXattrList* plain = xattr_capture_path(path, false);
FileXattrList* with_acls = xattr_capture_path(path, true);
bool plain_user = false, plain_acl = false, acl_user = false, acl_acl = false;
for (int i = 0; plain && i < plain->count; i++) {
if (strcmp(plain->items[i].name, "user.keep") == 0)
plain_user = true;
if (strcmp(plain->items[i].name, "system.posix_acl_access") == 0)
plain_acl = true;
}
for (int i = 0; with_acls && i < with_acls->count; i++) {
if (strcmp(with_acls->items[i].name, "user.keep") == 0)
acl_user = true;
if (strcmp(with_acls->items[i].name, "system.posix_acl_access") == 0)
acl_acl = true;
}
EXPECT_TRUE(plain_user);
EXPECT_FALSE(plain_acl);
EXPECT_TRUE(acl_user);
EXPECT_TRUE(acl_acl);
xattr_list_free(plain);
xattr_list_free(with_acls);
unlink(path);
}
/* --fake-super replay: fake_super_store_fd records the source stat into the /* --fake-super replay: fake_super_store_fd records the source stat into the
* reserved xattr, and fake_super_restore_fd re-applies mode/mtime (and owner, * reserved xattr, and fake_super_restore_fd re-applies mode/mtime (and owner,
* when the process may) fd-relative. Restore must also be a safe no-op with no * when the process may) fd-relative. Restore must also be a safe no-op with no
@@ -360,6 +483,8 @@ void test_xattr() {
test_xattr_reject_oversized_value(); test_xattr_reject_oversized_value();
test_xattr_count_bound(); test_xattr_count_bound();
test_xattr_capture_and_appliable(); test_xattr_capture_and_appliable();
test_xattr_capture_filters_acls();
test_xattr_receive_drops_acl_without_preserve_acls();
test_link_copy_fallback_preserves_xattrs(); test_link_copy_fallback_preserves_xattrs();
test_fake_super_restore(); test_fake_super_restore();
test_fake_super_owner_gate(); test_fake_super_owner_gate();