Release v2.28.0 #304
+26
-4
@@ -52,9 +52,10 @@
|
|||||||
`tests/test_config.c`). Two residuals were reclassified **divergent**: `-M`
|
`tests/test_config.c`). Two residuals were reclassified **divergent**: `-M`
|
||||||
over daemon/TCP (no argv channel in FastSync's binary config handshake;
|
over daemon/TCP (no argv channel in FastSync's binary config handshake;
|
||||||
rsync-daemon differential pins the rsync behavior) and receiver-side
|
rsync-daemon differential pins the rsync behavior) and receiver-side
|
||||||
`protect`/`risk` re-derivation for destination-only entries (would need a
|
`protect`/`risk` re-derivation for destination-only entries (would need a
|
||||||
receiver filter engine; differential pins the divergence). The options pass
|
receiver filter engine; differential pins the divergence — **reversed by
|
||||||
stands at **110 ✅ / 21 ⚠️ / 26 ❌**. New `tests/integration/test_option_parity.py`
|
track 4a below**, which adds that engine). The options pass
|
||||||
|
stands at **110 ✅ / 21 ⚠️ / 26 ❌**. New `tests/integration/test_option_parity.py`
|
||||||
holds the rsync differentials (bwlimit parse+rate, info lines, real-setpriv
|
holds the rsync differentials (bwlimit parse+rate, info lines, real-setpriv
|
||||||
`--ignore-errors`, rsync-daemon `-M`, filter-protect pin).
|
`--ignore-errors`, rsync-daemon `-M`, filter-protect pin).
|
||||||
|
|
||||||
@@ -85,7 +86,8 @@
|
|||||||
`-n --delete` now sends the same filter-excluded + size-pruned protected
|
`-n --delete` now sends the same filter-excluded + size-pruned protected
|
||||||
prefixes and synchronized-directory scope as a real run (dry-run would-delete
|
prefixes and synchronized-directory scope as a real run (dry-run would-delete
|
||||||
matches rsync for source-derived protections; the destination-only exclude
|
matches rsync for source-derived protections; the destination-only exclude
|
||||||
residual and readdir ordering remain); `--delete-delay` now charges
|
residual was later closed by track 4a, readdir ordering remains);
|
||||||
|
`--delete-delay` now charges
|
||||||
`--max-delete` on actual removals and re-scans a queued directory at commit
|
`--max-delete` on actual removals and re-scans a queued directory at commit
|
||||||
to remove content created after the plan, with an independent deferred-list
|
to remove content created after the plan, with an independent deferred-list
|
||||||
cap (only partial-delete ordering remains); and `--info=name2` emits `NAME is
|
cap (only partial-delete ordering remains); and `--info=name2` emits `NAME is
|
||||||
@@ -110,6 +112,26 @@
|
|||||||
caveats, so the row stays ⚠️ and the matrix is unchanged at
|
caveats, so the row stays ⚠️ and the matrix is unchanged at
|
||||||
**111 ✅ / 14 ⚠️ / 32 ❌ = 157**.
|
**111 ✅ / 14 ⚠️ / 32 ❌ = 157**.
|
||||||
|
|
||||||
|
13. **Wire parity track 4a** on `feat/parity-2.28` (`PROTOCOL_VERSION` stays
|
||||||
|
`2.28.0`): the receiver now has a delete-time filter engine. The sender
|
||||||
|
compiles its root-level selection rules exactly as the scanner does
|
||||||
|
(`filter_base_build`) and streams them as one bounded, self-describing
|
||||||
|
config-frame block (action, sides, anchored, dir-only, negate, owner,
|
||||||
|
pattern; bounded rule count and pattern bytes, unknown action/sides is a
|
||||||
|
protocol error). The receiver reconstructs `protect_rules` and applies them
|
||||||
|
first-match-wins to each extraneous destination path in every delete timing
|
||||||
|
(the whole-tree commit walker, the `--delete-during`/`--delete-delay`
|
||||||
|
per-directory plans, and the `-n` would-delete enumeration), so a
|
||||||
|
`P *.log` rule protects a destination-only `extra.log` like rsync (with
|
||||||
|
`risk` cancelling); the sender-derived protected-prefix behavior is
|
||||||
|
preserved when no rules are sent and `--delete-excluded` semantics are
|
||||||
|
unchanged. Per-directory merge (`:`/`.`) receiver re-derivation remains the
|
||||||
|
residual. `TestFilterProtect` (real + dry-run) plus differential cases
|
||||||
|
`filter_protect`, `filter_protect_during`, `filter_protect_delay` added and
|
||||||
|
the `--filter=RULE` row moves ❌ → ✅: matrix now
|
||||||
|
**115 ✅ / 11 ⚠️ / 31 ❌ = 157**; unit tests, the three named integration
|
||||||
|
files, clang-format and cppcheck clean.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
1. **Merge PR #284** (`dev` -> `main`) once reviewed (protected branch).
|
1. **Merge PR #284** (`dev` -> `main`) once reviewed (protected branch).
|
||||||
2. **Deferred security items** (documented, not implemented):
|
2. **Deferred security items** (documented, not implemented):
|
||||||
|
|||||||
+23
-14
@@ -6,9 +6,9 @@ This document maps rsync's full feature set to FastSync's current implementation
|
|||||||
|
|
||||||
| Status | Count | Description |
|
| Status | Count | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
| ✅ Parity | 114 | Reproduces rsync's semantics for this option's scope |
|
| ✅ Parity | 115 | Reproduces rsync's semantics for this option's scope |
|
||||||
| ⚠️ Caveat | 11 | Wired and tested, but carries a documented behavioral difference from rsync (named in the row and/or the wave notes) |
|
| ⚠️ Caveat | 11 | Wired and tested, but carries a documented behavioral difference from rsync (named in the row and/or the wave notes) |
|
||||||
| ❌ Divergent | 32 | Rejected, an accepted no-op, deliberately non-rsync (native config/auth/batch, privileged namespaces, safe-subset privilege), or impossible on any portable filesystem call |
|
| ❌ Divergent | 31 | Rejected, an accepted no-op, deliberately non-rsync (native config/auth/batch, privileged namespaces, safe-subset privilege), or impossible on any portable filesystem call |
|
||||||
| **Total** | **157** | One row per rsync option/feature group; a row may name several spellings |
|
| **Total** | **157** | One row per rsync option/feature group; a row may name several spellings |
|
||||||
|
|
||||||
This matrix reports honest rsync parity, not "implemented" as a synonym for
|
This matrix reports honest rsync parity, not "implemented" as a synonym for
|
||||||
@@ -32,12 +32,14 @@ that map to a FastSync event (`name`, `flist`, `del`, `remove`, `nonreg`,
|
|||||||
genuinely non-interoperable residuals are reclassified divergent (`-M` over a
|
genuinely non-interoperable residuals are reclassified divergent (`-M` over a
|
||||||
daemon/TCP connection, which FastSync's binary config handshake has no argv
|
daemon/TCP connection, which FastSync's binary config handshake has no argv
|
||||||
channel for, and receiver-side `protect`/`risk` re-derivation for
|
channel for, and receiver-side `protect`/`risk` re-derivation for
|
||||||
destination-only entries, which would need a receiver filter engine). After the
|
destination-only entries, which would need a receiver filter engine; the
|
||||||
|
latter was later implemented in parity track 4a below, so only `-M` over a
|
||||||
|
daemon remains divergent). After the
|
||||||
combined `fix/parity-stats` + `fix/parity-options` + `fix/parity-fs` passes (and
|
combined `fix/parity-stats` + `fix/parity-options` + `fix/parity-fs` passes (and
|
||||||
the later `fix/parity-review` correction that moved `--delete-delay` to ⚠️) the
|
the later `fix/parity-review` correction that moved `--delete-delay` to ⚠️) the
|
||||||
matrix is **111 ✅ / 13 ⚠️ / 33 ❌ = 157**.
|
matrix is **111 ✅ / 13 ⚠️ / 33 ❌ = 157**.
|
||||||
|
|
||||||
**Parity track 1 (no-wire, on `feat/parity-2.28`).** Three residual burn-downs that required no protocol change: (1) `-n --delete` now propagates the filter-excluded and size-pruned protected prefixes (and the synchronized-directory scope) into the dry-run keep-set manifest, so its would-delete report matches rsync for source-derived protections and no longer lists the file merely being updated (the destination-only exclude/filter residual and readdir ordering remain, leaving the row ⚠️); (2) `--delete-delay` now charges `--max-delete` on actual removals and re-scans a queued directory at commit to remove content created after the plan, with an independent cap bounding the deferred list and the row's prior caveat closed (ordering of partial removals remains, leaving the row ⚠️); and (3) `--info=name2` now emits `NAME is uptodate` and `--info=name` emits the leading `./` root name line (only the root-line trigger condition and the receiver-side `skip` wording remain, leaving the row ⚠️). The matrix is now **111 ✅ / 14 ⚠️ / 32 ❌ = 157**.
|
**Parity track 1 (no-wire, on `feat/parity-2.28`).** Three residual burn-downs that required no protocol change: (1) `-n --delete` now propagates the filter-excluded and size-pruned protected prefixes (and the synchronized-directory scope) into the dry-run keep-set manifest, so its would-delete report matches rsync for source-derived protections and no longer lists the file merely being updated (the destination-only exclude/filter residual was closed by track 4a below; readdir ordering remains, leaving the row ⚠️); (2) `--delete-delay` now charges `--max-delete` on actual removals and re-scans a queued directory at commit to remove content created after the plan, with an independent cap bounding the deferred list and the row's prior caveat closed (ordering of partial removals remains, leaving the row ⚠️); and (3) `--info=name2` now emits `NAME is uptodate` and `--info=name` emits the leading `./` root name line (only the root-line trigger condition and the receiver-side `skip` wording remain, leaving the row ⚠️). The matrix is now **111 ✅ / 14 ⚠️ / 32 ❌ = 157**.
|
||||||
|
|
||||||
**Parity track 2b (no-wire, on `feat/parity-2.28`).** An opt-in, paths-only metadata pre-count for `--progress`/`-P`/`--info=progress` (only when not `--quiet`) now gives the `to-chk` denominator rsync's full file-list total (every regular file, directory, symlink and special plus the transfer root) and emits the per-directory/symlink/special name lines, in both the sequential and `--threads` paths; `--delete-during`/`--delete-delay` reuse their keep-set pre-scan so no second walk happens, and non-progress runs are unaffected. A fresh multi-directory differential against rsync 3.4.1 matches the name set and the `to-chk` denominator, while a single-file transfer stays byte-identical; the remaining caveats are emission order (rsync's sorted depth-first vs FastSync's readdir/BFS stream, so the `to-chk` numerator and interleaving differ) and re-run/receiver-state-driven over-naming (unconditional `./`, ancestor dirs emitted with a transferred child, and no quick-check for symlinks/empty dirs), keeping the row ⚠️. The matrix is unchanged at **111 ✅ / 14 ⚠️ / 32 ❌ = 157**.
|
**Parity track 2b (no-wire, on `feat/parity-2.28`).** An opt-in, paths-only metadata pre-count for `--progress`/`-P`/`--info=progress` (only when not `--quiet`) now gives the `to-chk` denominator rsync's full file-list total (every regular file, directory, symlink and special plus the transfer root) and emits the per-directory/symlink/special name lines, in both the sequential and `--threads` paths; `--delete-during`/`--delete-delay` reuse their keep-set pre-scan so no second walk happens, and non-progress runs are unaffected. A fresh multi-directory differential against rsync 3.4.1 matches the name set and the `to-chk` denominator, while a single-file transfer stays byte-identical; the remaining caveats are emission order (rsync's sorted depth-first vs FastSync's readdir/BFS stream, so the `to-chk` numerator and interleaving differ) and re-run/receiver-state-driven over-naming (unconditional `./`, ancestor dirs emitted with a transferred child, and no quick-check for symlinks/empty dirs), keeping the row ⚠️. The matrix is unchanged at **111 ✅ / 14 ⚠️ / 32 ❌ = 157**.
|
||||||
|
|
||||||
@@ -45,6 +47,8 @@ matrix is **111 ✅ / 13 ⚠️ / 33 ❌ = 157**.
|
|||||||
|
|
||||||
**Parity track 3b (no-wire, on `feat/parity-2.28`).** `--checksum-choice`/`--cc` is reclassified out of the caveat list: its only documented residual was the delta BLOCK strong checksum (rsync applies the negotiated algorithm there; FastSync keeps a fixed xxHash32, `DeltaBlockSig`). A pre-seeded delta-transfer differential against rsync 3.4.1 (probe: `--no-whole-file -B8192 --stats --out-format=%c|%C %n` for rsync vs `--incremental --delta` for FastSync) shows the choice is **not observable** in the surface the parity gate compares — across `xxh64`/`xxh128`/`xxh3`/`md5`/`md4`/`sha1` and both two-name orders the destination tree is byte-identical, the `Matched data`/`Literal data`/`Total transferred file size` counters are unchanged (and, with the block size pinned, equal to rsync's), the `%c` block-checksum token is invariant, and the exit code is 0. The negotiated algorithm is only visible in `%C`, which renders the whole-file transfer digest and is already byte-identical to rsync. No wire field is added (`PROTOCOL_VERSION` stays 2.28.0). The matrix is now **114 ✅ / 11 ⚠️ / 32 ❌ = 157**.
|
**Parity track 3b (no-wire, on `feat/parity-2.28`).** `--checksum-choice`/`--cc` is reclassified out of the caveat list: its only documented residual was the delta BLOCK strong checksum (rsync applies the negotiated algorithm there; FastSync keeps a fixed xxHash32, `DeltaBlockSig`). A pre-seeded delta-transfer differential against rsync 3.4.1 (probe: `--no-whole-file -B8192 --stats --out-format=%c|%C %n` for rsync vs `--incremental --delta` for FastSync) shows the choice is **not observable** in the surface the parity gate compares — across `xxh64`/`xxh128`/`xxh3`/`md5`/`md4`/`sha1` and both two-name orders the destination tree is byte-identical, the `Matched data`/`Literal data`/`Total transferred file size` counters are unchanged (and, with the block size pinned, equal to rsync's), the `%c` block-checksum token is invariant, and the exit code is 0. The negotiated algorithm is only visible in `%C`, which renders the whole-file transfer digest and is already byte-identical to rsync. No wire field is added (`PROTOCOL_VERSION` stays 2.28.0). The matrix is now **114 ✅ / 11 ⚠️ / 32 ❌ = 157**.
|
||||||
|
|
||||||
|
**Parity track 4a (wire, on `feat/parity-2.28`; `PROTOCOL_VERSION` stays 2.28.0).** The receiver now has a filter engine for deletion: the sender compiles its root-level selection rules exactly as the scanner does (`filter_base_build`, covering `--filter`/`-f`, `--exclude`/`--include`, `-C` and the `protect`/`risk`/`hide`/`show` words) and streams them as one bounded, self-describing block appended to the config frame (per rule: action, sides, anchored, dir-only, negate, owner, pattern; strictly validated with a bounded rule count and total pattern+owner bytes, and an unknown action/sides is a protocol error). The receiver reconstructs `protect_rules` and evaluates them first-match-wins against each extraneous destination path in every delete timing — the whole-tree commit walker (plain `--delete`/`--delete-before`/`--delete-after`), the `--delete-during`/`--delete-delay` per-directory plans, and the `-n` would-delete enumeration — so a `protect`/`P` rule now shields a DESTINATION-ONLY entry that never appeared on the sender, with `risk`/`R` cancelling. The existing sender-derived protected-prefix behavior is preserved when no rules are sent (and for source-derived protections), and `--delete-excluded` semantics are unchanged. Per-directory merge (`:`/`.`) receiver-side re-derivation is the remaining residual: those rules are still enforced only through the sender-derived protected prefixes, so a destination-only entry matching ONLY a per-directory merge rule is not yet shielded (the `-F` row keeps this documented). Differential-tested against rsync 3.4.1: `filter_protect`, `filter_protect_during`, `filter_protect_delay` (`-a --delete[-during|-delay] --filter='P *.log'` over seeded destination-only `.log` extras) plus the dry-run would-delete enumeration (`TestFilterProtect`). The `--filter=RULE` row moves ❌ → ✅. The matrix is now **115 ✅ / 11 ⚠️ / 31 ❌ = 157**.
|
||||||
|
|
||||||
**Parity completion wave (protocol 2.23.0 → 2.26.0).** This wave closed the
|
**Parity completion wave (protocol 2.23.0 → 2.26.0).** This wave closed the
|
||||||
remaining gaps the rsync-parity wave left open (delete timing, wire counters and
|
remaining gaps the rsync-parity wave left open (delete timing, wire counters and
|
||||||
output, codec breadth, general `-R`/`-d`, the full filter grammar, receiver-side
|
output, codec breadth, general `-R`/`-d`, the full filter grammar, receiver-side
|
||||||
@@ -105,7 +109,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 |
|
| `--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 |
|
| `--include-from=FILE` | Read include patterns from file | ✅ Parity | Reads patterns from file |
|
||||||
| `--filter=RULE` | Add file-filtering rule | ❌ Divergent | 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. First match wins; the filter layer is independent of `--exclude`/`--include`. **Reclassified because the receiver-side `protect`/`risk` semantics cannot be reproduced:** rsync maintains an independent filter engine on the receiver and re-applies every rule to the destination during deletion, so a `P *.log` rule protects a destination-only `extra.log` (differential test vs rsync 3.4.1 pins this). FastSync is sender-derived: its delete protection is the set of source paths the scan actually pruned, so a rule that matches only a destination-only entry is never re-derived and the extra is deleted. Closing this would require shipping the whole (including per-directory merge) filter grammar to, and re-implementing rsync's dual-sided engine on, the receiver — a protocol/architecture change out of proportion to the residual |
|
| `--filter=RULE` | Add file-filtering rule | ✅ Parity | 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. 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 |
|
||||||
| `--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) |
|
| `--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) |
|
| `-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 |
|
| `--max-size=SIZE` | Skip files larger than SIZE | ✅ Parity | `max_size` in scanner |
|
||||||
@@ -117,7 +121,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` |
|
| `--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 | |
|
| `--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 |
|
| `-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 | ✅ Parity | 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 |
|
| `-F` | Add the default `.rsync-filter` rules | ✅ Parity | 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` |
|
||||||
|
|
||||||
## 4. Directory Options
|
## 4. Directory Options
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ Every one of those has an entry below with its remaining caveats.
|
|||||||
|
|
||||||
| Flag | Rsync Description | FastSync Status | Notes |
|
| Flag | Rsync Description | FastSync Status | Notes |
|
||||||
|------|-------------------|-----------------|-------|
|
|------|-------------------|-----------------|-------|
|
||||||
| `-n`, `--dry-run` | Trial run with no changes | ⚠️ Caveat | Server-contacting since protocol 2.21.0. The routing predicate `dry_run_targets_server()` selects the server-contacting path for any target a real run would reach over the wire (SSH, daemon `host::module`, explicit `--server-host`/`--server-port`, TLS, source-bind `--address`); the client handshakes with the receiver, which runs the normal read-only per-file check and answers `STATUS_DRY_RUN_TRANSFER`/`STATUS_OK` without mutating anything. Protocol 2.25.0 also reports would-delete lines: with `--delete` the receiver's `STATUS_STATS` carries the extras it would have removed and the client prints rsync-style `*deleting` lines (sequential and `--threads`; control bytes escaped). Dry-run never deletes. **Fixed (no-wire):** the dry-run keep-set manifest now carries the same filter-excluded and size-pruned protected prefixes and synchronized-directory scope a real run sends, and the would-be-transferred entries are in the keep-set, so `-n --delete` lists exactly rsync's extras for source-derived protections — the file merely being updated is kept and an excluded/pruned source entry is protected (`test_dry_run_delete_lines_match_rsync`, differential vs rsync 3.4.1). **Remaining caveat:** a destination-only entry that matches an exclude/filter rule is still reported (and really removed in a real run) because FastSync derives delete protection from the source scan rather than re-applying rules on the receiver — the same divergence as the `--filter` protect row, pinned by `TestOptionParity`; and the would-delete line ordering follows the destination readdir order rather than rsync's reverse-sorted walk (the differential compares the sorted set) |
|
| `-n`, `--dry-run` | Trial run with no changes | ⚠️ Caveat | Server-contacting since protocol 2.21.0. The routing predicate `dry_run_targets_server()` selects the server-contacting path for any target a real run would reach over the wire (SSH, daemon `host::module`, explicit `--server-host`/`--server-port`, TLS, source-bind `--address`); the client handshakes with the receiver, which runs the normal read-only per-file check and answers `STATUS_DRY_RUN_TRANSFER`/`STATUS_OK` without mutating anything. Protocol 2.25.0 also reports would-delete lines: with `--delete` the receiver's `STATUS_STATS` carries the extras it would have removed and the client prints rsync-style `*deleting` lines (sequential and `--threads`; control bytes escaped). Dry-run never deletes. **Fixed (no-wire):** the dry-run keep-set manifest now carries the same filter-excluded and size-pruned protected prefixes and synchronized-directory scope a real run sends, and the would-be-transferred entries are in the keep-set, so `-n --delete` lists exactly rsync's extras for source-derived protections — the file merely being updated is kept and an excluded/pruned source entry is protected (`test_dry_run_delete_lines_match_rsync`, differential vs rsync 3.4.1). **Track 4a (protocol 2.28.0):** the received receiver-side `protect`/`risk` rules are also applied to the would-delete enumeration, so a destination-only entry matching a `P` rule is no longer reported (or removed in a real run) — matching rsync (`TestFilterProtect::test_protect_dest_only_dry_run_enumeration`). **Remaining caveat:** the would-delete line ordering follows the destination readdir order rather than rsync's reverse-sorted walk (the differential compares the sorted set) |
|
||||||
| `-b`, `--backup` | Make backups of overwritten files | ✅ Parity | Backup before overwrite |
|
| `-b`, `--backup` | Make backups of overwritten files | ✅ Parity | Backup before overwrite |
|
||||||
| `--backup-dir=DIR` | Backup directory hierarchy | ✅ Parity | `backup_dir` config field |
|
| `--backup-dir=DIR` | Backup directory hierarchy | ✅ Parity | `backup_dir` config field |
|
||||||
| `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Parity | `suffix` config field |
|
| `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Parity | `suffix` config field |
|
||||||
@@ -164,7 +168,7 @@ Every one of those has an entry below with its remaining caveats.
|
|||||||
| `--del`, `--delete-during` | Delete during transfer | ⚠️ Caveat | Both spellings accepted; imply `--delete`. **Protocol 2.24.0 implements per-directory delete plans:** as the sender finishes each source directory it streams a `STATUS_DELETE_PLAN` for that directory and the receiver removes that directory's extras before applying the next directory's data, so a mid-transfer failure has already removed the extras of the directories reached (verified with a byte-slicing proxy). **Remaining divergence:** the exact abort boundary and the progressive ordering of removals versus rsync's generator can differ, and `-d`/`--dirs` (no descent) falls back to the end-of-transfer commit. `-R` plans are scoped to the transferred prefix subtree |
|
| `--del`, `--delete-during` | Delete during transfer | ⚠️ Caveat | Both spellings accepted; imply `--delete`. **Protocol 2.24.0 implements per-directory delete plans:** as the sender finishes each source directory it streams a `STATUS_DELETE_PLAN` for that directory and the receiver removes that directory's extras before applying the next directory's data, so a mid-transfer failure has already removed the extras of the directories reached (verified with a byte-slicing proxy). **Remaining divergence:** the exact abort boundary and the progressive ordering of removals versus rsync's generator can differ, and `-d`/`--dirs` (no descent) falls back to the end-of-transfer commit. `-R` plans are scoped to the transferred prefix subtree |
|
||||||
| `--delete-delay` | Find deletions during, delete after | ⚠️ Caveat | Implies `--delete`. **Protocol 2.24.0 implements rsync's delete-delay timing:** the sender records each directory's delete plan while scanning and the receiver commits those removals only after the whole transfer succeeds (per plan), so an extra created in the destination after its directory's plan survives while `--delete-after` re-scans and removes it, and a failed transfer removes nothing. The **reported** deleted count advances only on an actual removal. **Fixed (no-wire):** the `--max-delete` budget is now charged on ACTUAL removals (an unlink/rmdir that succeeded), not at plan/snapshot time, and a queued directory is re-scanned at commit and removed recursively (content created after the plan included), matching rsync: a snapshotted entry that fails or is skipped consumes no budget, so a later extra rsync would delete is still deleted. The deferred snapshot list keeps an independent hard cap (`DELETE_PLAN_SERVER_LIMIT`) so it cannot grow without bound now that the budget is no longer charged while scanning. A `--max-delete=2` partial delete reports exactly 2 and exits 25 in both tools, and the refilled-directory differential (late content removed, directory removed, budget shared) now matches rsync 3.4.1 on both sides (`test_delete_delay_budget_parity.py`, `test_delete_timing_parity.py`). Unit tests cover recursive removal, actual-removal charging, and the bounded deferred list. **Caveat:** the exact ordering of which extras are removed first under a partial `--max-delete` can still differ from rsync's generator (the survivor set is compared by count, not identity) |
|
| `--delete-delay` | Find deletions during, delete after | ⚠️ Caveat | Implies `--delete`. **Protocol 2.24.0 implements rsync's delete-delay timing:** the sender records each directory's delete plan while scanning and the receiver commits those removals only after the whole transfer succeeds (per plan), so an extra created in the destination after its directory's plan survives while `--delete-after` re-scans and removes it, and a failed transfer removes nothing. The **reported** deleted count advances only on an actual removal. **Fixed (no-wire):** the `--max-delete` budget is now charged on ACTUAL removals (an unlink/rmdir that succeeded), not at plan/snapshot time, and a queued directory is re-scanned at commit and removed recursively (content created after the plan included), matching rsync: a snapshotted entry that fails or is skipped consumes no budget, so a later extra rsync would delete is still deleted. The deferred snapshot list keeps an independent hard cap (`DELETE_PLAN_SERVER_LIMIT`) so it cannot grow without bound now that the budget is no longer charged while scanning. A `--max-delete=2` partial delete reports exactly 2 and exits 25 in both tools, and the refilled-directory differential (late content removed, directory removed, budget shared) now matches rsync 3.4.1 on both sides (`test_delete_delay_budget_parity.py`, `test_delete_timing_parity.py`). Unit tests cover recursive removal, actual-removal charging, and the bounded deferred list. **Caveat:** the exact ordering of which extras are removed first under a partial `--max-delete` can still differ from rsync's generator (the survivor set is compared by count, not identity) |
|
||||||
| `--delete-after` | Delete after transfer | ✅ Parity | Implies `--delete`. The delete-after timing is also what plain `--delete` does: the keep-set manifest closes the data stream and the receiver commits the bounded deletion only after the terminal `STATUS_FINISHED` proves the whole transfer (every data frame received and stored) succeeded. A failed or aborted transfer removes nothing |
|
| `--delete-after` | Delete after transfer | ✅ Parity | Implies `--delete`. The delete-after timing is also what plain `--delete` does: the keep-set manifest closes the data stream and the receiver commits the bounded deletion only after the terminal `STATUS_FINISHED` proves the whole transfer (every data frame received and stored) succeeded. A failed or aborted transfer removes nothing |
|
||||||
| `--delete-excluded` | Also delete excluded files | ⚠️ Caveat | `delete_excluded` config field. Under `--delete` FastSync protects (rsync's default) the destination mirror of paths the sender's source scan pruned by the user-selection rules — the `--filter`/`-F`/`-C` layer and the legacy `--exclude`/`--include` layer. The sender transmits those concrete pruned paths as **protected prefixes** in the delete-manifest frame (see the Phase-3 notes below); the walker never descends into or removes them. `--delete-excluded` opts back in: the sender sends an empty protected list, so the excluded destination mirrors become ordinary extras and are removed. **`--max-size`/`--min-size` pruned mirrors are a separate, always-on protection** (protocol 2.23.0, rsync parity): size-pruned source mirrors survive `--delete` even with `--delete-excluded`. Divergences (documented): protection is derived only from what the source scan actually pruned — a stray destination-only file that happens to match an exclude rule is not protected (FastSync never re-applies rules to the destination, keeping deletion sender-derived) |
|
| `--delete-excluded` | Also delete excluded files | ⚠️ Caveat | `delete_excluded` config field. Under `--delete` FastSync protects (rsync's default) the destination mirror of paths the sender's source scan pruned by the user-selection rules — the `--filter`/`-F`/`-C` layer and the legacy `--exclude`/`--include` layer. The sender transmits those concrete pruned paths as **protected prefixes** in the delete-manifest frame (see the Phase-3 notes below); the walker never descends into or removes them. `--delete-excluded` opts back in: the sender sends an empty protected list, so the excluded destination mirrors become ordinary extras and are removed. **`--max-size`/`--min-size` pruned mirrors are a separate, always-on protection** (protocol 2.23.0, rsync parity): size-pruned source mirrors survive `--delete` even with `--delete-excluded`. Track 4a (protocol 2.28.0) additionally re-applies the received `protect`/`risk` rules on the receiver, so a destination-only entry matching an exclude rule is protected (or left at risk) exactly like rsync; the remaining sender-derived `--delete-excluded` behavior (an unqualified rule becomes sender-only, so its source mirror and matching destination-only extras are deleted) is unchanged |
|
||||||
| `--max-delete=NUM` | Max files to delete | ✅ Parity | `max_delete` config field (default -1 = no client limit; 0 = delete nothing). **Protocol 2.23.0 matches rsync's partial semantics:** the receiver deletes up to NUM entries (regular files, symlinks and empty directories; each directory removal counts as one) and then **stops deleting, skips the rest, and reports the run as partial**. The client prints a "deletions stopped due to `--max-delete` limit" message and exits **25** (rsync's `RERR_PARTIAL`), not a hard failure — the transfer itself succeeded. NUM only applies together with `--delete` (it is inert otherwise, matching rsync). A client NUM below the server hard bound `MAX_SERVER_DELETE_COUNT` (100000) replaces it; a NUM above it never raises that cap. Deleting an entire destination with no limit is still bounded by the server's 100000-entry ceiling. `--delete-missing-args` exact-path deletions and the ordinary extras walk draw from the same budget, matching rsync |
|
| `--max-delete=NUM` | Max files to delete | ✅ Parity | `max_delete` config field (default -1 = no client limit; 0 = delete nothing). **Protocol 2.23.0 matches rsync's partial semantics:** the receiver deletes up to NUM entries (regular files, symlinks and empty directories; each directory removal counts as one) and then **stops deleting, skips the rest, and reports the run as partial**. The client prints a "deletions stopped due to `--max-delete` limit" message and exits **25** (rsync's `RERR_PARTIAL`), not a hard failure — the transfer itself succeeded. NUM only applies together with `--delete` (it is inert otherwise, matching rsync). A client NUM below the server hard bound `MAX_SERVER_DELETE_COUNT` (100000) replaces it; a NUM above it never raises that cap. Deleting an entire destination with no limit is still bounded by the server's 100000-entry ceiling. `--delete-missing-args` exact-path deletions and the ordinary extras walk draw from the same budget, matching rsync |
|
||||||
| `--ignore-errors` | Delete even with I/O errors | ✅ Parity | Sender-side, client-only config field. Matches rsync's semantics exactly: an unreadable source subdirectory is always skipped so the readable tree transfers (the transfer root itself stays fatal), and the run reports rsync's partial-transfer exit **23**. Deletion policy follows rsync: by default an I/O error suppresses deletion (`IO error encountered -- skipping file deletion`), while `--ignore-errors` lets the deletion commit. The decision applies to every timing (`--delete`, `--delete-before`, `--delete-during`, `--delete-delay`, `--delete-after`) in both the sequential and `--threads` send paths. Differential-tested against rsync 3.4.1 with both tools run as an unprivileged user (mode-000 source directory); the reference build's root-only gate still excludes the EACCES differential, but the setpriv differential test exercises it. The piece that stays FastSync-specific is documented under the recursive-empty-directory residual: FastSync never emits an unreadable (or empty) directory entry, so that mirror is an extra that a run with `--ignore-errors` removes, where rsync emits the directory and keeps its mirror |
|
| `--ignore-errors` | Delete even with I/O errors | ✅ Parity | Sender-side, client-only config field. Matches rsync's semantics exactly: an unreadable source subdirectory is always skipped so the readable tree transfers (the transfer root itself stays fatal), and the run reports rsync's partial-transfer exit **23**. Deletion policy follows rsync: by default an I/O error suppresses deletion (`IO error encountered -- skipping file deletion`), while `--ignore-errors` lets the deletion commit. The decision applies to every timing (`--delete`, `--delete-before`, `--delete-during`, `--delete-delay`, `--delete-after`) in both the sequential and `--threads` send paths. Differential-tested against rsync 3.4.1 with both tools run as an unprivileged user (mode-000 source directory); the reference build's root-only gate still excludes the EACCES differential, but the setpriv differential test exercises it. The piece that stays FastSync-specific is documented under the recursive-empty-directory residual: FastSync never emits an unreadable (or empty) directory entry, so that mirror is an extra that a run with `--ignore-errors` removes, where rsync emits the directory and keeps its mirror |
|
||||||
| `--force` | Force deletion of non-empty dirs | ✅ Parity | `force_delete` receiver config field (crosses the wire). rsync's `--force` lets an incoming non-directory replace a destination directory; FastSync implements exactly that: when a regular file (or symlink) is written to a path that is currently a (possibly non-empty) destination directory, `--force` removes that directory tree first — confined to the receive root and symlink-safe (O_NOFOLLOW fd walk, symlinks removed by name, never followed) — so the install can place the file. **Protocol 2.23.0 honors `--force` on the `--delay-updates` publication path too**, not only the immediate-install path. Without `--force` such a write fails and the run aborts. Gated by the server `--allow-delete` policy (a client cannot use `--force` to remove a destination tree on a server that forbids deletion) |
|
| `--force` | Force deletion of non-empty dirs | ✅ Parity | `force_delete` receiver config field (crosses the wire). rsync's `--force` lets an incoming non-directory replace a destination directory; FastSync implements exactly that: when a regular file (or symlink) is written to a path that is currently a (possibly non-empty) destination directory, `--force` removes that directory tree first — confined to the receive root and symlink-safe (O_NOFOLLOW fd walk, symlinks removed by name, never followed) — so the install can place the file. **Protocol 2.23.0 honors `--force` on the `--delay-updates` publication path too**, not only the immediate-install path. Without `--force` such a write fails and the run aborts. Gated by the server `--allow-delete` policy (a client cannot use `--force` to remove a destination tree on a server that forbids deletion) |
|
||||||
@@ -917,7 +921,10 @@ 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.
|
**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-completion wave (protocol 2.27.0), updated by the rsync-parity-stats, rsync-parity-options, rsync-parity-fs, parity-review, and no-wire parity-track-1 passes.** ✅ Parity 111 / ⚠️ Caveat 14 / ❌ Divergent 32 = 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 fs pass flipped `-d/--dirs` and `--iconv` to ✅ — recursive transfers now recreate empty source directories (and replace a blocking destination non-directory with an incoming directory); `-R --no-implied-dirs --files-from` places a listed file under a missing implied parent with default attributes instead of refusing; and `--iconv` now reproduces rsync's push direction (destination charset = the spec's REMOTE half) — and reclassified six rows to ❌ after reproducing their exact residual with differential tests: `--temp-dir` (the receiver confines the scratch dir to the receive root, so an absolute temp dir is deliberately rejected although standalone rsync follows it), the three basis-dir options (FastSync xxHash-verifies a basis hit while rsync's `--size-only` quick check installs the wrong basis content), `--delay-updates` (fixed staging name wipes an unrelated destination entry of that name), and `--dry-run` (would-delete report over-reports). `--fuzzy` was also reclassified to ❌ (deterministic heuristic with a 10× size window, not rsync's matcher), but its residual is the candidate-selection heuristic itself: the final tree is byte-exact by design, so no destination differential can expose it and the row is pinned by the `TestFuzzy` threshold suite rather than a byte-level rsync differential. The remaining ⚠️ rows are the ones with a documented residual (see the row notes and the **Parity Completion Wave (protocol 2.26.0)** section below).
|
**Honest status after the parity-completion wave (protocol 2.27.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 passes.** ✅ Parity 115 / ⚠️ Caveat 11 / ❌ Divergent 31 = 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 fs pass flips `-d/--dirs` and `--iconv` to ✅ — recursive transfers now recreate empty source directories (and replace a blocking destination non-directory with an incoming directory); `-R --no-implied-dirs --files-from` places a listed file under a missing implied parent with default attributes instead of refusing; and `--iconv` now reproduces rsync's push direction (destination charset = the spec's REMOTE half) — and reclassified six rows to ❌ after reproducing their exact residual with differential tests: `--temp-dir` (the receiver confines the scratch dir to the receive root, so an absolute temp dir is deliberately rejected although standalone rsync follows it), the three basis-dir options (FastSync xxHash-verifies a basis hit while rsync's `--size-only` quick check installs the wrong basis content), `--delay-updates` (fixed staging name wipes an unrelated destination entry of that name), and `--dry-run` (would-delete report over-reports). `--fuzzy` was also reclassified to ❌ (deterministic heuristic with a 10× size window, not rsync's matcher), but its residual is the candidate-selection heuristic itself: the final tree is byte-exact by design, so no destination differential can expose it and the row is pinned by the `TestFuzzy` threshold suite rather than a byte-level rsync differential. The remaining ⚠️ rows are the ones with a documented residual (see the row notes and the **Parity Completion Wave (protocol 2.26.0)** section below).
|
||||||
|
|
||||||
**Preserve-attribute split (protocol 2.21.0 → 2.22.0) — ✅ implemented.** FastSync splits the former single metadata bundle into four independent, rsync-compatible per-attribute flags — `-p/--perms`, `-t/--times`, `-o/--owner`, `-g/--group` — each with a negation (`--no-perms`/`--no-times`/`--no-owner`/`--no-group`, short `--no-p`/`--no-t`/`--no-o`/`--no-g`), plus `--no-preserve` clearing all four. `-a/--archive` is now full rsync `-rlptgoD` (owner and group included, though their application stays privilege-gated), `-A/--acls` implies `-p`, `-X/--xattrs` does not, `-E/--executability` sets only executability, and `-U`/`-N` do not imply `-t`. `--incremental`/`--delta` still auto-preserve perms+times unless the user explicitly negated them. Wire: the binary config frame gains four appended booleans (`preserve_perms`/`preserve_times`/`preserve_owner`/`preserve_group`) after `omit_link_times`, so `PROTOCOL_VERSION` is bumped **2.21.0 → 2.22.0**; the fixed-width `FileMetadata` layout is unchanged and the receiver gates the metadata frame on a derived `use_metadata`. Receiver behavior: each attribute is applied independently, directory modes are applied under `-p` (at the end of the transfer, alongside dir times), symlink mode under `-p`, and `-O/--omit-dir-times` suppresses directory times only. Documented divergences as of 2.22.0, **all but (d)/(e) removed by the rsync-parity wave (protocol 2.23.0)**: (a) the mode-masking divergence is **gone** — under `-p` the source mode is now copied exactly, including `S_IWGRP`/`S_IWOTH` and setuid/setgid/sticky; (b) a brand-new file without `-p` still gets `source_mode & ~umask` when metadata is present (else the historical fixed `0644`), and a new *directory* without `-p` still uses FastSync's `0755` default; (c) the `--chmod`-implies-`-p` divergence is **gone** — `--chmod` no longer implies `-p` (rsync parity); (d) `-o`/`-g` map by name on the receiver with a raw-numeric fallback (only numeric ids cross the wire); (e) a daemon module without `client owner = yes` does not refuse a plain `-a`/`-o`/`-g` — it forces super off, applies no ownership, and logs a warning, while explicit `--chown`/`--usermap`/`--groupmap`/`--numeric-ids`/`--copy-as`/`--super` are still refused.
|
**Preserve-attribute split (protocol 2.21.0 → 2.22.0) — ✅ implemented.** FastSync splits the former single metadata bundle into four independent, rsync-compatible per-attribute flags — `-p/--perms`, `-t/--times`, `-o/--owner`, `-g/--group` — each with a negation (`--no-perms`/`--no-times`/`--no-owner`/`--no-group`, short `--no-p`/`--no-t`/`--no-o`/`--no-g`), plus `--no-preserve` clearing all four. `-a/--archive` is now full rsync `-rlptgoD` (owner and group included, though their application stays privilege-gated), `-A/--acls` implies `-p`, `-X/--xattrs` does not, `-E/--executability` sets only executability, and `-U`/`-N` do not imply `-t`. `--incremental`/`--delta` still auto-preserve perms+times unless the user explicitly negated them. Wire: the binary config frame gains four appended booleans (`preserve_perms`/`preserve_times`/`preserve_owner`/`preserve_group`) after `omit_link_times`, so `PROTOCOL_VERSION` is bumped **2.21.0 → 2.22.0**; the fixed-width `FileMetadata` layout is unchanged and the receiver gates the metadata frame on a derived `use_metadata`. Receiver behavior: each attribute is applied independently, directory modes are applied under `-p` (at the end of the transfer, alongside dir times), symlink mode under `-p`, and `-O/--omit-dir-times` suppresses directory times only. Documented divergences as of 2.22.0, **all but (d)/(e) removed by the rsync-parity wave (protocol 2.23.0)**: (a) the mode-masking divergence is **gone** — under `-p` the source mode is now copied exactly, including `S_IWGRP`/`S_IWOTH` and setuid/setgid/sticky; (b) a brand-new file without `-p` still gets `source_mode & ~umask` when metadata is present (else the historical fixed `0644`), and a new *directory* without `-p` still uses FastSync's `0755` default; (c) the `--chmod`-implies-`-p` divergence is **gone** — `--chmod` no longer implies `-p` (rsync parity); (d) `-o`/`-g` map by name on the receiver with a raw-numeric fallback (only numeric ids cross the wire); (e) a daemon module without `client owner = yes` does not refuse a plain `-a`/`-o`/`-g` — it forces super off, applies no ownership, and logs a warning, while explicit `--chown`/`--usermap`/`--groupmap`/`--numeric-ids`/`--copy-as`/`--super` are still refused.
|
||||||
|
|
||||||
@@ -1156,13 +1163,15 @@ These remain after the wave; the individual rows carry the precise wording.
|
|||||||
byte-identical), but a multi-directory `to-chk` denominator and per-directory
|
byte-identical), but a multi-directory `to-chk` denominator and per-directory
|
||||||
name lines still differ; **`--out-format`** `%C` matches for every algorithm,
|
name lines still differ; **`--out-format`** `%C` matches for every algorithm,
|
||||||
but `%b`/`%c` count FastSync wire bytes (protocol-specific, hence ❌).
|
but `%b`/`%c` count FastSync wire bytes (protocol-specific, hence ❌).
|
||||||
- **`-n --delete`** ordering can differ from rsync's delete-during walk and a
|
- **`-n --delete`** ordering can differ from rsync's delete-during walk (the
|
||||||
filtered dry-run can over-report.
|
differential compares the sorted would-delete set).
|
||||||
- **Delete timing:** the default `--delete` remains delete-after rather than
|
- **Delete timing:** the default `--delete` remains delete-after rather than
|
||||||
rsync's delete-during; per-directory plans have generator-order/abort-boundary
|
rsync's delete-during; per-directory plans have generator-order/abort-boundary
|
||||||
differences; `--delete-before` keeps its pre-scan snapshot race; and
|
differences; `--delete-before` keeps its pre-scan snapshot race; and while
|
||||||
destination-only files matching an exclude are still removed (protection is
|
base-rule `protect`/`risk` rules are now re-applied on the receiver (track 4a),
|
||||||
sender-derived). `--ignore-errors` exits 23 but its EACCES differential is not
|
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.
|
||||||
|
`--ignore-errors` exits 23 but its EACCES differential is not
|
||||||
exercised in CI.
|
exercised in CI.
|
||||||
- **`--delay-updates`** uses a fixed staging name with an advisory lock and
|
- **`--delay-updates`** uses a fixed staging name with an advisory lock and
|
||||||
deletes before publication; **`--temp-dir`** rejects absolute/foreign paths
|
deletes before publication; **`--temp-dir`** rejects absolute/foreign paths
|
||||||
|
|||||||
+113
-2
@@ -791,6 +791,8 @@ void config_delete(Config* config) {
|
|||||||
if (config->filters) {
|
if (config->filters) {
|
||||||
array_list_delete(config->filters);
|
array_list_delete(config->filters);
|
||||||
}
|
}
|
||||||
|
filter_rule_list_free(config->protect_rules);
|
||||||
|
config->protect_rules = NULL;
|
||||||
/* A --delay-updates staging tree is transient receiver state: remove any
|
/* A --delay-updates staging tree is transient receiver state: remove any
|
||||||
leftovers on every exit path (success already emptied it). */
|
leftovers on every exit path (success already emptied it). */
|
||||||
if (config->delay_context)
|
if (config->delay_context)
|
||||||
@@ -1026,6 +1028,108 @@ static bool receive_basis_entries(int fd, Config* c, ConfigStringBudget* budget)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Receiver-side delete-protection rules (protocol 2.28.0). The sender compiles
|
||||||
|
* its command-line selection rules exactly as the scanner does and streams the
|
||||||
|
* result as one bounded, self-describing block (count + per-rule records); the
|
||||||
|
* receiver reconstructs a FilterRuleList for the --delete extras walk. owner
|
||||||
|
* and pattern are charged through the shared ConfigStringBudget and the block
|
||||||
|
* additionally enforces MAX_FILTER_RULES / MAX_FILTER_BYTES. */
|
||||||
|
static bool send_protect_entries(int fd, const Config* c) {
|
||||||
|
int count = c->filters ? c->filters->size : 0;
|
||||||
|
const char** texts = NULL;
|
||||||
|
if (count > 0) {
|
||||||
|
texts = malloc((size_t)count * sizeof(char*));
|
||||||
|
if (!texts)
|
||||||
|
return false;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
texts[i] = (const char*)c->filters->items[i];
|
||||||
|
}
|
||||||
|
char err[160];
|
||||||
|
FilterRuleList* rules =
|
||||||
|
filter_base_build(texts, count, c->cvs_exclude, c->delete_excluded, err, sizeof(err));
|
||||||
|
free(texts);
|
||||||
|
if (!rules) {
|
||||||
|
log_message(LOG_LEVEL_ERROR, "invalid filter rule: %s", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = send_int(fd, rules->count);
|
||||||
|
for (int i = 0; ok && i < rules->count; i++) {
|
||||||
|
const FilterRule* r = rules->items[i];
|
||||||
|
ok = send_int(fd, (int)r->action) && send_int(fd, (int)r->sides) &&
|
||||||
|
send_int(fd, r->anchored ? 1 : 0) && send_int(fd, r->dir_only ? 1 : 0) &&
|
||||||
|
send_int(fd, r->negate ? 1 : 0) && send_str(fd, r->owner ? r->owner : "") &&
|
||||||
|
send_str(fd, r->pattern ? r->pattern : "");
|
||||||
|
}
|
||||||
|
filter_rule_list_free(rules);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool receive_protect_entries(int fd, Config* c, ConfigStringBudget* budget) {
|
||||||
|
int count;
|
||||||
|
if (!receive_int(fd, &count))
|
||||||
|
return false;
|
||||||
|
if (count < 0 || count > MAX_FILTER_RULES)
|
||||||
|
return false;
|
||||||
|
if (count == 0)
|
||||||
|
return true;
|
||||||
|
FilterRuleList* list = filter_rule_list_create();
|
||||||
|
if (!list)
|
||||||
|
return false;
|
||||||
|
size_t pattern_bytes = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int action;
|
||||||
|
int sides;
|
||||||
|
bool anchored;
|
||||||
|
bool dir_only;
|
||||||
|
bool negate;
|
||||||
|
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_wire_bool(fd, &anchored) || !receive_wire_bool(fd, &dir_only) ||
|
||||||
|
!receive_wire_bool(fd, &negate))
|
||||||
|
goto fail;
|
||||||
|
char* owner = config_receive_str(fd, budget);
|
||||||
|
if (!owner)
|
||||||
|
goto fail;
|
||||||
|
char* pattern = config_receive_str(fd, budget);
|
||||||
|
if (!pattern || pattern[0] == '\0') {
|
||||||
|
free(owner);
|
||||||
|
free(pattern);
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
size_t bytes = strlen(owner) + strlen(pattern);
|
||||||
|
if (bytes > MAX_FILTER_BYTES - pattern_bytes) {
|
||||||
|
free(owner);
|
||||||
|
free(pattern);
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
pattern_bytes += bytes;
|
||||||
|
FilterRule* rule = calloc(1, sizeof(FilterRule));
|
||||||
|
if (!rule) {
|
||||||
|
free(owner);
|
||||||
|
free(pattern);
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
rule->action = (FilterAction)action;
|
||||||
|
rule->sides = (unsigned)sides;
|
||||||
|
rule->anchored = anchored;
|
||||||
|
rule->dir_only = dir_only;
|
||||||
|
rule->negate = negate;
|
||||||
|
rule->owner = owner;
|
||||||
|
rule->pattern = pattern;
|
||||||
|
if (!filter_rule_list_add(list, rule)) {
|
||||||
|
filter_rule_free(rule);
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c->protect_rules = list;
|
||||||
|
return true;
|
||||||
|
fail:
|
||||||
|
filter_rule_list_free(list);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static bool send_identity_entries(int fd, const IdentityMap* map, int count) {
|
static bool send_identity_entries(int fd, const IdentityMap* map, int count) {
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
if (!send_int(fd, map[i].from) || !send_int(fd, map[i].from_hi) || !send_int(fd, map[i].to) ||
|
if (!send_int(fd, map[i].from) || !send_int(fd, map[i].from_hi) || !send_int(fd, map[i].to) ||
|
||||||
@@ -1153,6 +1257,9 @@ fail:
|
|||||||
#define CONFIG_RECV_BLOCK_IDMAP(name) \
|
#define CONFIG_RECV_BLOCK_IDMAP(name) \
|
||||||
receive_identity_entries(fd, budget, c->name##_count, &c->name)
|
receive_identity_entries(fd, budget, c->name##_count, &c->name)
|
||||||
|
|
||||||
|
#define CONFIG_SEND_BLOCK_PROTECT_RULES(name) send_protect_entries(fd, c)
|
||||||
|
#define CONFIG_RECV_BLOCK_PROTECT_RULES(name) receive_protect_entries(fd, c, budget)
|
||||||
|
|
||||||
/* One table entry, applied in sequence. XSEND/XRECV are statement macros so
|
/* One table entry, applied in sequence. XSEND/XRECV are statement macros so
|
||||||
* consecutive entries read as a plain sequence of assignments. */
|
* consecutive entries read as a plain sequence of assignments. */
|
||||||
#define XSEND(name, ctype, def, kind) ok = ok && (CONFIG_SEND_##kind(name));
|
#define XSEND(name, ctype, def, kind) ok = ok && (CONFIG_SEND_##kind(name));
|
||||||
@@ -1190,6 +1297,7 @@ CONFIG_DEFINE_SEND(send_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS)
|
|||||||
CONFIG_DEFINE_SEND(send_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS)
|
CONFIG_DEFINE_SEND(send_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS)
|
||||||
CONFIG_DEFINE_SEND(send_output_options, CONFIG_WIRE_OUTPUT_FIELDS)
|
CONFIG_DEFINE_SEND(send_output_options, CONFIG_WIRE_OUTPUT_FIELDS)
|
||||||
CONFIG_DEFINE_SEND(send_codec_options, CONFIG_WIRE_CODEC_FIELDS)
|
CONFIG_DEFINE_SEND(send_codec_options, CONFIG_WIRE_CODEC_FIELDS)
|
||||||
|
CONFIG_DEFINE_SEND(send_protect_options, CONFIG_WIRE_PROTECT_FIELDS)
|
||||||
|
|
||||||
CONFIG_DEFINE_RECV(receive_core_fields, CONFIG_WIRE_CORE_FIELDS)
|
CONFIG_DEFINE_RECV(receive_core_fields, CONFIG_WIRE_CORE_FIELDS)
|
||||||
CONFIG_DEFINE_RECV(receive_delta_fields, CONFIG_WIRE_DELTA_FIELDS)
|
CONFIG_DEFINE_RECV(receive_delta_fields, CONFIG_WIRE_DELTA_FIELDS)
|
||||||
@@ -1210,6 +1318,7 @@ CONFIG_DEFINE_RECV(receive_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS)
|
|||||||
CONFIG_DEFINE_RECV(receive_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS)
|
CONFIG_DEFINE_RECV(receive_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS)
|
||||||
CONFIG_DEFINE_RECV(receive_output_options, CONFIG_WIRE_OUTPUT_FIELDS)
|
CONFIG_DEFINE_RECV(receive_output_options, CONFIG_WIRE_OUTPUT_FIELDS)
|
||||||
CONFIG_DEFINE_RECV(receive_codec_options, CONFIG_WIRE_CODEC_FIELDS)
|
CONFIG_DEFINE_RECV(receive_codec_options, CONFIG_WIRE_CODEC_FIELDS)
|
||||||
|
CONFIG_DEFINE_RECV(receive_protect_options, CONFIG_WIRE_PROTECT_FIELDS)
|
||||||
|
|
||||||
#undef XSEND
|
#undef XSEND
|
||||||
#undef XRECV
|
#undef XRECV
|
||||||
@@ -1328,7 +1437,8 @@ bool config_send_wire_block(int file_descriptor, const Config* config) {
|
|||||||
send_privilege_options(file_descriptor, config) &&
|
send_privilege_options(file_descriptor, config) &&
|
||||||
send_copy_as_options(file_descriptor, config) &&
|
send_copy_as_options(file_descriptor, config) &&
|
||||||
send_output_options(file_descriptor, config) &&
|
send_output_options(file_descriptor, config) &&
|
||||||
send_codec_options(file_descriptor, config);
|
send_codec_options(file_descriptor, config) &&
|
||||||
|
send_protect_options(file_descriptor, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool config_send(int file_descriptor, const Config* config) {
|
bool config_send(int file_descriptor, const Config* config) {
|
||||||
@@ -1400,7 +1510,8 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
|||||||
!receive_privilege_options(file_descriptor, config, &budget) ||
|
!receive_privilege_options(file_descriptor, config, &budget) ||
|
||||||
!receive_copy_as_options(file_descriptor, config, &budget) ||
|
!receive_copy_as_options(file_descriptor, config, &budget) ||
|
||||||
!receive_output_options(file_descriptor, config, &budget) ||
|
!receive_output_options(file_descriptor, config, &budget) ||
|
||||||
!receive_codec_options(file_descriptor, config, &budget))
|
!receive_codec_options(file_descriptor, config, &budget) ||
|
||||||
|
!receive_protect_options(file_descriptor, config, &budget))
|
||||||
goto error;
|
goto error;
|
||||||
/* Validate/normalize the negotiated codec. compress_choice is the human
|
/* Validate/normalize the negotiated codec. compress_choice is the human
|
||||||
* spelling (NULL or "" when -z was not given); compression_algo is the
|
* spelling (NULL or "" when -z was not given); compression_algo is the
|
||||||
|
|||||||
+29
-1
@@ -4,6 +4,7 @@
|
|||||||
#include "array_list.h"
|
#include "array_list.h"
|
||||||
#include "checksum.h"
|
#include "checksum.h"
|
||||||
#include "compression.h"
|
#include "compression.h"
|
||||||
|
#include "filter.h"
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -293,6 +294,20 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
|||||||
#define CONFIG_WIRE_CODEC_FIELDS(X) \
|
#define CONFIG_WIRE_CODEC_FIELDS(X) \
|
||||||
X(compression_algo, int, COMPRESSION_ALGO_ZSTD, INT_COMPRESSION_ALGO)
|
X(compression_algo, int, COMPRESSION_ALGO_ZSTD, INT_COMPRESSION_ALGO)
|
||||||
|
|
||||||
|
/* Receiver-side delete-protection filter rules (protocol 2.28.0). The sender
|
||||||
|
* compiles its root-level selection rules exactly as the scanner does
|
||||||
|
* (filter_base_build over --filter/-f/--exclude/--include/-C) and streams them
|
||||||
|
* as one self-describing, bounded block (count followed by per-rule records).
|
||||||
|
* The receiver reconstructs `protect_rules` and evaluates them against
|
||||||
|
* DESTINATION-ONLY entries during the --delete extras walk, so a
|
||||||
|
* `protect`/`P` rule protects an extra that never appeared on the sender
|
||||||
|
* (rsync re-derives deletion protection from the filter list; FastSync
|
||||||
|
* historically derived it only from the source scan). `protect_rules` is NULL
|
||||||
|
* on the sender and is owned/freed by the receiver Config. Bounded by
|
||||||
|
* MAX_FILTER_RULES and MAX_FILTER_BYTES; an unknown action/sides is a protocol
|
||||||
|
* error. */
|
||||||
|
#define CONFIG_WIRE_PROTECT_FIELDS(X) X(protect_rules, FilterRuleList*, NULL, BLOCK_PROTECT_RULES)
|
||||||
|
|
||||||
/* All serialized fields, in exact wire order. Concatenating the per-segment
|
/* All serialized fields, in exact wire order. Concatenating the per-segment
|
||||||
* lists here is what keeps the declaration order = the wire order. */
|
* lists here is what keeps the declaration order = the wire order. */
|
||||||
#define CONFIG_WIRE_FIELDS(X) \
|
#define CONFIG_WIRE_FIELDS(X) \
|
||||||
@@ -315,7 +330,8 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
|||||||
CONFIG_WIRE_PRIVILEGE_FIELDS(X) \
|
CONFIG_WIRE_PRIVILEGE_FIELDS(X) \
|
||||||
CONFIG_WIRE_COPY_AS_FIELDS(X) \
|
CONFIG_WIRE_COPY_AS_FIELDS(X) \
|
||||||
CONFIG_WIRE_OUTPUT_FIELDS(X) \
|
CONFIG_WIRE_OUTPUT_FIELDS(X) \
|
||||||
CONFIG_WIRE_CODEC_FIELDS(X)
|
CONFIG_WIRE_CODEC_FIELDS(X) \
|
||||||
|
CONFIG_WIRE_PROTECT_FIELDS(X)
|
||||||
|
|
||||||
typedef struct Config {
|
typedef struct Config {
|
||||||
/* -j/--threads=N: number of parallel scanner worker threads for the -m
|
/* -j/--threads=N: number of parallel scanner worker threads for the -m
|
||||||
@@ -1025,11 +1041,23 @@ typedef struct Config {
|
|||||||
* same-version handshake (config_receive rejects a mismatched version before
|
* same-version handshake (config_receive rejects a mismatched version before
|
||||||
* parsing anything else) keeps mixed deployments from ever reaching that
|
* parsing anything else) keeps mixed deployments from ever reaching that
|
||||||
* state. */
|
* state. */
|
||||||
|
/* (9) Receiver-side delete protection (still protocol 2.28.0): the config frame
|
||||||
|
* gains one trailing self-describing block carrying the sender's compiled base
|
||||||
|
* filter rules so the receiver can protect DESTINATION-ONLY entries from
|
||||||
|
* --delete with `protect`/`risk` rules (rsync parity). The block appends after
|
||||||
|
* compression_algo; see CONFIG_WIRE_PROTECT_FIELDS. */
|
||||||
#define PROTOCOL_VERSION "2.28.0"
|
#define PROTOCOL_VERSION "2.28.0"
|
||||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||||
#define MAX_BASIS_DIRS 64
|
#define MAX_BASIS_DIRS 64
|
||||||
|
|
||||||
|
/* Bounds on the received receiver-side delete-protection rule block. The rule
|
||||||
|
* count and the aggregate pattern+owner bytes are each capped so a hostile
|
||||||
|
* peer cannot pin unbounded pre-auth memory; both are validated strictly on
|
||||||
|
* receive (alongside the per-string ConfigStringBudget). */
|
||||||
|
#define MAX_FILTER_RULES 4096
|
||||||
|
#define MAX_FILTER_BYTES (256 * 1024)
|
||||||
|
|
||||||
/* Upper bound on the number of --skip-compress suffixes accepted from the wire.
|
/* Upper bound on the number of --skip-compress suffixes accepted from the wire.
|
||||||
* Each suffix is an independent wire string (up to MAX_STRING_SIZE = 64 KiB), so
|
* Each suffix is an independent wire string (up to MAX_STRING_SIZE = 64 KiB), so
|
||||||
* without this a hostile pre-auth client could otherwise retain
|
* without this a hostile pre-auth client could otherwise retain
|
||||||
|
|||||||
@@ -546,12 +546,18 @@ static int open_plan_dir(const Config* config, const char* dir) {
|
|||||||
typedef struct PlanSkips {
|
typedef struct PlanSkips {
|
||||||
DeleteSkipEntry* entries;
|
DeleteSkipEntry* entries;
|
||||||
int count;
|
int count;
|
||||||
|
/* 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
|
||||||
|
honored under --delete-during/--delete-delay exactly like the whole-tree
|
||||||
|
commit walker. */
|
||||||
|
const FilterRuleList* protect_rules;
|
||||||
} PlanSkips;
|
} PlanSkips;
|
||||||
|
|
||||||
static bool build_plan_skips(const Config* config, const DeletePlanSession* session,
|
static bool build_plan_skips(const Config* config, const DeletePlanSession* session,
|
||||||
PlanSkips* out) {
|
PlanSkips* out) {
|
||||||
out->entries = NULL;
|
out->entries = NULL;
|
||||||
out->count = 0;
|
out->count = 0;
|
||||||
|
out->protect_rules = config->protect_rules;
|
||||||
int count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
int count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
||||||
session->protected_prefixes->size + session->size_skipped->size;
|
session->protected_prefixes->size + session->size_skipped->size;
|
||||||
if (count == 0)
|
if (count == 0)
|
||||||
@@ -733,6 +739,10 @@ static bool process_children(int dirfd, const char* dir_rel, const ArrayList* ke
|
|||||||
bool is_dir = S_ISDIR(st.st_mode);
|
bool is_dir = S_ISDIR(st.st_mode);
|
||||||
bool in_keep_dirs = is_dir && list_contains_str(keep_dirs, entry->d_name);
|
bool in_keep_dirs = is_dir && list_contains_str(keep_dirs, entry->d_name);
|
||||||
bool in_keep_files = !is_dir && list_contains_str(keep_files, entry->d_name);
|
bool in_keep_files = !is_dir && list_contains_str(keep_files, entry->d_name);
|
||||||
|
bool rule_protected =
|
||||||
|
skips->protect_rules &&
|
||||||
|
filter_rules_apply_side(skips->protect_rules, child_rel, entry->d_name, is_dir,
|
||||||
|
FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT;
|
||||||
if (in_keep_dirs) {
|
if (in_keep_dirs) {
|
||||||
local_survives = true;
|
local_survives = true;
|
||||||
} else if (keep_dirs && !is_dir && list_contains_str(keep_dirs, entry->d_name)) {
|
} else if (keep_dirs && !is_dir && list_contains_str(keep_dirs, entry->d_name)) {
|
||||||
@@ -750,11 +760,18 @@ static bool process_children(int dirfd, const char* dir_rel, const ArrayList* ke
|
|||||||
else if (!removed)
|
else if (!removed)
|
||||||
local_survives = true;
|
local_survives = true;
|
||||||
} else if (is_dir) {
|
} else if (is_dir) {
|
||||||
bool removed = false;
|
if (rule_protected) {
|
||||||
if (!process_extra_dir(dirfd, entry->d_name, child_rel, force_now, skips, session, &removed))
|
|
||||||
operation_ok = false;
|
|
||||||
else if (!removed)
|
|
||||||
local_survives = true;
|
local_survives = true;
|
||||||
|
} else {
|
||||||
|
bool removed = false;
|
||||||
|
if (!process_extra_dir(dirfd, entry->d_name, child_rel, force_now, skips, session,
|
||||||
|
&removed))
|
||||||
|
operation_ok = false;
|
||||||
|
else if (!removed)
|
||||||
|
local_survives = true;
|
||||||
|
}
|
||||||
|
} else if (rule_protected) {
|
||||||
|
local_survives = true;
|
||||||
} else {
|
} else {
|
||||||
if (!process_extra_file(dirfd, entry->d_name, child_rel, force_now, session))
|
if (!process_extra_file(dirfd, entry->d_name, child_rel, force_now, session))
|
||||||
operation_ok = false;
|
operation_ok = false;
|
||||||
|
|||||||
@@ -3328,7 +3328,7 @@ static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest
|
|||||||
size_t skipped = 0;
|
size_t skipped = 0;
|
||||||
DeleteWalkResult result = delete_extras_limited_observed(
|
DeleteWalkResult result = delete_extras_limited_observed(
|
||||||
config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips, used,
|
config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips, used,
|
||||||
&deleted, &skipped, observer, observer_context);
|
config->protect_rules, &deleted, &skipped, observer, observer_context);
|
||||||
if (owned_prefixes) {
|
if (owned_prefixes) {
|
||||||
for (int i = 0; i < config->basis_count; i++)
|
for (int i = 0; i < config->basis_count; i++)
|
||||||
free(owned_prefixes[i]);
|
free(owned_prefixes[i]);
|
||||||
@@ -3509,7 +3509,7 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa
|
|||||||
PrefixedDeleteObserver nested = {observer, observer_context, rel};
|
PrefixedDeleteObserver nested = {observer, observer_context, rel};
|
||||||
DeleteWalkResult walk =
|
DeleteWalkResult walk =
|
||||||
no_keeps ? delete_extras_limited_observed(full, no_keeps, NULL, remaining, NULL, 0,
|
no_keeps ? delete_extras_limited_observed(full, no_keeps, NULL, remaining, NULL, 0,
|
||||||
&contents_deleted, &contents_skipped,
|
NULL, &contents_deleted, &contents_skipped,
|
||||||
observer ? prefixed_delete_observer : NULL,
|
observer ? prefixed_delete_observer : NULL,
|
||||||
observer ? &nested : NULL)
|
observer ? &nested : NULL)
|
||||||
: DELETE_WALK_ERROR;
|
: DELETE_WALK_ERROR;
|
||||||
@@ -3620,7 +3620,7 @@ bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest,
|
|||||||
used = idx;
|
used = idx;
|
||||||
}
|
}
|
||||||
bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs,
|
bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs,
|
||||||
skips, used, out, count_out);
|
skips, used, config->protect_rules, out, count_out);
|
||||||
if (owned_prefixes) {
|
if (owned_prefixes) {
|
||||||
for (int i = 0; i < config->basis_count; i++)
|
for (int i = 0; i < config->basis_count; i++)
|
||||||
free(owned_prefixes[i]);
|
free(owned_prefixes[i]);
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ typedef struct {
|
|||||||
char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */
|
char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */
|
||||||
} FilterRule;
|
} FilterRule;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct FilterRuleList {
|
||||||
FilterRule** items; /* owned array of rule pointers */
|
FilterRule** items; /* owned array of rule pointers */
|
||||||
int count;
|
int count;
|
||||||
int capacity;
|
int capacity;
|
||||||
|
|||||||
+40
-14
@@ -682,7 +682,8 @@ static bool is_synced_dir(const PathIndex* dirs, const char* rel) {
|
|||||||
like any other non-directory extra (never followed). */
|
like any other non-directory extra (never followed). */
|
||||||
static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
||||||
const PathIndex* dirs, DeleteBudget* budget,
|
const PathIndex* dirs, DeleteBudget* budget,
|
||||||
const DeleteSkipEntry* skips, int skip_count, bool parent_deletable,
|
const DeleteSkipEntry* skips, int skip_count,
|
||||||
|
const FilterRuleList* protect_rules, bool parent_deletable,
|
||||||
bool* all_removed, DeletePathObserver observer,
|
bool* all_removed, DeletePathObserver observer,
|
||||||
void* observer_context) {
|
void* observer_context) {
|
||||||
/* openat(dirfd, ".") opens an independent file description: a dup() would
|
/* openat(dirfd, ".") opens an independent file description: a dup() would
|
||||||
@@ -729,12 +730,23 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k
|
|||||||
free(child_rel);
|
free(child_rel);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (S_ISDIR(st.st_mode)) {
|
bool is_dir = S_ISDIR(st.st_mode);
|
||||||
|
if (protect_rules && filter_rules_apply_side(protect_rules, child_rel, entry->d_name, is_dir,
|
||||||
|
FILTER_SIDE_RECEIVER) == 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. */
|
||||||
|
local_survives = true;
|
||||||
|
free(child_rel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_dir) {
|
||||||
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||||
bool child_all_removed = false;
|
bool child_all_removed = false;
|
||||||
if (childfd >= 0) {
|
if (childfd >= 0) {
|
||||||
if (!delete_extras_fd(childfd, child_rel, keep, dirs, budget, skips, skip_count, deletable,
|
if (!delete_extras_fd(childfd, child_rel, keep, dirs, budget, skips, skip_count,
|
||||||
&child_all_removed, observer, observer_context))
|
protect_rules, deletable, &child_all_removed, observer,
|
||||||
|
observer_context))
|
||||||
operation_ok = false;
|
operation_ok = false;
|
||||||
close(childfd);
|
close(childfd);
|
||||||
} else if (errno != ENOENT) {
|
} else if (errno != ENOENT) {
|
||||||
@@ -802,7 +814,8 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k
|
|||||||
reportable children (depth-first), matching the delete pass's ordering. */
|
reportable children (depth-first), matching the delete pass's ordering. */
|
||||||
static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
||||||
const PathIndex* dirs, ArrayList* out, size_t* recorded,
|
const PathIndex* dirs, ArrayList* out, size_t* recorded,
|
||||||
const DeleteSkipEntry* skips, int skip_count, bool parent_deletable,
|
const DeleteSkipEntry* skips, int skip_count,
|
||||||
|
const FilterRuleList* protect_rules, bool parent_deletable,
|
||||||
bool* all_removed) {
|
bool* all_removed) {
|
||||||
int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||||
if (scanfd < 0)
|
if (scanfd < 0)
|
||||||
@@ -836,12 +849,21 @@ static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* kee
|
|||||||
free(child_rel);
|
free(child_rel);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (S_ISDIR(st.st_mode)) {
|
bool is_dir = S_ISDIR(st.st_mode);
|
||||||
|
if (protect_rules && filter_rules_apply_side(protect_rules, child_rel, entry->d_name, is_dir,
|
||||||
|
FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) {
|
||||||
|
/* Mirror the delete walk: a protected entry is never reported as a
|
||||||
|
would-delete and a protected directory's subtree is not enumerated. */
|
||||||
|
local_survives = true;
|
||||||
|
free(child_rel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_dir) {
|
||||||
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||||
bool child_all_removed = false;
|
bool child_all_removed = false;
|
||||||
if (childfd >= 0) {
|
if (childfd >= 0) {
|
||||||
if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count,
|
if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count,
|
||||||
deletable, &child_all_removed))
|
protect_rules, deletable, &child_all_removed))
|
||||||
operation_ok = false;
|
operation_ok = false;
|
||||||
close(childfd);
|
close(childfd);
|
||||||
} else if (errno != ENOENT) {
|
} else if (errno != ENOENT) {
|
||||||
@@ -892,7 +914,7 @@ static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* kee
|
|||||||
|
|
||||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||||
ArrayList* out, size_t* count_out) {
|
const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out) {
|
||||||
if (count_out)
|
if (count_out)
|
||||||
*count_out = 0;
|
*count_out = 0;
|
||||||
if (!manifest || !out)
|
if (!manifest || !out)
|
||||||
@@ -928,7 +950,7 @@ bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
|||||||
bool all_removed = false;
|
bool all_removed = false;
|
||||||
size_t recorded = 0;
|
size_t recorded = 0;
|
||||||
bool ok = list_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, out, &recorded, skips,
|
bool ok = list_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, out, &recorded, skips,
|
||||||
skip_count, false, &all_removed);
|
skip_count, protect_rules, false, &all_removed);
|
||||||
if (close(rootfd) != 0)
|
if (close(rootfd) != 0)
|
||||||
ok = false;
|
ok = false;
|
||||||
path_index_free(&keep);
|
path_index_free(&keep);
|
||||||
@@ -942,6 +964,7 @@ bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
|||||||
DeleteWalkResult delete_extras_limited_observed(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 ArrayList* synced_dirs, size_t max_delete,
|
||||||
const DeleteSkipEntry* skips, int skip_count,
|
const DeleteSkipEntry* skips, int skip_count,
|
||||||
|
const FilterRuleList* protect_rules,
|
||||||
size_t* deleted_out, size_t* skipped_out,
|
size_t* deleted_out, size_t* skipped_out,
|
||||||
DeletePathObserver observer,
|
DeletePathObserver observer,
|
||||||
void* observer_context) {
|
void* observer_context) {
|
||||||
@@ -984,8 +1007,9 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr
|
|||||||
}
|
}
|
||||||
DeleteBudget budget = {.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
DeleteBudget budget = {.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||||
bool all_removed = false;
|
bool all_removed = false;
|
||||||
bool ok = delete_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &budget, skips,
|
bool ok =
|
||||||
skip_count, false, &all_removed, observer, observer_context);
|
delete_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &budget, skips, skip_count,
|
||||||
|
protect_rules, false, &all_removed, observer, observer_context);
|
||||||
if (close(rootfd) != 0)
|
if (close(rootfd) != 0)
|
||||||
ok = false;
|
ok = false;
|
||||||
path_index_free(&keep);
|
path_index_free(&keep);
|
||||||
@@ -1003,13 +1027,15 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr
|
|||||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||||
const ArrayList* synced_dirs, size_t max_delete,
|
const ArrayList* synced_dirs, size_t max_delete,
|
||||||
const DeleteSkipEntry* skips, int skip_count,
|
const DeleteSkipEntry* skips, int skip_count,
|
||||||
size_t* deleted_out, size_t* skipped_out) {
|
const FilterRuleList* protect_rules, size_t* deleted_out,
|
||||||
|
size_t* skipped_out) {
|
||||||
return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips,
|
return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips,
|
||||||
skip_count, deleted_out, skipped_out, NULL, NULL);
|
skip_count, protect_rules, deleted_out, skipped_out, NULL,
|
||||||
|
NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool delete_extras(const char* dest_root, const ArrayList* manifest) {
|
bool delete_extras(const char* dest_root, const ArrayList* manifest) {
|
||||||
return delete_extras_limited(dest_root, manifest, NULL, SIZE_MAX, NULL, 0, NULL, NULL) ==
|
return delete_extras_limited(dest_root, manifest, NULL, SIZE_MAX, NULL, 0, NULL, NULL, NULL) ==
|
||||||
DELETE_WALK_OK;
|
DELETE_WALK_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -2,6 +2,7 @@
|
|||||||
#define UTILS_H
|
#define UTILS_H
|
||||||
|
|
||||||
#include "array_list.h"
|
#include "array_list.h"
|
||||||
|
#include "filter.h"
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -148,7 +149,8 @@ bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSki
|
|||||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||||
const ArrayList* synced_dirs, size_t max_delete,
|
const ArrayList* synced_dirs, size_t max_delete,
|
||||||
const DeleteSkipEntry* skips, int skip_count,
|
const DeleteSkipEntry* skips, int skip_count,
|
||||||
size_t* deleted_out, size_t* skipped_out);
|
const FilterRuleList* protect_rules, size_t* deleted_out,
|
||||||
|
size_t* skipped_out);
|
||||||
|
|
||||||
/* Optional per-deletion observer: called for each destination-relative path
|
/* Optional per-deletion observer: called for each destination-relative path
|
||||||
actually removed (a file, symlink, or directory), in removal order, so the
|
actually removed (a file, symlink, or directory), in removal order, so the
|
||||||
@@ -156,10 +158,15 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m
|
|||||||
typedef void (*DeletePathObserver)(void* context, const char* rel_path);
|
typedef void (*DeletePathObserver)(void* context, const char* rel_path);
|
||||||
|
|
||||||
/* `delete_extras_limited_observed` is delete_extras_limited with an optional
|
/* `delete_extras_limited_observed` is delete_extras_limited with an optional
|
||||||
observer; the observer is invoked only for entries truly removed. */
|
* observer; the observer is invoked only for entries truly removed. When
|
||||||
|
* `protect_rules` 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,
|
DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest,
|
||||||
const ArrayList* synced_dirs, size_t max_delete,
|
const ArrayList* synced_dirs, size_t max_delete,
|
||||||
const DeleteSkipEntry* skips, int skip_count,
|
const DeleteSkipEntry* skips, int skip_count,
|
||||||
|
const FilterRuleList* protect_rules,
|
||||||
size_t* deleted_out, size_t* skipped_out,
|
size_t* deleted_out, size_t* skipped_out,
|
||||||
DeletePathObserver observer,
|
DeletePathObserver observer,
|
||||||
void* observer_context);
|
void* observer_context);
|
||||||
@@ -170,7 +177,7 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr
|
|||||||
strings appended to `out` and receives their count in *count_out. */
|
strings appended to `out` and receives their count in *count_out. */
|
||||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||||
ArrayList* out, size_t* count_out);
|
const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out);
|
||||||
bool delete_extras(const char* dest_root, const ArrayList* manifest);
|
bool delete_extras(const char* dest_root, const ArrayList* manifest);
|
||||||
/* Open the existing destination directory at `dest_root`, confined to the
|
/* Open the existing destination directory at `dest_root`, confined to the
|
||||||
authorized root with an O_NOFOLLOW component walk (the same confinement the
|
authorized root with an O_NOFOLLOW component walk (the same confinement the
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ static void build_canonical_frame(void) {
|
|||||||
cfg->usermap[0].to = MAP_TO;
|
cfg->usermap[0].to = MAP_TO;
|
||||||
cfg->usermap[0].to_name = NULL;
|
cfg->usermap[0].to_name = NULL;
|
||||||
}
|
}
|
||||||
|
/* Force a non-empty receiver delete-protection block so the fuzzer mutates
|
||||||
|
* its rule count, action/sides codes and pattern strings. */
|
||||||
|
cfg->filters = array_list_create(free);
|
||||||
|
if (cfg->filters) {
|
||||||
|
array_list_add(cfg->filters, str_dup("P *.log"));
|
||||||
|
array_list_add(cfg->filters, str_dup("+r keep/**"));
|
||||||
|
}
|
||||||
if (!cfg->send_directory || !cfg->receive_root_directory || !cfg->usermap) {
|
if (!cfg->send_directory || !cfg->receive_root_directory || !cfg->usermap) {
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -116,6 +116,16 @@ def seed_delete_excluded(_src, rroot, froot):
|
|||||||
_mk(os.path.join(root, "keep.txt"), b"keep\n", _OLD_MTIME)
|
_mk(os.path.join(root, "keep.txt"), b"keep\n", _OLD_MTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_filter_protect(_src, rroot, froot):
|
||||||
|
"""Destination-only entries, including nested ones, for the receiver-side
|
||||||
|
`protect` rule: the `.log` extras must survive --delete, the rest go."""
|
||||||
|
for root in (rroot, froot):
|
||||||
|
_mk(os.path.join(root, "extra.log"), b"dest-only log\n", _OLD_MTIME)
|
||||||
|
_mk(os.path.join(root, "other.txt"), b"dest-only other\n", _OLD_MTIME)
|
||||||
|
_mk(os.path.join(root, "sub", "extra2.log"), b"nested dest-only log\n", _OLD_MTIME)
|
||||||
|
_mk(os.path.join(root, "sub", "other2.txt"), b"nested dest-only other\n", _OLD_MTIME)
|
||||||
|
|
||||||
|
|
||||||
def seed_max_delete(_src, rroot, froot):
|
def seed_max_delete(_src, rroot, froot):
|
||||||
for root in (rroot, froot):
|
for root in (rroot, froot):
|
||||||
_mk(os.path.join(root, "extra1.txt"), b"e1\n", _OLD_MTIME)
|
_mk(os.path.join(root, "extra1.txt"), b"e1\n", _OLD_MTIME)
|
||||||
@@ -215,6 +225,18 @@ _CASES = [
|
|||||||
seed=seed_max_delete, server_args=DELETE,
|
seed=seed_max_delete, server_args=DELETE,
|
||||||
extra_check=max_delete_count_check, compare_tree=False,
|
extra_check=max_delete_count_check, compare_tree=False,
|
||||||
ref="--max-delete"),
|
ref="--max-delete"),
|
||||||
|
H.Case("filter_protect", "filters",
|
||||||
|
["-a", "--delete", "--filter=P *.log"],
|
||||||
|
seed=seed_filter_protect, server_args=DELETE, ci=True,
|
||||||
|
ref="--filter P/--protect receiver-side delete protection"),
|
||||||
|
H.Case("filter_protect_during", "filters",
|
||||||
|
["-a", "--delete-during", "--filter=P *.log"],
|
||||||
|
seed=seed_filter_protect, server_args=DELETE, ci=True,
|
||||||
|
ref="--filter P/--protect under --delete-during"),
|
||||||
|
H.Case("filter_protect_delay", "filters",
|
||||||
|
["-a", "--delete-delay", "--filter=P *.log"],
|
||||||
|
seed=seed_filter_protect, server_args=DELETE, ci=True,
|
||||||
|
ref="--filter P/--protect under --delete-delay"),
|
||||||
|
|
||||||
# --- relative / dirs --------------------------------------------------
|
# --- relative / dirs --------------------------------------------------
|
||||||
H.Case("relative_general", "basic", ["-a", "-R"], layout=H.MIRROR_ABS,
|
H.Case("relative_general", "basic", ["-a", "-R"], layout=H.MIRROR_ABS,
|
||||||
|
|||||||
@@ -589,11 +589,10 @@ class TestRemoteDryRun:
|
|||||||
|
|
||||||
Covers the three cases that a real run protects: the file being updated
|
Covers the three cases that a real run protects: the file being updated
|
||||||
(in the keep set), a filter-excluded source entry (protected prefix), and
|
(in the keep set), a filter-excluded source entry (protected prefix), and
|
||||||
a --max-size-pruned source entry (always-protected prefix). Only the
|
a --max-size-pruned source entry (always-protected prefix). Track 4a
|
||||||
genuine destination-only extras may appear. Residual: a destination-only
|
adds a fourth: a destination-only entry matching the exclude rule is
|
||||||
entry matching an exclude pattern is still removed (FastSync derives
|
re-derived on the receiver and also protected, so only the genuine
|
||||||
delete protection from the source scan, not a receiver filter engine);
|
destination-only `extra.txt` appears.
|
||||||
that divergence is pinned by TestOptionParity.
|
|
||||||
"""
|
"""
|
||||||
source = os.path.join(TEST_DATA_DIR, "dryrep_src")
|
source = os.path.join(TEST_DATA_DIR, "dryrep_src")
|
||||||
rdst = os.path.join(TEST_DATA_DIR, "dryrep_rdst")
|
rdst = os.path.join(TEST_DATA_DIR, "dryrep_rdst")
|
||||||
@@ -610,7 +609,8 @@ class TestRemoteDryRun:
|
|||||||
os.makedirs(received, exist_ok=True)
|
os.makedirs(received, exist_ok=True)
|
||||||
for root in (rdst, received):
|
for root in (rdst, received):
|
||||||
for name, data in (("a.txt", b"old\n"), ("keep.log", b"log\n"),
|
for name, data in (("a.txt", b"old\n"), ("keep.log", b"log\n"),
|
||||||
("big.bin", b"B" * 2000), ("extra.txt", b"extra\n")):
|
("big.bin", b"B" * 2000), ("extra.txt", b"extra\n"),
|
||||||
|
("stray.log", b"dest only\n")):
|
||||||
with open(os.path.join(root, name), "wb") as fh:
|
with open(os.path.join(root, name), "wb") as fh:
|
||||||
fh.write(data)
|
fh.write(data)
|
||||||
os.utime(os.path.join(root, name), (1_500_000_000, 1_500_000_000))
|
os.utime(os.path.join(root, name), (1_500_000_000, 1_500_000_000))
|
||||||
@@ -631,6 +631,8 @@ class TestRemoteDryRun:
|
|||||||
fs_del = sorted(l for l in (result.stdout or "").splitlines()
|
fs_del = sorted(l for l in (result.stdout or "").splitlines()
|
||||||
if l.startswith("*deleting"))
|
if l.startswith("*deleting"))
|
||||||
assert fs_del == rsync_del, f"rsync={rsync_del}\nfastsync={fs_del}"
|
assert fs_del == rsync_del, f"rsync={rsync_del}\nfastsync={fs_del}"
|
||||||
|
assert os.path.exists(os.path.join(received, "stray.log")), \
|
||||||
|
"destination-only exclude match must be protected in the dry-run report"
|
||||||
|
|
||||||
@pytest.mark.ci
|
@pytest.mark.ci
|
||||||
def test_remote_dry_run_quiet_is_silent(self, shared_server):
|
def test_remote_dry_run_quiet_is_silent(self, shared_server):
|
||||||
@@ -4693,11 +4695,11 @@ class TestDeletePolicy:
|
|||||||
finally:
|
finally:
|
||||||
os.chmod(source, 0o755)
|
os.chmod(source, 0o755)
|
||||||
|
|
||||||
def test_delete_excluded_protection_is_sender_derived(self):
|
def test_delete_protection_reapplied_on_receiver(self):
|
||||||
"""Plain --delete protects destination mirrors of files the SOURCE scan
|
"""Plain --delete protects destination mirrors of files the SOURCE scan
|
||||||
excluded, but a destination-only file that merely matches an exclude
|
excluded, and (track 4a) also protects a destination-only file matching
|
||||||
rule is still an extra and is removed (protection never re-applies rules
|
an exclude rule because the compiled rule set is re-applied on the
|
||||||
to the destination)."""
|
receiver, matching rsync."""
|
||||||
source = os.path.join(TEST_DATA_DIR, "senderderived_src")
|
source = os.path.join(TEST_DATA_DIR, "senderderived_src")
|
||||||
clean_dir(source)
|
clean_dir(source)
|
||||||
self._write(os.path.join(source, "keep.txt"), b"kept\n")
|
self._write(os.path.join(source, "keep.txt"), b"kept\n")
|
||||||
@@ -4717,8 +4719,8 @@ class TestDeletePolicy:
|
|||||||
f"delete sync failed: {(result.stderr or result.stdout)[:300]}"
|
f"delete sync failed: {(result.stderr or result.stdout)[:300]}"
|
||||||
assert os.path.exists(os.path.join(received, "secret.log")), \
|
assert os.path.exists(os.path.join(received, "secret.log")), \
|
||||||
"source-excluded mirror was deleted under plain --delete"
|
"source-excluded mirror was deleted under plain --delete"
|
||||||
assert not os.path.exists(os.path.join(received, "stray.log")), \
|
assert os.path.exists(os.path.join(received, "stray.log")), \
|
||||||
"destination-only file matching the exclude rule was left (should be deleted)"
|
"destination-only file matching the exclude rule must be protected like rsync"
|
||||||
|
|
||||||
|
|
||||||
def _pin_mtime(path, ts):
|
def _pin_mtime(path, ts):
|
||||||
|
|||||||
@@ -478,14 +478,14 @@ class TestRemoteOptionDaemon:
|
|||||||
assert "remote-option" in (result.stderr + result.stdout)
|
assert "remote-option" in (result.stderr + result.stdout)
|
||||||
|
|
||||||
|
|
||||||
class TestFilterProtectDivergence:
|
class TestFilterProtect:
|
||||||
"""Documented residual: a protect rule that matches only a destination-only
|
"""Receiver-derived delete protection: a `protect`/`P` rule is compiled by
|
||||||
entry is not re-derived on the receiver (FastSync derives delete protection
|
the sender and sent on the config frame, so the receiver shields a
|
||||||
from the source scan), so rsync protects the extra but FastSync removes it."""
|
destination-only entry that never appeared on the sender, matching rsync."""
|
||||||
|
|
||||||
@requires_rsync
|
@requires_rsync
|
||||||
@pytest.mark.ci
|
@pytest.mark.ci
|
||||||
def test_protect_dest_only_divergence(self, shared_server):
|
def test_protect_dest_only_matches_rsync(self, shared_server):
|
||||||
source = os.path.join(TEST_DATA_DIR, "fpd_src")
|
source = os.path.join(TEST_DATA_DIR, "fpd_src")
|
||||||
dest = os.path.join(TEST_DATA_DIR, "fpd_dst")
|
dest = os.path.join(TEST_DATA_DIR, "fpd_dst")
|
||||||
rdst = os.path.join(TEST_DATA_DIR, "fpd_rdst")
|
rdst = os.path.join(TEST_DATA_DIR, "fpd_rdst")
|
||||||
@@ -498,6 +498,7 @@ class TestFilterProtectDivergence:
|
|||||||
rsync_result = _rsync(["-a", "--delete", "--filter=P *.log", source + "/", rdst + "/"])
|
rsync_result = _rsync(["-a", "--delete", "--filter=P *.log", source + "/", rdst + "/"])
|
||||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
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 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")), "rsync did not delete other.txt"
|
||||||
|
|
||||||
clean_dir(dest)
|
clean_dir(dest)
|
||||||
received = get_dest_received_dir(dest, source)
|
received = get_dest_received_dir(dest, source)
|
||||||
@@ -509,9 +510,29 @@ class TestFilterProtectDivergence:
|
|||||||
flags=["-a", "--delete", "--filter=P *.log"],
|
flags=["-a", "--delete", "--filter=P *.log"],
|
||||||
port=server.port)
|
port=server.port)
|
||||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||||
# Pin the known divergence: FastSync deletes the destination-only file.
|
assert os.path.exists(os.path.join(received, "extra.log")), (
|
||||||
assert not os.path.exists(os.path.join(received, "extra.log")), (
|
"FastSync must protect a destination-only P match like rsync")
|
||||||
"FastSync now protects destination-only P matches; the --filter row may be "
|
|
||||||
"upgradable to full parity"
|
|
||||||
)
|
|
||||||
assert not os.path.exists(os.path.join(received, "other.txt"))
|
assert not os.path.exists(os.path.join(received, "other.txt"))
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_protect_dest_only_dry_run_enumeration(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "fpd_nd_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "fpd_nd_dst")
|
||||||
|
clean_dir(source)
|
||||||
|
_write(os.path.join(source, "keep.txt"), b"keep\n")
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
clean_dir(received)
|
||||||
|
_write(os.path.join(received, "keep.txt"), b"keep\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=["-a", "-n", "--delete", "--out-format=%n",
|
||||||
|
"--filter=P *.log"],
|
||||||
|
port=server.port)
|
||||||
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||||
|
assert "other.txt" in result.stdout, result.stdout
|
||||||
|
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"))
|
||||||
|
|||||||
+56
-6
@@ -1,6 +1,7 @@
|
|||||||
#include "test_config.h"
|
#include "test_config.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "delta.h"
|
#include "delta.h"
|
||||||
|
#include "filter.h"
|
||||||
#include "identity.h"
|
#include "identity.h"
|
||||||
#include "multiprocessing.h"
|
#include "multiprocessing.h"
|
||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
@@ -2590,6 +2591,46 @@ static bool basis_equal(const Config* a, const Config* b) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The receiver reconstructs its delete-protection list from the sender's
|
||||||
|
* compiled base rules, so compare the received list against a fresh
|
||||||
|
* filter_base_build() of the sender's raw --filter texts. */
|
||||||
|
static bool filter_rules_equal(const Config* a, const FilterRuleList* got) {
|
||||||
|
int count = a->filters ? a->filters->size : 0;
|
||||||
|
const char** texts = NULL;
|
||||||
|
if (count > 0) {
|
||||||
|
texts = calloc((size_t)count, sizeof(char*));
|
||||||
|
if (!texts)
|
||||||
|
return false;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
texts[i] = (const char*)a->filters->items[i];
|
||||||
|
}
|
||||||
|
char err[160];
|
||||||
|
FilterRuleList* expected =
|
||||||
|
filter_base_build(texts, count, a->cvs_exclude, a->delete_excluded, err, sizeof(err));
|
||||||
|
free(texts);
|
||||||
|
if (!expected)
|
||||||
|
return false;
|
||||||
|
bool equal = true;
|
||||||
|
int want = expected->count;
|
||||||
|
int have = got ? got->count : 0;
|
||||||
|
if (want != have) {
|
||||||
|
equal = false;
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < want; i++) {
|
||||||
|
const FilterRule* x = expected->items[i];
|
||||||
|
const FilterRule* y = got->items[i];
|
||||||
|
if (x->action != y->action || x->sides != y->sides || x->anchored != y->anchored ||
|
||||||
|
x->dir_only != y->dir_only || x->negate != y->negate ||
|
||||||
|
!str_opt_equal(x->owner, y->owner) || !str_opt_equal(x->pattern, y->pattern)) {
|
||||||
|
equal = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter_rule_list_free(expected);
|
||||||
|
return equal;
|
||||||
|
}
|
||||||
|
|
||||||
#define CONFIG_CMP_BOOL(a, b, name) ((a)->name == (b)->name)
|
#define CONFIG_CMP_BOOL(a, b, name) ((a)->name == (b)->name)
|
||||||
#define CONFIG_CMP_INT(a, b, name) ((a)->name == (b)->name)
|
#define CONFIG_CMP_INT(a, b, name) ((a)->name == (b)->name)
|
||||||
#define CONFIG_CMP_RAW(a, b, name) ((a)->name == (b)->name)
|
#define CONFIG_CMP_RAW(a, b, name) ((a)->name == (b)->name)
|
||||||
@@ -2615,6 +2656,7 @@ static bool basis_equal(const Config* a, const Config* b) {
|
|||||||
#define CONFIG_CMP_COPY_AS_ID(a, b, name) (!(a)->copy_as_set || (a)->name == (b)->name)
|
#define CONFIG_CMP_COPY_AS_ID(a, b, name) (!(a)->copy_as_set || (a)->name == (b)->name)
|
||||||
#define CONFIG_CMP_BLOCK_SKIP_SUFFIXES(a, b, name) skip_suffixes_equal((a), (b))
|
#define CONFIG_CMP_BLOCK_SKIP_SUFFIXES(a, b, name) skip_suffixes_equal((a), (b))
|
||||||
#define CONFIG_CMP_BLOCK_BASIS(a, b, name) basis_equal((a), (b))
|
#define CONFIG_CMP_BLOCK_BASIS(a, b, name) basis_equal((a), (b))
|
||||||
|
#define CONFIG_CMP_BLOCK_PROTECT_RULES(a, b, name) filter_rules_equal((a), (b)->name)
|
||||||
#define CONFIG_CMP_BLOCK_IDMAP(a, b, name) \
|
#define CONFIG_CMP_BLOCK_IDMAP(a, b, name) \
|
||||||
idmap_equal((a)->name, (a)->name##_count, (b)->name, (b)->name##_count)
|
idmap_equal((a)->name, (a)->name##_count, (b)->name, (b)->name##_count)
|
||||||
|
|
||||||
@@ -2829,6 +2871,14 @@ static void golden_config_populate(Config* c) {
|
|||||||
c->copy_as_set = true;
|
c->copy_as_set = true;
|
||||||
c->copy_as_uid = 111;
|
c->copy_as_uid = 111;
|
||||||
c->copy_as_gid = 222;
|
c->copy_as_gid = 222;
|
||||||
|
/* Compile-through delete-protection rules (protocol 2.28.0). The golden
|
||||||
|
* sender serializes its compiled base rules, so populate a diverse set that
|
||||||
|
* exercises both sides, negate, anchoring and dir-only. */
|
||||||
|
c->filters = array_list_create(free);
|
||||||
|
array_list_add(c->filters, str_dup("P *.log"));
|
||||||
|
array_list_add(c->filters, str_dup("+r **/*.txt"));
|
||||||
|
array_list_add(c->filters, str_dup("H,!secret"));
|
||||||
|
array_list_add(c->filters, str_dup("- /sub/dir/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The pinned golden frame (protocol 2.28.0). The values below are the only
|
/* The pinned golden frame (protocol 2.28.0). The values below are the only
|
||||||
@@ -2836,12 +2886,12 @@ static void golden_config_populate(Config* c) {
|
|||||||
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.24.0
|
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.24.0
|
||||||
* delete-plan wave changed only the version string; 2.25.0 appended the
|
* delete-plan wave changed only the version string; 2.25.0 appended the
|
||||||
* report_stats bool, 2.26.0 appended the compression_algo int, 2.27.0 appended
|
* report_stats bool, 2.26.0 appended the compression_algo int, 2.27.0 appended
|
||||||
* the report_deletes bool, and 2.28.0 changed only the version string (the
|
* the report_deletes bool, and 2.28.0 changed only the version string and
|
||||||
* STATUS_STATS body grew, but the config frame layout is unchanged, so the
|
* appended the receiver-side delete-protection rule block (the STATUS_STATS
|
||||||
* frame length is identical). The byte-exact values are recomputed for the
|
* body also grew, but that is not part of this frame). The byte-exact values
|
||||||
* merged layout. */
|
* are recomputed for the merged layout. */
|
||||||
#define GOLDEN_WIRE_LEN 709
|
#define GOLDEN_WIRE_LEN 882
|
||||||
#define GOLDEN_WIRE_HASH 417335736347473203ULL
|
#define GOLDEN_WIRE_HASH 10588362715396735070ULL
|
||||||
|
|
||||||
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
||||||
unsigned long long h = 1469598103934665603ULL;
|
unsigned long long h = 1469598103934665603ULL;
|
||||||
|
|||||||
@@ -18,10 +18,11 @@
|
|||||||
|
|
||||||
/* P8 config-frame tail: super_mode (4) + copy-as presence (4) + uid (4) + gid (4). */
|
/* P8 config-frame tail: super_mode (4) + copy-as presence (4) + uid (4) + gid (4). */
|
||||||
#define P8_TAIL_BYTES 16
|
#define P8_TAIL_BYTES 16
|
||||||
/* Bytes after the P8 tail: report_dest_info (4), report_stats (4, wire-stats
|
/* Bytes after the P8 tail: report_dest_info (4), report_stats (4),
|
||||||
* wave) and compression_algo (4, codec wave). The P8 fields sit this many
|
* report_deletes (4, --info=del wave), compression_algo (4, codec wave) and the
|
||||||
* bytes before the end of the frame. */
|
* receiver delete-protection count (4, protocol 2.28.0). The P8 fields sit
|
||||||
#define POST_P8_TAIL_BYTES 12
|
* this many bytes before the end of the frame. */
|
||||||
|
#define POST_P8_TAIL_BYTES 20
|
||||||
|
|
||||||
/* Smoke test for chunk_deserialize fuzz target */
|
/* Smoke test for chunk_deserialize fuzz target */
|
||||||
static void test_fuzz_chunk_deserialize() {
|
static void test_fuzz_chunk_deserialize() {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ static void test_walker_removes_extras_keeps_manifest_and_protected() {
|
|||||||
DeleteSkipEntry skip = {"prot", false};
|
DeleteSkipEntry skip = {"prot", false};
|
||||||
size_t deleted = 0;
|
size_t deleted = 0;
|
||||||
DeleteWalkResult result =
|
DeleteWalkResult result =
|
||||||
delete_extras_limited(root, manifest, NULL, 100000, &skip, 1, &deleted, NULL);
|
delete_extras_limited(root, manifest, NULL, 100000, &skip, 1, NULL, &deleted, NULL);
|
||||||
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
||||||
EXPECT_FALSE(file_exists(root, "a.txt"));
|
EXPECT_FALSE(file_exists(root, "a.txt"));
|
||||||
EXPECT_TRUE(file_exists(root, "keep.txt"));
|
EXPECT_TRUE(file_exists(root, "keep.txt"));
|
||||||
@@ -173,7 +173,7 @@ static void test_walker_keeps_nested_manifest_dirs() {
|
|||||||
EXPECT_NOT_NULL(manifest);
|
EXPECT_NOT_NULL(manifest);
|
||||||
size_t deleted = 0;
|
size_t deleted = 0;
|
||||||
DeleteWalkResult result =
|
DeleteWalkResult result =
|
||||||
delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, &deleted, NULL);
|
delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, NULL, &deleted, NULL);
|
||||||
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
||||||
EXPECT_FALSE(file_exists(root, "extra.txt"));
|
EXPECT_FALSE(file_exists(root, "extra.txt"));
|
||||||
EXPECT_TRUE(file_exists(root, "keepdir/deep/keep.txt"));
|
EXPECT_TRUE(file_exists(root, "keepdir/deep/keep.txt"));
|
||||||
@@ -204,7 +204,7 @@ static void test_walker_max_delete_partial_deletes_up_to_cap() {
|
|||||||
size_t deleted = 999;
|
size_t deleted = 999;
|
||||||
size_t skipped = 0;
|
size_t skipped = 0;
|
||||||
DeleteWalkResult result =
|
DeleteWalkResult result =
|
||||||
delete_extras_limited(root, manifest, NULL, 2, NULL, 0, &deleted, &skipped);
|
delete_extras_limited(root, manifest, NULL, 2, NULL, 0, NULL, &deleted, &skipped);
|
||||||
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_LIMIT_REACHED);
|
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_LIMIT_REACHED);
|
||||||
EXPECT_EQ_INT((int)deleted, 2);
|
EXPECT_EQ_INT((int)deleted, 2);
|
||||||
EXPECT_EQ_INT((int)skipped, 1);
|
EXPECT_EQ_INT((int)skipped, 1);
|
||||||
@@ -225,7 +225,8 @@ static void test_walker_max_delete_exact_bound_deletes() {
|
|||||||
ArrayList* manifest = make_manifest_strings(keeps, 0);
|
ArrayList* manifest = make_manifest_strings(keeps, 0);
|
||||||
EXPECT_NOT_NULL(manifest);
|
EXPECT_NOT_NULL(manifest);
|
||||||
size_t deleted = 0;
|
size_t deleted = 0;
|
||||||
DeleteWalkResult result = delete_extras_limited(root, manifest, NULL, 2, NULL, 0, &deleted, NULL);
|
DeleteWalkResult result =
|
||||||
|
delete_extras_limited(root, manifest, NULL, 2, NULL, 0, NULL, &deleted, NULL);
|
||||||
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
||||||
EXPECT_EQ_INT((int)deleted, 2);
|
EXPECT_EQ_INT((int)deleted, 2);
|
||||||
EXPECT_FALSE(file_exists(root, "a.txt"));
|
EXPECT_FALSE(file_exists(root, "a.txt"));
|
||||||
@@ -258,7 +259,7 @@ static void test_walker_removes_extraneous_symlinks() {
|
|||||||
EXPECT_NOT_NULL(manifest);
|
EXPECT_NOT_NULL(manifest);
|
||||||
size_t deleted = 0;
|
size_t deleted = 0;
|
||||||
DeleteWalkResult result =
|
DeleteWalkResult result =
|
||||||
delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, &deleted, NULL);
|
delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, NULL, &deleted, NULL);
|
||||||
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
||||||
EXPECT_FALSE(file_exists(root, "link_file"));
|
EXPECT_FALSE(file_exists(root, "link_file"));
|
||||||
EXPECT_FALSE(file_exists(root, "link_dir"));
|
EXPECT_FALSE(file_exists(root, "link_dir"));
|
||||||
@@ -294,7 +295,7 @@ static void test_walker_confines_deletion_to_synced_dirs() {
|
|||||||
EXPECT_TRUE(array_list_add(dirs, str_dup("inscope")));
|
EXPECT_TRUE(array_list_add(dirs, str_dup("inscope")));
|
||||||
size_t deleted = 0;
|
size_t deleted = 0;
|
||||||
DeleteWalkResult result =
|
DeleteWalkResult result =
|
||||||
delete_extras_limited(root, manifest, dirs, 100000, NULL, 0, &deleted, NULL);
|
delete_extras_limited(root, manifest, dirs, 100000, NULL, 0, NULL, &deleted, NULL);
|
||||||
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
|
||||||
EXPECT_TRUE(file_exists(root, "rootextra.txt"));
|
EXPECT_TRUE(file_exists(root, "rootextra.txt"));
|
||||||
EXPECT_FALSE(file_exists(root, "inscope/extra.txt"));
|
EXPECT_FALSE(file_exists(root, "inscope/extra.txt"));
|
||||||
@@ -324,6 +325,45 @@ static void test_walker_unlimited_deletes_all() {
|
|||||||
free(root);
|
free(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Receiver-side filter protection (protocol 2.28.0): a compiled protect rule
|
||||||
|
shields a DESTINATION-ONLY extra that never appeared on the sender, a risk
|
||||||
|
rule cancels an earlier/later protect (first match wins), and a dir-only
|
||||||
|
protect rule shields the whole subtree. */
|
||||||
|
static void test_walker_protect_rules_shield_dest_only() {
|
||||||
|
char* root = make_walk_root("protectrules");
|
||||||
|
EXPECT_NOT_NULL(root);
|
||||||
|
EXPECT_TRUE(write_file_at(root, "keep.txt", "kept"));
|
||||||
|
EXPECT_TRUE(write_file_at(root, "extra.log", "risk cancels protect"));
|
||||||
|
EXPECT_TRUE(write_file_at(root, "safe.log", "protected"));
|
||||||
|
EXPECT_TRUE(write_file_at(root, "other.txt", "deleted"));
|
||||||
|
EXPECT_EQ_INT(make_subdir(root, "prot"), 0);
|
||||||
|
EXPECT_TRUE(write_file_at(root, "prot/inside.txt", "shielded subtree"));
|
||||||
|
EXPECT_TRUE(write_file_at(root, "prot/deep.log", "shielded subtree"));
|
||||||
|
|
||||||
|
const char* keeps[] = {"keep.txt"};
|
||||||
|
ArrayList* manifest = make_manifest_strings(keeps, 1);
|
||||||
|
EXPECT_NOT_NULL(manifest);
|
||||||
|
const char* rule_text[] = {"R extra.log", "P *.log", "P prot/"};
|
||||||
|
char err[160];
|
||||||
|
FilterRuleList* rules = filter_base_build(rule_text, 3, false, false, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(rules);
|
||||||
|
size_t deleted = 0;
|
||||||
|
DeleteWalkResult result =
|
||||||
|
delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, rules, &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 */
|
||||||
|
EXPECT_TRUE(file_exists(root, "safe.log")); /* protect shields the extra */
|
||||||
|
EXPECT_FALSE(file_exists(root, "other.txt"));
|
||||||
|
EXPECT_TRUE(dir_exists(root, "prot"));
|
||||||
|
EXPECT_TRUE(file_exists(root, "prot/inside.txt"));
|
||||||
|
EXPECT_TRUE(file_exists(root, "prot/deep.log"));
|
||||||
|
filter_rule_list_free(rules);
|
||||||
|
array_list_delete(manifest);
|
||||||
|
remove_walk_tree(root);
|
||||||
|
free(root);
|
||||||
|
}
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
bool eight_bit_output;
|
bool eight_bit_output;
|
||||||
const char* expected;
|
const char* expected;
|
||||||
@@ -626,6 +666,7 @@ void test_shared_utils() {
|
|||||||
test_walker_removes_extraneous_symlinks();
|
test_walker_removes_extraneous_symlinks();
|
||||||
test_walker_confines_deletion_to_synced_dirs();
|
test_walker_confines_deletion_to_synced_dirs();
|
||||||
test_walker_unlimited_deletes_all();
|
test_walker_unlimited_deletes_all();
|
||||||
|
test_walker_protect_rules_shield_dest_only();
|
||||||
test_loopback_helpers();
|
test_loopback_helpers();
|
||||||
test_fd_peer_ip();
|
test_fd_peer_ip();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user