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/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/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(); }