diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index b0fdc48..135dc4d 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -138,7 +138,7 @@ Every one of those has an entry below with its remaining caveats. |------|-------------------|-----------------|-------| | `--exclude-from=FILE` | Read exclude patterns from file | ✅ Parity | Reads patterns from file | | `--include-from=FILE` | Read include patterns from file | ✅ Parity | Reads patterns from file | -| `--filter=RULE` | Add file-filtering rule | ⚠️ Caveat | The short `-f` **is** bound to `--filter` (the old FastSync sendfile conflict is gone; sendfile is long-only `--sendfile`), and `-f RULE`, `-f=RULE`, `--filter=RULE` and the two-argument form all parse. Protocol 2.26.0 implements rsync's filter grammar: `+`/`-`, `include`/`exclude`, a leading `/` anchor (to the transfer root or a `.rsync-filter` file's directory), a trailing `/` dir-only rule, and the `merge`/`.`, `dir-merge`/`:`, `hide`/`H`, `show`/`S`, `protect`/`P`, `risk`/`R` and `clear`/`!` words, including the `:`/`.` modifiers. The xattr-name `x` modifier is **explicitly rejected everywhere with a clear error**. The merge-only `e`/`n`/`w` and `-` modifiers are **accepted and consumed on `merge`/`dir-merge` rules** (so they no longer leak into the merge filename) while still being **rejected on non-merge rules**, matching rsync; their semantics remain unimplemented, so they are accepted-but-ignored (the reason this row is a caveat rather than parity). A token made up solely of modifier characters that names an unsupported modifier is rejected on non-merge rules, while glued patterns (`-newfile`, `-e2e`) and mixed tokens (`H,!secret`) keep their historical parsing. First match wins; the filter layer is independent of `--exclude`/`--include`. **Track 4a (protocol 2.28.0) adds the receiver filter engine:** the sender compiles its root-level rules exactly as the scanner does (`filter_base_build`) and streams them as one bounded, self-describing config-frame block; the receiver reconstructs them and re-applies first-match-wins to every extraneous destination path during deletion, so a `P *.log` rule protects a destination-only `extra.log` (differential `filter_protect`/`filter_protect_during`/`filter_protect_delay` vs rsync 3.4.1, plus the `-n` would-delete enumeration) — matching rsync's dual-sided engine for the command-line rule set. **Remaining residual:** per-directory merge (`:`/`.`, and therefore `-F`) is not yet re-derived on the receiver; a destination-only entry that matches ONLY a per-directory merge rule is still protected only through the sender-derived source-mirror prefixes, not by the received base rule list | +| `--filter=RULE` | Add file-filtering rule | ⚠️ Caveat | The short `-f` **is** bound to `--filter` (the old FastSync sendfile conflict is gone; sendfile is long-only `--sendfile`), and `-f RULE`, `-f=RULE`, `--filter=RULE` and the two-argument form all parse. Protocol 2.26.0 implements rsync's filter grammar: `+`/`-`, `include`/`exclude`, a leading `/` anchor (to the transfer root or a `.rsync-filter` file's directory), a trailing `/` dir-only rule, and the `merge`/`.`, `dir-merge`/`:`, `hide`/`H`, `show`/`S`, `protect`/`P`, `risk`/`R` and `clear`/`!` words, including the `:`/`.` modifiers. The xattr-name `x` modifier is **explicitly rejected everywhere with a clear error**. The merge-only `e` (exclude the merge file itself), `n` (do not inherit into subdirectories), `w` (word-split the file on whitespace) and `-` (read the file as bare exclude/include patterns) modifiers are **implemented** on `merge`/`dir-merge` rules (they are still **rejected on non-merge rules**, matching rsync 3.4.1), verified by the `dir_merge_e`/`dir_merge_n`/`dir_merge_dash`/`dir_merge_w` differential cases. A token made up solely of modifier characters that names an unsupported modifier is rejected on non-merge rules, while glued patterns (`-newfile`, `-e2e`) and mixed tokens (`H,!secret`) keep their historical parsing. First match wins; the filter layer is independent of `--exclude`/`--include`. **Track 4a (protocol 2.28.0) adds the receiver filter engine:** the sender compiles its root-level rules exactly as the scanner does (`filter_base_build`) and streams them as one bounded, self-describing config-frame block; the receiver reconstructs them and re-applies first-match-wins to every extraneous destination path during deletion, so a `P *.log` rule protects a destination-only `extra.log` (differential `filter_protect`/`filter_protect_during`/`filter_protect_delay` vs rsync 3.4.1, plus the `-n` would-delete enumeration) — matching rsync's dual-sided engine for the command-line rule set. **Per-directory rules (protocol 2.30.0, issue #315) now reach the receiver:** the sender streams each traversed directory's compiled rules (owner directory + no-inherit flag, self-describing and bounded/validated) on the delete carrier, and the receiver evaluates the containing directory's rules before each ancestor's and then the command-line base (rsync's per-directory-before-ancestors order), so a destination-only entry that matches ONLY a per-directory `.rsync-filter`/dir-merge rule is shielded under every delete timing — the whole-tree commit, the `--delete-during`/`--delete-delay` per-directory plans, and the `-n` would-delete enumeration (differential `filter_perdir_protect{,_during,_delay,_after}`, `filter_perdir_exclude_{protect,deleted}`, plus the `TestFilterProtect::test_perdir_protect_*` regressions). **Narrowed residual:** rsync's receiver reads the merge file from its OWN side (the destination), whereas FastSync carries the sender's compiled source-side rules, so the two differ when the merge files differ — most visibly a destination-only `.rsync-filter` with no source counterpart is honored by rsync but not by FastSync; rsync's merge `C` (CVS-compatible) modifier is also not implemented | | `--files-from=FILE` | Read source file list from file | ✅ Parity | Entries are paths relative to the source root (leading `./` stripped, `..`/absolute rejected at parse time, blank lines ignored; NUL-delimited with `-0`). A listed regular file is transferred; a listed directory transfers its whole subtree (FastSync recursion is always on). Non-listed paths are pruned by the scanner; the delete manifest is scoped to the listed directory subtrees. A listed entry that does not exist is a hard error unless `--ignore-missing-args`/`--delete-missing-args` is given. **An empty list is a zero-transfer success (exit 0), matching rsync 3.4.1** — the earlier claim that rsync reports "no source files specified" was wrong. Scalability note: `file_list_affects` is O(list size) per scanned entry, so a very large list against a huge tree is quadratic (the documented bound) | | `-0`, `--from0` | Delimit *-from files with NULs | ✅ Parity | `--files-from` entries become NUL-delimited; the flag may appear before or after `--files-from` on the command line. NUL mode preserves entry bytes exactly (trailing CR/LF are part of the name; only newline mode trims them) | | `--max-size=SIZE` | Skip files larger than SIZE | ✅ Parity | `max_size` in scanner | @@ -150,7 +150,7 @@ Every one of those has an entry below with its remaining caveats. | `--ignore-existing` | Skip updating existing files | ✅ Parity | `ignore_existing` config field (crosses the wire; receiver-side policy). Protocol 2.26.0 short-circuits in the per-file check **before any payload**: when the destination entry already exists, the receiver answers the skip during the incremental handshake instead of letting the sender stream data that would be discarded, so an existing 4 MiB destination costs only the config/check frames (verified with a counting proxy, matching rsync). The write-time paths (regular, delay-updates-staged, hardlink-sibling, special/device) still return `FILE_SAVE_SKIPPED` without overwriting, and `--backup` is disabled for skipped files. Like rsync, it does not apply to directories/symlinks. Combines with `-j`/`--threads` and `--delay-updates` | | `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Parity | | | `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Parity | Sender scanner captures the root device and does not descend into mount-point crossings (`st_dev` differs). **Protocol 2.23.0 matches rsync's entry emission:** the mount-point directory itself is emitted as a payload-less directory entry (so the destination gets an empty directory) while its contents are skipped; previously the crossing subdirectory was dropped entirely | -| `-F` | Add the default `.rsync-filter` rules | ⚠️ Caveat | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (first match wins). **A single `-F` transfers the `.rsync-filter` files themselves, matching rsync; a repeated `-FF` additionally excludes them** (rsync 3.4.1's `-F`/`-FF` are exactly these two rules, with no `.cvsignore` branch). Unsupported/unparseable rules inside a per-directory file fail the scan with a clear error. **Residual (track 4a):** per-directory rules are still enforced receiver-side only through the sender-derived source-mirror protected prefixes; the base-rule receiver filter engine does not carry per-directory rules, so a destination-only entry matching ONLY a `.rsync-filter` rule is not yet shielded from `--delete` | +| `-F` | Add the default `.rsync-filter` rules | ⚠️ Caveat | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (first match wins). **A single `-F` transfers the `.rsync-filter` files themselves, matching rsync; a repeated `-FF` additionally excludes them** (rsync 3.4.1's `-F`/`-FF` are exactly these two rules, with no `.cvsignore` branch). Unsupported/unparseable rules inside a per-directory file fail the scan with a clear error. The merge-only `e`/`n`/`w`/`-` modifiers on a `dir-merge` registration are implemented (`e` excludes the merge file, `n` stops inheritance into subdirectories, `w` word-splits, `-` reads bare patterns). **Per-directory rules (protocol 2.30.0, issue #315) are now carried to the receiver:** the sender streams each traversed directory's compiled rules (owner + no-inherit flag, bounded and validated) on the delete carrier and the receiver applies them deepest-directory-first before the command-line base, so a destination-only entry matching ONLY a `.rsync-filter` rule is shielded from `--delete` under every timing, including the `-n` would-delete enumeration (`filter_perdir_protect*` differential and `TestFilterProtect::test_perdir_protect_*`). **Residual:** rsync's receiver scans the DESTINATION's `.rsync-filter` files while FastSync carries the source's compiled rules, so a rule present only in a destination-side `.rsync-filter` is honored by rsync but not by FastSync | ## 4. Directory Options @@ -964,7 +964,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`, `--msgs2stderr`, `--stats`, `--progress`, `-i`, `--filter`, `-F`, the three basis-dir options, `-y/--fuzzy`, `--fake-super`, and `--devices`. 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 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`, and `--devices` (15). 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**. 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 @@ -1207,9 +1207,12 @@ wire protocol three times (full rationale in `src/shared/config.h`): modifiers; `-f` is bound to `--filter`; a single `-F` transfers `.rsync-filter` and `-FF` excludes it. The xattr-name `x` modifier is **not implemented** and is rejected with a clear error everywhere. The merge-only - `e`/`n`/`w` and `-` modifiers are accepted and consumed on `merge`/`dir-merge` - rules (rejected elsewhere, matching rsync), but their semantics are **not - implemented** (accepted-but-ignored). + `e`/`n`/`w` and `-` modifiers are implemented on `merge`/`dir-merge` rules + (rejected elsewhere, matching rsync). Per-directory rules are carried to the + receiver (protocol 2.30.0) and re-applied deepest-first before the + command-line base, so a destination-only entry matching only a per-directory + rule is shielded; the residual is that rsync reads the destination's merge + file while FastSync carries the source's. - **Absolute basis directories** are used verbatim (rsync semantics) and **`--link-dest`** relinks an already up-to-date destination. @@ -1242,9 +1245,11 @@ These remain after the wave; the individual rows carry the precise wording. rsync's generator removes all extras ahead of its throttled sender while FastSync removes only the reached directories (completed runs agree). `--delete-before` keeps its pre-scan snapshot race; and while - base-rule `protect`/`risk` rules are now re-applied on the receiver (track 4a), - per-directory merge (`.rsync-filter`) protection is still sender-derived, so a - destination-only entry matching only a per-directory rule is not re-derived. + base-rule `protect`/`risk` rules and the per-directory merge rules are now + re-applied on the receiver (protocol 2.30.0), so a destination-only entry + matching only a per-directory rule is re-derived; the residual is the side + the merge file is read from (rsync reads the destination's, FastSync carries + the source's). `--ignore-errors` exits 23 but its EACCES differential is not exercised in CI. - **`--delay-updates`** uses a fixed staging name with an advisory lock and diff --git a/src/client/client_manifest.c b/src/client/client_manifest.c index 55a3f67..1d4215f 100644 --- a/src/client/client_manifest.c +++ b/src/client/client_manifest.c @@ -330,9 +330,11 @@ int send_list_only(const Config* config) { } /* Send the delete manifest to the server. Returns 0 on success, -1 on - failure. It carries FOUR sections: the keep-set paths, the protected - excluded prefixes, the --delete-missing-args exact-delete paths, and the - destination-relative directories the sender synchronized this run. + failure. It carries FOUR path sections (keep-set paths, protected excluded + prefixes, --delete-missing-args exact-delete paths, and the destination- + relative directories the sender synchronized this run) followed by the + protocol-2.30.0 per-directory filter-rule block (`per_dir_rules`, the rules + the scan compiled from each directory's merge files). When --delete-excluded is given `protected` is empty: excluded destination mirrors are then ordinary extras and are removed. When --delete-missing-args is active `missing_args` holds the destination mirrors @@ -346,7 +348,8 @@ int send_list_only(const Config* config) { frame. A heavily filtered source whose exclusion list is large therefore fails the run cleanly on the receiver rather than being truncated. */ int send_delete_manifest(int fd, ArrayList* manifest, ArrayList* protected_prefixes, - ArrayList* size_skipped, ArrayList* missing_args, ArrayList* synced_dirs) { + ArrayList* size_skipped, ArrayList* missing_args, ArrayList* synced_dirs, + const FilterRuleList* per_dir_rules) { if (!send_status(fd, STATUS_MANIFEST)) return -1; int keep_count = manifest ? manifest->size : 0; @@ -389,6 +392,11 @@ int send_delete_manifest(int fd, ArrayList* manifest, ArrayList* protected_prefi if (!send_wire_str(fd, (char*)synced_dirs->items[i])) return -1; } + /* Protocol 2.30.0: the receiver-side per-directory filter rules discovered by + the sender's scan, so the whole-tree commit walker can shield a + destination-only entry that matches only a per-directory merge rule. */ + if (!delete_filter_dir_rules_send(fd, per_dir_rules)) + return -1; return 0; } @@ -409,11 +417,11 @@ int send_delete_manifest(int fd, ArrayList* manifest, ArrayList* protected_prefi bool send_delete_manifest_early(Client* client, ArrayList* manifest, ArrayList* protected_prefixes, ArrayList* size_skipped, ArrayList* missing_args, - ArrayList* synced_dirs) { + ArrayList* synced_dirs, const FilterRuleList* per_dir_rules) { if (!client || !manifest) return false; if (send_delete_manifest(client->file_descriptor, manifest, protected_prefixes, size_skipped, - missing_args, synced_dirs) != 0) + missing_args, synced_dirs, per_dir_rules) != 0) return false; Status ack; /* The wait is long (up to an hour) and runs inline on this thread: a helper @@ -497,6 +505,7 @@ int send_dry_run_remote(Config* config) { ArrayList* dry_dirs = NULL; ArrayList* dry_excluded = NULL; ArrayList* dry_size_skipped = NULL; + FilterRuleList* dry_per_dir = NULL; if (!config_send(client->file_descriptor, config)) goto dry_fail; receive_daemon_motd(client, config); @@ -509,8 +518,10 @@ int send_dry_run_remote(Config* config) { dry_manifest = array_list_create(free); dry_dirs = array_list_create(free); dry_size_skipped = array_list_create(free); - if (!dry_manifest || !dry_dirs || !dry_size_skipped) + dry_per_dir = filter_rule_list_create(); + if (!dry_manifest || !dry_dirs || !dry_size_skipped || !dry_per_dir) goto dry_fail; + prepared.options.per_dir_rules = dry_per_dir; if (!config->delete_excluded) { dry_excluded = array_list_create(free); if (!dry_excluded) @@ -612,7 +623,7 @@ int send_dry_run_remote(Config* config) { bool early_delete = config->use_delete && config_delete_timing_early(config); if (dry_manifest) { if (send_delete_manifest(client->file_descriptor, dry_manifest, dry_excluded, dry_size_skipped, - NULL, dry_dirs) != 0) + NULL, dry_dirs, dry_per_dir) != 0) goto dry_fail; if (early_delete) { Status ack; @@ -672,6 +683,8 @@ dry_fail: array_list_delete(dry_excluded); if (dry_size_skipped) array_list_delete(dry_size_skipped); + if (dry_per_dir) + filter_rule_list_free(dry_per_dir); if (scanner) directory_scanner_destroy(scanner); prepared_scanner_destroy(&prepared); diff --git a/src/client/client_send.c b/src/client/client_send.c index bec5e6c..5337ccb 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -1033,7 +1033,7 @@ static int send_chunks_multithreaded(void* pipeline_context) { and wait for the receiver to delete extras before streaming any data. */ if (!send_delete_manifest_early(client, context->manifest, context->excluded_paths, context->size_skipped_paths, context->missing_args, - context->synced_dirs)) { + context->synced_dirs, context->per_dir_rules)) { pipeline_cancel(context); disconnect_transfer_client(client); mark_sender_done(context); @@ -1163,7 +1163,8 @@ static int send_chunks_multithreaded(void* pipeline_context) { log_message(LOG_LEVEL_WARNING, "IO error encountered -- skipping file deletion"); } else if (send_delete_manifest(client->file_descriptor, context->manifest, context->excluded_paths, context->size_skipped_paths, - context->missing_args, context->synced_dirs) != 0) { + context->missing_args, context->synced_dirs, + context->per_dir_rules) != 0) { goto send_fail; } } else if (context->config->delete_missing_args && !context->early_delete && @@ -1171,7 +1172,7 @@ static int send_chunks_multithreaded(void* pipeline_context) { /* --delete-missing-args without --delete: no keep-set is built, but the exact-delete paths still ride the same manifest frame (commit once the transfer succeeded). */ - if (send_delete_manifest(client->file_descriptor, NULL, NULL, NULL, context->missing_args, + if (send_delete_manifest(client->file_descriptor, NULL, NULL, NULL, context->missing_args, NULL, NULL) != 0) goto send_fail; } @@ -1285,6 +1286,7 @@ static int scan_directory_multithreaded(void* pipeline_context) { its protected lists, so the data pass must not append to them again. */ if (!context->early_delete && !context->delete_plans) { prepared.options.excluded_paths = context->excluded_paths; + prepared.options.per_dir_rules = context->per_dir_rules; /* The root marker for a full recursive transfer is already in the list; do not let the scanner append every directory to it. */ if (context->config->files_from_set != NULL) @@ -1518,6 +1520,8 @@ typedef struct { ArrayList* synced_dirs; ArrayList* plan_dirs; ArrayList* missing_args; + /* Per-directory filter rules compiled by the scan (protocol 2.30.0). */ + FilterRuleList* per_dir_rules; PreparedScanner prepared; StopCondition stop; TransferStats transfer_stats; @@ -1562,9 +1566,11 @@ static bool send_files_prepare(Config* config, SendFilesState* state) { } state->size_skipped = array_list_create(free); state->synced_dirs = array_list_create(free); - if (!state->size_skipped || !state->synced_dirs) + state->per_dir_rules = filter_rule_list_create(); + if (!state->size_skipped || !state->synced_dirs || !state->per_dir_rules) return false; state->prepared.options.size_skipped_paths = state->size_skipped; + state->prepared.options.per_dir_rules = state->per_dir_rules; /* Only a --files-from subset confines the extras walk to the directories the scan synchronized; a full recursive transfer deletes throughout the receive root, so mark the root itself (the "." sentinel) and let the @@ -1629,9 +1635,9 @@ static bool send_files_prepare_delete(Config* config, SendFilesState* state) { log_message(LOG_LEVEL_WARNING, "IO error encountered -- skipping file deletion"); skip_delete = true; } else { - early_ok = - send_delete_manifest_early(client, early_manifest, state->excluded, state->size_skipped, - state->missing_args, state->synced_dirs); + early_ok = send_delete_manifest_early(client, early_manifest, state->excluded, + state->size_skipped, state->missing_args, + state->synced_dirs, state->per_dir_rules); } } array_list_delete(early_manifest); @@ -1640,6 +1646,7 @@ static bool send_files_prepare_delete(Config* config, SendFilesState* state) { state->prepared.options.excluded_paths = NULL; state->prepared.options.size_skipped_paths = NULL; state->prepared.options.synced_dirs = NULL; + state->prepared.options.per_dir_rules = NULL; if (!prescan_ok || (!early_ok && !skip_delete)) { array_list_delete(prescan_chunks); return false; @@ -1669,7 +1676,7 @@ static bool send_files_prepare_delete(Config* config, SendFilesState* state) { config->files_from_set ? state->synced_dirs : (walk_root ? state->synced_dirs : NULL); delete_plan_sender_finalize(state->plan_sender, scope, walk_root); delete_plan_sender_set_config(state->plan_sender, state->excluded, state->size_skipped, - state->missing_args); + state->missing_args, state->per_dir_rules); if (state->had_scan_io && delete_plan_sender_empty(state->plan_sender)) { log_message(LOG_LEVEL_ERROR, "source scan hit an I/O error before finding any file; refusing to delete " @@ -1693,6 +1700,7 @@ static bool send_files_prepare_delete(Config* config, SendFilesState* state) { state->prepared.options.size_skipped_paths = NULL; state->prepared.options.synced_dirs = NULL; state->prepared.options.plan_dirs = NULL; + state->prepared.options.per_dir_rules = NULL; if (!prescan_ok || (!plans_ok && !skip_delete)) return false; } else if (config->use_delete) { @@ -1874,7 +1882,8 @@ static int send_files_finalize(const Config* config, SendFilesState* state) { modes the deletion already went out with the data, so nothing is re-sent here. */ if (send_delete_manifest(client->file_descriptor, state->manifest, state->excluded, - state->size_skipped, state->missing_args, state->synced_dirs) != 0) { + state->size_skipped, state->missing_args, state->synced_dirs, + state->per_dir_rules) != 0) { if (state->manifest) { array_list_delete(state->manifest); state->manifest = NULL; @@ -1949,6 +1958,8 @@ static void send_files_cleanup(SendFilesState* state) { array_list_delete(state->plan_dirs); if (state->missing_args) array_list_delete(state->missing_args); + if (state->per_dir_rules) + filter_rule_list_free(state->per_dir_rules); if (state->remove_sources) array_list_delete(state->remove_sources); if (state->dir_entries) @@ -2130,7 +2141,8 @@ static int send_files_multithreaded_impl(Config* config) { confined; only a --files-from subset records concrete directories. */ context->size_skipped_paths = array_list_create(free); context->synced_dirs = array_list_create(free); - if (!context->size_skipped_paths || !context->synced_dirs) { + context->per_dir_rules = filter_rule_list_create(); + if (!context->size_skipped_paths || !context->synced_dirs || !context->per_dir_rules) { pipeline_context_sender_destroy(context); return 1; } @@ -2159,6 +2171,7 @@ static int send_files_multithreaded_impl(Config* config) { if (context->excluded_paths) prepared.options.excluded_paths = context->excluded_paths; prepared.options.size_skipped_paths = context->size_skipped_paths; + prepared.options.per_dir_rules = context->per_dir_rules; /* The root marker for a full recursive transfer is already in the list; only a --files-from subset needs the scanner to record directories. */ if (config->files_from_set != NULL) @@ -2199,7 +2212,8 @@ static int send_files_multithreaded_impl(Config* config) { : (walk_root ? context->synced_dirs : NULL); delete_plan_sender_finalize(context->delete_plans, scope, walk_root); delete_plan_sender_set_config(context->delete_plans, context->excluded_paths, - context->size_skipped_paths, context->missing_args); + context->size_skipped_paths, context->missing_args, + context->per_dir_rules); } bool empty = per_dir ? (context->delete_plans && delete_plan_sender_empty(context->delete_plans)) diff --git a/src/client/client_send_internal.h b/src/client/client_send_internal.h index a7804fc..4197ce2 100644 --- a/src/client/client_send_internal.h +++ b/src/client/client_send_internal.h @@ -102,9 +102,10 @@ int send_dry_run_manifest(const Config* config); int send_list_only(const Config* config); int send_dry_run_remote(Config* config); int send_delete_manifest(int fd, ArrayList* manifest, ArrayList* protected_prefixes, - ArrayList* size_skipped, ArrayList* missing_args, ArrayList* synced_dirs); + ArrayList* size_skipped, ArrayList* missing_args, ArrayList* synced_dirs, + const FilterRuleList* per_dir_rules); bool send_delete_manifest_early(Client* client, ArrayList* manifest, ArrayList* protected_prefixes, ArrayList* size_skipped, ArrayList* missing_args, - ArrayList* synced_dirs); + ArrayList* synced_dirs, const FilterRuleList* per_dir_rules); #endif diff --git a/src/client/scanner.h b/src/client/scanner.h index a9fa3d0..88ff79d 100644 --- a/src/client/scanner.h +++ b/src/client/scanner.h @@ -115,6 +115,13 @@ typedef struct { * directories, exactly like rsync; the receive root is the "." sentinel. * Guarded by `excluded_mutex`. */ ArrayList* synced_dirs; + /* Per-directory filter-rule sink (optional): when non-NULL the scanner appends + * a deep copy of every rule it reads from a per-directory merge file, each + * carrying its owner directory and no-inherit flag (see filter.h). The delete + * carriers transmit them so the receiver re-derives the per-directory + * protect/risk set for destination-only entries. Guarded by `excluded_mutex` + * like the other sinks. */ + FilterRuleList* per_dir_rules; /* Delete-plan directory sink (optional): when non-NULL the scanner appends * the destination-relative path of every directory it traverses (except the * receive root). The per-directory --delete-during/--delete-delay plan diff --git a/src/client/scanner_filter.c b/src/client/scanner_filter.c index 374aa45..f352ae1 100644 --- a/src/client/scanner_filter.c +++ b/src/client/scanner_filter.c @@ -511,13 +511,31 @@ FilterRuleList* read_dir_filters(const ScannerOptions* options, const char* dir_ } if (base) { for (int i = 0; i < base->dir_merge_count; i++) { - if (!filter_file_append(own, dir_path, base->dir_merge_names[i], rel, &opts, &exists, err, - err_size)) + if (!filter_dir_merge_append(own, dir_path, &base->dir_merges[i], rel, &opts, &exists, err, + err_size)) goto fail; if (exists && any_exists) *any_exists = true; } } + /* Mirror the directory's rules into the delete-carrier sink so the receiver + * can reconstruct its per-directory protect/risk set. */ + if (options->per_dir_rules && own->count > 0) { + mtx_t* mtx = options->excluded_mutex; + if (mtx) + mtx_lock(mtx); + for (int i = 0; i < own->count; i++) { + FilterRule* copy = filter_rule_clone(own->items[i]); + if (!copy || !filter_rule_list_add(options->per_dir_rules, copy)) { + filter_rule_free(copy); + if (mtx) + mtx_unlock(mtx); + goto fail; + } + } + if (mtx) + mtx_unlock(mtx); + } return own; fail: filter_rule_list_free(own); diff --git a/src/shared/delete.c b/src/shared/delete.c index 66a1aca..0388cd5 100644 --- a/src/shared/delete.c +++ b/src/shared/delete.c @@ -32,6 +32,19 @@ static bool keep_is_file(const PathIndex* index, const char* rel_path) { return path_index_contains(index, rel_path); } +/* rsync's receiver-side verdict for one candidate extra: the per-directory + * chain first (deepest directory before ancestors), then the command-line base + * rules. Either rule set may be absent. */ +FilterAction delete_protect_verdict(const DeleteProtectRules* protect, const char* rel_path, + const char* leaf, bool is_dir) { + if (!protect) + return FILTER_ACTION_NONE; + FilterAction action = filter_dir_rules_apply_side(protect->dir_rules, rel_path, leaf, is_dir); + if (action != FILTER_ACTION_NONE) + return action; + return filter_rules_apply_side(protect->base_rules, rel_path, leaf, is_dir, FILTER_SIDE_RECEIVER); +} + /* Classify a removed entry from its st_mode for the per-type delete counters. */ DeleteEntryType delete_entry_type_of_mode(mode_t mode) { if (S_ISDIR(mode)) @@ -209,7 +222,7 @@ typedef struct { 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 FilterRuleList* protect_rules, bool parent_deletable, + const DeleteProtectRules* protect, bool parent_deletable, bool* all_removed) { DeleteDirEntry* entries = NULL; size_t count = 0; @@ -260,9 +273,8 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { shielded[i] = true; local_survives = true; - } else if (protect_rules && - filter_rules_apply_side(protect_rules, child_rel, entries[i].name, entries[i].is_dir, - FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) { + } 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. */ @@ -293,7 +305,7 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee 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_rules, + if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect, deletable, &child_all_removed)) operation_ok = false; close(childfd); @@ -408,7 +420,7 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee 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_rules, + if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect, deletable, &child_all_removed)) operation_ok = false; close(childfd); @@ -443,7 +455,7 @@ static int open_destination_root(const char* dest_root) { bool delete_extras_list(const char* dest_root, const ArrayList* manifest, const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out) { + const DeleteProtectRules* protect, ArrayList* out, size_t* count_out) { if (count_out) *count_out = 0; if (!manifest || !out) @@ -474,7 +486,7 @@ bool delete_extras_list(const char* dest_root, const ArrayList* manifest, .observer = NULL, .observer_context = NULL}; bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, - protect_rules, false, &all_removed); + protect, false, &all_removed); if (close(rootfd) != 0) ok = false; path_index_free(&keep); @@ -488,7 +500,7 @@ bool delete_extras_list(const char* dest_root, const ArrayList* manifest, DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest, const ArrayList* synced_dirs, size_t max_delete, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, + const DeleteProtectRules* protect, size_t* deleted_out, size_t* skipped_out, DeletePathObserver observer, void* observer_context) { @@ -527,7 +539,7 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr .observer = observer, .observer_context = observer_context}; bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, - protect_rules, false, &all_removed); + protect, false, &all_removed); if (close(rootfd) != 0) ok = false; path_index_free(&keep); @@ -545,11 +557,10 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest, const ArrayList* synced_dirs, size_t max_delete, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, size_t* deleted_out, + const DeleteProtectRules* protect, size_t* deleted_out, size_t* skipped_out) { return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips, - skip_count, protect_rules, deleted_out, skipped_out, NULL, - NULL); + skip_count, protect, deleted_out, skipped_out, NULL, NULL); } bool delete_extras(const char* dest_root, const ArrayList* manifest) { diff --git a/src/shared/delete.h b/src/shared/delete.h index dd77231..47c8eea 100644 --- a/src/shared/delete.h +++ b/src/shared/delete.h @@ -3,6 +3,7 @@ #include "array_list.h" #include "config.h" +#include "filter.h" #include #include #include @@ -29,6 +30,24 @@ typedef enum { DELETE_WALK_ERROR } DeleteWalkResult; +/* Receiver-side delete-protection rules for one walk. `base_rules` is the + * command-line rule set the config frame carried (owner "" rules); `dir_rules` + * is the received per-directory rule set (rules carrying their owner directory + * and no-inherit flag). Either may be NULL. */ +typedef struct { + const FilterRuleList* base_rules; + const FilterRuleList* dir_rules; +} DeleteProtectRules; + +/* rsync's first-match-wins receiver verdict for one candidate extra: the + * per-directory chain is evaluated first (the containing directory's rules, + * then each ancestor's, then the receive root's), then the base rules. Returns + * FILTER_ACTION_PROTECT when the entry is shielded by a receiver-side exclude, + * FILTER_ACTION_RISK when an include explicitly leaves it at risk, or + * FILTER_ACTION_NONE when no rule matched. */ +FilterAction delete_protect_verdict(const DeleteProtectRules* protect, const char* rel_path, + const char* leaf, bool is_dir); + /* One protected entry for the delete walker. When top_level_only is true the prefix is skipped only as a DIRECT child of dest_root (the --delay-updates staging directory, which must not hide genuine extras inside a nested @@ -100,7 +119,7 @@ int delete_dir_entry_cmp_asc(const void* a, const void* b); DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest, const ArrayList* synced_dirs, size_t max_delete, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, size_t* deleted_out, + const DeleteProtectRules* protect, size_t* deleted_out, size_t* skipped_out); /* Entry kind of a removed path, reported to the delete observer so the receiver @@ -124,14 +143,14 @@ DeleteEntryType delete_entry_type_of_mode(mode_t mode); /* `delete_extras_limited_observed` is delete_extras_limited with an optional * observer; the observer is invoked only for entries truly removed. When - * `protect_rules` is non-NULL its receiver-side verdict is evaluated for every + * `protect` is non-NULL its receiver-side verdict is evaluated for every * candidate extra: a first-match PROTECT leaves the entry (and, for a * directory, its whole subtree) in place, while RISK/NONE fall through to the * ordinary skip-prefix/keep-set logic. */ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest, const ArrayList* synced_dirs, size_t max_delete, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, + const DeleteProtectRules* protect, size_t* deleted_out, size_t* skipped_out, DeletePathObserver observer, void* observer_context); @@ -142,7 +161,7 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr strings appended to `out` and receives their count in *count_out. */ bool delete_extras_list(const char* dest_root, const ArrayList* manifest, const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out); + const DeleteProtectRules* protect, ArrayList* out, size_t* count_out); bool delete_extras(const char* dest_root, const ArrayList* manifest); /* Build the delete walk's skip-prefix set from the config's --delay-updates diff --git a/src/shared/delete_commit.c b/src/shared/delete_commit.c index fac8fc2..d175e9c 100644 --- a/src/shared/delete_commit.c +++ b/src/shared/delete_commit.c @@ -19,6 +19,7 @@ #include "data.h" #include "delay_updates.h" #include "delete_commit.h" +#include "delete_plan.h" #include "delta.h" #include "file.h" #include "format.h" @@ -102,6 +103,13 @@ DeleteManifest* receive_manifest_entries(int fd) { delete_manifest_free(manifest); return NULL; } + /* Per-directory filter rules (protocol 2.30.0) follow the manifest sections + * with their own bounded self-describing format. */ + if (!delete_filter_dir_rules_receive(fd, &manifest->per_dir_rules)) { + delete_manifest_free(manifest); + send_status(fd, STATUS_ERROR); + return NULL; + } return manifest; } @@ -112,6 +120,7 @@ void delete_manifest_free(DeleteManifest* manifest) { array_list_delete(manifest->protected); array_list_delete(manifest->missing); array_list_delete(manifest->dirs); + filter_rule_list_free(manifest->per_dir_rules); free(manifest); } @@ -160,9 +169,11 @@ static bool delete_extras_budgeted_observed(const Config* config, const DeleteMa remaining = budget->max_delete - budget->deleted; size_t deleted = 0; size_t skipped = 0; + DeleteProtectRules protect = {.base_rules = config->protect_rules, + .dir_rules = manifest->per_dir_rules}; DeleteWalkResult result = delete_extras_limited_observed( config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips.entries, - skips.count, config->protect_rules, &deleted, &skipped, observer, observer_context); + skips.count, &protect, &deleted, &skipped, observer, observer_context); delete_skips_free(&skips); budget->deleted += deleted; budget->skipped += skipped; @@ -386,8 +397,10 @@ bool manifest_would_delete_list(const Config* config, const DeleteManifest* mani DeleteSkipSet skips; if (!delete_skips_build(config, manifest->protected, NULL, true, &skips)) return false; + DeleteProtectRules protect = {.base_rules = config->protect_rules, + .dir_rules = manifest->per_dir_rules}; bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs, - skips.entries, skips.count, config->protect_rules, out, count_out); + skips.entries, skips.count, &protect, out, count_out); delete_skips_free(&skips); return ok; } diff --git a/src/shared/delete_commit.h b/src/shared/delete_commit.h index ba760f1..1602e4b 100644 --- a/src/shared/delete_commit.h +++ b/src/shared/delete_commit.h @@ -4,6 +4,7 @@ #include "array_list.h" #include "config.h" #include "delete.h" +#include "filter.h" #include /* Delete-commit module: delete-manifest receive plus the budgeted extras and @@ -30,6 +31,13 @@ typedef struct DeleteManifest { leave untransmitted directories and the unlisted parts of listed ones alone, matching rsync's "delete only in synchronized directories". */ ArrayList* dirs; + /* Per-directory filter rules the sender compiled while scanning (protocol + 2.30.0), each carrying its owner directory and no-inherit flag. The + receiver evaluates them (deepest before ancestors, then the command-line + base rules) against every candidate extra so a destination-only entry that + matches ONLY a per-directory `.rsync-filter`/dir-merge rule is shielded. + NULL when the sender transmitted none. */ + FilterRuleList* per_dir_rules; } DeleteManifest; void delete_manifest_free(DeleteManifest* manifest); diff --git a/src/shared/delete_plan.c b/src/shared/delete_plan.c index 6b2b84d..1939960 100644 --- a/src/shared/delete_plan.c +++ b/src/shared/delete_plan.c @@ -48,6 +48,7 @@ struct DeletePlanSender { const ArrayList* protected_prefixes; const ArrayList* size_skipped; const ArrayList* missing_args; + const FilterRuleList* per_dir_rules; size_t entries; /* Transmitted FILE entries only. The caller's "empty scan" safety guard keys off this (an I/O error that hid every file must refuse to delete even when @@ -284,12 +285,14 @@ bool delete_plan_sender_empty(const DeletePlanSender* sender) { } void delete_plan_sender_set_config(DeletePlanSender* sender, const ArrayList* protected_prefixes, - const ArrayList* size_skipped, const ArrayList* missing_args) { + const ArrayList* size_skipped, const ArrayList* missing_args, + const FilterRuleList* per_dir_rules) { if (!sender) return; sender->protected_prefixes = protected_prefixes; sender->size_skipped = size_skipped; sender->missing_args = missing_args; + sender->per_dir_rules = per_dir_rules; } /* True when `dir` is `root` itself or a descendant of it (path-component @@ -320,6 +323,160 @@ static int send_str_section(int fd, const ArrayList* list) { return 0; } +/* The directory a rule belongs to (its owner, or the transfer root for ""). */ +static const char* filter_dir_rule_owner(const FilterRule* rule) { + return (rule && rule->owner) ? rule->owner : ""; +} + +/* Transmit the received-side per-directory filter rules (protocol 2.30.0) as a + * self-describing list of directory groups: a group count, then for each group + * the relative owner directory followed by that directory's rule records (run + * order = the sender's traversal/rule order). Rules of one directory are + * appended to the sink contiguously, so runs reproduce the compilation order. + * Bounded by MAX_FILTER_RULES / MAX_FILTER_BYTES and MAX_PROTECT_PATTERN_LEN so + * the peer never sees a frame it would reject. */ +bool delete_filter_dir_rules_send(int fd, const FilterRuleList* rules) { + int count = rules ? rules->count : 0; + if (count < 0 || count > MAX_FILTER_RULES) { + log_message(LOG_LEVEL_ERROR, "too many per-directory filter rules: %d (maximum %d)", count, + MAX_FILTER_RULES); + return false; + } + for (int i = 0; i < count; i++) { + const FilterRule* rule = rules->items[i]; + size_t pattern_len = rule && rule->pattern ? strlen(rule->pattern) : 0; + if (!rule || !rule->pattern || pattern_len == 0 || pattern_len > MAX_PROTECT_PATTERN_LEN) { + log_message(LOG_LEVEL_ERROR, "invalid per-directory filter pattern"); + return false; + } + } + int groups = 0; + for (int i = 0; i < count;) { + const char* owner = filter_dir_rule_owner(rules->items[i]); + groups++; + i++; + while (i < count && strcmp(filter_dir_rule_owner(rules->items[i]), owner) == 0) + i++; + } + if (!send_int(fd, groups)) + return false; + for (int i = 0; i < count;) { + const char* owner = filter_dir_rule_owner(rules->items[i]); + int start = i; + i++; + while (i < count && strcmp(filter_dir_rule_owner(rules->items[i]), owner) == 0) + i++; + if (!send_wire_str(fd, owner) || !send_int(fd, i - start)) + return false; + for (int j = start; j < i; j++) { + const FilterRule* rule = rules->items[j]; + if (!send_int(fd, (int)rule->action) || !send_int(fd, (int)rule->sides) || + !send_int(fd, rule->anchored ? 1 : 0) || !send_int(fd, rule->dir_only ? 1 : 0) || + !send_int(fd, rule->negate ? 1 : 0) || !send_int(fd, rule->no_inherit ? 1 : 0) || + !send_wire_str(fd, rule->pattern)) + return false; + } + } + return true; +} + +/* Read one wire flag (an int restricted to 0/1). */ +static bool receive_flag(int fd, bool* value) { + int raw; + if (!receive_int(fd, &raw) || (raw != 0 && raw != 1)) + return false; + *value = raw != 0; + return true; +} + +/* Read the per-directory filter block emitted by delete_filter_dir_rules_send. + * Reconstructs a flat FilterRuleList whose rules carry their owner directory; + * `*out` is NULL when the sender transmitted no rules. Every bound is enforced + * (group/rule counts, owner/pattern bytes, pattern length, action/sides domain) + * so a malicious peer can neither overread nor allocate unboundedly. Returns + * false on a malformed frame (the caller signals STATUS_ERROR). */ +bool delete_filter_dir_rules_receive(int fd, FilterRuleList** out) { + if (!out) + return false; + *out = NULL; + int groups; + if (!receive_int(fd, &groups) || groups < 0 || groups > MAX_FILTER_RULES) + return false; + if (groups == 0) + return true; + FilterRuleList* list = filter_rule_list_create(); + if (!list) + return false; + int total_rules = 0; + size_t bytes = 0; + for (int g = 0; g < groups; g++) { + char* dir = receive_wire_str(fd); + if (!dir) + goto fail; + size_t dir_bytes = strlen(dir); + if (!(dir[0] == '\0' || (dir[0] != '/' && !has_path_traversal(dir))) || + dir_bytes > MAX_FILTER_BYTES - bytes) { + free(dir); + goto fail; + } + bytes += dir_bytes; + int rule_count; + if (!receive_int(fd, &rule_count) || rule_count < 0 || rule_count > MAX_FILTER_RULES || + rule_count > MAX_FILTER_RULES - total_rules) { + free(dir); + goto fail; + } + for (int r = 0; r < rule_count; r++) { + int action, sides; + bool anchored, dir_only, negate, no_inherit; + if (!receive_int(fd, &action) || + (action != FILTER_ACTION_EXCLUDE && action != FILTER_ACTION_INCLUDE) || + !receive_int(fd, &sides) || sides < (int)FILTER_SIDE_SENDER || + sides > (int)(FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER) || + !receive_flag(fd, &anchored) || !receive_flag(fd, &dir_only) || + !receive_flag(fd, &negate) || !receive_flag(fd, &no_inherit)) { + free(dir); + goto fail; + } + char* pattern = receive_wire_str(fd); + size_t pattern_bytes = pattern ? strlen(pattern) : 0; + if (!pattern || pattern_bytes == 0 || pattern_bytes > MAX_PROTECT_PATTERN_LEN || + pattern_bytes > MAX_FILTER_BYTES - bytes) { + free(pattern); + free(dir); + goto fail; + } + bytes += pattern_bytes; + FilterRule* rule = calloc(1, sizeof(FilterRule)); + if (!rule) { + free(pattern); + free(dir); + goto fail; + } + rule->action = (FilterAction)action; + rule->sides = (unsigned)sides; + rule->anchored = anchored; + rule->dir_only = dir_only; + rule->negate = negate; + rule->no_inherit = no_inherit; + rule->owner = str_dup(dir); + rule->pattern = pattern; + if (!rule->owner || !filter_rule_list_add(list, rule)) { + filter_rule_free(rule); + free(dir); + goto fail; + } + total_rules++; + } + free(dir); + } + *out = list; + return true; +fail: + filter_rule_list_free(list); + return false; +} + static int send_plan_node(int fd, DeletePlanSender* sender, PlanNode* node) { if (!send_status(fd, STATUS_DELETE_PLAN)) return -1; @@ -328,7 +485,8 @@ static int send_plan_node(int fd, DeletePlanSender* sender, PlanNode* node) { if (!sender->config_sent) { if (send_str_section(fd, sender->protected_prefixes) != 0 || send_str_section(fd, sender->size_skipped) != 0 || - send_str_section(fd, sender->missing_args) != 0) + send_str_section(fd, sender->missing_args) != 0 || + !delete_filter_dir_rules_send(fd, sender->per_dir_rules)) return -1; sender->config_sent = true; } @@ -355,7 +513,8 @@ static int send_config_only(int fd, DeletePlanSender* sender) { return -1; if (send_str_section(fd, sender->protected_prefixes) != 0 || send_str_section(fd, sender->size_skipped) != 0 || - send_str_section(fd, sender->missing_args) != 0) + send_str_section(fd, sender->missing_args) != 0 || + !delete_filter_dir_rules_send(fd, sender->per_dir_rules)) return -1; sender->config_sent = true; if (!send_int(fd, 0)) /* apply = false */ @@ -472,6 +631,10 @@ struct DeletePlanSession { ArrayList* protected_prefixes; ArrayList* size_skipped; ArrayList* missing; + /* Received per-directory filter rules (protocol 2.30.0), or NULL. Evaluated + deepest-directory-first for every candidate extra so a destination-only + entry matching only a per-directory rule is protected. */ + FilterRuleList* per_dir_rules; ArrayList* deferred; DeletePathObserver observer; void* observer_context; @@ -532,6 +695,7 @@ void delete_plan_session_destroy(DeletePlanSession* session) { array_list_delete(session->protected_prefixes); array_list_delete(session->size_skipped); array_list_delete(session->missing); + filter_rule_list_free(session->per_dir_rules); array_list_delete(session->deferred); free(session); } @@ -604,16 +768,18 @@ static int open_plan_dir(const Config* config, const char* dir) { typedef struct { DeleteSkipSet set; - /* Receiver-side delete-protection rules received on the config frame (NULL - when the sender sent none). Evaluated per extra so a protect/risk rule is + /* Receiver-side delete-protection rules. `base` is the config-frame + command-line set and `dir` the received per-directory set (both NULL when + the sender sent none). Evaluated per extra so a protect/risk rule is honored under --delete-during/--delete-delay exactly like the whole-tree commit walker. */ - const FilterRuleList* protect_rules; + DeleteProtectRules protect; } PlanSkips; static bool build_plan_skips(const Config* config, const DeletePlanSession* session, PlanSkips* out) { - out->protect_rules = config->protect_rules; + out->protect.base_rules = config->protect_rules; + out->protect.dir_rules = session->per_dir_rules; /* The per-directory plan walk keeps each basis path verbatim (it does not convert an absolute under-root path to its root-relative form, unlike the whole-tree commit walk). */ @@ -778,10 +944,8 @@ static bool process_children(int dirfd, const char* dir_rel, const ArrayList* ke bool is_dir = entries[i].is_dir; bool in_keep_dirs = is_dir && list_contains_str(keep_dirs, entries[i].name); bool in_keep_files = !is_dir && list_contains_str(keep_files, entries[i].name); - bool rule_protected = - skips->protect_rules && - filter_rules_apply_side(skips->protect_rules, child_rel, entries[i].name, is_dir, - FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT; + bool rule_protected = delete_protect_verdict(&skips->protect, child_rel, entries[i].name, + is_dir) == FILTER_ACTION_PROTECT; if (in_keep_dirs || in_keep_files || rule_protected) { shielded[i] = true; local_survives = true; @@ -903,7 +1067,8 @@ int delete_plan_session_receive(DeletePlanSession* session, const Config* config if (has_config) { if (session->config_seen || !read_section(fd, session->protected_prefixes, true, &bytes) || !read_section(fd, session->size_skipped, true, &bytes) || - !read_section(fd, session->missing, true, &bytes)) { + !read_section(fd, session->missing, true, &bytes) || + !delete_filter_dir_rules_receive(fd, &session->per_dir_rules)) { send_status(fd, STATUS_ERROR); return -1; } diff --git a/src/shared/delete_plan.h b/src/shared/delete_plan.h index 9472d65..52d0720 100644 --- a/src/shared/delete_plan.h +++ b/src/shared/delete_plan.h @@ -25,6 +25,19 @@ * --delete-missing-args exact deletions, the shared --max-delete budget and, * for --delete-delay, the snapshotted extras. */ +/* Per-directory filter-rule block (protocol 2.30.0). The sender compiles the + * source's per-directory merge rules as it scans and streams them so the + * receiver can re-derive the receiver-side protect/risk verdicts for + * destination-only entries. The wire format is a group count, then for each + * directory group its relative owner path followed by that directory's rule + * records (action, sides, anchored, dir-only, negate, no-inherit, pattern). + * All bounds (MAX_FILTER_RULES, MAX_FILTER_BYTES, MAX_PROTECT_PATTERN_LEN) are + * enforced on both sides; a malformed receive frame signals STATUS_ERROR and + * returns false. Receive yields a flat FilterRuleList whose rules carry their + * owner, or NULL when no rules were sent. */ +bool delete_filter_dir_rules_send(int fd, const FilterRuleList* rules); +bool delete_filter_dir_rules_receive(int fd, FilterRuleList** out); + /* ---- Sender: plan builder ---- */ typedef struct DeletePlanSender DeletePlanSender; @@ -53,7 +66,8 @@ bool delete_plan_sender_empty(const DeletePlanSender* sender); * block is always transmitted by delete_plan_send_root(), on a config-only * carrier frame when the scope allows no directory plan. */ void delete_plan_sender_set_config(DeletePlanSender* sender, const ArrayList* protected_prefixes, - const ArrayList* size_skipped, const ArrayList* missing_args); + const ArrayList* size_skipped, const ArrayList* missing_args, + const FilterRuleList* per_dir_rules); /* Send the root plan (even before any data, so root extras are handled like * rsync's first generator directory), after transmitting the per-run config * block on its own carrier frame. Returns -1 on I/O error. */ diff --git a/src/shared/filter.c b/src/shared/filter.c index 56cc6ed..8c4087d 100644 --- a/src/shared/filter.c +++ b/src/shared/filter.c @@ -11,6 +11,8 @@ /* Write a diagnostic message into the caller's optional buffer. */ #define filter_set_error utils_set_error +static bool set_rule_owner(FilterRule* rule, const char* owner); + /* ---- Ordered rule lists ---- */ void filter_rule_free(FilterRule* rule) { @@ -21,6 +23,28 @@ void filter_rule_free(FilterRule* rule) { free(rule); } +FilterRule* filter_rule_clone(const FilterRule* rule) { + if (!rule) + return NULL; + FilterRule* copy = calloc(1, sizeof(FilterRule)); + if (!copy) + return NULL; + copy->action = rule->action; + copy->sides = rule->sides; + copy->anchored = rule->anchored; + copy->dir_only = rule->dir_only; + copy->negate = rule->negate; + copy->perishable = rule->perishable; + copy->no_inherit = rule->no_inherit; + copy->owner = str_dup(rule->owner ? rule->owner : ""); + copy->pattern = str_dup(rule->pattern ? rule->pattern : ""); + if (!copy->owner || !copy->pattern) { + filter_rule_free(copy); + return NULL; + } + return copy; +} + FilterRuleList* filter_rule_list_create(void) { return calloc(1, sizeof(FilterRuleList)); } @@ -48,33 +72,76 @@ void filter_rule_list_free(FilterRuleList* list) { for (int i = 0; i < list->count; i++) filter_rule_free(list->items[i]); for (int i = 0; i < list->dir_merge_count; i++) - free(list->dir_merge_names[i]); - free(list->dir_merge_names); + free(list->dir_merges[i].name); + free(list->dir_merges); free(list->items); free(list); } +/* Append an implicit exclude rule for the merge file itself (rsync's 'e' + * modifier). The rule is owned by the transfer root and matches the basename + * anywhere, exactly like rsync's EXCLUDE_SELF (a dual-sided exclude: it hides + * the file and protects its destination mirror from --delete). */ +static bool filter_list_add_exclude_self(FilterRuleList* list, const char* name) { + const char* base = strrchr(name, '/'); + base = base ? base + 1 : name; + if (base[0] == '\0') + return true; + FilterRule* rule = calloc(1, sizeof(FilterRule)); + if (!rule) + return false; + rule->action = FILTER_ACTION_EXCLUDE; + rule->sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER; + rule->pattern = str_dup(base); + if (!rule->pattern || !set_rule_owner(rule, "")) { + filter_rule_free(rule); + return false; + } + if (!filter_rule_list_add(list, rule)) { + filter_rule_free(rule); + return false; + } + return true; +} + /* Register a per-directory merge-file basename (for "dir-merge NAME"/": NAME" * and -F's .rsync-filter). Duplicate names are ignored. */ bool filter_rule_list_add_dir_merge(FilterRuleList* list, const char* name) { + return filter_rule_list_add_dir_merge_ex(list, name, false, false, false, false, false); +} + +bool filter_rule_list_add_dir_merge_ex(FilterRuleList* list, const char* name, bool no_prefixes, + bool include, bool word_split, bool no_inherit, + bool exclude_self) { if (!list || !name || name[0] == '\0') return false; for (int i = 0; i < list->dir_merge_count; i++) { - if (strcmp(list->dir_merge_names[i], name) == 0) + if (strcmp(list->dir_merges[i].name, name) == 0) return true; } if (list->dir_merge_count == list->dir_merge_capacity) { int new_cap = list->dir_merge_capacity > 0 ? list->dir_merge_capacity * 2 : 4; - char** grown = realloc(list->dir_merge_names, (size_t)new_cap * sizeof(char*)); + FilterDirMerge* grown = realloc(list->dir_merges, (size_t)new_cap * sizeof(*grown)); if (!grown) return false; - list->dir_merge_names = grown; + list->dir_merges = grown; list->dir_merge_capacity = new_cap; } char* dup = str_dup(name); if (!dup) return false; - list->dir_merge_names[list->dir_merge_count++] = dup; + if (exclude_self && !filter_list_add_exclude_self(list, name)) { + free(dup); + return false; + } + FilterDirMerge* entry = &list->dir_merges[list->dir_merge_count]; + entry->name = dup; + entry->no_prefixes = no_prefixes; + entry->include = include; + entry->word_split = word_split; + entry->no_inherit = no_inherit; + entry->exclude_self = exclude_self; + list->dir_merge_count++; return true; } @@ -177,10 +244,11 @@ static bool is_unsupported_modifier_char(char c) { return c == 'e' || c == 'n' || c == 'w'; } -/* Merge-file modifiers rsync accepts on merge/dir-merge rules: 'e', 'n', 'w' - * and '-' (do not transfer the merge file). */ +/* Merge-file modifiers rsync accepts on merge/dir-merge rules: 'e' (exclude + * self), 'n' (no inherit), 'w' (word split), '-' (bare excludes) and '+' + * (bare includes). */ static bool is_merge_modifier_char(char c) { - return c == 'e' || c == 'n' || c == 'w' || c == '-'; + return c == 'e' || c == 'n' || c == 'w' || c == '-' || c == '+'; } /* Characters that count as part of a modifier run for `kind` when deciding @@ -228,8 +296,10 @@ static char unsupported_modifier_in_token(const char* tok, RuleKind kind) { * generic syntax error so callers can emit a precise diagnostic. */ static bool parse_rule_syntax(const char* text, RuleKind* kind, unsigned* sides, bool* sides_explicit, bool* negate, bool* anchored_mod, - bool* perishable, bool* xattr, bool* cvs_inject, - const char** pat_start, size_t* pat_len, char* bad_mod) { + bool* perishable, bool* xattr, bool* cvs_inject, bool* no_prefixes, + bool* include_defaults, bool* word_split, bool* no_inherit, + bool* exclude_self, const char** pat_start, size_t* pat_len, + char* bad_mod) { const char* p = text; *sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER; *sides_explicit = false; @@ -238,6 +308,11 @@ static bool parse_rule_syntax(const char* text, RuleKind* kind, unsigned* sides, *perishable = false; *xattr = false; *cvs_inject = false; + *no_prefixes = false; + *include_defaults = false; + *word_split = false; + *no_inherit = false; + *exclude_self = false; *pat_start = NULL; *pat_len = 0; *bad_mod = '\0'; @@ -312,6 +387,21 @@ static bool parse_rule_syntax(const char* text, RuleKind* kind, unsigned* sides, case 'C': *cvs_inject = true; break; + case '-': + *no_prefixes = true; + break; + case '+': + *include_defaults = true; + break; + case 'e': + *exclude_self = true; + break; + case 'n': + *no_inherit = true; + break; + case 'w': + *word_split = true; + break; default: break; } @@ -347,11 +437,13 @@ FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts, RuleKind kind = RULE_KIND_UNKNOWN; unsigned sides; bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject; + bool no_prefixes, include_defaults, word_split, no_inherit, exclude_self; const char* pat; size_t pat_len; char bad_mod; if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable, - &xattr, &cvs_inject, &pat, &pat_len, &bad_mod)) { + &xattr, &cvs_inject, &no_prefixes, &include_defaults, &word_split, + &no_inherit, &exclude_self, &pat, &pat_len, &bad_mod)) { if (bad_mod != '\0') filter_set_error(err, err_size, "unsupported filter modifier '%c'", bad_mod); else @@ -522,16 +614,130 @@ static bool filter_list_parse_append_depth(FilterRuleList* list, const char* lin const FilterParseOptions* opts, const char* base_dir, int depth, char* err, size_t err_size); -/* Read a merge file and splice its rules into `list`. A relative path is - * resolved below `base_dir` when given, else used as-is (rsync resolves a - * command-line merge file relative to the current directory). */ +/* Append one merge-file token/line to `list`, honoring the merge rule's + * no-prefix/include mode. In no-prefix mode the token is a bare pattern whose + * include/exclude default comes from the merge rule (rsync's "-"/"+" merge + * modifiers); otherwise the token is parsed as a full filter rule. */ +static bool filter_merge_append_token(FilterRuleList* list, const char* token, + const FilterDirMerge* spec, const FilterParseOptions* opts, + const char* base_dir, int depth, char* err, size_t err_size) { + if (spec->no_prefixes || spec->include) { + size_t tlen = strlen(token); + char* text = malloc(tlen + 3); + if (!text) { + filter_set_error(err, err_size, "memory allocation failed"); + return false; + } + text[0] = spec->include ? '+' : '-'; + text[1] = ' '; + memcpy(text + 2, token, tlen + 1); + bool ok = filter_list_parse_append_depth(list, text, opts, base_dir, depth + 1, err, err_size); + free(text); + return ok; + } + return filter_list_parse_append_depth(list, token, opts, base_dir, depth + 1, err, err_size); +} + +/* Read a merge file's tokens/lines into `list` for `spec`. A `w` merge rule + * word-splits on whitespace (turning comments off); otherwise lines are parsed + * and whole-line `#` comments skipped. When `owner_rel` is non-NULL the newly + * added rules are owned by that directory; a no-inherit spec marks them so they + * apply only there. Returns false on parse/allocation failure. */ +static bool filter_merge_read(FilterRuleList* list, FILE* fp, const char* display_path, + const FilterDirMerge* spec, const FilterParseOptions* opts, + const char* base_dir, const char* owner_rel, int depth, char* err, + size_t err_size) { + int rules_before = list->count; + char* line = NULL; + size_t cap = 0; + bool ok = true; + while (ok) { + ssize_t n = utils_getdelim_bounded(fp, &line, &cap, '\n', UTILS_MAX_LINE_LEN); + if (n < 0) { + if (errno == EFBIG) + filter_set_error(err, err_size, "line in %s exceeds %d bytes", display_path, + (int)UTILS_MAX_LINE_LEN); + else + filter_set_error(err, err_size, "error reading %s: %s", display_path, strerror(errno)); + ok = false; + break; + } + if (n == 0) + break; + if (spec->word_split) { + /* Whitespace-separated tokens; newlines are ordinary separators and + * comments are disabled. */ + const char* s = line; + while (*s) { + while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') + s++; + if (*s == '\0') + break; + const char* start = s; + while (*s != '\0' && *s != ' ' && *s != '\t' && *s != '\n' && *s != '\r') + s++; + size_t tlen = (size_t)(s - start); + char* token = malloc(tlen + 1); + if (!token) { + filter_set_error(err, err_size, "memory allocation failed"); + ok = false; + break; + } + memcpy(token, start, tlen); + token[tlen] = '\0'; + if (!filter_merge_append_token(list, token, spec, opts, base_dir, depth, err, err_size)) + ok = false; + free(token); + } + } else { + const char* lp = line; + while (*lp == ' ' || *lp == '\t') + lp++; + if (*lp == '\0' || *lp == '\n' || *lp == '\r' || *lp == '#') + continue; + if (!filter_merge_append_token(list, lp, spec, opts, base_dir, depth, err, err_size)) + ok = false; + } + } + free(line); + if (!ok) { + /* Drop the rules this read appended (a "clear" inside the file may have + * freed earlier rules too; clamp like filter_file_rollback). */ + int first = rules_before < list->count ? rules_before : list->count; + for (int i = first; i < list->count; i++) + filter_rule_free(list->items[i]); + list->count = first; + return false; + } + for (int i = rules_before; i < list->count; i++) { + FilterRule* rule = list->items[i]; + if (spec->no_inherit) + rule->no_inherit = true; + if (owner_rel && !set_rule_owner(rule, owner_rel)) { + filter_set_error(err, err_size, "memory allocation failed"); + for (int j = rules_before; j < list->count; j++) + filter_rule_free(list->items[j]); + list->count = rules_before; + return false; + } + } + return true; +} + +/* Read a single-instance merge file and splice its rules into `list`. A + * relative path is resolved below `base_dir` when given, else used as-is (rsync + * resolves a command-line merge file relative to the current directory). */ static bool filter_list_merge_file(FilterRuleList* list, const char* name, - const FilterParseOptions* opts, const char* base_dir, int depth, - char* err, size_t err_size) { + const FilterDirMerge* spec, const FilterParseOptions* opts, + const char* base_dir, int depth, char* err, size_t err_size) { if (name[0] == '\0') { filter_set_error(err, err_size, "merge requires a filename"); return false; } + if (spec->exclude_self && !filter_list_add_exclude_self(list, name)) { + filter_set_error(err, err_size, "memory allocation failed"); + return false; + } char* path = (base_dir && base_dir[0] && name[0] != '/') ? path_cat(base_dir, name) : str_dup(name); if (!path) { @@ -544,29 +750,7 @@ static bool filter_list_merge_file(FilterRuleList* list, const char* name, free(path); return false; } - char* line = NULL; - size_t cap = 0; - bool ok = true; - while (true) { - ssize_t n = utils_getdelim_bounded(fp, &line, &cap, '\n', UTILS_MAX_LINE_LEN); - if (n < 0) { - filter_set_error(err, err_size, "error reading merge file '%s'", path); - ok = false; - break; - } - if (n == 0) - break; - const char* lp = line; - while (*lp == ' ' || *lp == '\t') - lp++; - if (*lp == '\0' || *lp == '\n' || *lp == '\r' || *lp == '#') - continue; - if (!filter_list_parse_append_depth(list, lp, opts, base_dir, depth + 1, err, err_size)) { - ok = false; - break; - } - } - free(line); + bool ok = filter_merge_read(list, fp, path, spec, opts, base_dir, NULL, depth, err, err_size); fclose(fp); free(path); return ok; @@ -590,11 +774,13 @@ static bool filter_list_parse_append_depth(FilterRuleList* list, const char* lin RuleKind kind = RULE_KIND_UNKNOWN; unsigned sides; bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject; + bool no_prefixes, include_defaults, word_split, no_inherit, exclude_self; const char* pat; size_t pat_len; char bad_mod; if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable, - &xattr, &cvs_inject, &pat, &pat_len, &bad_mod)) { + &xattr, &cvs_inject, &no_prefixes, &include_defaults, &word_split, + &no_inherit, &exclude_self, &pat, &pat_len, &bad_mod)) { if (bad_mod != '\0') filter_set_error(err, err_size, "unsupported filter modifier '%c': %s", bad_mod, p); else @@ -640,7 +826,15 @@ static bool filter_list_parse_append_depth(FilterRuleList* list, const char* lin } memcpy(name, pat, pat_len); name[pat_len] = '\0'; - bool ok = filter_list_merge_file(list, name, opts, base_dir, depth, err, err_size); + /* A single-instance merge has no inheritance, so 'n' is meaningless; the + * other merge modifiers still shape how the file is read. */ + FilterDirMerge spec = {.name = name, + .no_prefixes = no_prefixes, + .include = include_defaults, + .word_split = word_split, + .no_inherit = false, + .exclude_self = exclude_self}; + bool ok = filter_list_merge_file(list, name, &spec, opts, base_dir, depth, err, err_size); free(name); return ok; } @@ -656,7 +850,8 @@ static bool filter_list_parse_append_depth(FilterRuleList* list, const char* lin } memcpy(name, pat, pat_len); name[pat_len] = '\0'; - bool ok = filter_rule_list_add_dir_merge(list, name); + bool ok = filter_rule_list_add_dir_merge_ex(list, name, no_prefixes, include_defaults, + word_split, no_inherit, exclude_self); free(name); if (!ok) { filter_set_error(err, err_size, "memory allocation failed"); @@ -714,6 +909,10 @@ FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, /* ---- Per-directory merge files ---- */ +/* Undo the rules and dir-merge registrations that one merge file appended, + * leaving the caller's earlier content intact. A "clear" rule inside the file + * frees every rule, including the caller's; clamp to the surviving count so + * those already-freed rules are never resurrected and freed a second time. */ /* Undo the rules and dir-merge registrations that one merge file appended, * leaving the caller's earlier content intact. A "clear" rule inside the file * frees every rule, including the caller's; clamp to the surviving count so @@ -724,20 +923,20 @@ static void filter_file_rollback(FilterRuleList* list, int rules_before, int dir filter_rule_free(list->items[i]); list->count = first; for (int i = dir_merges_before; i < list->dir_merge_count; i++) - free(list->dir_merge_names[i]); + free(list->dir_merges[i].name); list->dir_merge_count = dir_merges_before; } -bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* name, - const char* owner_rel, const FilterParseOptions* opts, bool* exists, - char* err, size_t err_size) { +bool filter_dir_merge_append(FilterRuleList* list, const char* dir_path, const FilterDirMerge* spec, + const char* owner_rel, const FilterParseOptions* opts, bool* exists, + char* err, size_t err_size) { if (err && err_size > 0) err[0] = '\0'; if (exists) *exists = false; - if (!list) + if (!list || !spec || !spec->name) return false; - char* filter_path = path_cat(dir_path, name); + char* filter_path = path_cat(dir_path, spec->name); if (!filter_path) { filter_set_error(err, err_size, "memory allocation failed"); return false; @@ -748,7 +947,7 @@ bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* if (errno == ENOENT || errno == ENOTDIR) return true; char* escaped_dir = output_escape(dir_path, log_get_8_bit_output()); - log_message(LOG_LEVEL_WARNING, "Could not read %s in %s: %s", name, + log_message(LOG_LEVEL_WARNING, "Could not read %s in %s: %s", spec->name, escaped_dir ? escaped_dir : "", strerror(errno)); free(escaped_dir); return true; @@ -757,51 +956,25 @@ bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* *exists = true; int rules_before = list->count; int dir_merges_before = list->dir_merge_count; - char* line = NULL; - size_t line_cap = 0; - bool ok = true; - while (true) { - ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, '\n', UTILS_MAX_LINE_LEN); - if (n < 0) { - if (errno == EFBIG) { - filter_set_error(err, err_size, "line in %s exceeds %d bytes", name, - (int)UTILS_MAX_LINE_LEN); - } else { - filter_set_error(err, err_size, "error reading %s: %s", name, strerror(errno)); - } - ok = false; - break; - } - if (n == 0) - break; - const char* p = line; - while (*p == ' ' || *p == '\t') - p++; - if (*p == '\0' || *p == '\n' || *p == '\r' || *p == '#') - continue; - /* Merge files inside a per-directory file resolve relative to that - directory. */ - if (!filter_list_parse_append_depth(list, p, opts, dir_path, 0, err, err_size)) { - ok = false; - break; - } - } - free(line); + /* Merge files inside a per-directory file resolve relative to that + directory. */ + bool ok = + filter_merge_read(list, fp, spec->name, spec, opts, dir_path, owner_rel, 0, err, err_size); fclose(fp); if (!ok) { filter_file_rollback(list, rules_before, dir_merges_before); return false; } - for (int i = rules_before; i < list->count; i++) { - if (!set_rule_owner(list->items[i], owner_rel)) { - filter_set_error(err, err_size, "memory allocation failed"); - filter_file_rollback(list, rules_before, dir_merges_before); - return false; - } - } return true; } +bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* name, + const char* owner_rel, const FilterParseOptions* opts, bool* exists, + char* err, size_t err_size) { + FilterDirMerge spec = {.name = (char*)name}; + return filter_dir_merge_append(list, dir_path, &spec, owner_rel, opts, exists, err, err_size); +} + FilterRuleList* filter_file_read_named(const char* dir_path, const char* name, const char* owner_rel, const FilterParseOptions* opts, bool* exists, char* err, size_t err_size) { @@ -855,6 +1028,10 @@ static FilterAction rule_matches(const FilterRule* rule, const char* rel_path, c } if (rel2[0] == '\0') return FILTER_ACTION_NONE; + /* A no-inherit rule ('n' on its dir-merge) applies only to direct children of + * its owner directory, never to deeper entries. */ + if (rule->no_inherit && strchr(rel2, '/') != NULL) + return FILTER_ACTION_NONE; bool matched; if (rule->dir_only && !is_dir) matched = false; @@ -884,3 +1061,41 @@ FilterAction filter_rules_apply_side(const FilterRuleList* list, const char* rel } return FILTER_ACTION_NONE; } + +FilterAction filter_dir_rules_apply_side(const FilterRuleList* dir_rules, const char* rel_path, + const char* leaf, bool is_dir) { + if (!dir_rules || !rel_path) + return FILTER_ACTION_NONE; + size_t len = strlen(rel_path); + if (len == 0) + return FILTER_ACTION_NONE; + /* The containing directory of rel_path is the prefix before its final '/'. */ + size_t owner_len = 0; + for (size_t i = 0; i < len; i++) { + if (rel_path[i] == '/') + owner_len = i; + } + for (;;) { + for (int i = 0; i < dir_rules->count; i++) { + const FilterRule* rule = dir_rules->items[i]; + size_t rule_owner_len = rule && rule->owner ? strlen(rule->owner) : 0; + if (rule_owner_len != owner_len) + continue; + if (owner_len != 0 && memcmp(rule->owner, rel_path, owner_len) != 0) + continue; + FilterAction action = rule_matches(rule, rel_path, leaf, is_dir, FILTER_SIDE_RECEIVER); + if (action != FILTER_ACTION_NONE) + return action; + } + if (owner_len == 0) + break; + /* Move to the parent directory: the last '/' before owner_len. */ + size_t parent = 0; + for (size_t j = 0; j < owner_len; j++) { + if (rel_path[j] == '/') + parent = j; + } + owner_len = parent; + } + return FILTER_ACTION_NONE; +} diff --git a/src/shared/filter.h b/src/shared/filter.h index c651afc..d5f5ea7 100644 --- a/src/shared/filter.h +++ b/src/shared/filter.h @@ -25,11 +25,12 @@ * Modifiers: '/' absolute anchor, '!' negate match, 'C' inject CVS defaults, * 's' sender side, 'r' receiver side, 'p' perishable. The rsync 'x' * (xattr-name) modifier is not implemented and is rejected explicitly - * everywhere. The merge-file modifiers 'e' (exclude the merge file itself), - * 'n' (do not inherit the merge file), 'w' (word-split the merge file) and '-' - * (do not transfer the merge file) are accepted and consumed only on merge/ - * dir-merge rules (rejected on every other rule, matching rsync); their - * semantics are not implemented and they are otherwise ignored. + * everywhere. The merge-only modifiers are accepted only on merge/dir-merge + * rules (rejected on every other rule, matching rsync): 'e' excludes the merge + * file itself, 'n' makes the merged rules non-inheriting (they apply only to + * the directory that holds the merge file), 'w' word-splits the merge file on + * whitespace instead of lines, and '-' reads the merge file as a list of bare + * exclude patterns with no rule prefixes. * A trailing '/' makes a pattern match directories only. A leading '/' anchors * the pattern to its owner directory. */ @@ -55,18 +56,32 @@ typedef struct { bool dir_only; /* pattern had a trailing '/': matches directories only */ bool negate; /* '!' modifier: match succeeds when the pattern does not */ bool perishable; /* 'p' modifier (ignored in deleted directories) */ + bool no_inherit; /* 'n' on the owning dir-merge: applies only in `owner` */ char* owner; /* owning directory rel path ("" == transfer root) */ char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */ } FilterRule; +/* One per-directory merge-file registration ("dir-merge NAME"/": NAME", "merge + * NAME"/". NAME" and -F's .rsync-filter) together with the merge-only modifiers + * parsed from the rule. The scanner reads `name` in every directory it + * traverses and applies `no_prefixes`/`include`/`word_split`/`no_inherit`/ + * `exclude_self` while merging the file's rules. */ +typedef struct { + char* name; + bool no_prefixes; /* '-' : file holds only bare exclude patterns */ + bool include; /* '+' : file holds only bare include patterns */ + bool word_split; /* 'w' : split the file on whitespace, not lines */ + bool no_inherit; /* 'n' : the merged rules do not inherit below their dir */ + bool exclude_self; /* 'e' : exclude the merge file itself from the transfer */ +} FilterDirMerge; + typedef struct FilterRuleList { FilterRule** items; /* owned array of rule pointers */ int count; int capacity; - /* Per-directory merge-file basenames registered by "dir-merge NAME"/": NAME" - * or by -F (.rsync-filter). Owned strings; the scanner reads each name in - * every directory it traverses. */ - char** dir_merge_names; + /* Per-directory merge-file registrations. Owned; the scanner reads each + * name in every directory it traverses. */ + FilterDirMerge* dir_merges; int dir_merge_count; int dir_merge_capacity; } FilterRuleList; @@ -83,13 +98,23 @@ typedef struct { FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts, char* err, size_t err_size); void filter_rule_free(FilterRule* rule); +/* Deep-copy a rule (owned pattern/owner). Returns NULL on allocation failure. */ +FilterRule* filter_rule_clone(const FilterRule* rule); FilterRuleList* filter_rule_list_create(void); /* Append a fully-parsed rule (takes ownership). Returns false on OOM. */ bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule); -/* Register a per-directory merge-file basename (idempotent). Returns false on - * OOM. Used by the scanner to read custom "dir-merge" files. */ +/* Register a per-directory merge-file basename (idempotent, no modifiers). + * Returns false on OOM. Used by the scanner to read custom "dir-merge" files. */ bool filter_rule_list_add_dir_merge(FilterRuleList* list, const char* name); +/* Register a per-directory merge file with its merge-only modifiers. On a + * duplicate name the existing registration is kept (rsync's first wins) and true + * is returned. When `exclude_self` is set an implicit exclude rule for the + * merge file's basename is appended to the list at this position, matching + * rsync's `e` modifier. Returns false on OOM. */ +bool filter_rule_list_add_dir_merge_ex(FilterRuleList* list, const char* name, bool no_prefixes, + bool include, bool word_split, bool no_inherit, + bool exclude_self); /* Parse `line` and append it. Handles "clear"/"!" (resets the list), "merge * FILE"/". FILE" (splices the file's rules) and "dir-merge NAME"/": NAME" * (registers a per-directory filename). Returns false and fills `err` on bad @@ -122,6 +147,15 @@ bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* const char* owner_rel, const FilterParseOptions* opts, bool* exists, char* err, size_t err_size); +/* Append a per-directory merge file honoring its merge-only modifiers: `-` + * reads every (word-split, when `w`) token as a bare exclude, `+` as a bare + * include, and `n` marks each read rule non-inheriting. A plain name behaves + * like filter_file_append. A missing file yields *exists=false with no error; + * returns false only on a parse/allocation failure (message in `err`). */ +bool filter_dir_merge_append(FilterRuleList* list, const char* dir_path, const FilterDirMerge* spec, + const char* owner_rel, const FilterParseOptions* opts, bool* exists, + char* err, size_t err_size); + /* filter_file_read_named with the default ".rsync-filter" name. */ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists, char* err, size_t err_size); @@ -135,4 +169,15 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo FilterAction filter_rules_apply_side(const FilterRuleList* list, const char* rel_path, const char* leaf, bool is_dir, unsigned side); +/* Evaluate a received per-directory rule set for the receiver side, using + * rsync's per-directory-before-ancestors order: the entry's containing + * directory's rules are tried first, then each ancestor's, then the receive + * root's. `dir_rules` is a flat list whose rules carry `owner`; a rule applies + * only when `owner` is exactly the directory being examined (a no-inherit rule + * therefore applies only to that directory's direct children). Returns the + * first matching rule's receiver verdict (PROTECT/RISK) or FILTER_ACTION_NONE. + * The caller evaluates the command-line base rules after this chain. */ +FilterAction filter_dir_rules_apply_side(const FilterRuleList* dir_rules, const char* rel_path, + const char* leaf, bool is_dir); + #endif diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index 6bad215..26c23f5 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -34,6 +34,7 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que context->synced_dirs = NULL; context->plan_dirs = NULL; context->missing_args = NULL; + context->per_dir_rules = NULL; context->scan_had_io_error = false; context->remove_source_files = NULL; context->early_delete = false; @@ -210,6 +211,8 @@ void pipeline_context_sender_destroy(PipelineContextSender* context) { array_list_delete(context->plan_dirs); if (context->missing_args) array_list_delete(context->missing_args); + if (context->per_dir_rules) + filter_rule_list_free(context->per_dir_rules); if (context->remove_source_files) array_list_delete(context->remove_source_files); if (context->dir_entries) diff --git a/src/shared/multiprocessing.h b/src/shared/multiprocessing.h index 4e75d26..2150ace 100644 --- a/src/shared/multiprocessing.h +++ b/src/shared/multiprocessing.h @@ -67,6 +67,12 @@ typedef struct { them in the manifest frame's third section and the receiver deletes each as an explicit request. */ ArrayList* missing_args; + /* Per-directory filter rules the source scan compiled (protocol 2.30.0), + sent with the delete manifest/plan config so the receiver can re-derive the + per-directory protect/risk set. NULL when --delete is off. Populated by + the scanner (parallel workers append under mutex_scanner) or the early + pre-scan. */ + FilterRuleList* per_dir_rules; /* A source I/O error (unreadable directory) was recorded during the scan. Set by the pre-scan (before the threads start) or by the scanner thread under mutex_scanner; the caller turns it into a non-zero exit when diff --git a/tests/integration/test_differential_parity.py b/tests/integration/test_differential_parity.py index 5e0261c..d95e95c 100644 --- a/tests/integration/test_differential_parity.py +++ b/tests/integration/test_differential_parity.py @@ -128,6 +128,39 @@ def seed_filter_protect(_src, rroot, froot): _mk(os.path.join(root, "sub", "other2.txt"), b"nested dest-only other\n", _OLD_MTIME) +def seed_perdir_protect(_src, rroot, froot): + """Per-directory `.rsync-filter` carrying `P` rules on the source and both + destinations, plus destination-only extras. The `.log` extras must survive + --delete under every timing while the other extras go; the source's + `.rsync-filter` (which FastSync carries to the receiver) and the seeded + destination one (which rsync's receiver reads) are byte-identical.""" + for root in (_src, rroot, froot): + _mk(os.path.join(root, ".rsync-filter"), b"P extra.log\nP nested.log\n") + for root in (rroot, froot): + _mk(os.path.join(root, "extra.log"), b"dest-only protected\n", _OLD_MTIME) + _mk(os.path.join(root, "other.txt"), b"dest-only deleted\n", _OLD_MTIME) + _mk(os.path.join(root, "sub", "nested.log"), b"nested protected\n", _OLD_MTIME) + _mk(os.path.join(root, "sub", "other2.txt"), b"nested deleted\n", _OLD_MTIME) + + +def seed_perdir_exclude(_src, rroot, froot): + """Per-directory unqualified exclude (`-`): dual-sided, so it protects the + matching destination-only extra (and is opted back in by + --delete-excluded).""" + for root in (_src, rroot, froot): + _mk(os.path.join(root, ".rsync-filter"), b"- extra.log\n") + for root in (rroot, froot): + _mk(os.path.join(root, "extra.log"), b"dest-only excluded\n", _OLD_MTIME) + _mk(os.path.join(root, "other.txt"), b"dest-only deleted\n", _OLD_MTIME) + + +def _seed_rules(content): + def seed(_src, _rroot, _froot): + _mk(os.path.join(_src, ".rules"), content) + + return seed + + def seed_max_delete(_src, rroot, froot): for root in (rroot, froot): _mk(os.path.join(root, "extra1.txt"), b"e1\n", _OLD_MTIME) @@ -272,6 +305,50 @@ _CASES = [ ["-a", "--delete-after", "--filter=P *.log"], seed=seed_filter_protect, server_args=DELETE, ci=True, ref="--filter P/--protect under the whole-tree --delete-after commit"), + # Per-directory merge rules (#315): the receiver must re-derive the + # protect/risk verdict from the carried per-directory rules, so a + # destination-only entry matching ONLY a per-directory rule is shielded. + H.Case("filter_perdir_protect", "filters", + ["-a", "-F", "--delete"], + seed=seed_perdir_protect, server_args=DELETE, ci=True, + ref="-F per-directory P rule under the default --delete timing"), + H.Case("filter_perdir_protect_during", "filters", + ["-a", "-F", "--delete-during"], + seed=seed_perdir_protect, server_args=DELETE, ci=True, + ref="-F per-directory P rule under --delete-during"), + H.Case("filter_perdir_protect_delay", "filters", + ["-a", "-F", "--delete-delay"], + seed=seed_perdir_protect, server_args=DELETE, ci=True, + ref="-F per-directory P rule under --delete-delay"), + H.Case("filter_perdir_protect_before", "filters", + ["-a", "-F", "--delete-before"], + seed=seed_perdir_protect, server_args=DELETE, ci=True, + ref="-F per-directory P rule under the whole-tree --delete-before commit"), + H.Case("filter_perdir_protect_after", "filters", + ["-a", "-F", "--delete-after"], + seed=seed_perdir_protect, server_args=DELETE, ci=True, + ref="-F per-directory P rule under the whole-tree --delete-after commit"), + H.Case("filter_perdir_exclude_protect", "filters", + ["-a", "-F", "--delete"], + seed=seed_perdir_exclude, server_args=DELETE, ci=True, + ref="-F per-directory exclude protects its destination mirror"), + H.Case("filter_perdir_exclude_deleted", "filters", + ["-a", "-F", "--delete", "--delete-excluded"], + seed=seed_perdir_exclude, server_args=DELETE, ci=True, + ref="-F per-directory exclude under --delete-excluded is at risk"), + # Merge-file modifiers (#315): e/n/w/- semantics match rsync 3.4.1. + H.Case("dir_merge_e", "filters", ["-a", "--filter=:e .rules"], + seed=_seed_rules(b"- *.log\n"), ci=True, + ref="dir-merge,e excludes the merge file itself"), + H.Case("dir_merge_n", "filters", ["-a", "--filter=:n .rules"], + seed=_seed_rules(b"- *.log\n"), ci=True, + ref="dir-merge,n does not inherit into subdirectories"), + H.Case("dir_merge_dash", "filters", ["-a", "--filter=:- .rules"], + seed=_seed_rules(b"*.log\n*.bin\n"), ci=True, + ref="dir-merge,- reads the file as bare exclude patterns"), + H.Case("dir_merge_w", "filters", ["-a", "--filter=:-w .rules"], + seed=_seed_rules(b"*.log *.bin\n"), ci=True, + ref="dir-merge,w word-splits bare patterns on whitespace"), # --- relative / dirs -------------------------------------------------- H.Case("relative_general", "basic", ["-a", "-R"], layout=H.MIRROR_ABS, diff --git a/tests/integration/test_option_parity.py b/tests/integration/test_option_parity.py index 52a2540..1143b29 100644 --- a/tests/integration/test_option_parity.py +++ b/tests/integration/test_option_parity.py @@ -536,3 +536,78 @@ class TestFilterProtect: assert "extra.log" not in result.stdout, result.stdout assert os.path.exists(os.path.join(received, "extra.log")) assert os.path.exists(os.path.join(received, "other.txt")) + + @pytest.mark.ci + def test_perdir_protect_dest_only_matches_rsync(self): + """#315: a `P` rule inside a per-directory `.rsync-filter` is carried to + the receiver, so a destination-only extra matching ONLY that rule is + shielded under --delete. Both roots carry the same filter file (rsync's + receiver reads the destination one; FastSync carries the source's).""" + source = os.path.join(TEST_DATA_DIR, "fpdp_src") + dest = os.path.join(TEST_DATA_DIR, "fpdp_dst") + rdst = os.path.join(TEST_DATA_DIR, "fpdp_rdst") + clean_dir(source) + _write(os.path.join(source, "keep.txt"), b"keep\n") + _write(os.path.join(source, ".rsync-filter"), b"P extra.log\n") + clean_dir(rdst) + _write(os.path.join(rdst, ".rsync-filter"), b"P extra.log\n") + _write(os.path.join(rdst, "extra.log"), b"extra\n") + _write(os.path.join(rdst, "other.txt"), b"other\n") + + rsync_result = _rsync(["-aF", "--delete", source + "/", rdst + "/"]) + assert rsync_result.returncode == 0, rsync_result.stderr + assert os.path.exists(os.path.join(rdst, "extra.log")), "rsync did not protect extra.log" + assert not os.path.exists(os.path.join(rdst, "other.txt")) + + clean_dir(dest) + received = get_dest_received_dir(dest, source) + os.makedirs(received, exist_ok=True) + _write(os.path.join(received, ".rsync-filter"), b"P extra.log\n") + _write(os.path.join(received, "extra.log"), b"extra\n") + _write(os.path.join(received, "other.txt"), b"other\n") + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, flags=["-aF", "--delete"], port=server.port) + assert result.returncode == 0, (result.stderr or result.stdout)[:300] + assert os.path.exists(os.path.join(received, "extra.log")), ( + "FastSync must protect a destination-only per-directory P match like rsync") + assert not os.path.exists(os.path.join(received, "other.txt")) + + @requires_rsync + @pytest.mark.ci + def test_perdir_protect_dry_run_enumeration(self, shared_server): + """#315: the -n/--dry-run would-delete enumeration also honors the + carried per-directory rules, matching rsync's `*deleting` set: a + destination-only entry matching only a `.rsync-filter` P rule is not + reported (nor removed).""" + source = os.path.join(TEST_DATA_DIR, "fpdp_nd_src") + dest = os.path.join(TEST_DATA_DIR, "fpdp_nd_dst") + rdst = os.path.join(TEST_DATA_DIR, "fpdp_nd_rdst") + clean_dir(source) + _write(os.path.join(source, "keep.txt"), b"keep\n") + _write(os.path.join(source, ".rsync-filter"), b"P extra.log\n") + received = get_dest_received_dir(dest, source) + clean_dir(rdst) + clean_dir(received) + for root in (rdst, received): + _write(os.path.join(root, "keep.txt"), b"keep\n") + _write(os.path.join(root, ".rsync-filter"), b"P extra.log\n") + _write(os.path.join(root, "extra.log"), b"extra\n") + _write(os.path.join(root, "other.txt"), b"other\n") + + rsync_result = _rsync(["-an", "-i", "-F", "--delete", source + "/", rdst + "/"]) + assert rsync_result.returncode == 0, rsync_result.stderr + rsync_del = sorted(l for l in rsync_result.stdout.splitlines() + if l.startswith("*deleting")) + assert rsync_del == ["*deleting other.txt"], f"unexpected rsync set: {rsync_del}" + + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, flags=["-aF", "-n", "-i", "--delete"], + port=server.port) + assert result.returncode == 0, (result.stderr or result.stdout)[:300] + fs_del = sorted(l for l in (result.stdout or "").splitlines() + if l.startswith("*deleting")) + assert fs_del == rsync_del, f"rsync={rsync_del}\nfastsync={fs_del}" + assert os.path.exists(os.path.join(received, "extra.log")) + assert os.path.exists(os.path.join(received, "other.txt")) diff --git a/tests/test_delete_plan.c b/tests/test_delete_plan.c index 9d67567..f026eec 100644 --- a/tests/test_delete_plan.c +++ b/tests/test_delete_plan.c @@ -225,6 +225,7 @@ static void send_config_only_frame(int fd, const char* missing_path) { EXPECT_TRUE(send_int(fd, 0)); /* size-skipped */ EXPECT_TRUE(send_int(fd, 1)); /* missing args */ EXPECT_TRUE(send_wire_str(fd, missing_path)); + EXPECT_TRUE(send_int(fd, 0)); /* per-directory filter-rule block is empty */ EXPECT_TRUE(send_int(fd, 0)); /* apply = false */ EXPECT_TRUE(send_wire_str(fd, ".")); EXPECT_TRUE(send_int(fd, 0)); @@ -265,10 +266,111 @@ static void test_config_only_frame_applies_missing_args(void) { config_delete(config); } +/* The per-directory filter-rule block (protocol 2.30.0) must be bounded on + * receive: every count, the action/sides domain, the owner-directory syntax and + * the pattern length are validated so a hostile peer can neither overread nor + * allocate unboundedly. It also round-trips a valid group faithfully. */ +static void test_filter_dir_rules_receive_bounds(void) { + int p[2]; + FilterRuleList* out = NULL; + + /* Group count beyond the cap is rejected. */ + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + EXPECT_TRUE(send_int(p[1], MAX_FILTER_RULES + 1)); + EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); + EXPECT_NULL(out); + close(p[0]); + close(p[1]); + + /* A negative group count is rejected. */ + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + EXPECT_TRUE(send_int(p[1], -1)); + EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); + EXPECT_NULL(out); + close(p[0]); + close(p[1]); + + /* An empty block is valid and yields NULL. */ + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(delete_filter_dir_rules_receive(p[0], &out)); + EXPECT_NULL(out); + close(p[0]); + close(p[1]); + + /* An unknown action is a protocol error. */ + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_wire_str(p[1], "")); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_int(p[1], 999)); + EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); + EXPECT_NULL(out); + close(p[0]); + close(p[1]); + + /* An absolute owner directory is rejected (confinement). */ + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_wire_str(p[1], "/etc")); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); + EXPECT_NULL(out); + close(p[0]); + close(p[1]); + + /* An over-long pattern is rejected before allocation. */ + { + char* big = malloc(MAX_PROTECT_PATTERN_LEN + 2); + EXPECT_NOT_NULL(big); + memset(big, 'a', MAX_PROTECT_PATTERN_LEN + 1); + big[MAX_PROTECT_PATTERN_LEN + 1] = '\0'; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_wire_str(p[1], "")); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_int(p[1], (int)FILTER_ACTION_EXCLUDE)); + EXPECT_TRUE(send_int(p[1], (int)(FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER))); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_wire_str(p[1], big)); + EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); + EXPECT_NULL(out); + close(p[0]); + close(p[1]); + free(big); + } + + /* A valid group round-trips its owner, no-inherit flag and pattern. */ + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_wire_str(p[1], "sub")); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_int(p[1], (int)FILTER_ACTION_EXCLUDE)); + EXPECT_TRUE(send_int(p[1], (int)(FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER))); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 1)); /* no_inherit */ + EXPECT_TRUE(send_wire_str(p[1], "*.log")); + EXPECT_TRUE(delete_filter_dir_rules_receive(p[0], &out)); + EXPECT_NOT_NULL(out); + EXPECT_EQ_INT(out->count, 1); + EXPECT_EQ_STR(out->items[0]->owner, "sub"); + EXPECT_EQ_STR(out->items[0]->pattern, "*.log"); + EXPECT_TRUE(out->items[0]->no_inherit); + filter_rule_list_free(out); + close(p[0]); + close(p[1]); +} + void test_delete_plan(void) { test_delete_delay_refilled_dir_removed_recursively(); test_delete_delay_removed_file_counted(); test_delete_delay_max_delete_bounds_actual(); test_delete_delay_actual_removal_charges_budget(); test_config_only_frame_applies_missing_args(); + test_filter_dir_rules_receive_bounds(); } diff --git a/tests/test_filter.c b/tests/test_filter.c index ae426f9..03a38cd 100644 --- a/tests/test_filter.c +++ b/tests/test_filter.c @@ -1,6 +1,7 @@ #include "test_filter.h" #include "filter.h" #include "test_utils.h" +#include "utils.h" #include #include #include @@ -64,44 +65,47 @@ static void test_filter_list_rejects_unsupported_modifiers() { } /* rsync accepts the merge-file modifiers e/n/w/- on merge and dir-merge rules. - * They must be consumed so they never leak into the merge filename. */ + * They must be consumed so they never leak into the merge filename, and their + * semantics (exclude-self, no-inherit, word-split, no-prefixes) must be + * applied while the file is read. */ static void test_filter_list_accepts_merge_modifiers() { char tmpl[] = "/tmp/fastsync_filter_mmod_XXXXXX"; EXPECT_TRUE(mkdtemp(tmpl) != NULL); - char path[512]; - snprintf(path, sizeof(path), "%s/rules", tmpl); - FILE* fp = fopen(path, "w"); + char prefixed[512]; + char bare[512]; + char words[512]; + snprintf(prefixed, sizeof(prefixed), "%s/prefixed", tmpl); + snprintf(bare, sizeof(bare), "%s/bare", tmpl); + snprintf(words, sizeof(words), "%s/words", tmpl); + FILE* fp = fopen(prefixed, "w"); EXPECT_NOT_NULL(fp); fputs("- *.tmp\n", fp); fclose(fp); + fp = fopen(bare, "w"); + EXPECT_NOT_NULL(fp); + fputs("*.log\n*.tmp\n", fp); + fclose(fp); + fp = fopen(words, "w"); + EXPECT_NOT_NULL(fp); + fputs("*.log *.tmp\n", fp); + fclose(fp); - /* merge with e/n/w/- consumes the modifiers and reads the right file. */ - static const char* const fmts[] = { - "merge,e %s", "merge,n %s", "merge,w %s", "merge,- %s", ".e %s", ".- %s", - }; - for (size_t i = 0; i < sizeof(fmts) / sizeof(fmts[0]); i++) { - FilterRuleList* list = filter_rule_list_create(); - EXPECT_NOT_NULL(list); - char rule[600]; - char err[256] = ""; - snprintf(rule, sizeof(rule), fmts[i], path); - bool ok = filter_rule_list_parse_append(list, rule, NULL, NULL, err, sizeof(err)); - if (!ok) - printf(" merge rule '%s' errored: %s\n", rule, err); - EXPECT_TRUE(ok); - EXPECT_EQ_INT(list->count, 1); - EXPECT_EQ_STR(list->items[0]->pattern, "*.tmp"); - filter_rule_list_free(list); - } - - /* dir-merge with e/n/w/- registers the basename without the modifiers. */ + /* dir-merge with e/n/w/- registers the basename without the modifiers and + * records the modifier flags; 'e' appends an exclude-self rule. */ static const struct { const char* rule; const char* want; + bool no_prefixes; + bool word_split; + bool no_inherit; + bool exclude_self; } drules[] = { - {"dir-merge,e .rules", ".rules"}, {"dir-merge,n .rules", ".rules"}, - {"dir-merge,w .rules", ".rules"}, {"dir-merge,- .rules", ".rules"}, - {":e .rules", ".rules"}, {":- .rules", ".rules"}, + {"dir-merge,e .rules", ".rules", false, false, false, true}, + {"dir-merge,n .rules", ".rules", false, false, true, false}, + {"dir-merge,w .rules", ".rules", false, true, false, false}, + {"dir-merge,- .rules", ".rules", true, false, false, false}, + {":e .rules", ".rules", false, false, false, true}, + {":- .rules", ".rules", true, false, false, false}, }; for (size_t i = 0; i < sizeof(drules) / sizeof(drules[0]); i++) { FilterRuleList* list = filter_rule_list_create(); @@ -112,11 +116,78 @@ static void test_filter_list_accepts_merge_modifiers() { printf(" dir-merge rule '%s' errored: %s\n", drules[i].rule, err); EXPECT_TRUE(ok); EXPECT_EQ_INT(list->dir_merge_count, 1); - EXPECT_EQ_STR(list->dir_merge_names[0], drules[i].want); + EXPECT_EQ_STR(list->dir_merges[0].name, drules[i].want); + EXPECT_EQ_INT(list->dir_merges[0].no_prefixes, drules[i].no_prefixes); + EXPECT_EQ_INT(list->dir_merges[0].word_split, drules[i].word_split); + EXPECT_EQ_INT(list->dir_merges[0].no_inherit, drules[i].no_inherit); + EXPECT_EQ_INT(list->dir_merges[0].exclude_self, drules[i].exclude_self); + if (drules[i].exclude_self) { + EXPECT_EQ_INT(list->count, 1); + EXPECT_EQ_STR(list->items[0]->pattern, ".rules"); + EXPECT_EQ_INT(list->items[0]->action, FILTER_ACTION_EXCLUDE); + } else { + EXPECT_EQ_INT(list->count, 0); + } filter_rule_list_free(list); } - unlink(path); + /* merge,n reads the file normally; no-inherit is meaningless for a single + * merge so the rule is not marked. */ + { + FilterRuleList* list = filter_rule_list_create(); + char err[256] = ""; + char rule[600]; + snprintf(rule, sizeof(rule), "merge,n %s", prefixed); + EXPECT_TRUE(filter_rule_list_parse_append(list, rule, NULL, NULL, err, sizeof(err))); + EXPECT_EQ_INT(list->count, 1); + EXPECT_EQ_STR(list->items[0]->pattern, "*.tmp"); + EXPECT_FALSE(list->items[0]->no_inherit); + filter_rule_list_free(list); + } + + /* merge,e adds an implicit exclude for the merge file's basename. */ + { + FilterRuleList* list = filter_rule_list_create(); + char err[256] = ""; + char rule[600]; + snprintf(rule, sizeof(rule), "merge,e %s", prefixed); + EXPECT_TRUE(filter_rule_list_parse_append(list, rule, NULL, NULL, err, sizeof(err))); + EXPECT_EQ_INT(list->count, 2); + EXPECT_EQ_STR(list->items[0]->pattern, "prefixed"); + EXPECT_EQ_INT(list->items[0]->action, FILTER_ACTION_EXCLUDE); + EXPECT_EQ_STR(list->items[1]->pattern, "*.tmp"); + filter_rule_list_free(list); + } + + /* merge,- reads the file as bare exclude patterns with no prefix parsing. */ + { + FilterRuleList* list = filter_rule_list_create(); + char err[256] = ""; + char rule[600]; + snprintf(rule, sizeof(rule), "merge,- %s", bare); + EXPECT_TRUE(filter_rule_list_parse_append(list, rule, NULL, NULL, err, sizeof(err))); + EXPECT_EQ_INT(list->count, 2); + EXPECT_EQ_STR(list->items[0]->pattern, "*.log"); + EXPECT_EQ_STR(list->items[1]->pattern, "*.tmp"); + filter_rule_list_free(list); + } + + /* merge,-w word-splits bare patterns on whitespace. */ + { + FilterRuleList* list = filter_rule_list_create(); + char err[256] = ""; + char rule[600]; + snprintf(rule, sizeof(rule), "merge,w- %s", words); + EXPECT_TRUE(filter_rule_list_parse_append(list, rule, NULL, NULL, err, sizeof(err))); + EXPECT_EQ_INT(list->count, 2); + EXPECT_EQ_STR(list->items[0]->pattern, "*.log"); + EXPECT_EQ_STR(list->items[1]->pattern, "*.tmp"); + filter_rule_list_free(list); + } + + unlink(prefixed); + unlink(bare); + unlink(words); rmdir(tmpl); } @@ -283,6 +354,76 @@ static void test_filter_rules_apply_supported_modifiers() { } } +static FilterRule* chain_rule(const char* owner, const char* pattern, FilterAction action, + bool no_inherit) { + FilterRule* rule = calloc(1, sizeof(FilterRule)); + if (!rule) + return NULL; + rule->action = action; + rule->sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER; + rule->owner = str_dup(owner); + rule->pattern = str_dup(pattern); + rule->no_inherit = no_inherit; + if (!rule->owner || !rule->pattern) { + filter_rule_free(rule); + return NULL; + } + return rule; +} + +/* The receiver's per-directory chain: a containing directory's rules win over + * an ancestor's (deepest-first), root rules are inherited, and a no-inherit + * rule applies only to its own directory's direct children. */ +static void test_filter_dir_rules_chain(void) { + FilterRuleList* list = filter_rule_list_create(); + EXPECT_NOT_NULL(list); + FilterRule* root_log = chain_rule("", "*.log", FILTER_ACTION_EXCLUDE, false); + FilterRule* sub_keep = chain_rule("sub", "keep.log", FILTER_ACTION_INCLUDE, false); + FilterRule* sub_tmp = chain_rule("sub", "*.tmp", FILTER_ACTION_EXCLUDE, true); + EXPECT_NOT_NULL(root_log); + EXPECT_NOT_NULL(sub_keep); + EXPECT_NOT_NULL(sub_tmp); + EXPECT_TRUE(filter_rule_list_add(list, root_log)); + EXPECT_TRUE(filter_rule_list_add(list, sub_keep)); + EXPECT_TRUE(filter_rule_list_add(list, sub_tmp)); + + /* Root rule inherited by every directory (leaf match). */ + EXPECT_EQ_INT(filter_dir_rules_apply_side(list, "a.log", "a.log", false), FILTER_ACTION_PROTECT); + EXPECT_EQ_INT(filter_dir_rules_apply_side(list, "sub/a.log", "a.log", false), + FILTER_ACTION_PROTECT); + /* The deeper include overrides the inherited root exclude. */ + EXPECT_EQ_INT(filter_dir_rules_apply_side(list, "sub/keep.log", "keep.log", false), + FILTER_ACTION_RISK); + /* No-inherit applies directly in its owner... */ + EXPECT_EQ_INT(filter_dir_rules_apply_side(list, "sub/x.tmp", "x.tmp", false), + FILTER_ACTION_PROTECT); + /* ...but not below it. */ + EXPECT_EQ_INT(filter_dir_rules_apply_side(list, "sub/deep/x.tmp", "x.tmp", false), + FILTER_ACTION_NONE); + /* No matching rule. */ + EXPECT_EQ_INT(filter_dir_rules_apply_side(list, "sub/deep/plain.txt", "plain.txt", false), + FILTER_ACTION_NONE); + filter_rule_list_free(list); +} + +static void test_filter_rule_clone_copies_every_field(void) { + char err[128] = ""; + FilterRule* original = filter_rule_parse("-!p /a/*.o", NULL, err, sizeof(err)); + EXPECT_NOT_NULL(original); + FilterRule* copy = filter_rule_clone(original); + EXPECT_NOT_NULL(copy); + EXPECT_TRUE(copy != original); + EXPECT_EQ_INT(copy->action, original->action); + EXPECT_EQ_INT(copy->sides, original->sides); + EXPECT_EQ_INT(copy->anchored, original->anchored); + EXPECT_EQ_INT(copy->dir_only, original->dir_only); + EXPECT_EQ_INT(copy->negate, original->negate); + EXPECT_EQ_INT(copy->perishable, original->perishable); + EXPECT_EQ_STR(copy->pattern, original->pattern); + filter_rule_free(original); + filter_rule_free(copy); +} + void test_filter() { test_filter_list_rejects_xattr_modifier(); test_filter_list_rejects_unsupported_modifiers(); @@ -291,4 +432,6 @@ void test_filter() { test_filter_list_merge_file_still_supported(); test_filter_rule_parse_rejects_unsupported_and_keeps_supported(); test_filter_rules_apply_supported_modifiers(); + test_filter_dir_rules_chain(); + test_filter_rule_clone_copies_every_field(); } diff --git a/tests/test_server.c b/tests/test_server.c index 849dff2..9940948 100644 --- a/tests/test_server.c +++ b/tests/test_server.c @@ -708,6 +708,7 @@ static void test_late_manifest_abort_frees_keepset() { EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* synchronized-directories section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* per-directory filter-rule block is empty */ EXPECT_TRUE(send_status(p[1], STATUS_ABORT)); DeleteManifest* pending = NULL; @@ -733,6 +734,7 @@ static void test_late_manifest_eof_frees_keepset() { EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* synchronized-directories section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* per-directory filter-rule block is empty */ shutdown(p[1], SHUT_WR); DeleteManifest* pending = NULL; @@ -758,12 +760,14 @@ static void test_late_second_manifest_frees_both() { EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* synchronized-directories section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* per-directory filter-rule block is empty */ EXPECT_TRUE(send_status(p[1], STATUS_MANIFEST)); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_str(p[1], "second.txt")); EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ EXPECT_TRUE(send_int(p[1], 0)); /* synchronized-directories section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* per-directory filter-rule block is empty */ DeleteManifest* pending = NULL; EXPECT_EQ_INT(run_pending_receiver(cfg, p[0], &pending), -1); @@ -799,6 +803,7 @@ static void test_receive_manifest_three_sections() { EXPECT_TRUE(send_int(p[1], 2)); EXPECT_TRUE(send_str(p[1], ".")); EXPECT_TRUE(send_str(p[1], "dir")); + EXPECT_TRUE(send_int(p[1], 0)); /* per-directory filter-rule block is empty */ DeleteManifest* manifest = receive_manifest_entries(p[0]); EXPECT_NOT_NULL(manifest); @@ -917,6 +922,7 @@ static void test_receiver_pending_commits_missing_args() { EXPECT_TRUE(send_str(p[1], "gone.txt")); EXPECT_TRUE(send_str(p[1], "never_here.txt")); EXPECT_TRUE(send_int(p[1], 0)); /* no synchronized directories */ + EXPECT_TRUE(send_int(p[1], 0)); /* per-directory filter-rule block is empty */ EXPECT_TRUE(send_status(p[1], STATUS_FINISHED)); /* NULL pending: the single-threaded commit path deletes at FINISHED. The @@ -1309,6 +1315,7 @@ static void test_dry_run_delete_plan_commit_does_not_delete() { EXPECT_TRUE(send_int(p[1], 0)); /* size-skipped prefixes */ EXPECT_TRUE(send_int(p[1], 1)); /* missing-args exact deletions */ EXPECT_TRUE(send_str(p[1], "victim.txt")); + EXPECT_TRUE(send_int(p[1], 0)); /* per-directory filter-rule block is empty */ EXPECT_TRUE(send_int(p[1], 1)); /* apply: a real plan */ EXPECT_TRUE(send_str(p[1], ".")); /* receive root plan */ EXPECT_TRUE(send_int(p[1], 0)); /* kept child directories */ diff --git a/tests/test_shared_utils.c b/tests/test_shared_utils.c index 5fafa7e..25425e1 100644 --- a/tests/test_shared_utils.c +++ b/tests/test_shared_utils.c @@ -349,8 +349,9 @@ static void test_walker_protect_rules_shield_dest_only() { FilterRuleList* rules = filter_base_build(rule_text, 3, false, false, err, sizeof(err)); EXPECT_NOT_NULL(rules); size_t deleted = 0; + DeleteProtectRules protect = {.base_rules = rules, .dir_rules = NULL}; DeleteWalkResult result = - delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, rules, &deleted, NULL); + delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, &protect, &deleted, NULL); EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK); EXPECT_TRUE(file_exists(root, "keep.txt")); EXPECT_FALSE(file_exists(root, "extra.log")); /* risk wins the first match */