diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 6ccc8b2..4b34d08 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -353,7 +353,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`. | `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Parity | 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 (an empty source directory is created by the separate `STATUS_MKDIR` entry the scanner now emits, and `-m/--prune-empty-dirs` suppresses that; the trailing dir-time simply re-applies the metadata). 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 | ✅ Parity | 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 | ❌ Divergent | Safe-subset privilege model. `--super` permits the receiver to attempt already-confined super-user activities (ownership application, char/block device-node creation, `--write-devices`); `--no-super` forbids them even for root; `auto` keeps the historical best-effort attempt. **FastSync never elevates** — no `setuid`/`seteuid`/`setgid` — and `--super` never bypasses the confinement floor, so it diverges from rsync's real elevation. A server `--no-super` veto forces it off for every connection; a privileged standalone listener defaults off without `--allow-super`; daemon modules opt in with `client owner = yes` | -| `--fake-super` | Store/recover privileged attrs via xattrs | ⚠️ Caveat | Writes rsync 3.4.1's reserved `user.rsync.%stat` xattr with rsync's exact value grammar ` , :` (e.g. `104711 0,0 1234:5678`), recording the RESOLVED owner (the `--chown`/`--usermap`/`--groupmap`/`--copy-as` mapping when active, else the source's own id) plus the full mode and rdev; it **never performs a real `chown`**. mtime is carried by the file's own timestamp, exactly as rsync does it (there is no mtime field). The receiver parses the same grammar and replays the permission bits fd-relative, stripping the recorded special bits on disk exactly like rsync's fake-super receiver. Regular files are interoperable with real rsync 3.4.1 in both directions (the differential test has rsync read a FastSync fake-super tree and re-emit the identical record). Char/block devices **are** faked: a device is written as a regular empty file and its `user.rsync.%stat` records the real `rdev` (e.g. `20644 1,3 0:0`), never `mknod`'d, on both privileged and unprivileged receivers, exactly as rsync does. The record parser range-checks every field (mode/rdev/uid/gid) and rejects malformed records cleanly. Residual: directories are not yet faked — no `%stat` record is written for a directory. Implies metadata transmission; incompatible with `-s` | +| `--fake-super` | Store/recover privileged attrs via xattrs | ⚠️ Caveat | Writes rsync 3.4.1's reserved `user.rsync.%stat` xattr with rsync's exact value grammar ` , :` (e.g. `104711 0,0 1234:5678`), recording the RESOLVED owner (the `--chown`/`--usermap`/`--groupmap`/`--copy-as` mapping when active, else the source's own id) plus the full mode and rdev; it **never performs a real `chown`**. mtime is carried by the file's own timestamp, exactly as rsync does it (there is no mtime field). The receiver parses the same grammar and replays the permission bits fd-relative, stripping the recorded special bits on disk exactly like rsync's fake-super receiver. Regular files are interoperable with real rsync 3.4.1 in both directions (the differential test has rsync read a FastSync fake-super tree and re-emit the identical record). Char/block devices **are** faked: a device is written as a regular empty file and its `user.rsync.%stat` records the real `rdev` (e.g. `20644 1,3 0:0`), never `mknod`'d, on both privileged and unprivileged receivers, exactly as rsync does. Directories **are** faked too: the directory's full stat (with `S_IFDIR` and any special bits) is parked on the directory ITSELF when it is created (explicit `--dirs`/`STATUS_MKDIR`) and again in the deferred directory-metadata pass that runs after every child is written, and the receiver replays only the permission bits on disk (the setgid/sticky bits stay in the record). Real rsync 3.4.1 reads a FastSync directory record and re-emits it verbatim (differential-tested). The record parser range-checks every field (mode/rdev/uid/gid) and rejects malformed records cleanly. Residual: symlinks are not faked — FastSync creates real symlinks, whereas rsync writes a regular file carrying an `S_IFLNK` (`120777`) `%stat` record; and FastSync writes a directory record unconditionally, where rsync omits it when the on-disk mode already fully represents the source (a benign extra xattr, still read correctly by rsync). Implies metadata transmission; incompatible with `-s` | | `--open-noatime` | Avoid changing access time when opening files | ✅ Parity | 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 | ✅ Parity | **A mapping modifier only:** when ownership is being applied it uses the transmitted numeric uid/gid directly, skipping the name lookup. It does **not** request ownership application on its own — combine it with `-o`/`-g`, `-a`, or an explicit map (`--chown`/`--usermap`/`--groupmap`) — and it does not need any metadata flag merely to parse. Ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the Phase-4 identity notes) | | `--usermap=STRING` | Map usernames | ✅ Parity | Opt-in ownership application. Comma-separated `FROM:TO` rules evaluated in order, first match wins. `FROM` accepts a source-resolved user name, a name **glob** (`*`/`?`/`[...]`, expanded sender-side at CLI-parse time against the sender's passwd/group database and collapsed into numeric `LOW-HIGH` ranges, bounded by `MAX_IDENTITY_MAP`), an `@N`/bare `N` numeric id, an inclusive `LOW-HIGH` id range, `*`, or an empty field (ids with no source name). `TO` accepts a receiver-resolved **name** (protocol 2.26.0 resolves it on the receiving side against the receiver's account database, matching rsync), an `@N`/bare `N` id, or `*` (the receiving process's euid). Rules travel as resolved numeric pairs plus an optional TO name; the receiver applies a matching rule, else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup, via fd-relative `fchown`. Malformed specs are clear errors. Implies metadata; only effective where the receiver can chown (otherwise a warning) | @@ -429,9 +429,12 @@ match, exactly as prior phases did). `chown` — `--fake-super` only *records* the resolved owner (the active `--chown`/`--usermap`/`--groupmap`/`--copy-as` mapping when one is in effect, otherwise the source's own id) for a later privileged restore. Because the - key and grammar are rsync's, a regular-file fake-super tree is interoperable - with rsync 3.4.1 in both directions; directories and device nodes are not yet - faked. + key and grammar are rsync's, a regular-file, device and directory fake-super + tree is interoperable with rsync 3.4.1 in both directions; a directory's + record is written on the directory itself at creation and re-stamped by the + deferred directory-metadata pass. Symlinks are the remaining residual: + FastSync creates a real symlink where rsync writes a regular file carrying an + `S_IFLNK` (`120777`) record. - **Chunk serialization (`-s`) incompatibility:** the per-file xattr block rides the streaming per-file frame, which `-s` replaces with a fixed buffer format, so `-X` / `-A` combined with `-s` is rejected up front on both ends (mirroring @@ -966,7 +969,7 @@ These are the last compatibility items and the closing phase toward rsync flag p **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. -**Honest status after the parity 2.29 cycle (protocol 2.29.0 since the symlink-xattr wire wave, which adds no config-frame field and leaves this matrix unchanged), updated by the parity cycle 2.29 pass, the audit-cycle follow-ups, the triage cycle, and a later no-wire parity pass.** The wire backlog cycle (protocol 2.30.0) then moved `--stderr=MODE` ❌ → ⚠️ (the `client` mode is now accepted over the new `STATUS_CLIENT_MSG` channel; only the client->server direction is reproduced) and closed the `--devices` exit-code and `--remove-source-files` residuals via `STATUS_PARTIAL` (exit 23 with successful sources removed), leaving ✅ Parity 119 / ⚠️ Caveat 15 / ❌ Divergent 23 = 157 rows. The same cycle then extended the destination-state report to directories and symlinks (#314: the receiver answers `STATUS_MKDIR`/`STATUS_SYMLINK` and an ancestor probe, so `-i`/`--progress`/`--out-format` render `.d..t......`/`cLc........` instead of `cd`/`cL` and suppress unchanged entries) and appended the per-type `deleted_*` counters to `STATUS_STATS` (#316: `--stats` now reproduces rsync's `Number of deleted files (reg/dir/link/special)` breakdown) — both differential-tested against rsync 3.4.1; the affected rows' caveats narrow but their classifications are unchanged, so the matrix stays **119 ✅ / 15 ⚠️ / 23 ❌ = 157**. The no-wire parity pass accepted `--inc-recursive`/`--no-inc-recursive` as inert no-ops (❌ → ✅, since FastSync's full scan is rsync's `--no-inc-recursive` and the destination is identical), narrowed the `--temp-dir` divergence by accepting an absolute path that canonicalizes inside the receive root (the row stays ❌ for out-of-root absolute paths), closed the `--delete-before` phase-0 divergence (⚠️ → ✅: both the single-threaded and the `--threads` data passes now replay the pre-scan file list, so a source file created after the scan is neither transferred nor kept, matching rsync), and moved `--fake-super` and `--devices` ❌ → ⚠️ (`--fake-super` now writes/reads rsync's exact `user.rsync.%stat` key and ` , :` grammar, interoperating with real rsync 3.4.1 for regular files and faking char/block devices as regular files carrying the real rdev; `--devices` now logs a failed device `mknod` as a per-entry failure that continues the transfer instead of a silent non-root skip — see those rows for the remaining directory-faking and exit-code residuals). A review pass then hardened the fake-super stat parser (strict range-checked parsing), made rsync-style daemon modules read-only by default with a startup warning for accepted-but-unenforced access-control keys, and extended the `--delete-before` replay to the `--threads` path. The 2.29 cycle closed the scanner-order, delete-timing, relative-basis, and fuzzy-eligibility residuals (moving `-n`/`--delete`/`--del`/`--delete-delay` to ✅) and improved the `--info`/`--stats`/`--debug` partial rows; the triage cycle moved `-F` and `-i`/`--itemize-changes` ✅ → ⚠️ for their documented residuals. The remaining ⚠️ rows are `--info`, `--debug`, `--stderr=MODE`, `--msgs2stderr`, `--stats`, `--progress`, `-i`, `--filter`, `-F`, the three basis-dir options, `-y/--fuzzy`, `--fake-super`, `--devices`, and `--delay-updates` (16). The wire cycle for issue #315 then closed the `--filter`/`-F` merge-modifier and per-directory-receiver residuals (protocol 2.30.0 carries each directory's compiled rules and implements `e`/`n`/`w`/`-`), narrowing both rows to the merge-file-side residual (rsync reads the destination's merge file, FastSync carries the source's) and leaving the tally at **119 ✅ / 15 ⚠️ / 23 ❌ = 157**; the #317 pass then moved `--delay-updates` ❌ → ⚠️, for **119 ✅ / 16 ⚠️ / 22 ❌ = 157**. Earlier: **Honest status after the parity 2.28.0 cycle (protocol 2.28.0), updated by the rsync-parity-stats, rsync-parity-options, rsync-parity-fs, parity-review, no-wire parity-track-1/2b and wire parity-track-4a/5a passes.** ✅ Parity 116 / ⚠️ Caveat 14 / ❌ Divergent 27 = 157 rows. Earlier revisions of this document reported "143 ✅ / 0 divergence / 0 partial"; that conflated "parsed and tested" with "rsync parity", because many rows carried documented behavioral differences and some short options were not parsed at all. This reclassification makes every difference explicit. The completion wave closed 23 previously-caveated rows (9 that triage showed were already parity, plus 14 genuine fixes) and turned the 17 inherently non-rsync rows — native daemon config/auth, the FastSync batch container, the safe-subset device/privilege flags, `-X`'s privileged namespaces, `--fake-super`'s native xattr format, and the `--old-args` no-op — into explicit ❌ divergences. The stats pass flipped `--delete-delay` to ✅ (actual-removal accounting), but the parity-review pass moved it back to ⚠️ because FastSync charged the `--max-delete` budget at plan/snapshot time and left a refilled snapshotted directory in place, whereas rsync charges on actual removals and recursively removes a queued directory (including content created after its plan). The no-wire parity-track-1 pass fixed both (actual-removal charging plus recursive deferred removal with an independent deferred-list cap), narrowing the caveat to the partial-delete ordering. The stats pass also reclassified `--out-format` to ❌ (protocol-specific `%b`/delta-`%c`), and sharpened the `--stats`/`--progress`/`--checksum-choice` residuals. The options pass flipped `--bwlimit` and `--ignore-errors` to ✅ (rsync-exact size parsing and ~100 ms leaky-bucket throttling, and rsync's skip-unreadable-subdir plus IO-error-suppressed deletion with exit 23) and emits rsync-format `--info=name/flist/del/remove/nonreg/progress` lines (real-run `deleting`/`*deleting` carried over a new trailing `report_deletes` wire bool, `PROTOCOL_VERSION` 2.26.0 → 2.27.0), while reclassifying `-M` over daemon/TCP +**Honest status after the parity 2.29 cycle (protocol 2.29.0 since the symlink-xattr wire wave, which adds no config-frame field and leaves this matrix unchanged), updated by the parity cycle 2.29 pass, the audit-cycle follow-ups, the triage cycle, and a later no-wire parity pass.** The wire backlog cycle (protocol 2.30.0) then moved `--stderr=MODE` ❌ → ⚠️ (the `client` mode is now accepted over the new `STATUS_CLIENT_MSG` channel; only the client->server direction is reproduced) and closed the `--devices` exit-code and `--remove-source-files` residuals via `STATUS_PARTIAL` (exit 23 with successful sources removed), leaving ✅ Parity 119 / ⚠️ Caveat 15 / ❌ Divergent 23 = 157 rows. The same cycle then extended the destination-state report to directories and symlinks (#314: the receiver answers `STATUS_MKDIR`/`STATUS_SYMLINK` and an ancestor probe, so `-i`/`--progress`/`--out-format` render `.d..t......`/`cLc........` instead of `cd`/`cL` and suppress unchanged entries) and appended the per-type `deleted_*` counters to `STATUS_STATS` (#316: `--stats` now reproduces rsync's `Number of deleted files (reg/dir/link/special)` breakdown) — both differential-tested against rsync 3.4.1; the affected rows' caveats narrow but their classifications are unchanged, so the matrix stays **119 ✅ / 15 ⚠️ / 23 ❌ = 157**. The no-wire parity pass accepted `--inc-recursive`/`--no-inc-recursive` as inert no-ops (❌ → ✅, since FastSync's full scan is rsync's `--no-inc-recursive` and the destination is identical), narrowed the `--temp-dir` divergence by accepting an absolute path that canonicalizes inside the receive root (the row stays ❌ for out-of-root absolute paths), closed the `--delete-before` phase-0 divergence (⚠️ → ✅: both the single-threaded and the `--threads` data passes now replay the pre-scan file list, so a source file created after the scan is neither transferred nor kept, matching rsync), and moved `--fake-super` and `--devices` ❌ → ⚠️ (`--fake-super` now writes/reads rsync's exact `user.rsync.%stat` key and ` , :` grammar, interoperating with real rsync 3.4.1 for regular files and faking char/block devices as regular files carrying the real rdev; `--devices` now logs a failed device `mknod` as a per-entry failure that continues the transfer instead of a silent non-root skip — see those rows for the remaining symlink-faking and exit-code residuals). A review pass then hardened the fake-super stat parser (strict range-checked parsing), made rsync-style daemon modules read-only by default with a startup warning for accepted-but-unenforced access-control keys, and extended the `--delete-before` replay to the `--threads` path. The 2.29 cycle closed the scanner-order, delete-timing, relative-basis, and fuzzy-eligibility residuals (moving `-n`/`--delete`/`--del`/`--delete-delay` to ✅) and improved the `--info`/`--stats`/`--debug` partial rows; the triage cycle moved `-F` and `-i`/`--itemize-changes` ✅ → ⚠️ for their documented residuals. The remaining ⚠️ rows are `--info`, `--debug`, `--stderr=MODE`, `--msgs2stderr`, `--stats`, `--progress`, `-i`, `--filter`, `-F`, the three basis-dir options, `-y/--fuzzy`, `--fake-super`, `--devices`, and `--delay-updates` (16). The wire cycle for issue #315 then closed the `--filter`/`-F` merge-modifier and per-directory-receiver residuals (protocol 2.30.0 carries each directory's compiled rules and implements `e`/`n`/`w`/`-`), narrowing both rows to the merge-file-side residual (rsync reads the destination's merge file, FastSync carries the source's) and leaving the tally at **119 ✅ / 15 ⚠️ / 23 ❌ = 157**; the #317 pass then moved `--delay-updates` ❌ → ⚠️, for **119 ✅ / 16 ⚠️ / 22 ❌ = 157**. Earlier: **Honest status after the parity 2.28.0 cycle (protocol 2.28.0), updated by the rsync-parity-stats, rsync-parity-options, rsync-parity-fs, parity-review, no-wire parity-track-1/2b and wire parity-track-4a/5a passes.** ✅ Parity 116 / ⚠️ Caveat 14 / ❌ Divergent 27 = 157 rows. Earlier revisions of this document reported "143 ✅ / 0 divergence / 0 partial"; that conflated "parsed and tested" with "rsync parity", because many rows carried documented behavioral differences and some short options were not parsed at all. This reclassification makes every difference explicit. The completion wave closed 23 previously-caveated rows (9 that triage showed were already parity, plus 14 genuine fixes) and turned the 17 inherently non-rsync rows — native daemon config/auth, the FastSync batch container, the safe-subset device/privilege flags, `-X`'s privileged namespaces, `--fake-super`'s native xattr format, and the `--old-args` no-op — into explicit ❌ divergences. The stats pass flipped `--delete-delay` to ✅ (actual-removal accounting), but the parity-review pass moved it back to ⚠️ because FastSync charged the `--max-delete` budget at plan/snapshot time and left a refilled snapshotted directory in place, whereas rsync charges on actual removals and recursively removes a queued directory (including content created after its plan). The no-wire parity-track-1 pass fixed both (actual-removal charging plus recursive deferred removal with an independent deferred-list cap), narrowing the caveat to the partial-delete ordering. The stats pass also reclassified `--out-format` to ❌ (protocol-specific `%b`/delta-`%c`), and sharpened the `--stats`/`--progress`/`--checksum-choice` residuals. The options pass flipped `--bwlimit` and `--ignore-errors` to ✅ (rsync-exact size parsing and ~100 ms leaky-bucket throttling, and rsync's skip-unreadable-subdir plus IO-error-suppressed deletion with exit 23) and emits rsync-format `--info=name/flist/del/remove/nonreg/progress` lines (real-run `deleting`/`*deleting` carried over a new trailing `report_deletes` wire bool, `PROTOCOL_VERSION` 2.26.0 → 2.27.0), while reclassifying `-M` over daemon/TCP and receiver-side `protect`/`risk` re-derivation to ❌ (no argv channel / receiver filter engine); the wire parity-track-4a pass later added that receiver filter engine, flipping `--filter=RULE` back to ✅ (see above; the @@ -1071,9 +1074,10 @@ integration tests unless it is explicitly listed as a limitation. - **`--fake-super` never real-chowns.** It records the *resolved* owner (the active mapping, else the source id) in rsync's `user.rsync.%stat` for a later privileged restore and replays only the permission bits (mtime travels through - the normal metadata path). Directory ownership and - directory xattrs/ACLs are preserved alongside file entries, though directories - themselves are not yet given a `%stat%` record. + the normal metadata path). Directory ownership, xattrs/ACLs and the + directory's own `%stat` record are preserved alongside file entries: the + record is written on the directory at creation and re-stamped by the deferred + directory-metadata pass. - **`--chmod`** implements rsync's `D`/`F`/`X` selectors, `s`/`t`, append semantics, does not imply `-p`, and applies its changes without sanitization. diff --git a/src/client/client_manifest.c b/src/client/client_manifest.c index 04fae0b..2ce0dd2 100644 --- a/src/client/client_manifest.c +++ b/src/client/client_manifest.c @@ -64,16 +64,8 @@ bool add_chunk_to_manifest(ArrayList* manifest, const Chunk* chunk) { int send_dry_run_manifest(const Config* config) { int skipped = 0; ArrayList* missing_dest = NULL; - if (config->delete_missing_args) { - missing_dest = array_list_create(free); - if (!missing_dest) - return -1; - } - if (!files_from_list_check(config, missing_dest, &skipped)) { - if (missing_dest) - array_list_delete(missing_dest); + if (!client_prepare_files_from(config, &missing_dest, &skipped)) return -1; - } PreparedScanner prepared; if (!prepare_scanner(config, 0, &prepared)) { if (missing_dest) @@ -466,33 +458,18 @@ bool send_delete_manifest_early(Client* client, ArrayList* manifest, ArrayList* int send_dry_run_remote(Config* config) { int from_skipped = 0; ArrayList* missing_args = NULL; - if (config->delete_missing_args) { - missing_args = array_list_create(free); - if (!missing_args) - return 1; - } - if (!files_from_list_check(config, missing_args, &from_skipped)) { - if (missing_args) - array_list_delete(missing_args); + if (!client_prepare_files_from(config, &missing_args, &from_skipped)) return 1; - } if (missing_args) array_list_delete(missing_args); /* A live session may follow, so arm graceful abort handling. */ client_set_abort_armed(true); - Client* client = connect_transfer_client(config); + ProtocolSession session; + Client* client = client_connect_and_bind_session(config, &session); if (!client) { - if (config->transport == TRANSPORT_TCP) - log_message(LOG_LEVEL_ERROR, "could not connect to server%s", - config->use_tls ? " via TLS" : ""); client_set_abort_armed(false); return 1; } - ProtocolSession session; - protocol_session_init(&session, client->file_descriptor, client->file_descriptor); - protocol_session_set_io_timeout(&session, config->timeout); - protocol_session_set_ssl(&session, (SSL*)client->ssl); - protocol_session_bind(&session); int ret = 1; bool partial = false; diff --git a/src/client/client_send.c b/src/client/client_send.c index 27f7745..e41edb5 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -136,6 +136,50 @@ void disconnect_transfer_client(Client* client) { client_delete(client); } +/* Connect the configured transport and install the per-thread protocol session + * on it: init with the socket fd pair, apply the I/O timeout and (when + * negotiated) the TLS object, then bind it to this thread. Returns the + * connected client, or NULL (after logging the connect failure) when the + * transport could not connect. */ +Client* client_connect_and_bind_session(const Config* config, ProtocolSession* session) { + Client* client = connect_transfer_client(config); + if (!client) { + if (config->transport == TRANSPORT_TCP) + log_message(LOG_LEVEL_ERROR, "could not connect to server%s", + config->use_tls ? " via TLS" : ""); + return NULL; + } + protocol_session_init(session, client->file_descriptor, client->file_descriptor); + protocol_session_set_io_timeout(session, config->timeout); + protocol_session_set_ssl(session, (SSL*)client->ssl); + protocol_session_bind(session); + return client; +} + +/* Shared --files-from/--delete-missing-args preamble: allocate the missing-args + * destination list when the option is set, then validate the --files-from list + * (collecting the destination mirrors of missing entries for the receiver's + * exact-deletion request). On success the caller owns *missing_args_out (NULL + * when the option is off); on failure the list is freed and false is returned. */ +bool client_prepare_files_from(const Config* config, ArrayList** missing_args_out, + int* skipped_out) { + ArrayList* missing_args = NULL; + if (config->delete_missing_args) { + missing_args = array_list_create(free); + if (!missing_args) + return false; + } + int skipped = 0; + if (!files_from_list_check(config, missing_args, &skipped)) { + if (missing_args) + array_list_delete(missing_args); + return false; + } + *missing_args_out = missing_args; + *skipped_out = skipped; + return true; +} + /* (finalize_transfer is defined after the SourceFile helpers below.) */ typedef struct SourceFile { @@ -1039,20 +1083,13 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config, static int send_chunks_multithreaded(void* pipeline_context) { PipelineContextSender* context = (PipelineContextSender*)pipeline_context; time_t start = time(NULL); - Client* client = connect_transfer_client(context->config); + ProtocolSession session; + Client* client = client_connect_and_bind_session(context->config, &session); if (!client) { - if (context->config->transport == TRANSPORT_TCP) - log_message(LOG_LEVEL_ERROR, "could not connect to server%s", - context->config->use_tls ? " via TLS" : ""); pipeline_cancel(context); mark_sender_done(context); return thrd_error; } - ProtocolSession session; - protocol_session_init(&session, client->file_descriptor, client->file_descriptor); - protocol_session_set_io_timeout(&session, context->config->timeout); - protocol_session_set_ssl(&session, (SSL*)client->ssl); - protocol_session_bind(&session); client_messages_activate(true); if (!config_send(client->file_descriptor, context->config)) { pipeline_cancel(context); @@ -2035,35 +2072,20 @@ static int send_files_impl(Config* config) { shielded -- rsync's `-d DIR/ --delete`. */ state.delete_per_dir = config->use_delete && config_delete_timing_per_dir(config); int skipped = 0; - if (config->delete_missing_args) { - state.missing_args = array_list_create(free); - if (!state.missing_args) - return 1; - } - if (!files_from_list_check(config, state.missing_args, &skipped)) { - if (state.missing_args) - array_list_delete(state.missing_args); + if (!client_prepare_files_from(config, &state.missing_args, &skipped)) return 1; - } /* From here on a server session may be live, so Ctrl-C/SIGTERM should set the abort flag (and be forwarded as STATUS_ABORT) instead of terminating. */ client_set_abort_armed(true); - Client* client = connect_transfer_client(config); + ProtocolSession session; + Client* client = client_connect_and_bind_session(config, &session); if (!client) { - if (config->transport == TRANSPORT_TCP) - log_message(LOG_LEVEL_ERROR, "could not connect to server%s", - config->use_tls ? " via TLS" : ""); if (state.missing_args) array_list_delete(state.missing_args); return 1; } state.client = client; - ProtocolSession session; - protocol_session_init(&session, client->file_descriptor, client->file_descriptor); - protocol_session_set_io_timeout(&session, config->timeout); - protocol_session_set_ssl(&session, (SSL*)client->ssl); - protocol_session_bind(&session); client_messages_activate(true); int ret = 1; @@ -2099,16 +2121,8 @@ static int send_files_multithreaded_impl(Config* config) { : send_dry_run_manifest(config); ArrayList* missing_args = NULL; int skipped = 0; - if (config->delete_missing_args) { - missing_args = array_list_create(free); - if (!missing_args) - return 1; - } - if (!files_from_list_check(config, missing_args, &skipped)) { - if (missing_args) - array_list_delete(missing_args); + if (!client_prepare_files_from(config, &missing_args, &skipped)) return 1; - } /* Armed only once a session may go live (see send_files). */ client_set_abort_armed(true); @@ -2137,6 +2151,8 @@ static int send_files_multithreaded_impl(Config* config) { queue_destroy(q1); if (q2) queue_destroy(q2); + if (missing_args) + array_list_delete(missing_args); return 1; } PipelineContextSender* context = pipeline_context_sender_create(config, q1, q2); diff --git a/src/client/client_send_internal.h b/src/client/client_send_internal.h index 4197ce2..f058fa4 100644 --- a/src/client/client_send_internal.h +++ b/src/client/client_send_internal.h @@ -13,6 +13,7 @@ #include "delta.h" #include "format.h" #include "log.h" +#include "protocol.h" #include "scanner.h" #include #include @@ -91,6 +92,20 @@ void client_messages_end(void); /* client_send.c */ void receive_daemon_motd(Client* client, const Config* config); Client* connect_transfer_client(const Config* config); +/* Connect the configured transport and install `session` on it: init with the + * socket fd pair, apply the I/O timeout and (when negotiated) the TLS object, + * then bind the session to this thread. Returns the connected client, or NULL + * after logging the connect failure. The caller owns the client and must keep + * `session` alive until it calls protocol_session_unbind(). */ +Client* client_connect_and_bind_session(const Config* config, ProtocolSession* session); +/* Shared --files-from/--delete-missing-args preamble for the send entry points: + * when --delete-missing-args is set, allocate the list that + * files_from_list_check fills with the destination mirrors of missing entries; + * then validate the --files-from list. On success returns true and stores the + * (possibly NULL) owned list in *missing_args_out plus the skipped count; on + * failure returns false after freeing the list. */ +bool client_prepare_files_from(const Config* config, ArrayList** missing_args_out, + int* skipped_out); void disconnect_transfer_client(Client* client); int incremental_check(Client* client, File* file, const Config* config, DeltaSignature** out_sig, unsigned long long* resume_offset); diff --git a/src/client/scanner.c b/src/client/scanner.c index 55f38f1..9bbee02 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -149,7 +149,7 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo scanner->options = *options; if (scanner->options.chunk_size == 0) scanner->options.chunk_size = DESIRED_CHUNK_SIZE; - scanner->directories = queue_create(100, dir_entry_destroy); + scanner->directories = queue_create(SCANNER_RESULT_QUEUE_CAP, dir_entry_destroy); if (!scanner->directories) { free(scanner); return NULL; @@ -1026,7 +1026,7 @@ static ScannerAction scanner_process_entry(DirectoryScanner* scanner, ArrayList* Chunk** out_chunk) { const char* name = sorted->name; ScannerEntry* inspected = &sorted->entry; - char* cur_path = inspected->path; + const char* cur_path = inspected->path; struct stat stats = inspected->stats; /* --files-from allow-set and the filter layer apply to files and to @@ -1101,61 +1101,23 @@ static ScannerAction scanner_process_entry(DirectoryScanner* scanner, ArrayList* free(rel_copy); return SCANNER_ACTION_CONTINUE; } - File* file = file_create(cur_path); - if (file == NULL) { - free(rel_copy); - free(inspected->link_target); - inspected->link_target = NULL; + /* Entry construction (data size, -R wire path, special/devices, hardlink + group, metadata, xattrs) is shared with the parallel scanner. */ + File* file = NULL; + bool build_failed = false; + ScannerBuildStatus status = + scanner_build_file_entry(&scanner->options, inspected, rel_copy, &file, &build_failed); + free(rel_copy); + rel_copy = NULL; + if (build_failed) scanner->failed = true; + if (status == SCANNER_BUILD_SKIP) return SCANNER_ACTION_CONTINUE; - } - if (inspected->is_symlink) { - file->is_symlink = true; - file->symlink_target = inspected->link_target; - inspected->link_target = NULL; - } else { - file->data->size = stats.st_size; - } - if (scanner->relative_mode) { - file->send_path = rel_copy; - rel_copy = NULL; - } else if (scanner->options.relative_prefix) { - file->send_path = scanner_prefix_send_path(scanner->options.relative_prefix, rel_copy); - free(rel_copy); - rel_copy = NULL; - if (!file->send_path) { - file_destroy(file); - scanner->failed = true; - return SCANNER_ACTION_BREAK; - } - } - /* --devices/--specials: a device/FIFO/socket entry marked for preservation - becomes a node to recreate (is_special, no data, rdev captured); an - unrequested non-regular entry is skipped (rsync default). */ - ScannerSpecial special = - scanner_prepare_special(scanner->options.preserve_devices, scanner->options.preserve_specials, - scanner->options.copy_devices, file, &stats); - if (special == SCANNER_SPECIAL_SKIP) { - scanner_note_nonreg(&scanner->options, file->path); - free(rel_copy); - file_destroy(file); - return SCANNER_ACTION_CONTINUE; - } - if (scanner->options.hardlinks && S_ISREG(stats.st_mode)) - scanner_assign_hardlink(scanner, scanner->options.hardlinks, file, &stats); - if (scanner->options.use_metadata) - file->metadata = file_metadata_create(file->path, &stats, scanner->options.preserve_atimes, - scanner->options.preserve_crtimes); - if (scanner->options.use_metadata && !file->metadata) { - free(rel_copy); - file_destroy(file); + if (status != SCANNER_BUILD_OK) { scanner->failed = true; - return SCANNER_ACTION_BREAK; + return status == SCANNER_BUILD_FAIL_CONTINUE ? SCANNER_ACTION_CONTINUE : SCANNER_ACTION_BREAK; } - if (!(file->link_group != 0 && !file->link_first)) - scanner_capture_xattrs(scanner, file); if (!array_list_add(chunk_data, file)) { - free(rel_copy); file_destroy(file); scanner->failed = true; return SCANNER_ACTION_BREAK; @@ -1163,14 +1125,12 @@ static ScannerAction scanner_process_entry(DirectoryScanner* scanner, ArrayList* scanner->current_dir_produced = true; *chunk_data_size += file->data->size; if (*chunk_data_size > scanner->options.chunk_size) { - free(rel_copy); Chunk* result = chunk_data_to_chunk(chunk_data); if (!result) scanner->failed = true; *out_chunk = result; return SCANNER_ACTION_CHUNK; } - free(rel_copy); return SCANNER_ACTION_CONTINUE; } diff --git a/src/client/scanner.h b/src/client/scanner.h index 88ff79d..406e4b1 100644 --- a/src/client/scanner.h +++ b/src/client/scanner.h @@ -19,6 +19,11 @@ * keeps one transfer from spawning an unbounded pool on a very large machine. */ #define MAX_SCANNER_THREADS 256 +/* Depth of the scanner's work queues: the sequential scanner's pending-directory + * stack and the parallel scanner's result queue. Bounds memory for a very wide + * or very deep tree while leaving ample headroom for normal scans. */ +#define SCANNER_RESULT_QUEUE_CAP 100 + typedef struct { bool use_metadata; /* Phase 4 metadata capture: -U/--atimes and -N/--crtimes tell the scanner to diff --git a/src/client/scanner_filter.c b/src/client/scanner_filter.c index d4b0007..155b835 100644 --- a/src/client/scanner_filter.c +++ b/src/client/scanner_filter.c @@ -285,32 +285,34 @@ bool entry_passes_selection(const FileListSet* file_list, const FilterRuleList* * read xattrs is non-fatal: the file is transferred without them. A symlink * entry reads the LINK's own xattrs (never the referent's) with the no-follow * variant; on Linux the VFS refuses xattrs on symlinks, so that yields NULL. */ -void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) { - if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls)) +void scanner_capture_xattrs_opts(const ScannerOptions* options, File* file) { + if (!options || !file || !(options->preserve_xattrs || options->preserve_acls)) return; - file->xattrs = file->is_symlink - ? xattr_capture_path_nofollow(file->path, scanner->options.preserve_acls) - : xattr_capture_path(file->path, scanner->options.preserve_acls); + file->xattrs = file->is_symlink ? xattr_capture_path_nofollow(file->path, options->preserve_acls) + : xattr_capture_path(file->path, options->preserve_acls); +} + +void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) { + if (!scanner) + return; + scanner_capture_xattrs_opts(&scanner->options, file); } /* Apply --hard-links (-H) detection to one regular File. On a sibling (a * later member of an already-seen source inode) the File keeps the group id * and the first member's wire path but carries NO data payload (size 0); the - * first member is left untouched (data present, link_first). Allocation - * failure is fatal: the scanner is marked failed. */ -void scanner_assign_hardlink(DirectoryScanner* scanner, HardLinkTable* table, File* file, - const struct stat* stats) { + * first member is left untouched (data present, link_first). Returns false on + * allocation failure (the caller marks the scan failed); the File stays usable + * either way. */ +bool scanner_assign_hardlink(HardLinkTable* table, File* file, const struct stat* stats) { if (!table || !file || !stats) - return; + return true; int gid; bool is_first; char* first_path = NULL; if (!hardlink_table_assign(table, file_wire_path(file), stats->st_dev, stats->st_ino, &gid, - &is_first, &first_path)) { - if (scanner) - scanner->failed = true; - return; - } + &is_first, &first_path)) + return false; file->link_group = gid; file->link_first = is_first; if (!is_first) { @@ -319,6 +321,7 @@ void scanner_assign_hardlink(DirectoryScanner* scanner, HardLinkTable* table, Fi } else { free(first_path); } + return true; } /* Phase 4 special/devices decision for one non-regular entry, matching rsync: @@ -399,6 +402,76 @@ void scanner_note_nonreg(const ScannerOptions* options, const char* fs_path) { fflush(stdout); } +/* Construct one non-directory File from an inspected entry. Shared by the + * sequential and parallel scanners so entry construction has a single + * implementation: data size (or carried symlink), -R wire path, special/devices + * classification, hardlink group, metadata and xattr capture all happen here in + * the same order for both. See the declaration for the ownership contract. */ +ScannerBuildStatus scanner_build_file_entry(const ScannerOptions* options, ScannerEntry* inspected, + const char* rel, File** out_file, bool* failed) { + *out_file = NULL; + if (failed) + *failed = false; + File* file = file_create(inspected->path); + if (!file) { + /* The File never existed, so drop the not-yet-transferred symlink target + here; the caller's entry teardown would otherwise double-free it. */ + free(inspected->link_target); + inspected->link_target = NULL; + return SCANNER_BUILD_FAIL_CONTINUE; + } + if (inspected->is_symlink) { + file->is_symlink = true; + file->symlink_target = inspected->link_target; + inspected->link_target = NULL; + } else { + file->data->size = inspected->stats.st_size; + } + /* -R + --files-from uses the bare transfer-relative path; -R without + --files-from prefixes it. Plain scans keep the source path. */ + bool relative_mode = options->relative && options->file_list != NULL; + if (relative_mode) { + file->send_path = str_dup(rel); + } else if (options->relative_prefix) { + file->send_path = scanner_prefix_send_path(options->relative_prefix, rel); + } + if ((relative_mode || options->relative_prefix) && !file->send_path) { + file_destroy(file); + return SCANNER_BUILD_FAIL_BREAK; + } + /* --devices/--specials: a device/FIFO/socket entry marked for preservation + becomes a node to recreate (is_special, no data, rdev captured); an + unrequested non-regular entry is skipped (rsync default). */ + ScannerSpecial special = + scanner_prepare_special(options->preserve_devices, options->preserve_specials, + options->copy_devices, file, &inspected->stats); + if (special == SCANNER_SPECIAL_SKIP) { + scanner_note_nonreg(options, file->path); + file_destroy(file); + return SCANNER_BUILD_SKIP; + } + if (options->hardlinks && S_ISREG(inspected->stats.st_mode) && + !scanner_assign_hardlink(options->hardlinks, file, &inspected->stats)) { + /* Allocation failure is non-fatal to this entry (it is still emitted) but + marks the scan failed, matching the historical inlined behaviour. */ + if (failed) + *failed = true; + } + if (options->use_metadata) { + file->metadata = file_metadata_create(file->path, &inspected->stats, options->preserve_atimes, + options->preserve_crtimes); + if (!file->metadata) { + file_destroy(file); + return SCANNER_BUILD_FAIL_BREAK; + } + } + /* A hardlink sibling carries no data, so it carries no xattrs. */ + if (!(file->link_group != 0 && !file->link_first)) + scanner_capture_xattrs_opts(options, file); + *out_file = file; + return SCANNER_BUILD_OK; +} + /* rsync 3.4.1's `--info=mount` line, emitted when `-xx` drops a mount-point * directory: `[sender] skipping mount-point dir NAME` (the client is the * sender). Plain `-x` keeps the empty directory and prints nothing, matching diff --git a/src/client/scanner_internal.h b/src/client/scanner_internal.h index d32bd40..400d661 100644 --- a/src/client/scanner_internal.h +++ b/src/client/scanner_internal.h @@ -62,6 +62,17 @@ typedef enum { SCANNER_SPECIAL_SKIP, /* non-regular entry not requested: skip */ } ScannerSpecial; +/* Result of scanner_build_file_entry(). The two failure variants preserve the + * sequential scanner's historical distinction between a failure before the + * File existed (which kept walking the directory) and one afterwards (which cut + * the chunk short); both mark the scan failed. */ +typedef enum { + SCANNER_BUILD_OK, /* File built; caller owns it */ + SCANNER_BUILD_SKIP, /* non-regular entry not preserved; no File */ + SCANNER_BUILD_FAIL_CONTINUE, /* failed before the File existed */ + SCANNER_BUILD_FAIL_BREAK, /* failed after the File existed */ +} ScannerBuildStatus; + /* scanner_filter.c */ void filter_node_destroy(void* item); FilterNode* filter_node_alloc(FilterNode* parent, FilterRuleList* own); @@ -79,10 +90,21 @@ bool entry_passes_selection(const FileListSet* file_list, const FilterRuleList* const FilterNode* node, const char* rel, const char* leaf, bool is_dir, bool per_dir_filters, bool exclude_filter_files, bool* protect_out); void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file); -void scanner_assign_hardlink(DirectoryScanner* scanner, HardLinkTable* table, File* file, - const struct stat* stats); +void scanner_capture_xattrs_opts(const ScannerOptions* options, File* file); +bool scanner_assign_hardlink(HardLinkTable* table, File* file, const struct stat* stats); ScannerSpecial scanner_prepare_special(bool preserve_devices, bool preserve_specials, bool copy_devices, File* file, const struct stat* stats); +/* Build one non-directory transfer File from an inspected entry. `rel` is the + * entry's transfer-root-relative path (used for the -R wire path); `inspected` + * supplies the on-disk path, stats and (for a carried symlink) the target whose + * ownership transfers to the File. Populates data size, send_path, special-node + * state, hardlink group, metadata and xattrs. On SCANNER_BUILD_OK the caller + * owns *out_file; on SCANNER_BUILD_SKIP it is NULL and the entry is dropped; on + * either failure it is NULL and the caller must mark the scan failed. `*failed` + * additionally reports a non-fatal hardlink-table allocation failure, in which + * case a usable File is still returned. */ +ScannerBuildStatus scanner_build_file_entry(const ScannerOptions* options, ScannerEntry* inspected, + const char* rel, File** out_file, bool* failed); bool excluded_sink_append(ArrayList* list, mtx_t* mtx, const char* rel); void scanner_note_nonreg(const ScannerOptions* options, const char* fs_path); void scanner_note_mount(const ScannerOptions* options, const char* fs_path); diff --git a/src/client/scanner_parallel.c b/src/client/scanner_parallel.c index 40e4330..263937c 100644 --- a/src/client/scanner_parallel.c +++ b/src/client/scanner_parallel.c @@ -109,7 +109,7 @@ static void parallel_scanner_creation_failed(ParallelScanner* ps) { /* Initialize result queue and synchronization primitives. Returns true on success. */ static bool parallel_scanner_init(ParallelScanner* ps) { - ps->result_queue = queue_create(100, chunk_destroy); + ps->result_queue = queue_create(SCANNER_RESULT_QUEUE_CAP, chunk_destroy); if (!ps->result_queue) return false; atomic_init(&ps->cancelled, false); @@ -210,6 +210,157 @@ static Chunk* batch_files(ArrayList* files, unsigned long long chunk_size, Queue return first; } +/* Record the delete-protection mirror of a root entry that + * scanner_inspect_entry() skipped (inspection == 0): a dereferenced symlink + * with no referent is a partial-transfer I/O error and a user-selection or size + * prune protects the entry's destination mirror. */ +static void scan_root_record_skipped(const ScannerOptions* options, const char* root_directory, + const char* name, const ScannerEntry* inspected, + ParallelScanner* ps) { + if (inspected->referent_error) + ps->io_error = true; + ArrayList* sink = NULL; + if (inspected->excluded) + sink = inspected->size_excluded ? options->size_skipped_paths : options->excluded_paths; + if (!sink) + return; + /* A root-level prune protects the destination mirror of the entry's wire + path: under -R + --files-from that is the bare relative name, otherwise it + is the full source path with a leading '/' removed (matching the + send_path/file_wire_path the scanner hands the sender). */ + if (options->relative && options->file_list != NULL) { + if (!excluded_sink_append(sink, options->excluded_mutex, name)) + ps->failed = true; + } else if (options->relative_prefix) { + char* wrel = scanner_prefix_send_path(options->relative_prefix, name); + if (!wrel) { + ps->failed = true; + } else { + if (!excluded_sink_append(sink, options->excluded_mutex, wrel)) + ps->failed = true; + free(wrel); + } + } else { + char* abs_path = path_cat(root_directory, name); + if (!abs_path) { + ps->failed = true; + } else { + const char* rel = *abs_path == '/' ? abs_path + 1 : abs_path; + if (!excluded_sink_append(sink, options->excluded_mutex, rel)) + ps->failed = true; + free(abs_path); + } + } +} + +/* Record the delete-protection mirror of a root entry dropped by the + * --files-from allow-set or a filter rule. Returns false only when the -R + * prefix could not be built (the caller must abandon the entry immediately); + * other allocation failures mark the scan failed but let the caller continue to + * the filter-notice step, matching the historical inlined flow. */ +static bool scan_root_record_protection(const ScannerOptions* options, const char* rel, + const char* name, const char* cur_path, bool protect, + bool passes, bool use_rel, ParallelScanner* ps) { + if (passes && !protect) + return true; + /* --files-from subset pruning is not a filter exclusion; -R bare-wire-path + exclusions are never recorded (see ScannerOptions.excluded_paths). */ + bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel); + if ((!files_from_prune && !use_rel) || protect) { + const char* rel_path; + char* prefixed = NULL; + if (use_rel) { + /* -R + --files-from: the destination/wire path is the bare relative + name, not the source path. */ + rel_path = rel; + } else if (options->relative_prefix) { + prefixed = scanner_prefix_send_path(options->relative_prefix, name); + if (!prefixed) + return false; + rel_path = prefixed; + } else { + rel_path = *cur_path == '/' ? cur_path + 1 : cur_path; + } + if (options->excluded_paths && + !excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path)) + ps->failed = true; + free(prefixed); + } + return true; +} + +/* Root-level directory node: apply -x/--one-file-system and either emit the + * mount-point directory (plain -x) or queue the directory for a worker. */ +static void scan_root_dir(const ScannerOptions* options, const char* cur_path, const char* rel, + const struct stat* st, ArrayList* root_files, ArrayList* subdirs, + dev_t root_dev, ParallelScanner* ps) { + if (!scanner_same_filesystem(options->one_file_system, root_dev, st->st_dev)) { + if (options->one_file_system > 1) { + /* -xx: drop the mount-point directory entirely (rsync) and print the + --info=mount line when enabled. */ + scanner_note_mount(options, cur_path); + return; + } + /* -x/--one-file-system: emit the mount-point directory entry (empty) but do + not descend into it (see the sequential scanner for the same rule). */ + File* mount = scanner_build_dir_file(cur_path, st, options); + if (!mount) { + ps->failed = true; + return; + } + if (options->relative_prefix) { + mount->send_path = scanner_prefix_send_path(options->relative_prefix, rel); + if (!mount->send_path) { + file_destroy(mount); + ps->failed = true; + return; + } + } + if (!array_list_add(root_files, mount)) { + file_destroy(mount); + ps->failed = true; + } + return; + } + char* dir = str_dup(cur_path); + if (!dir || !array_list_add(subdirs, dir)) { + free(dir); + ps->failed = true; + } +} + +/* Build a non-directory root entry through the shared construction path and add + * it to `root_files`. A non-regular entry the options do not preserve is + * dropped by the builder (which prints rsync's nonreg line); an allocation + * failure marks the scan failed. */ +static void scan_root_add_non_dir(const ScannerOptions* options, ScannerEntry* inspected, + const char* rel, ArrayList* root_files, ParallelScanner* ps) { + File* file = NULL; + bool failed = false; + ScannerBuildStatus status = scanner_build_file_entry(options, inspected, rel, &file, &failed); + if (failed || status == SCANNER_BUILD_FAIL_CONTINUE || status == SCANNER_BUILD_FAIL_BREAK) + ps->failed = true; + if (status != SCANNER_BUILD_OK) + return; + if (!array_list_add(root_files, file)) { + file_destroy(file); + ps->failed = true; + } +} + +/* Regular file or carried symlink at the transfer root. */ +static void scan_root_file(const ScannerOptions* options, ScannerEntry* inspected, const char* rel, + ArrayList* root_files, ParallelScanner* ps) { + scan_root_add_non_dir(options, inspected, rel, root_files, ps); +} + +/* Device/FIFO/socket at the transfer root: recreated under --devices/--specials, + * otherwise dropped by the shared builder. */ +static void scan_root_special(const ScannerOptions* options, ScannerEntry* inspected, + const char* rel, ArrayList* root_files, ParallelScanner* ps) { + scan_root_add_non_dir(options, inspected, rel, root_files, ps); +} + /* Scan one root-directory entry into either the subdirs or files list. */ static void scan_root_entry(const ScannerOptions* options, const FilterNode* root_node, const char* root_directory, const struct dirent* entry, @@ -223,51 +374,16 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo return; } if (inspection == 0) { - if (inspected.referent_error) - ps->io_error = true; - ArrayList* sink = NULL; - if (inspected.excluded) - sink = inspected.size_excluded ? options->size_skipped_paths : options->excluded_paths; - if (sink) { - /* A root-level prune protects the destination mirror of the entry's wire - path: under -R + --files-from that is the bare relative name, otherwise - it is the full source path with a leading '/' removed (matching the - send_path/file_wire_path the scanner hands the sender). */ - if (options->relative && options->file_list != NULL) { - if (!excluded_sink_append(sink, options->excluded_mutex, entry->d_name)) - ps->failed = true; - } else if (options->relative_prefix) { - char* wrel = scanner_prefix_send_path(options->relative_prefix, entry->d_name); - if (!wrel) { - ps->failed = true; - } else { - if (!excluded_sink_append(sink, options->excluded_mutex, wrel)) - ps->failed = true; - free(wrel); - } - } else { - char* abs_path = path_cat(root_directory, entry->d_name); - if (!abs_path) { - ps->failed = true; - } else { - const char* rel = *abs_path == '/' ? abs_path + 1 : abs_path; - if (!excluded_sink_append(sink, options->excluded_mutex, rel)) - ps->failed = true; - free(abs_path); - } - } - } + scan_root_record_skipped(options, root_directory, entry->d_name, &inspected, ps); return; } char* cur_path = inspected.path; - struct stat st = inspected.stats; - bool is_dir = inspected.is_directory; char* rel = str_dup(entry->d_name); if (!rel) { - free(cur_path); ps->failed = true; - return; + goto done; } + bool is_dir = inspected.is_directory; bool protect = false; bool passes = entry_passes_selection(options->file_list, options->base_filters, root_node, rel, entry->d_name, is_dir, options->per_dir_filters, @@ -275,169 +391,28 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo /* -R + --files-from: root-level files keep their bare relative send path. */ bool use_rel = options->relative && options->file_list != NULL; if (!passes || protect) { - /* --files-from subset pruning is not a filter exclusion; -R bare-wire-path - exclusions are never recorded (see ScannerOptions.excluded_paths). */ - bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel); - if ((!files_from_prune && !use_rel) || protect) { - const char* rel_path; - char* prefixed = NULL; - if (use_rel) { - /* -R + --files-from: the destination/wire path is the bare relative - name, not the source path. */ - rel_path = rel; - } else if (options->relative_prefix) { - prefixed = scanner_prefix_send_path(options->relative_prefix, entry->d_name); - if (!prefixed) { - free(rel); - free(cur_path); - ps->failed = true; - return; - } - rel_path = prefixed; - } else { - rel_path = *cur_path == '/' ? cur_path + 1 : cur_path; - } - if (options->excluded_paths && - !excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path)) - ps->failed = true; - free(prefixed); + if (!scan_root_record_protection(options, rel, entry->d_name, cur_path, protect, passes, + use_rel, ps)) { + ps->failed = true; + goto done; } if (!passes) { scanner_note_filter(options, entry->d_name); - free(rel); - free(cur_path); - return; + goto done; } } if (is_dir) { - if (!scanner_same_filesystem(options->one_file_system, root_dev, st.st_dev)) { - if (options->one_file_system > 1) { - /* -xx: drop the mount-point directory entirely (rsync) and print the - --info=mount line when enabled. */ - scanner_note_mount(options, cur_path); - free(rel); - free(cur_path); - return; - } - /* -x/--one-file-system: emit the mount-point directory entry (empty) but - do not descend into it (see the sequential scanner for the same rule). */ - File* mount = file_create(cur_path); - free(cur_path); - if (mount == NULL) { - free(rel); - ps->failed = true; - return; - } - mount->is_dir = true; - if (options->use_metadata) { - mount->metadata = file_metadata_create(mount->path, &st, options->preserve_atimes, - options->preserve_crtimes); - if (!mount->metadata) { - free(rel); - file_destroy(mount); - ps->failed = true; - return; - } - } - if (options->relative_prefix) { - mount->send_path = scanner_prefix_send_path(options->relative_prefix, rel); - if (!mount->send_path) { - free(rel); - file_destroy(mount); - ps->failed = true; - return; - } - } - free(rel); - if (!array_list_add(root_files, mount)) { - file_destroy(mount); - ps->failed = true; - } - return; - } - free(rel); - if (!array_list_add(subdirs, cur_path)) { - free(cur_path); - ps->failed = true; - } - return; - } - File* file = file_create(cur_path); - free(cur_path); - if (!file) { - free(rel); - free(inspected.link_target); - inspected.link_target = NULL; - ps->failed = true; - return; - } - if (inspected.is_symlink) { - file->is_symlink = true; - file->symlink_target = inspected.link_target; - inspected.link_target = NULL; + scan_root_dir(options, cur_path, rel, &inspected.stats, root_files, subdirs, root_dev, ps); + } else if (S_ISCHR(inspected.stats.st_mode) || S_ISBLK(inspected.stats.st_mode) || + S_ISFIFO(inspected.stats.st_mode) || S_ISSOCK(inspected.stats.st_mode)) { + scan_root_special(options, &inspected, rel, root_files, ps); } else { - file->data->size = st.st_size; - } - if (use_rel) { - file->send_path = rel; - rel = NULL; - } else if (options->relative_prefix) { - file->send_path = scanner_prefix_send_path(options->relative_prefix, rel); - free(rel); - rel = NULL; - if (!file->send_path) { - file_destroy(file); - ps->failed = true; - return; - } - } - ScannerSpecial special = scanner_prepare_special( - options->preserve_devices, options->preserve_specials, options->copy_devices, file, &st); - if (special == SCANNER_SPECIAL_SKIP) { - scanner_note_nonreg(ps->options, file->path); - free(rel); - file_destroy(file); - return; - } - if (options->hardlinks && S_ISREG(st.st_mode)) { - int gid; - bool is_first; - char* first_path = NULL; - if (!hardlink_table_assign((HardLinkTable*)options->hardlinks, file_wire_path(file), st.st_dev, - st.st_ino, &gid, &is_first, &first_path)) { - ps->failed = true; - } else { - file->link_group = gid; - file->link_first = is_first; - if (!is_first) { - file->hardlink_target = first_path; - file->data->size = 0; - } else { - free(first_path); - } - } - } - if (options->use_metadata) - file->metadata = - file_metadata_create(file->path, &st, options->preserve_atimes, options->preserve_crtimes); - if (options->use_metadata && !file->metadata) { - free(rel); - file_destroy(file); - ps->failed = true; - return; - } - if ((options->preserve_xattrs || options->preserve_acls) && - !(file->link_group != 0 && !file->link_first)) - file->xattrs = file->is_symlink - ? xattr_capture_path_nofollow(file->path, options->preserve_acls) - : xattr_capture_path(file->path, options->preserve_acls); - if (!array_list_add(root_files, file)) { - free(rel); - file_destroy(file); - ps->failed = true; - return; + scan_root_file(options, &inspected, rel, root_files, ps); } +done: free(rel); + free(cur_path); + free(inspected.link_target); } /* Scan the root directory itself, collecting root files and subdirectories. diff --git a/src/server/server.c b/src/server/server.c index aea5de3..6785ec6 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -1306,6 +1306,284 @@ static bool daemonize(void) { return true; } +/* Apply the process-wide policies shared by the stdio and listener + * entrypoints: logging verbosity, signal handling, the parsed server + * authorization policies, and the socket timeout floor. Runs after CLI + * parsing and after the standalone --hash-credentials tool has been ruled + * out. */ +static void configure_server_process(const ServerCliOptions* opts) { + signal(SIGPIPE, SIG_IGN); + if (opts->verbose) { + set_log_level(LOG_LEVEL_DEBUG); + set_log_debug_flags(LOG_DEBUG_ALL); + } + if (opts->tls_ca && !opts->use_tls) + log_message(LOG_LEVEL_WARNING, "--ca has no effect without --tls"); + /* Persist the parsed server policies into the process-global policy state + * BEFORE the stdio branch: an SSH-launched `--stdio` server (whose argv came + * from the client via --remote-option and friends) must honor --allow-delete, + * --trust-sender and --client-cn exactly like the standalone listener. */ + required_client_cn = opts->client_cn; + allow_delete = opts->allow_delete; + trust_sender = opts->trust_sender; + allow_unauthenticated = opts->allow_unauthenticated; + 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; + install_cleanup_handler(SIGINT); + install_cleanup_handler(SIGTERM); + /* Server-owned socket deadline floor: the client default --timeout=0 would + * otherwise leave accepted sockets without SO_RCVTIMEO/SO_SNDTIMEO and let a + * silent peer hold a connection (and its process slot) forever. */ + tcp_set_timeouts(SERVER_IO_TIMEOUT_SEC, SERVER_IO_TIMEOUT_SEC); +} + +/* --hash-credentials: standalone offline tool; read user:password lines and + * emit new-format credential-store lines, then exit. Consumes and frees + * opts. */ +static int run_hash_credentials_tool(ServerCliOptions* opts) { + uint32_t iters = opts->hash_iterations_set ? opts->hash_iterations : CREDENTIAL_DEFAULT_ITERS; + /* The output is secret material: if it is redirected to a regular file, + * warn when that file is group/other-accessible (the store must be 0600). */ + struct stat out_st; + if (fstat(STDOUT_FILENO, &out_st) == 0 && S_ISREG(out_st.st_mode) && + (out_st.st_mode & (S_IRWXG | S_IRWXO)) != 0) + fprintf(stderr, + "Warning: credential-store output is a group/other-accessible file; restrict it to " + "mode 0600 (chmod 600)\n"); + char hash_err[512]; + if (credentials_hash_file(opts->hash_credentials_file, iters, stdout, hash_err, + sizeof(hash_err)) != 0) { + fprintf(stderr, "Error: %s\n", hash_err); + server_cli_options_free(opts); + return 1; + } + server_cli_options_free(opts); + return 0; +} + +/* SSH --stdio session: the transport is authenticated by sshd outside of + * FastSync, so the single connection is served over STDIN/STDOUT and the + * process exits. The destination root is authorized exactly like the listener + * path. Consumes and frees opts. */ +static int run_stdio_server(ServerCliOptions* opts) { + /* SSH authenticates the stdio transport outside of FastSync. */ + allow_unauthenticated = true; + if (!configure_authorization(opts->destination_root)) { + char* escaped = output_escape(opts->destination_root, false); + fprintf(stderr, "Error: invalid destination root '%s'\n", + escaped ? escaped : ""); + free(escaped); + server_cli_options_free(opts); + return 1; + } + io_set_fds(STDIN_FILENO, STDOUT_FILENO); + /* handler() does not own the stdio fds: it never closes its descriptor + * argument, so STDIN/STDOUT stay open for this (single-shot) SSH session + * and are released by process exit. */ + handler(STDIN_FILENO); + release_authorization(); + server_cli_options_free(opts); + return 0; +} + +/* Load the daemon config, apply --dparam overrides, resolve the effective + * port/address, and surface the operator-facing module warnings. On failure + * the error is printed and false is returned. */ +static bool load_daemon_policy(ServerCliOptions* opts, int* port, const char** bind_address, + char* err, size_t err_size) { + const char* config_path = opts->config_path ? opts->config_path : default_daemon_config_path(); + g_daemon_conf = daemon_conf_load(config_path, err, err_size); + if (!g_daemon_conf) { + fprintf(stderr, "Error: %s\n", err); + return false; + } + for (int i = 0; i < opts->dparam_count; i++) { + if (daemon_conf_apply_dparam(g_daemon_conf, opts->dparams[i], err, err_size) != 0) { + fprintf(stderr, "Error: --dparam: %s\n", err); + return false; + } + } + /* Effective port: -p (highest) > --dparam port > config port (default 873). */ + if (!opts->port_set) + *port = g_daemon_conf->global.port; + if (!*bind_address) + *bind_address = g_daemon_conf->global.address; + if (g_daemon_conf->module_count == 0) + log_message(LOG_LEVEL_WARNING, + "daemon config has no modules; every connection will be refused"); + /* Surface the operator's client-chosen-ownership opt-in prominently: an + opted-in module lets its clients request arbitrary owner ids inside that + module root. */ + for (int i = 0; i < g_daemon_conf->module_count; i++) { + if (g_daemon_conf->modules[i].client_owner) + log_message(LOG_LEVEL_WARNING, + "daemon module '%s' allows client-chosen ownership and super-user device " + "activities (`client owner = yes`); clients may request arbitrary owner ids " + "and device nodes within that module root -- pair it with `auth users` " + "unless the module is intentionally open to the network", + g_daemon_conf->modules[i].name); + if (g_daemon_conf->modules[i].max_connections > 0) + log_message(LOG_LEVEL_INFO, + "daemon module '%s': per-module 'max connections' cap = %d (enforced " + "across all connection children)", + g_daemon_conf->modules[i].name, g_daemon_conf->modules[i].max_connections); + } + return true; +} + +/* Load the daemon credential store (Wave B) and enforce the fail-closed + * startup check: a module that declares `auth users` without a store (or with + * an empty store) refuses to start rather than serving a module whose + * credentials can never be verified. On failure the error is printed and + * false is returned. */ +static bool validate_daemon_credentials(const ServerCliOptions* opts, char* err, size_t err_size) { + /* --password-file and --early-input feed the same store, loaded BEFORE the + * listener forks so every connection child shares one read-only store. */ + g_credentials = credentials_load(opts->password_file, opts->early_input_file, err, err_size); + if (!g_credentials) { + fprintf(stderr, "Error: %s\n", err); + return false; + } + bool credential_source_given = opts->password_file != NULL || opts->early_input_file != NULL; + for (int i = 0; i < g_daemon_conf->module_count; i++) { + const DaemonModule* module = &g_daemon_conf->modules[i]; + if (module->auth_user_count == 0) + continue; + if (!credential_source_given) { + fprintf(stderr, + "Error: module '%s' declares 'auth users' but no credential store was given " + "(--password-file or --early-input); refusing to start (fail closed)\n", + module->name); + return false; + } + if (credentials_store_size(g_credentials) == 0) { + fprintf(stderr, + "Error: module '%s' declares 'auth users' but the credential store is empty; " + "refusing to start (fail closed)\n", + module->name); + return false; + } + for (int j = 0; j < module->auth_user_count; j++) { + if (!credentials_store_has(g_credentials, module->auth_users[j])) + log_message(LOG_LEVEL_WARNING, + "daemon module '%s': auth user '%s' has no credential store entry; that " + "user can never authenticate", + module->name, module->auth_users[j]); + } + } + return true; +} + +/* Create the shared cross-process registry for the per-module / per-source + * caps and the auth lockout. Called in the parent before any accept-loop fork; + * every connection child inherits the mapping. A failure degrades to + * "registry disabled" (the global cap and host ACLs still apply) rather than + * refusing to start. */ +static void create_daemon_limits(void) { + g_daemon_limits = daemon_limits_create( + (int)g_daemon_conf->global.max_connections, g_daemon_conf->module_count, + g_daemon_conf->global.max_connections_per_host, g_daemon_conf->global.auth_lockout_threshold, + g_daemon_conf->global.auth_lockout_duration_sec); + if (!g_daemon_limits) + log_message(LOG_LEVEL_WARNING, + "daemon: could not allocate the shared connection registry; per-module / " + "per-host caps and the cross-process auth lockout are disabled (the global " + "'max connections' cap and host ACLs still apply)"); +} + +/* Bind the listener, apply the daemon caps, set up TLS when requested, detach + * when daemonizing, and run the accept loop. Returns the process exit code. */ +static int start_listener(ServerCliOptions* opts, int port, int bind_family, + const char* bind_address) { + ServerBindOptions bind_opts; + bind_opts.bind_address = bind_address; + bind_opts.family = bind_family; + g_server = server_create_ex(port, &bind_opts); + if (!g_server) { + log_message(LOG_LEVEL_ERROR, "Failed to create server"); + release_authorization(); + return 1; + } + if (g_daemon_conf) + server_set_max_connections(g_server, (unsigned int)g_daemon_conf->global.max_connections); + if (g_daemon_limits) + server_set_limit_registry(g_server, g_daemon_limits); + if (opts->use_tls) { + if (!opts->tls_cert || !opts->tls_key || !opts->tls_ca || !opts->client_cn) { + fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n"); + server_delete(&g_server); + release_authorization(); + return 1; + } + tls_global_init(); + if (!server_create_tls(g_server, opts->tls_cert, opts->tls_key, opts->tls_ca)) { + log_message(LOG_LEVEL_ERROR, "Failed to set up TLS"); + server_delete(&g_server); + release_authorization(); + return 1; + } + } + /* Detach after the listening socket (and TLS context) exist so the + * background daemon inherits a fully-bound listener. --no-detach runs in + * the foreground, which is how tests drive the daemon. */ + if (opts->daemon_mode && !opts->no_detach) { + if (!daemonize()) { + log_message(LOG_LEVEL_ERROR, "Failed to daemonize"); + server_delete(&g_server); + release_authorization(); + return 1; + } + } + if (opts->use_tls) + server_listen_tls(g_server, handler); + else + server_listen(g_server, handler); + server_delete(&g_server); + release_authorization(); + return 0; +} + +/* Listener entrypoint: the daemon (config-driven, possibly detached) and the + * standalone TCP server share the same bind/TLS/listen path. Consumes and + * frees opts. */ +static int run_daemon_server(ServerCliOptions* opts) { + int port = opts->port; + const char* bind_address = opts->bind_address; + char cli_err[512]; + int exit_code = 0; + + if (opts->daemon_mode) { + if (!load_daemon_policy(opts, &port, &bind_address, cli_err, sizeof(cli_err)) || + !validate_daemon_credentials(opts, cli_err, sizeof(cli_err))) { + exit_code = 1; + goto out; + } + create_daemon_limits(); + } else if (!configure_authorization(opts->destination_root)) { + char* escaped = output_escape(opts->destination_root, false); + fprintf(stderr, "Error: invalid destination root '%s'\n", + escaped ? escaped : ""); + free(escaped); + exit_code = 1; + goto out; + } + + exit_code = start_listener(opts, port, opts->bind_family, bind_address); + +out: + daemon_limits_destroy(g_daemon_limits); + g_daemon_limits = NULL; + daemon_conf_free(g_daemon_conf); + g_daemon_conf = NULL; + credentials_free(g_credentials); + g_credentials = NULL; + server_cli_options_free(opts); + return exit_code; +} + int main(int argc, char* argv[]) { /* Capture the process umask now, while still single-threaded: the cached * value is what file_mode_base() uses, and reading it later would race with @@ -1325,250 +1603,12 @@ int main(int argc, char* argv[]) { return 1; } - /* --hash-credentials: standalone offline tool; read user:password lines and - * emit new-format credential-store lines, then exit. */ - if (opts.hash_credentials_file) { - uint32_t iters = opts.hash_iterations_set ? opts.hash_iterations : CREDENTIAL_DEFAULT_ITERS; - /* The output is secret material: if it is redirected to a regular file, - * warn when that file is group/other-accessible (the store must be 0600). */ - struct stat out_st; - if (fstat(STDOUT_FILENO, &out_st) == 0 && S_ISREG(out_st.st_mode) && - (out_st.st_mode & (S_IRWXG | S_IRWXO)) != 0) - fprintf(stderr, - "Warning: credential-store output is a group/other-accessible file; restrict it to " - "mode 0600 (chmod 600)\n"); - char hash_err[512]; - if (credentials_hash_file(opts.hash_credentials_file, iters, stdout, hash_err, - sizeof(hash_err)) != 0) { - fprintf(stderr, "Error: %s\n", hash_err); - server_cli_options_free(&opts); - return 1; - } - server_cli_options_free(&opts); - return 0; - } + if (opts.hash_credentials_file) + return run_hash_credentials_tool(&opts); - int exit_code = 0; - signal(SIGPIPE, SIG_IGN); - if (opts.verbose) { - set_log_level(LOG_LEVEL_DEBUG); - set_log_debug_flags(LOG_DEBUG_ALL); - } - if (opts.tls_ca && !opts.use_tls) - log_message(LOG_LEVEL_WARNING, "--ca has no effect without --tls"); - /* Persist the parsed server policies into the process-global policy state - * BEFORE the stdio branch: an SSH-launched `--stdio` server (whose argv came - * from the client via --remote-option and friends) must honor --allow-delete, - * --trust-sender and --client-cn exactly like the standalone listener. */ - required_client_cn = opts.client_cn; - allow_delete = opts.allow_delete; - trust_sender = opts.trust_sender; - allow_unauthenticated = opts.allow_unauthenticated; - 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; - install_cleanup_handler(SIGINT); - install_cleanup_handler(SIGTERM); - /* Server-owned socket deadline floor: the client default --timeout=0 would - * otherwise leave accepted sockets without SO_RCVTIMEO/SO_SNDTIMEO and let a - * silent peer hold a connection (and its process slot) forever. */ - tcp_set_timeouts(SERVER_IO_TIMEOUT_SEC, SERVER_IO_TIMEOUT_SEC); - - if (opts.stdio_mode) { - /* SSH authenticates the stdio transport outside of FastSync. */ - allow_unauthenticated = true; - if (!configure_authorization(opts.destination_root)) { - char* escaped = output_escape(opts.destination_root, false); - fprintf(stderr, "Error: invalid destination root '%s'\n", - escaped ? escaped : ""); - free(escaped); - server_cli_options_free(&opts); - return 1; - } - io_set_fds(STDIN_FILENO, STDOUT_FILENO); - /* handler() does not own the stdio fds: it never closes its descriptor - * argument, so STDIN/STDOUT stay open for this (single-shot) SSH session - * and are released by process exit. */ - handler(STDIN_FILENO); - release_authorization(); - server_cli_options_free(&opts); - return 0; - } - - int port = opts.port; - int bind_family = opts.bind_family; - const char* bind_address = opts.bind_address; - - if (opts.daemon_mode) { - const char* config_path = opts.config_path ? opts.config_path : default_daemon_config_path(); - g_daemon_conf = daemon_conf_load(config_path, cli_err, sizeof(cli_err)); - if (!g_daemon_conf) { - server_cli_options_free(&opts); - fprintf(stderr, "Error: %s\n", cli_err); - return 1; - } - for (int i = 0; i < opts.dparam_count; i++) { - if (daemon_conf_apply_dparam(g_daemon_conf, opts.dparams[i], cli_err, sizeof(cli_err)) != 0) { - fprintf(stderr, "Error: --dparam: %s\n", cli_err); - exit_code = 1; - goto out; - } - } - /* Effective port: -p (highest) > --dparam port > config port (default 873). */ - if (!opts.port_set) - port = g_daemon_conf->global.port; - if (!bind_address) - bind_address = g_daemon_conf->global.address; - if (g_daemon_conf->module_count == 0) - log_message(LOG_LEVEL_WARNING, - "daemon config has no modules; every connection will be refused"); - /* Surface the operator's client-chosen-ownership opt-in prominently: an - opted-in module lets its clients request arbitrary owner ids inside that - module root. */ - for (int i = 0; i < g_daemon_conf->module_count; i++) { - if (g_daemon_conf->modules[i].client_owner) - log_message(LOG_LEVEL_WARNING, - "daemon module '%s' allows client-chosen ownership and super-user device " - "activities (`client owner = yes`); clients may request arbitrary owner ids " - "and device nodes within that module root -- pair it with `auth users` " - "unless the module is intentionally open to the network", - g_daemon_conf->modules[i].name); - if (g_daemon_conf->modules[i].max_connections > 0) - log_message(LOG_LEVEL_INFO, - "daemon module '%s': per-module 'max connections' cap = %d (enforced " - "across all connection children)", - g_daemon_conf->modules[i].name, g_daemon_conf->modules[i].max_connections); - } - /* Daemon credential store (Wave B). --password-file and --early-input - * feed the same store, loaded BEFORE the listener forks so every - * connection child shares one read-only store. Fail closed at startup: a - * module that declares `auth users` without a store (or with an empty - * store) refuses to start rather than serving a module whose credentials - * can never be verified. */ - g_credentials = - credentials_load(opts.password_file, opts.early_input_file, cli_err, sizeof(cli_err)); - if (!g_credentials) { - server_cli_options_free(&opts); - fprintf(stderr, "Error: %s\n", cli_err); - return 1; - } - bool credential_source_given = opts.password_file != NULL || opts.early_input_file != NULL; - for (int i = 0; i < g_daemon_conf->module_count; i++) { - const DaemonModule* module = &g_daemon_conf->modules[i]; - if (module->auth_user_count == 0) - continue; - if (!credential_source_given) { - fprintf(stderr, - "Error: module '%s' declares 'auth users' but no credential store was given " - "(--password-file or --early-input); refusing to start (fail closed)\n", - module->name); - server_cli_options_free(&opts); - return 1; - } - if (credentials_store_size(g_credentials) == 0) { - fprintf(stderr, - "Error: module '%s' declares 'auth users' but the credential store is empty; " - "refusing to start (fail closed)\n", - module->name); - server_cli_options_free(&opts); - return 1; - } - for (int j = 0; j < module->auth_user_count; j++) { - if (!credentials_store_has(g_credentials, module->auth_users[j])) - log_message(LOG_LEVEL_WARNING, - "daemon module '%s': auth user '%s' has no credential store entry; that " - "user can never authenticate", - module->name, module->auth_users[j]); - } - } - /* Shared cross-process registry for the per-module / per-source caps and - * the auth lockout. Created HERE in the parent before any accept-loop - * fork; every connection child inherits the mapping. A failure degrades to - * "registry disabled" (the global cap and host ACLs still apply) rather - * than refusing to start. */ - g_daemon_limits = daemon_limits_create((int)g_daemon_conf->global.max_connections, - g_daemon_conf->module_count, - g_daemon_conf->global.max_connections_per_host, - g_daemon_conf->global.auth_lockout_threshold, - g_daemon_conf->global.auth_lockout_duration_sec); - if (!g_daemon_limits) - log_message(LOG_LEVEL_WARNING, - "daemon: could not allocate the shared connection registry; per-module / " - "per-host caps and the cross-process auth lockout are disabled (the global " - "'max connections' cap and host ACLs still apply)"); - } else { - if (!configure_authorization(opts.destination_root)) { - char* escaped = output_escape(opts.destination_root, false); - fprintf(stderr, "Error: invalid destination root '%s'\n", - escaped ? escaped : ""); - free(escaped); - server_cli_options_free(&opts); - return 1; - } - } - - ServerBindOptions bind_opts; - bind_opts.bind_address = bind_address; - bind_opts.family = bind_family; - g_server = server_create_ex(port, &bind_opts); - if (!g_server) { - log_message(LOG_LEVEL_ERROR, "Failed to create server"); - release_authorization(); - exit_code = 1; - goto out; - } - if (g_daemon_conf) - server_set_max_connections(g_server, (unsigned int)g_daemon_conf->global.max_connections); - if (g_daemon_limits) - server_set_limit_registry(g_server, g_daemon_limits); - if (opts.use_tls) { - if (!opts.tls_cert || !opts.tls_key || !opts.tls_ca || !opts.client_cn) { - fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n"); - server_delete(&g_server); - release_authorization(); - exit_code = 1; - goto out; - } - tls_global_init(); - if (!server_create_tls(g_server, opts.tls_cert, opts.tls_key, opts.tls_ca)) { - log_message(LOG_LEVEL_ERROR, "Failed to set up TLS"); - server_delete(&g_server); - release_authorization(); - exit_code = 1; - goto out; - } - } - - /* Detach after the listening socket (and TLS context) exist so the - * background daemon inherits a fully-bound listener. --no-detach runs in - * the foreground, which is how tests drive the daemon. */ - if (opts.daemon_mode && !opts.no_detach) { - if (!daemonize()) { - log_message(LOG_LEVEL_ERROR, "Failed to daemonize"); - server_delete(&g_server); - release_authorization(); - exit_code = 1; - goto out; - } - } - - if (opts.use_tls) - server_listen_tls(g_server, handler); - else - server_listen(g_server, handler); - server_delete(&g_server); - release_authorization(); - -out: - daemon_limits_destroy(g_daemon_limits); - g_daemon_limits = NULL; - daemon_conf_free(g_daemon_conf); - g_daemon_conf = NULL; - credentials_free(g_credentials); - g_credentials = NULL; - server_cli_options_free(&opts); - return exit_code; + configure_server_process(&opts); + if (opts.stdio_mode) + return run_stdio_server(&opts); + return run_daemon_server(&opts); } #endif diff --git a/src/shared/delete.c b/src/shared/delete.c index 942ec1c..8184d01 100644 --- a/src/shared/delete.c +++ b/src/shared/delete.c @@ -224,6 +224,235 @@ typedef struct { void* observer_context; /* DELETE mode */ } DeleteWalkState; +/* The per-walk invariants threaded unchanged through every recursive descent: + the keep/synchronized-dir indexes, the destination mode and the protection + rules. Bundling them keeps the recursive helpers below to a handful of + positional arguments. */ +typedef struct { + const PathIndex* keep; + const PathIndex* dirs; + DeleteWalkState* state; + const DeleteSkipEntry* skips; + int skip_count; + const DeleteProtectRules* protect; +} DeleteWalkContext; + +/* Duplicate `path` with rsync's trailing-slash convention, used to report a + removed (or would-be-removed) directory. Returns NULL on allocation + failure. */ +static char* with_trailing_slash(const char* path) { + size_t len = strlen(path); + char* copy = malloc(len + 2); + if (!copy) + return NULL; + memcpy(copy, path, len); + copy[len] = '/'; + copy[len + 1] = '\0'; + return copy; +} + +/* Forward declaration: the ordered passes below recurse through the driver. */ +static bool delete_walk_fd(int dirfd, const char* rel_path, const DeleteWalkContext* ctx, + bool parent_deletable, bool* all_removed); + +/* Descend into the child directory `name` of `dirfd`, walking it as part of the + current operation. Returns false on a genuine open/walk failure; on success + *child_all_removed reports whether the child removed everything it held (so + the caller may rmdir it). */ +static bool delete_walk_child(int dirfd, const char* name, const char* child_rel, + const DeleteWalkContext* ctx, bool deletable, + bool* child_all_removed) { + *child_all_removed = false; + int childfd = openat(dirfd, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (childfd < 0) + return errno == ENOENT; + bool ok = delete_walk_fd(childfd, child_rel, ctx, deletable, child_all_removed); + close(childfd); + return ok; +} + +/* Classify every entry up front (the verdict does not depend on processing + order) so the ordered passes below can act on it. Sets shielded[]/is_extra[] + and reports through *local_survives whether anything in this directory stays + in place. Returns false on a path-construction failure. */ +static bool delete_walk_classify(const char* rel_path, const DeleteDirEntry* entries, size_t count, + const DeleteWalkContext* ctx, bool deletable, bool at_root, + bool* shielded, bool* is_extra, bool* local_survives) { + bool ok = true; + for (size_t i = 0; i < count; i++) { + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + ok = false; + continue; + } + /* A --delay-updates run keeps its staging directory as a direct child of + the receive root, and basis-dir snapshots live below it too. Their + contents are not manifest entries, so descending into them would delete + every staged / basis file as an "extra". Only the staging name (a + top-level-only prefix) and the basis prefixes are protected: a nested + destination directory that happens to be called .fastsync-stage is + ordinary content. */ + if (path_under_skip_prefix(child_rel, at_root, ctx->skips, ctx->skip_count)) { + shielded[i] = true; + *local_survives = true; + } else if (delete_protect_verdict(ctx->protect, child_rel, entries[i].name, + entries[i].is_dir) == FILTER_ACTION_PROTECT) { + /* A first-match protect rule shields the extra; for a directory the whole + subtree is shielded (rsync prunes an excluded directory), so do not + descend. */ + shielded[i] = true; + *local_survives = true; + } else if (entries[i].is_dir) { + bool child_synced = ctx->dirs && path_index_contains(ctx->dirs, child_rel); + is_extra[i] = deletable && !child_synced && !keep_is_dir(ctx->keep, child_rel); + if (!is_extra[i]) + *local_survives = true; + } else { + is_extra[i] = deletable && !keep_is_file(ctx->keep, child_rel); + if (!is_extra[i]) + *local_survives = true; + } + free(child_rel); + } + return ok; +} + +/* Pass 1: extraneous subdirectories, descending. Recurses into each and, when + the child removed everything it held, records or removes it and charges the + budget. */ +static bool delete_walk_extra_dirs(int dirfd, const char* rel_path, const DeleteDirEntry* entries, + size_t dir_count, const DeleteWalkContext* ctx, bool deletable, + const bool* is_extra, bool* local_survives) { + bool ok = true; + for (size_t i = 0; i < dir_count; i++) { + if (!is_extra[i]) + continue; + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + ok = false; + continue; + } + bool child_all_removed = false; + if (!delete_walk_child(dirfd, entries[i].name, child_rel, ctx, deletable, &child_all_removed)) + ok = false; + if (child_all_removed && deletable) { + if (ctx->state->mode == DELETE_WALK_MODE_LIST) { + /* Record the directory with rsync's trailing slash. */ + char* copy = with_trailing_slash(child_rel); + if (!copy) { + ok = false; + } else if (!array_list_add(ctx->state->out, copy)) { + free(copy); + ok = false; + } else { + (*ctx->state->recorded)++; + } + } else if (ctx->state->budget->deleted >= ctx->state->budget->max_delete) { + ctx->state->budget->limit_hit = true; + ctx->state->budget->skipped++; + *local_survives = true; + } else if (unlinkat(dirfd, entries[i].name, AT_REMOVEDIR) != 0) { + /* ENOENT: already gone (fine). ENOTEMPTY/EEXIST: the directory still + holds entries the walker leaves in place (a protected excluded + prefix, a kept file the manifest protects, a symlink); rsync leaves + such a directory behind, so this is not an error. Only genuine I/O + failures abort the deletion. */ + if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) + ok = false; + *local_survives = true; + } else { + ctx->state->budget->deleted++; + /* rsync reports a removed directory with a trailing slash. */ + if (ctx->state->observer) { + char* with_slash = with_trailing_slash(child_rel); + if (with_slash) { + ctx->state->observer(ctx->state->observer_context, with_slash, DELETE_ENTRY_DIR); + free(with_slash); + } else { + ctx->state->observer(ctx->state->observer_context, child_rel, DELETE_ENTRY_DIR); + } + } + } + } else { + *local_survives = true; + } + free(child_rel); + } + return ok; +} + +/* Pass 2: extraneous files, descending. */ +static bool delete_walk_extra_files(int dirfd, const char* rel_path, const DeleteDirEntry* entries, + size_t dir_count, size_t count, const DeleteWalkContext* ctx, + const bool* is_extra, bool* local_survives) { + bool ok = true; + for (size_t i = dir_count; i < count; i++) { + if (!is_extra[i]) + continue; + if (ctx->state->mode == DELETE_WALK_MODE_LIST) { + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + ok = false; + continue; + } + char* copy = str_dup(child_rel); + if (!copy || !array_list_add(ctx->state->out, copy)) { + free(copy); + ok = false; + } else { + (*ctx->state->recorded)++; + } + free(child_rel); + } else if (ctx->state->budget->deleted >= ctx->state->budget->max_delete) { + ctx->state->budget->limit_hit = true; + ctx->state->budget->skipped++; + *local_survives = true; + } else if (unlinkat(dirfd, entries[i].name, 0) != 0) { + if (errno != ENOENT) + ok = false; + *local_survives = true; + } else { + ctx->state->budget->deleted++; + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (child_rel) { + if (ctx->state->observer) + ctx->state->observer(ctx->state->observer_context, child_rel, + delete_entry_type_of_mode(entries[i].mode)); + char* escaped_path = output_escape(child_rel, log_get_8_bit_output()); + fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : ""); + free(escaped_path); + } + free(child_rel); + } + } + return ok; +} + +/* Pass 3: kept subdirectories, ascending (rsync descends into these only after + the parent's own extras have been handled). */ +static bool delete_walk_kept_dirs(int dirfd, const char* rel_path, const DeleteDirEntry* entries, + size_t dir_count, const DeleteWalkContext* ctx, bool deletable, + const bool* is_extra, const bool* shielded, + bool* local_survives) { + bool ok = true; + for (size_t i = dir_count; i-- > 0;) { + if (is_extra[i] || shielded[i]) + continue; + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + ok = false; + continue; + } + bool child_all_removed = false; + if (!delete_walk_child(dirfd, entries[i].name, child_rel, ctx, deletable, &child_all_removed)) + ok = false; + /* A kept/synchronized directory is never removed. */ + *local_survives = true; + free(child_rel); + } + return ok; +} + /* Remove the extras directly inside the directory open on `dirfd` (DELETE mode) or record the paths that WOULD be removed (LIST mode), recursing into every child directory so kept content below a synchronized prefix is reached. @@ -238,11 +467,8 @@ typedef struct { descending name order, then extraneous files, then kept subdirectories in ascending order) rather than readdir() order, so `--max-delete` leaves the same survivors and the `--info=del`/dry-run line order matches rsync. */ -static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* keep, - const PathIndex* dirs, DeleteWalkState* state, - const DeleteSkipEntry* skips, int skip_count, - const DeleteProtectRules* protect, bool parent_deletable, - bool* all_removed) { +static bool delete_walk_fd(int dirfd, const char* rel_path, const DeleteWalkContext* ctx, + bool parent_deletable, bool* all_removed) { DeleteDirEntry* entries = NULL; size_t count = 0; bool collect_ok = true; @@ -261,7 +487,7 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee /* A directory is deletable when it or ANY ancestor is synchronized; the `parent_deletable` flag carries that down the recursion so dest-only directories below a synchronized root are removed wholesale. */ - bool deletable = parent_deletable || is_synced_dir(dirs, rel_path); + bool deletable = parent_deletable || is_synced_dir(ctx->dirs, rel_path); bool at_root = rel_path[0] == '\0'; /* Reproduce rsync's traversal order: extraneous subdirectories in descending @@ -274,182 +500,18 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee while (dir_count < count && entries[dir_count].is_dir) dir_count++; - /* Classify every entry up front (the verdict does not depend on processing - order) so the ordered passes below can act on it. */ - for (size_t i = 0; i < count; i++) { - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - /* A --delay-updates run keeps its staging directory as a direct child of - the receive root, and basis-dir snapshots live below it too. Their - contents are not manifest entries, so descending into them would delete - every staged / basis file as an "extra". Only the staging name (a - top-level-only prefix) and the basis prefixes are protected: a nested - destination directory that happens to be called .fastsync-stage is - ordinary content. */ - if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { - shielded[i] = true; - local_survives = true; - } else if (delete_protect_verdict(protect, child_rel, entries[i].name, entries[i].is_dir) == - FILTER_ACTION_PROTECT) { - /* A first-match protect rule shields the extra; for a directory the whole - subtree is shielded (rsync prunes an excluded directory), so do not - descend. */ - shielded[i] = true; - local_survives = true; - } else if (entries[i].is_dir) { - bool child_synced = dirs && path_index_contains(dirs, child_rel); - is_extra[i] = deletable && !child_synced && !keep_is_dir(keep, child_rel); - if (!is_extra[i]) - local_survives = true; - } else { - is_extra[i] = deletable && !keep_is_file(keep, child_rel); - if (!is_extra[i]) - local_survives = true; - } - free(child_rel); - } - - /* Pass 1: extraneous subdirectories, descending. */ - for (size_t i = 0; i < dir_count; i++) { - if (!is_extra[i]) - continue; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - bool child_all_removed = false; - if (childfd >= 0) { - if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect, - deletable, &child_all_removed)) - operation_ok = false; - close(childfd); - } else if (errno != ENOENT) { - operation_ok = false; - } - if (child_all_removed && deletable) { - if (state->mode == DELETE_WALK_MODE_LIST) { - /* Record the directory with rsync's trailing slash. */ - size_t len = strlen(child_rel); - char* copy = malloc(len + 2); - if (!copy) { - operation_ok = false; - } else { - memcpy(copy, child_rel, len); - copy[len] = '/'; - copy[len + 1] = '\0'; - if (!array_list_add(state->out, copy)) { - free(copy); - operation_ok = false; - } else { - (*state->recorded)++; - } - } - } else if (state->budget->deleted >= state->budget->max_delete) { - state->budget->limit_hit = true; - state->budget->skipped++; - local_survives = true; - } else if (unlinkat(dirfd, entries[i].name, AT_REMOVEDIR) != 0) { - /* ENOENT: already gone (fine). ENOTEMPTY/EEXIST: the directory still - holds entries the walker leaves in place (a protected excluded - prefix, a kept file the manifest protects, a symlink); rsync leaves - such a directory behind, so this is not an error. Only genuine I/O - failures abort the deletion. */ - if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) - operation_ok = false; - local_survives = true; - } else { - state->budget->deleted++; - /* rsync reports a removed directory with a trailing slash. */ - if (state->observer) { - size_t len = strlen(child_rel); - char* with_slash = malloc(len + 2); - if (with_slash) { - memcpy(with_slash, child_rel, len); - with_slash[len] = '/'; - with_slash[len + 1] = '\0'; - state->observer(state->observer_context, with_slash, DELETE_ENTRY_DIR); - free(with_slash); - } else { - state->observer(state->observer_context, child_rel, DELETE_ENTRY_DIR); - } - } - } - } else { - local_survives = true; - } - free(child_rel); - } - - /* Pass 2: extraneous files, descending. */ - for (size_t i = dir_count; i < count; i++) { - if (!is_extra[i]) - continue; - if (state->mode == DELETE_WALK_MODE_LIST) { - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - char* copy = str_dup(child_rel); - if (!copy || !array_list_add(state->out, copy)) { - free(copy); - operation_ok = false; - } else { - (*state->recorded)++; - } - free(child_rel); - } else if (state->budget->deleted >= state->budget->max_delete) { - state->budget->limit_hit = true; - state->budget->skipped++; - local_survives = true; - } else if (unlinkat(dirfd, entries[i].name, 0) != 0) { - if (errno != ENOENT) - operation_ok = false; - local_survives = true; - } else { - state->budget->deleted++; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (child_rel) { - if (state->observer) - state->observer(state->observer_context, child_rel, - delete_entry_type_of_mode(entries[i].mode)); - char* escaped_path = output_escape(child_rel, log_get_8_bit_output()); - fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : ""); - free(escaped_path); - } - free(child_rel); - } - } - - /* Pass 3: kept subdirectories, ascending (rsync descends into these only - after the parent's own extras have been handled). */ - for (size_t i = dir_count; i-- > 0;) { - if (is_extra[i] || shielded[i]) - continue; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - bool child_all_removed = false; - if (childfd >= 0) { - if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect, - deletable, &child_all_removed)) - operation_ok = false; - close(childfd); - } else if (errno != ENOENT) { - operation_ok = false; - } - /* A kept/synchronized directory is never removed. */ - local_survives = true; - free(child_rel); - } + if (!delete_walk_classify(rel_path, entries, count, ctx, deletable, at_root, shielded, is_extra, + &local_survives)) + operation_ok = false; + if (!delete_walk_extra_dirs(dirfd, rel_path, entries, dir_count, ctx, deletable, is_extra, + &local_survives)) + operation_ok = false; + if (!delete_walk_extra_files(dirfd, rel_path, entries, dir_count, count, ctx, is_extra, + &local_survives)) + operation_ok = false; + if (!delete_walk_kept_dirs(dirfd, rel_path, entries, dir_count, ctx, deletable, is_extra, + shielded, &local_survives)) + operation_ok = false; free(shielded); free(is_extra); @@ -504,8 +566,13 @@ bool delete_extras_list(const char* dest_root, const ArrayList* manifest, .recorded = &recorded, .observer = NULL, .observer_context = NULL}; - bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, - protect, false, &all_removed); + DeleteWalkContext ctx = {.keep = &keep, + .dirs = have_dirs ? &dirs : NULL, + .state = &state, + .skips = skips, + .skip_count = skip_count, + .protect = protect}; + bool ok = delete_walk_fd(rootfd, "", &ctx, false, &all_removed); if (close(rootfd) != 0) ok = false; path_index_free(&keep); @@ -557,8 +624,13 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr .recorded = NULL, .observer = observer, .observer_context = observer_context}; - bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, - protect, false, &all_removed); + DeleteWalkContext ctx = {.keep = &keep, + .dirs = have_dirs ? &dirs : NULL, + .state = &state, + .skips = skips, + .skip_count = skip_count, + .protect = protect}; + bool ok = delete_walk_fd(rootfd, "", &ctx, false, &all_removed); if (close(rootfd) != 0) ok = false; path_index_free(&keep); diff --git a/src/shared/delete_commit.c b/src/shared/delete_commit.c index 1360486..3f6e5d4 100644 --- a/src/shared/delete_commit.c +++ b/src/shared/delete_commit.c @@ -227,6 +227,166 @@ static void prefixed_delete_observer(void* context, const char* rel, DeleteEntry --max-delete budget: once it is exhausted the remaining requests are skipped and counted. Returns false only on a genuine error (a confinement failure on a validated path or an I/O error), which fails the run. */ + +/* How one missing-args request leaves the driver loop. The original walker + `continue`s past an invalid/protected/absent/budget-skipped request (without + breaking) but stops after a request that ran to completion while an error is + pending; NEXT/STOP preserve that control flow exactly. */ +typedef enum { MISSING_ARG_NEXT, MISSING_ARG_STOP } MissingArgStep; + +/* Remove a NON-empty missing-args directory recursively (--delete/--force in + effect): walk its contents through the budgeted extras walker so every removed + file/dir counts toward --max-delete (rsync parity), then remove the now-empty + directory itself, which costs one more budget unit. A run that hits the cap + leaves the remaining entries in place. The observer is wrapped so the nested + walk reports receive-root-relative paths. Sets the *removed and *ok outputs. */ +static void delete_nonempty_missing_dir(const char* full, const char* rel, + DeleteBudgetState* budget, DeletePathObserver observer, + void* observer_context, bool* removed, bool* ok) { + ArrayList* no_keeps = array_list_create(free); + /* Never let an accounting slip (deleted > max_delete) underflow the remaining + budget into SIZE_MAX, which would grant unlimited deletions. */ + size_t remaining = + budget->deleted >= budget->max_delete ? 0 : budget->max_delete - budget->deleted; + size_t contents_deleted = 0; + size_t contents_skipped = 0; + PrefixedDeleteObserver nested = {observer, observer_context, rel}; + DeleteWalkResult walk = + no_keeps ? delete_extras_limited_observed(full, no_keeps, NULL, remaining, NULL, 0, NULL, + &contents_deleted, &contents_skipped, + observer ? prefixed_delete_observer : NULL, + observer ? &nested : NULL) + : DELETE_WALK_ERROR; + if (no_keeps) + array_list_delete(no_keeps); + budget->deleted += contents_deleted; + budget->skipped += contents_skipped; + if (walk == DELETE_WALK_LIMIT_REACHED) { + budget->limit_hit = true; + } else if (walk != DELETE_WALK_OK) { + *ok = false; + } else if (budget->deleted >= budget->max_delete) { + budget->limit_hit = true; + budget->skipped++; + } else if (file_remove_tree_secure(full)) { + /* The shared `if (removed)` tail charges this directory exactly once; + counting it here too would consume two budget units. */ + *removed = true; + } else { + *ok = false; + } +} + +/* Remove one missing-args destination mirror. `skips` holds the receiver + artifacts (staging directory, basis snapshots) that stay protected. Returns + MISSING_ARG_STOP when the driver loop must stop (a completed removal left a + genuine error pending) and MISSING_ARG_NEXT otherwise; *ok accumulates the + overall success across the whole run. */ +static MissingArgStep delete_one_missing_arg(const Config* config, const char* rel, + const DeleteSkipSet* skips, DeleteBudgetState* budget, + DeletePathObserver observer, void* observer_context, + bool* ok) { + if (!rel || *rel == '\0' || *rel == '/' || has_path_traversal(rel)) { + /* Defensive only: receive_manifest_entries already validated every + section identically, so a controlled peer never reaches this branch. */ + log_message(LOG_LEVEL_ERROR, "invalid missing-args delete path"); + *ok = false; + return MISSING_ARG_NEXT; + } + bool at_root = strchr(rel, '/') == NULL; + if (path_under_skip_prefix(rel, at_root, skips->entries, skips->count)) { + char* escaped = output_escape(rel, log_get_8_bit_output()); + log_message(LOG_LEVEL_WARNING, + "missing-args path '%s' is protected (staging directory or basis snapshot); " + "not deleting", + escaped ? escaped : ""); + free(escaped); + return MISSING_ARG_NEXT; + } + char* full = path_cat(config->receive_root_directory, rel); + if (!full) { + *ok = false; + return MISSING_ARG_NEXT; + } + char* leaf = NULL; + int parent_fd = file_open_secure_parent(full, &leaf, false); + if (parent_fd < 0) { + /* The mirror's parent directory may itself not exist on the destination + (a deeper missing entry whose leading directories were never created). + That is a no-op -- there is nothing to delete -- matching + file_remove_tree_secure's absent-path handling; only a genuine I/O + error (EACCES, a symlink loop, ...) fails the run. */ + bool absent = errno == ENOENT || errno == ENOTDIR; + free(full); + free(leaf); + if (!absent) + *ok = false; + return MISSING_ARG_NEXT; + } + struct stat st; + if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) { + /* Already absent: nothing to delete (a no-op, not a deletion). */ + if (errno != ENOENT) + *ok = false; + close(parent_fd); + free(leaf); + free(full); + return MISSING_ARG_NEXT; + } + /* An entry that exists is one deletion: skip it (and count it) when the + shared --max-delete budget is already exhausted. */ + if (budget->deleted >= budget->max_delete) { + budget->limit_hit = true; + budget->skipped++; + close(parent_fd); + free(leaf); + free(full); + return MISSING_ARG_NEXT; + } + bool removed = false; + if (S_ISDIR(st.st_mode)) { + if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0) { + removed = true; + } else if (errno == ENOTEMPTY || errno == EEXIST) { + close(parent_fd); + parent_fd = -1; + free(leaf); + leaf = NULL; + if (config->use_delete || config->force_delete) { + delete_nonempty_missing_dir(full, rel, budget, observer, observer_context, &removed, ok); + } else { + char* escaped = output_escape(rel, log_get_8_bit_output()); + log_message(LOG_LEVEL_WARNING, + "missing-args destination '%s' is a non-empty directory; use --force or " + "--delete to remove it", + escaped ? escaped : ""); + free(escaped); + } + } else if (errno != ENOENT) { + *ok = false; + } + } else { + if (unlinkat(parent_fd, leaf, 0) == 0) { + removed = true; + } else if (errno != ENOENT) { + *ok = false; + } + } + if (removed) { + budget->deleted++; + if (observer) + observer(observer_context, rel, delete_entry_type_of_mode(st.st_mode)); + char* escaped = output_escape(rel, log_get_8_bit_output()); + fprintf(stderr, " Deleted: %s\n", escaped ? escaped : ""); + free(escaped); + } + if (parent_fd >= 0) + close(parent_fd); + free(leaf); + free(full); + return *ok ? MISSING_ARG_NEXT : MISSING_ARG_STOP; +} + static bool delete_missing_args_budgeted_observed(const Config* config, const DeleteManifest* manifest, DeleteBudgetState* budget, @@ -246,141 +406,8 @@ static bool delete_missing_args_budgeted_observed(const Config* config, bool ok = true; for (int i = 0; i < manifest->missing->size; i++) { const char* rel = (const char*)manifest->missing->items[i]; - if (!rel || *rel == '\0' || *rel == '/' || has_path_traversal(rel)) { - /* Defensive only: receive_manifest_entries already validated every - section identically, so a controlled peer never reaches this branch. */ - log_message(LOG_LEVEL_ERROR, "invalid missing-args delete path"); - ok = false; - continue; - } - bool at_root = strchr(rel, '/') == NULL; - if (path_under_skip_prefix(rel, at_root, skips.entries, skips.count)) { - char* escaped = output_escape(rel, log_get_8_bit_output()); - log_message(LOG_LEVEL_WARNING, - "missing-args path '%s' is protected (staging directory or basis snapshot); " - "not deleting", - escaped ? escaped : ""); - free(escaped); - continue; - } - char* full = path_cat(config->receive_root_directory, rel); - if (!full) { - ok = false; - continue; - } - char* leaf = NULL; - int parent_fd = file_open_secure_parent(full, &leaf, false); - if (parent_fd < 0) { - /* The mirror's parent directory may itself not exist on the destination - (a deeper missing entry whose leading directories were never created). - That is a no-op -- there is nothing to delete -- matching - file_remove_tree_secure's absent-path handling; only a genuine I/O - error (EACCES, a symlink loop, ...) fails the run. */ - bool absent = errno == ENOENT || errno == ENOTDIR; - free(full); - free(leaf); - if (!absent) - ok = false; - continue; - } - struct stat st; - if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) { - /* Already absent: nothing to delete (a no-op, not a deletion). */ - if (errno != ENOENT) - ok = false; - close(parent_fd); - free(leaf); - free(full); - continue; - } - /* An entry that exists is one deletion: skip it (and count it) when the - shared --max-delete budget is already exhausted. */ - if (budget->deleted >= budget->max_delete) { - budget->limit_hit = true; - budget->skipped++; - close(parent_fd); - free(leaf); - free(full); - continue; - } - bool removed = false; - if (S_ISDIR(st.st_mode)) { - if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0) { - removed = true; - } else if (errno == ENOTEMPTY || errno == EEXIST) { - close(parent_fd); - parent_fd = -1; - free(leaf); - leaf = NULL; - if (config->use_delete || config->force_delete) { - /* Remove the contents entry-by-entry through the budgeted extras - walker so every deleted file/dir counts toward --max-delete (rsync - parity); the now-empty directory itself costs one more. A run that - hits the cap leaves the remaining entries in place. */ - ArrayList* no_keeps = array_list_create(free); - /* Never let an accounting slip (deleted > max_delete) underflow the - remaining budget into SIZE_MAX, which would grant unlimited - deletions. */ - size_t remaining = - budget->deleted >= budget->max_delete ? 0 : budget->max_delete - budget->deleted; - size_t contents_deleted = 0; - size_t contents_skipped = 0; - PrefixedDeleteObserver nested = {observer, observer_context, rel}; - DeleteWalkResult walk = - no_keeps ? delete_extras_limited_observed(full, no_keeps, NULL, remaining, NULL, 0, - NULL, &contents_deleted, &contents_skipped, - observer ? prefixed_delete_observer : NULL, - observer ? &nested : NULL) - : DELETE_WALK_ERROR; - if (no_keeps) - array_list_delete(no_keeps); - budget->deleted += contents_deleted; - budget->skipped += contents_skipped; - if (walk == DELETE_WALK_LIMIT_REACHED) { - budget->limit_hit = true; - } else if (walk != DELETE_WALK_OK) { - ok = false; - } else if (budget->deleted >= budget->max_delete) { - budget->limit_hit = true; - budget->skipped++; - } else if (file_remove_tree_secure(full)) { - /* The shared `if (removed)` tail charges this directory exactly - once; counting it here too would consume two budget units. */ - removed = true; - } else { - ok = false; - } - } else { - char* escaped = output_escape(rel, log_get_8_bit_output()); - log_message(LOG_LEVEL_WARNING, - "missing-args destination '%s' is a non-empty directory; use --force or " - "--delete to remove it", - escaped ? escaped : ""); - free(escaped); - } - } else if (errno != ENOENT) { - ok = false; - } - } else { - if (unlinkat(parent_fd, leaf, 0) == 0) { - removed = true; - } else if (errno != ENOENT) { - ok = false; - } - } - if (removed) { - budget->deleted++; - if (observer) - observer(observer_context, rel, delete_entry_type_of_mode(st.st_mode)); - char* escaped = output_escape(rel, log_get_8_bit_output()); - fprintf(stderr, " Deleted: %s\n", escaped ? escaped : ""); - free(escaped); - } - if (parent_fd >= 0) - close(parent_fd); - free(leaf); - free(full); - if (!ok) + if (delete_one_missing_arg(config, rel, &skips, budget, observer, observer_context, &ok) == + MISSING_ARG_STOP) break; } delete_skips_free(&skips); diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 763b221..fd0bda2 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -102,12 +102,14 @@ bool dir_metadata_should_capture(const Config* config) { /* Directory metadata is captured when a directory attribute is actually * requested: -p/--perms (directory modes), -t/--times (directory mtimes, * unless -O/--omit-dir-times suppresses them), -o/-g (directory ownership), - * or -X/-A (directory xattrs/ACLs). --atimes/-U alone does not pull - * directory metadata (matching the original dir-time bundle). */ + * -X/-A (directory xattrs/ACLs), or --fake-super (whose reserved %stat record + * is written on the directory itself, so its metadata must travel). + * --atimes/-U alone does not pull directory metadata (matching the original + * dir-time bundle). */ return config && config->use_metadata && (config->preserve_perms || (config->preserve_times && !config->omit_dir_times) || config->preserve_owner || config->preserve_group || config->preserve_xattrs || - config->preserve_acls); + config->preserve_acls || config->fake_super); } void dir_time_list_init(DirTimeList* list) { @@ -205,12 +207,19 @@ void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory bool apply_times = config->preserve_times && !config->omit_dir_times; bool apply_mode = config->preserve_perms; bool apply_xattrs = config->use_xattrs; + bool apply_fake_super = config->fake_super; /* Ownership is applied through the active identity snapshot (which no-ops - * unless an ownership request is active), and xattrs only when -X/-A was - * negotiated. Times/mode keep their own per-attribute gates. */ - bool have_any = apply_times || apply_mode || apply_xattrs || identity_active_enabled(); + * unless an ownership request is active), xattrs only when -X/-A was + * negotiated, and the --fake-super record whenever the flag is active. + * Times/mode keep their own per-attribute gates. */ + bool have_any = + apply_times || apply_mode || apply_xattrs || apply_fake_super || identity_active_enabled(); if (!have_any) return; + /* Built once: the --fake-super replay uses it to apply only the recorded + * permission bits (the special bits stay in the record, exactly like the + * regular-file fake-super receiver). */ + FileAttrPolicy policy = file_attr_policy_from_config(config); for (size_t i = 0; i < list->count; i++) { char* dir_path = path_cat(root_directory, list->paths[i]); if (!dir_path) @@ -260,38 +269,59 @@ void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory free(escaped_path); } } - if (apply_mode) { - mode_t dir_mode = list->entries[i].mode; - bool mode_ready = true; - if (config->chmod_spec && *config->chmod_spec && - !chmod_apply(dir_mode, config->chmod_spec, &dir_mode)) { + /* The final directory mode (after any --chmod) is computed once so the + --fake-super record can carry it even when the on-disk replay is + restricted to the permission bits below. */ + mode_t dir_mode = list->entries[i].mode; + bool mode_ready = true; + if (apply_mode && config->chmod_spec && *config->chmod_spec && + !chmod_apply(dir_mode, config->chmod_spec, &dir_mode)) { + char* escaped_path = output_escape(dir_path, log_get_8_bit_output()); + log_message(LOG_LEVEL_WARNING, "Failed to apply --chmod to directory %s", + escaped_path ? escaped_path : ""); + free(escaped_path); + mode_ready = false; + } + /* Under --fake-super the normal fchmod below still applies the mode, but + the fake-super replay that follows narrows the on-disk result to the + recorded permission bits (the full mode, including setuid/setgid/sticky, + lives only in the record). Keeping the normal fchmod first means a + filesystem without xattr support still gets the directory mode rather than + silently losing it. */ + if (apply_mode && mode_ready) { + /* rsync -p copies the source directory mode exactly, including + * group/other write and the setgid/sticky bits. Setuid/setgid/sticky + * are super-user activities: when the connection forbade them + * (SUPER_MODE_OFF / --no-super), strip them even under -p. */ + mode_t safe_mode = dir_mode & (mode_t)(S_ISUID | S_ISGID | S_ISVTX | 0777); + if (!privilege_super_mode_permitted(config->super_mode)) + safe_mode &= ~(mode_t)(S_ISUID | S_ISGID | S_ISVTX); + if (dir_fd < 0) { char* escaped_path = output_escape(dir_path, log_get_8_bit_output()); - log_message(LOG_LEVEL_WARNING, "Failed to apply --chmod to directory %s", - escaped_path ? escaped_path : ""); + log_message(LOG_LEVEL_WARNING, "Failed to open directory %s to set its mode: %s", + escaped_path ? escaped_path : "", strerror(errno)); + free(escaped_path); + } else if (fchmod(dir_fd, safe_mode) != 0) { + char* escaped_path = output_escape(dir_path, log_get_8_bit_output()); + log_message(LOG_LEVEL_WARNING, "Failed to set directory mode on %s: %s", + escaped_path ? escaped_path : "", strerror(errno)); free(escaped_path); - mode_ready = false; - } - if (mode_ready) { - /* rsync -p copies the source directory mode exactly, including - * group/other write and the setgid/sticky bits. Setuid/setgid/sticky - * are super-user activities: when the connection forbade them - * (SUPER_MODE_OFF / --no-super), strip them even under -p. */ - mode_t safe_mode = dir_mode & (mode_t)(S_ISUID | S_ISGID | S_ISVTX | 0777); - if (!privilege_super_mode_permitted(config->super_mode)) - safe_mode &= ~(mode_t)(S_ISUID | S_ISGID | S_ISVTX); - if (dir_fd < 0) { - char* escaped_path = output_escape(dir_path, log_get_8_bit_output()); - log_message(LOG_LEVEL_WARNING, "Failed to open directory %s to set its mode: %s", - escaped_path ? escaped_path : "", strerror(errno)); - free(escaped_path); - } else if (fchmod(dir_fd, safe_mode) != 0) { - char* escaped_path = output_escape(dir_path, log_get_8_bit_output()); - log_message(LOG_LEVEL_WARNING, "Failed to set directory mode on %s: %s", - escaped_path ? escaped_path : "", strerror(errno)); - free(escaped_path); - } } } + /* --fake-super: park the directory's full stat (rsync 3.4.1's exact + grammar) on the directory ITSELF, then replay only the recorded + permission bits fd-relative. The special bits live only in the record + and the recorded ownership is never real-chowned: the resolved ids are + stored for a later privileged restore, exactly like the file path. Runs + before the xattr apply so a mode change cannot clobber the ACL mask. */ + if (apply_fake_super && dir_fd >= 0) { + uint32_t store_uid = 0; + uint32_t store_gid = 0; + identity_resolve_storage_ids((int32_t)list->entries[i].uid, (int32_t)list->entries[i].gid, + &store_uid, &store_gid); + fake_super_store_fd(dir_fd, store_uid, store_gid, (uint32_t)dir_mode, 0, 0); + fake_super_restore_fd(dir_fd, policy); + } /* xattrs/ACLs last: a mode change can rewrite the ACL mask, so the ACL xattrs must be (re)applied after fchmod. */ if (apply_xattrs && dir_fd >= 0 && list->xattrs) diff --git a/src/shared/file_save.c b/src/shared/file_save.c index f51215b..76f016e 100644 --- a/src/shared/file_save.c +++ b/src/shared/file_save.c @@ -817,6 +817,21 @@ static FileSaveResult file_save_directory_to_disk(const FileSavePlan* plan, bool } else if (ok && identity_copy_as_active()) { ok = false; } + /* --fake-super: park the directory's full stat in rsync's reserved + user.rsync.%stat xattr as soon as the directory exists. This makes even a + direct file_save_to_disk_full() caller -- which never runs the deferred + DirTimeList pass -- produce an rsync-readable fake-super record. The record + carries the full mode/uid/gid; the permission bits are replayed by the + deferred pass (never inline, so a restrictive mode cannot block child + creation) and the recorded ownership is never real-chowned. Best-effort: + fake_super_store_fd() logs and skips a failure, never failing the entry. */ + if (ok && plan->config && plan->config->fake_super && file->metadata && dir_fd >= 0) { + uint32_t store_uid = 0; + uint32_t store_gid = 0; + identity_resolve_storage_ids((int32_t)file->metadata->uid, (int32_t)file->metadata->gid, + &store_uid, &store_gid); + fake_super_store_fd(dir_fd, store_uid, store_gid, (uint32_t)file->metadata->mode, 0, 0); + } /* The final source MODE is deliberately NOT applied inline. A restrictive source mode (for example 0555) would make the directory unwritable before its children are created, so a non-root receiver fails each child with diff --git a/src/shared/identity.c b/src/shared/identity.c index f2b32ad..80e30ee 100644 --- a/src/shared/identity.c +++ b/src/shared/identity.c @@ -276,7 +276,7 @@ static bool identity_wire_map_valid(const IdentityMap* map) { } if (map->to < IDENTITY_CURRENT) return false; - if (map->to_name && strlen(map->to_name) > 255) + if (map->to_name && strlen(map->to_name) > IDENTITY_MAX_NAME_LEN) return false; return true; } diff --git a/src/shared/identity.h b/src/shared/identity.h index a7548c2..7b99bbe 100644 --- a/src/shared/identity.h +++ b/src/shared/identity.h @@ -6,6 +6,11 @@ #include #include +/* Maximum length of a receiver-resolved identity name in a FROM:TO map's TO + * field. Bounded so a malicious/huge name can never cross the wire (see + * identity_wire_map_valid). */ +#define IDENTITY_MAX_NAME_LEN 255 + /* * Identity mapping: --numeric-ids / --usermap / --groupmap / --chown / --copy-as. * diff --git a/src/shared/incremental_check.c b/src/shared/incremental_check.c index 8fd4bbf..985f0ab 100644 --- a/src/shared/incremental_check.c +++ b/src/shared/incremental_check.c @@ -47,285 +47,236 @@ bool receive_file_xattrs(File* file, int fd, const Config* config) { static bool receive_file_payload_into(File* file, int fd, const Config* config, const char* dest_path, unsigned long long expected_size); +/* Receive a STATUS_DELTA_DATA response: the sender's delta against the basis we + signed. Deserializes, decompresses and applies the delta (in memory or + through a spool temp file), then receives the metadata/xattr block and + installs the reconstructed payload. Takes ownership of `old_data` and `sig`, + releasing both on every path. */ +static File* receive_delta_data_branch(int fd, const Config* config, const char* check_path, + void* old_data, unsigned long long old_size, int basis_fd, + DeltaSignature* sig, bool* failed) { + Data* raw_delta = NULL; + Delta* delta = NULL; + void* new_data = NULL; + char* spool = NULL; + File* file = NULL; + + raw_delta = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE); + if (!raw_delta) + goto fail; + + if (config->use_compression && + !compression_should_skip_with_suffixes(check_path, config->skip_compress_suffixes, + config->skip_compress_set ? config->skip_compress_count + : -1)) { + ProtocolSession* owner = raw_delta->owner; + Data* decompressed = data_decompress_limited(raw_delta, MAX_RECEIVE_WHOLE_FILE_SIZE); + data_destroy(raw_delta); + raw_delta = decompressed; + if (!raw_delta) + goto fail; + /* 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)) + goto fail; + } + + delta = delta_deserialize(raw_delta); + data_destroy(raw_delta); + raw_delta = NULL; + if (!delta) + goto fail; + + uint64_t new_size = delta->new_file_size; + if (new_size > SIZE_MAX) { + send_status(fd, STATUS_ERROR); + goto fail; + } + /* Wire-stats tally: bytes taken straight from the basis file (matched + delta blocks) and bytes shipped literally (protocol 2.28.0). Computed + before the delta is destroyed. */ + unsigned long long matched = 0; + unsigned long long literal = 0; + for (uint32_t k = 0; k < delta->instruction_count; k++) { + if (delta->instructions[k].type == DELTA_INSTR_BLOCK_MATCH) + matched += delta->instructions[k].match.length; + else if (delta->instructions[k].type == DELTA_INSTR_LITERAL) + literal += delta->instructions[k].literal.length; + } + + /* A reconstructed file above the streaming bound is written into a spool + temp file through delta_apply_to_fd; a smaller one keeps the historical + in-memory reconstruction. */ + if (new_size > protocol_whole_file_receive_limit()) { + char* dest_path = path_cat(config->receive_root_directory, check_path); + int spool_fd = dest_path ? file_spool_for_payload(dest_path, &spool) : -1; + free(dest_path); + if (spool_fd < 0) { + send_status(fd, STATUS_ERROR); + goto fail; + } + bool applied = + delta_apply_to_fd(old_data, basis_fd, old_size, delta, config->delta_block_size, spool_fd); + if (close(spool_fd) != 0) + applied = false; + delta_destroy(delta); + delta = NULL; + if (!applied) { + send_status(fd, STATUS_ERROR); + goto fail; + } + } else { + new_data = old_data ? delta_apply(old_data, old_size, delta, config->delta_block_size) + : delta_apply_fd(basis_fd, old_size, delta, config->delta_block_size); + delta_destroy(delta); + delta = NULL; + if (!new_data) + goto fail; + } + + file = file_create(check_path); + if (!file) + goto fail; + file->matched_bytes = matched; + file->literal_bytes = literal; + + if (config->use_metadata) { + int meta_ok = 1; + file->metadata = metadata_receive(fd, &meta_ok); + if (!meta_ok) + goto fail; + } + if (!receive_file_xattrs(file, fd, config)) + goto fail; + + if (spool) { + Data* reserved = data_create_reserve((size_t)new_size); + if (reserved == NULL) { + send_status(fd, STATUS_ERROR); + goto fail; + } + data_destroy(file->data); + file->data = reserved; + file->basis_copy = spool; + spool = NULL; /* ownership moved into file->basis_copy */ + file->data_spool = true; + } else { + Data* replacement = data_create(new_data, (size_t)new_size); + new_data = NULL; /* data_create owns, and frees, the buffer on failure */ + if (replacement == NULL) { + send_status(fd, STATUS_ERROR); + goto fail; + } + data_destroy(file->data); + file->data = replacement; + } + + free(old_data); + delta_signature_destroy(sig); + return file; + +fail: + free(new_data); + if (spool) { + unlink(spool); + free(spool); + } + file_destroy(file); + delta_destroy(delta); + data_destroy(raw_delta); + free(old_data); + delta_signature_destroy(sig); + *failed = true; + return NULL; +} + +/* Receive a STATUS_NEXT response: the sender declined the delta and will send + the whole file. Releases the basis signature and snapshot, then receives the + metadata/xattr block and the full payload. Takes ownership of `old_data` and + `sig`, releasing both immediately. */ +static File* receive_next_branch(int fd, const Config* config, const char* check_path, + unsigned long long expected_size, void* old_data, + DeltaSignature* sig, bool* failed) { + delta_signature_destroy(sig); + free(old_data); + + File* file = file_create(check_path); + if (!file) + goto fail; + + if (config->use_metadata) { + int meta_ok = 1; + file->metadata = metadata_receive(fd, &meta_ok); + if (!meta_ok) + goto fail; + } + if (!receive_file_xattrs(file, fd, config)) + goto fail; + + char* dest_path = path_cat(config->receive_root_directory, check_path); + if (!dest_path) + goto fail; + bool payload_ok = receive_file_payload_into(file, fd, config, dest_path, expected_size); + free(dest_path); + if (!payload_ok) + goto fail; + return file; + +fail: + file_destroy(file); + *failed = true; + return NULL; +} + +/* Delta handshake dispatcher: sign the basis, ship the signature, then hand the + response off to the matching branch helper. Takes ownership of `old_data` + (and, once created, `sig`); sets `*failed` on every error path. */ static File* receive_delta_file(int fd, const Config* config, const char* check_path, void* old_data, unsigned long long old_size, unsigned long long expected_size, int basis_fd, bool* failed) { - if (!old_data && basis_fd < 0) { - free(old_data); /* defensive: a basis source is always provided today */ - *failed = true; - return NULL; - } - /* The basis is either an in-memory snapshot (the destination file, bounded) or * a confined descriptor (a --fuzzy sibling, possibly larger than memory) that * is signed/applied in bounded chunks. */ + Data* sig_data = NULL; + Status resp = STATUS_ERROR; + bool sig_sent = false; + /* A delta check needs at least one basis source: the in-memory destination + * snapshot or a confined basis descriptor. */ + if (!old_data && basis_fd < 0) { + *failed = true; + return NULL; + } DeltaSignature* sig = old_data ? delta_signature_create_seeded(old_data, old_size, config->delta_block_size, (uint32_t)config->checksum_seed) : delta_signature_create_fd_seeded(basis_fd, old_size, config->delta_block_size, (uint32_t)config->checksum_seed); - if (!sig) { - free(old_data); - *failed = true; - return NULL; - } + if (!sig) + goto fail; - Data* sig_data = delta_signature_serialize(sig); - if (!sig_data) { - delta_signature_destroy(sig); - free(old_data); - *failed = true; - return NULL; - } + sig_data = delta_signature_serialize(sig); + if (!sig_data) + goto fail; - bool sig_sent = send_status(fd, STATUS_DELTA_SIGNATURE) && send_data(fd, sig_data); + sig_sent = send_status(fd, STATUS_DELTA_SIGNATURE) && send_data(fd, sig_data); data_destroy(sig_data); + sig_data = NULL; - if (!sig_sent) { - delta_signature_destroy(sig); - free(old_data); - *failed = true; - return NULL; - } + if (!sig_sent || !receive_status(fd, &resp)) + goto fail; - Status resp; - if (!receive_status(fd, &resp)) { - delta_signature_destroy(sig); - free(old_data); - *failed = true; - return NULL; - } + if (resp == STATUS_DELTA_DATA) + return receive_delta_data_branch(fd, config, check_path, old_data, old_size, basis_fd, sig, + failed); - if (resp == STATUS_DELTA_DATA) { - Data* delta_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE); - if (!delta_data) { - delta_signature_destroy(sig); - free(old_data); - *failed = true; - return NULL; - } - - Data* raw_delta = delta_data; - if (config->use_compression && - !compression_should_skip_with_suffixes( - check_path, config->skip_compress_suffixes, - 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); - data_destroy(delta_data); - if (!raw_delta) { - free(old_data); - delta_signature_destroy(sig); - *failed = true; - 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); - data_destroy(raw_delta); - if (!delta) { - free(old_data); - delta_signature_destroy(sig); - *failed = true; - return NULL; - } - - uint64_t new_size = delta->new_file_size; - if (new_size > SIZE_MAX) { - delta_destroy(delta); - free(old_data); - delta_signature_destroy(sig); - send_status(fd, STATUS_ERROR); - *failed = true; - return NULL; - } - /* Wire-stats tally: bytes taken straight from the basis file (matched - delta blocks) and bytes shipped literally (protocol 2.28.0). Computed - before the delta is destroyed. */ - unsigned long long matched = 0; - unsigned long long literal = 0; - for (uint32_t k = 0; k < delta->instruction_count; k++) { - if (delta->instructions[k].type == DELTA_INSTR_BLOCK_MATCH) - matched += delta->instructions[k].match.length; - else if (delta->instructions[k].type == DELTA_INSTR_LITERAL) - literal += delta->instructions[k].literal.length; - } - - /* A reconstructed file above the streaming bound is written into a spool - temp file through delta_apply_to_fd; a smaller one keeps the historical - in-memory reconstruction. */ - void* new_data = NULL; - char* spool = NULL; - if (new_size > protocol_whole_file_receive_limit()) { - char* dest_path = path_cat(config->receive_root_directory, check_path); - int spool_fd = dest_path ? file_spool_for_payload(dest_path, &spool) : -1; - free(dest_path); - if (spool_fd < 0) { - delta_destroy(delta); - free(old_data); - delta_signature_destroy(sig); - send_status(fd, STATUS_ERROR); - *failed = true; - return NULL; - } - bool applied = delta_apply_to_fd(old_data, basis_fd, old_size, delta, - config->delta_block_size, spool_fd); - if (close(spool_fd) != 0) - applied = false; - delta_destroy(delta); - if (!applied) { - unlink(spool); - free(spool); - free(old_data); - delta_signature_destroy(sig); - send_status(fd, STATUS_ERROR); - *failed = true; - return NULL; - } - } else { - new_data = old_data ? delta_apply(old_data, old_size, delta, config->delta_block_size) - : delta_apply_fd(basis_fd, old_size, delta, config->delta_block_size); - delta_destroy(delta); - if (!new_data) { - free(old_data); - delta_signature_destroy(sig); - *failed = true; - return NULL; - } - } - - File* file = file_create(check_path); - if (!file) { - free(new_data); - if (spool) { - unlink(spool); - free(spool); - } - free(old_data); - delta_signature_destroy(sig); - *failed = true; - return NULL; - } - file->matched_bytes = matched; - file->literal_bytes = literal; - - if (config->use_metadata) { - int meta_ok = 1; - file->metadata = metadata_receive(fd, &meta_ok); - if (!meta_ok) { - file_destroy(file); - free(new_data); - if (spool) { - unlink(spool); - free(spool); - } - free(old_data); - delta_signature_destroy(sig); - *failed = true; - return NULL; - } - } - if (!receive_file_xattrs(file, fd, config)) { - file_destroy(file); - free(new_data); - if (spool) { - unlink(spool); - free(spool); - } - free(old_data); - delta_signature_destroy(sig); - *failed = true; - return NULL; - } - - if (spool) { - Data* reserved = data_create_reserve((size_t)new_size); - if (reserved == NULL) { - unlink(spool); - free(spool); - file_destroy(file); - free(old_data); - delta_signature_destroy(sig); - send_status(fd, STATUS_ERROR); - *failed = true; - return NULL; - } - data_destroy(file->data); - file->data = reserved; - file->basis_copy = spool; - file->data_spool = true; - } else { - Data* replacement = data_create(new_data, (size_t)new_size); - if (replacement == NULL) { - file_destroy(file); - free(old_data); - delta_signature_destroy(sig); - send_status(fd, STATUS_ERROR); - *failed = true; - return NULL; - } - data_destroy(file->data); - file->data = replacement; - } - - free(old_data); - delta_signature_destroy(sig); - return file; - } - - if (resp == STATUS_NEXT) { - delta_signature_destroy(sig); - free(old_data); - - File* file = file_create(check_path); - if (!file) { - *failed = true; - return NULL; - } - - if (config->use_metadata) { - int meta_ok = 1; - file->metadata = metadata_receive(fd, &meta_ok); - if (!meta_ok) { - file_destroy(file); - *failed = true; - return NULL; - } - } - if (!receive_file_xattrs(file, fd, config)) { - file_destroy(file); - *failed = true; - return NULL; - } - - char* dest_path = path_cat(config->receive_root_directory, check_path); - if (!dest_path) { - file_destroy(file); - *failed = true; - return NULL; - } - bool payload_ok = receive_file_payload_into(file, fd, config, dest_path, expected_size); - free(dest_path); - if (!payload_ok) { - file_destroy(file); - *failed = true; - return NULL; - } - return file; - } + if (resp == STATUS_NEXT) + return receive_next_branch(fd, config, check_path, expected_size, old_data, sig, failed); + send_status(fd, STATUS_ERROR); +fail: + data_destroy(sig_data); delta_signature_destroy(sig); free(old_data); - send_status(fd, STATUS_ERROR); *failed = true; return NULL; } @@ -1485,20 +1436,24 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh if (config->use_metadata) { int meta_ok = 1; meta = metadata_receive(fd, &meta_ok); - if (!meta_ok) + if (!meta_ok) { + file_metadata_destroy(meta); return INCREMENTAL_ERROR; + } } if (config->use_xattrs) { int xok = 0; append_xattrs = xattr_receive(fd, &xok, config->preserve_acls); if (!xok) { xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } } Data* tail = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE); if (tail == NULL) { xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } if (config->use_compression && @@ -1510,16 +1465,19 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh data_destroy(tail); if (uncompressed == NULL) { xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } if (!data_charge_session(uncompressed, owner, uncompressed->size)) { data_destroy(uncompressed); xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } if (uncompressed->size > MAX_FILE_DATA_SIZE) { data_destroy(uncompressed); xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } tail = uncompressed; @@ -1532,6 +1490,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh send_status(fd, STATUS_ERROR); data_destroy(tail); xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } size_t full_size = (size_t)check_size; @@ -1539,6 +1498,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh if (!full) { data_destroy(tail); xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } if (old_size > 0 && state->old_data) @@ -1553,6 +1513,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh if (!file) { free(full); xattr_list_free(append_xattrs); + file_metadata_destroy(meta); return INCREMENTAL_ERROR; } file->metadata = meta; diff --git a/src/shared/transport_tcp.c b/src/shared/transport_tcp.c index 059e4f1..2238a94 100644 --- a/src/shared/transport_tcp.c +++ b/src/shared/transport_tcp.c @@ -134,7 +134,7 @@ Server* server_create_ex(int port, const ServerBindOptions* bind_opts) { server->file_descriptor = file_descriptor; server->ssl_ctx = NULL; - server->max_connections = 100; + server->max_connections = SERVER_DEFAULT_MAX_CONNECTIONS; server->active_connections = 0; server->limit_registry = NULL; diff --git a/src/shared/transport_tcp.h b/src/shared/transport_tcp.h index c3862a6..59f6e89 100644 --- a/src/shared/transport_tcp.h +++ b/src/shared/transport_tcp.h @@ -11,6 +11,10 @@ * stored here so the transport layer does not depend on daemon config. */ struct DaemonLimitRegistry; +/* Connection cap applied by server_create_ex() until the daemon's configured + * `max connections` overrides it via server_set_max_connections(). */ +#define SERVER_DEFAULT_MAX_CONNECTIONS 100 + typedef struct Server { struct sockaddr_storage address; unsigned int address_length; diff --git a/src/shared/xattr.c b/src/shared/xattr.c index aa0e747..d4c4e37 100644 --- a/src/shared/xattr.c +++ b/src/shared/xattr.c @@ -450,7 +450,7 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint if (len <= 0 || (size_t)len >= sizeof(record)) return; if (fsetxattr(fd, FAKESUPER_XATTR, record, (size_t)len, 0) != 0) { - log_message(LOG_LEVEL_WARNING, "--fake-super: could not store %s on destination file: %s", + log_message(LOG_LEVEL_WARNING, "--fake-super: could not store %s on destination entry: %s", FAKESUPER_XATTR, strerror(errno)); } } @@ -550,7 +550,7 @@ bool fake_super_restore_fd(int fd, FileAttrPolicy policy) { } else if (metadata_mode_for_policy((mode_t)(ul_mode & 0777U), cur.st_mode, policy, &want)) { if (fchmod(fd, want) != 0) log_message(LOG_LEVEL_WARNING, - "--fake-super: could not restore mode on destination file: %s", + "--fake-super: could not restore mode on destination entry: %s", strerror(errno)); } } diff --git a/src/shared/xattr.h b/src/shared/xattr.h index bbaa045..6ee5925 100644 --- a/src/shared/xattr.h +++ b/src/shared/xattr.h @@ -140,21 +140,25 @@ bool xattr_apply_path_nofollow(int parent_fd, const char* leaf, const FileXattrL /* --fake-super: write the source uid/gid/mode/rdev record into the reserved * FAKESUPER_XATTR on `fd`, using rsync 3.4.1's exact grammar (see the key * comment above). `mode` is the full st_mode including its S_IFMT bits. - * Best-effort (logged, never fatal). Only meaningful when metadata was - * transmitted so the values exist. */ + * `fd` may be a regular file, a faked char/block device (written as a regular + * file), or a DIRECTORY: rsync stores a directory's faked mode/uid/gid in the + * reserved xattr on the directory itself. Best-effort (logged, never fatal). + * Only meaningful when metadata was transmitted so the values exist. */ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint32_t rdev_major, uint32_t rdev_minor); /* --fake-super replay: parse the FAKESUPER_XATTR record previously written on * `fd` by fake_super_store_fd and re-apply the recorded permission bits - * fd-relative. The recorded uid/gid are deliberately NOT chowned for real: - * --fake-super only RECORDS ownership (the caller stores the resolved mapping - * via identity_resolve_storage_ids), it never performs a real chown. The + * fd-relative. `fd` may be a regular file, a faked device, or a DIRECTORY; + * fgetxattr/fchmod work identically on a directory descriptor. The recorded + * uid/gid are deliberately NOT chowned for real: --fake-super only RECORDS + * ownership (the caller stores the resolved mapping via + * identity_resolve_storage_ids), it never performs a real chown. The * recorded rdev is retained for a later privileged restore but is not acted on * here. Best-effort: absence of the xattr or a malformed record is a silent * no-op that never fails the transfer. The MODE leg is applied only when * policy.perms||policy.executability, and the recorded special bits - * (setuid/setgid/sticky) are NOT applied to the real file -- exactly like + * (setuid/setgid/sticky) are NOT applied to the real entry -- exactly like * rsync's fake-super receiver, which stores the full mode in the xattr but * strips the special bits on disk. mtime is not part of the record; the normal * metadata path carries it (policy.times) exactly as rsync sets the file's own diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 5602c4f..9666009 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -7042,6 +7042,72 @@ class TestExtendedAttributes: f"is not interoperable: ours={rec!r} rsync={out_rec!r}" ) + @pytest.mark.ci + def test_fake_super_directory_rsync_interop(self, shared_server): + """#319: --fake-super fakes DIRECTORIES too. A recursive -a + --fake-super run must write rsync 3.4.1's `user.rsync.%stat` record on + the directory itself (full mode with S_IFDIR + special bits, rdev 0,0, + uid:gid), replay only the permission bits on disk, and real rsync must + read the tree and re-emit the identical record.""" + rsync = shutil.which("rsync") + if rsync is None: + pytest.skip("rsync not installed") + source, dest = self._source_and_dest("fakesuper_dir_interop") + sub = os.path.join(source, "subdir") + os.makedirs(sub) + with open(os.path.join(sub, "f.txt"), "wb") as fh: + fh.write(b"dir interop\n") + if not _xattr_supported(sub): + pytest.skip("filesystem does not support user xattrs") + # A special bit (setgid) is exactly what a fake-super record exists to + # carry: rsync only re-emits a directory record when there is something + # it cannot represent on disk (a special bit, or a mode it would widen + # to keep the owner's rwx). Skip cleanly when the filesystem drops it. + os.chmod(sub, 0o2751) + if stat.S_IMODE(os.stat(sub).st_mode) & 0o7000 == 0: + pytest.skip("filesystem drops directory special bits") + uid = os.stat(sub).st_uid + + result, _ = run_client(source, dest, flags=["-a", "--fake-super"], + port=shared_server.port) + assert result.returncode == 0, \ + f"-a --fake-super dir sync failed: {(result.stderr or result.stdout)[:300]}" + received = get_dest_received_dir(dest, source) + dst_sub = os.path.join(received, "subdir") + assert os.path.isdir(dst_sub), "the directory entry was not transferred" + + rec = os.getxattr(dst_sub, "user.rsync.%stat").decode() + fields = rec.split() + assert len(fields) == 3, f"unexpected rsync fake-super record {rec!r}" + mode_field, rdev_field, owner_field = fields + assert rdev_field == "0,0", f"directory rdev must be 0,0, got {rdev_field!r}" + assert int(mode_field, 8) & 0o170000 == stat.S_IFDIR, ( + f"recorded mode {mode_field!r} must carry S_IFDIR" + ) + assert int(mode_field, 8) & 0o7777 == 0o2751, ( + f"recorded mode {mode_field!r} must carry the full source mode 02751" + ) + assert owner_field.split(":")[0] == str(uid), \ + f"recorded uid {owner_field!r} != source uid {uid}" + # Permission bits only on disk: the setgid bit stays in the record. + assert stat.S_IMODE(os.stat(dst_sub).st_mode) == 0o751, ( + "the directory's special bits must not be installed on disk" + ) + + # Real rsync reads FastSync's directory record and re-emits it verbatim. + out = os.path.join(TEST_DATA_DIR, "fakesuper_dir_interop_rsync") + clean_dir(out) + rs = subprocess.run([rsync, "-aX", "--fake-super", received + "/", out + "/"], + capture_output=True, text=True, timeout=120) + assert rs.returncode == 0, ( + f"rsync could not read FastSync's fake-super directory tree: {rs.stderr[:300]}" + ) + out_rec = os.getxattr(os.path.join(out, "subdir"), "user.rsync.%stat").decode() + assert out_rec == rec, ( + "rsync re-emitted a different directory fake-super record; FastSync's " + f"grammar is not interoperable: ours={rec!r} rsync={out_rec!r}" + ) + @pytest.mark.ci def test_directory_xattrs_preserved(self, shared_server): """#286.3: -aX must preserve user.* xattrs on DIRECTORIES, not just files.""" diff --git a/tests/test_xattr.c b/tests/test_xattr.c index 314fbaf..7ea9b30 100644 --- a/tests/test_xattr.c +++ b/tests/test_xattr.c @@ -892,6 +892,114 @@ static void test_symlink_frame_carries_xattrs() { EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); } +/* --fake-super for DIRECTORIES: rsync stores a directory's faked mode/uid/gid + * in `user.rsync.%stat` on the directory itself. fake_super_store_fd() and + * fake_super_restore_fd() operate on a directory descriptor exactly like a + * file: the full mode (with S_IFDIR + special bits) is recorded, only the + * permission bits are replayed on disk, and the owner is never real-chowned. + * Guarded on filesystem xattr support. */ +static void test_fake_super_directory_fd_roundtrip() { + const char* root = "test_fake_super_dirfd_tmp"; + const char* path = "test_fake_super_dirfd_tmp/subdir"; + rmdir(path); + rmdir(root); + EXPECT_EQ_INT(mkdir(root, 0700), 0); + if (setxattr(root, "user.fastsync-dirprobe", "p", 1, 0) != 0) { + rmdir(root); + return; /* skip silently when the filesystem has no xattr support */ + } + removexattr(root, "user.fastsync-dirprobe"); + EXPECT_EQ_INT(mkdir(path, 0755), 0); + + int fd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + EXPECT_TRUE(fd >= 0); + FileAttrPolicy policy = {true, true, false, false, true}; + + /* No record yet: restore is a silent no-op on a directory too. */ + EXPECT_FALSE(fake_super_restore_fd(fd, policy)); + + struct stat before; + EXPECT_EQ_INT(fstat(fd, &before), 0); + fake_super_store_fd(fd, 2222, 3333, S_IFDIR | 01777, 0, 0); + char value[64]; + ssize_t got = fgetxattr(fd, FAKESUPER_XATTR, value, sizeof(value)); + /* S_IFDIR | 01777 == 0041777 -> "41777 0,0 2222:3333" */ + EXPECT_EQ_INT((int)got, 19); + EXPECT_TRUE(got == 19 && memcmp(value, "41777 0,0 2222:3333", 19) == 0); + + EXPECT_TRUE(fake_super_restore_fd(fd, policy)); + struct stat after; + EXPECT_EQ_INT(fstat(fd, &after), 0); + /* The sticky bit is stored in the record but never installed on disk. */ + EXPECT_EQ_INT((int)(after.st_mode & 07777), 0777); + EXPECT_EQ_INT((int)(after.st_mode & (S_ISUID | S_ISGID | S_ISVTX)), 0); + EXPECT_EQ_INT((int)after.st_uid, (int)before.st_uid); + EXPECT_EQ_INT((int)after.st_gid, (int)before.st_gid); + + close(fd); + removexattr(path, FAKESUPER_XATTR); + rmdir(path); + rmdir(root); +} + +/* The deferred directory-metadata pass is where a recursive -a --fake-super + * transfer stamps each directory: dir_metadata_list_apply() must park the + * directory's full stat in the reserved xattr and replay only its permission + * bits on disk. This is the recursive-path counterpart of the explicit + * --dirs store in file_save_directory_to_disk(). Guarded on xattr support. */ +static void test_fake_super_directory_deferred_apply() { + const char* root = "test_fake_super_dirdir_tmp"; + const char* leaf = "subdir"; + const char* path = "test_fake_super_dirdir_tmp/subdir"; + rmdir(path); + rmdir(root); + EXPECT_EQ_INT(mkdir(root, 0700), 0); + if (setxattr(root, "user.fastsync-dirprobe", "p", 1, 0) != 0) { + rmdir(root); + return; /* skip silently when the filesystem has no xattr support */ + } + removexattr(root, "user.fastsync-dirprobe"); + EXPECT_EQ_INT(mkdir(path, 0755), 0); + + FileMetadata m; + memset(&m, 0, sizeof(m)); + m.mode = S_IFDIR | 02751; + m.uid = 1001; + m.gid = 1002; + m.mtime_sec = 1234567890; + + Config* config = config_create(); + EXPECT_NOT_NULL(config); + config->use_metadata = true; + config->preserve_perms = true; + config->preserve_times = true; + config->fake_super = true; + + identity_clear_active(); + DirTimeList list; + dir_time_list_init(&list); + EXPECT_TRUE(dir_time_list_add(&list, leaf, &m, NULL)); + dir_metadata_list_apply(&list, root, config); + dir_time_list_free(&list); + + char value[64]; + ssize_t got = getxattr(path, FAKESUPER_XATTR, value, sizeof(value)); + /* S_IFDIR | 02751 -> "42751 0,0 1001:1002" (resolved ids == source ids). */ + EXPECT_EQ_INT((int)got, 19); + EXPECT_TRUE(got == 19 && memcmp(value, "42751 0,0 1001:1002", 19) == 0); + + struct stat st; + EXPECT_EQ_INT(stat(path, &st), 0); + /* Only the permission bits land on disk; setgid stays in the record. */ + EXPECT_EQ_INT((int)(st.st_mode & 07777), 0751); + EXPECT_EQ_INT((int)(st.st_mode & (S_ISUID | S_ISGID | S_ISVTX)), 0); + + config_delete(config); + removexattr(path, FAKESUPER_XATTR); + rmdir(path); + rmdir(root); +} + void test_xattr() { test_xattr_list_clone(); test_xattr_capture_symlink_nofollow(); @@ -910,5 +1018,7 @@ void test_xattr() { test_fake_super_rsync_format(); test_fake_super_no_real_chown(); test_fake_super_storage_resolution(); + test_fake_super_directory_fd_roundtrip(); + test_fake_super_directory_deferred_apply(); test_file_save_directory_applies_xattrs(); }