Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef76c9034d |
@@ -49,47 +49,6 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
run: python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv" --durations=25 --tb=short -q
|
||||
|
||||
# Differential rsync-parity gate: runs real rsync 3.4.1 and FastSync over the
|
||||
# same corpora and compares destinations + normalized output. The fast subset
|
||||
# guards the ✅ surface on every PR; the full set (with FASTSYNC_PARITY_STRICT
|
||||
# so a fixed caveat must be removed from the allowlist) burns the documented
|
||||
# ⚠️/❌ residuals down on push. See tests/integration/README.md.
|
||||
parity-fast:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v11
|
||||
needs: lint
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DSTRICT_WARNINGS=ON
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Differential parity (fast subset)
|
||||
run: python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity_ci -q
|
||||
|
||||
parity-full:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v11
|
||||
needs: lint
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DSTRICT_WARNINGS=ON
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Differential parity (full set)
|
||||
run: FASTSYNC_PARITY_STRICT=1 python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity -q
|
||||
|
||||
sanitizers:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v11
|
||||
|
||||
@@ -56,21 +56,6 @@ cmake -B build -S . && cmake --build build -j$(nproc)
|
||||
./build/tests # unit tests
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv" # full integration suite (CI excludes env-dependent privilege tests)
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m ci # PR-gate subset only
|
||||
|
||||
# Differential rsync-parity gate (real rsync 3.4.1 vs FastSync)
|
||||
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity_ci # fast PR subset
|
||||
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity # full set
|
||||
```
|
||||
|
||||
See `tests/integration/README.md` for the differential parity gate and its
|
||||
`parity_caveats.py` allowlist (the residual burn-down mechanism).
|
||||
|
||||
Unit tests under valgrind must set `FASTSYNC_UNDER_VALGRIND=1` (CI does): the
|
||||
tests use it to skip fork-based tests, because valgrind 3.22 does not expose
|
||||
`vgpreload` in the guest's `/proc/self/maps`.
|
||||
|
||||
```bash
|
||||
FASTSYNC_UNDER_VALGRIND=1 valgrind --leak-check=full --show-leak-kinds=definite --error-exitcode=1 ./build/tests
|
||||
```
|
||||
|
||||
## CI Workflow — Waiting for Results
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
project(FastFileTransfer VERSION 2.28.0)
|
||||
project(FastFileTransfer VERSION 2.26.0)
|
||||
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
@@ -221,7 +221,6 @@ set(TEST_SRCS
|
||||
tests/test_daemon_limits.c
|
||||
tests/test_data.c
|
||||
tests/test_delay_updates.c
|
||||
tests/test_delete_plan.c
|
||||
tests/test_delta.c
|
||||
tests/test_file.c
|
||||
tests/test_file_list.c
|
||||
|
||||
+3
-72
@@ -8,8 +8,8 @@
|
||||
- **Release PR #284 (`dev` -> `main`)** open, CI green (run 553).
|
||||
`main` is protected: it needs review/approval to merge.
|
||||
https://gitea.tap-tap.win/TapTap/FastSync/pulls/284
|
||||
- **`PROTOCOL_VERSION` = `"2.27.0"`** (`src/shared/config.h`); CMake
|
||||
`project(FastFileTransfer VERSION 2.27.0)`.
|
||||
- **`PROTOCOL_VERSION` = `"2.26.0"`** (`src/shared/config.h`); CMake
|
||||
`project(FastFileTransfer VERSION 2.26.0)`.
|
||||
- Working tree clean; no wave worktrees remain.
|
||||
|
||||
## What landed this session
|
||||
@@ -39,76 +39,7 @@
|
||||
docs state push-only / remote-source unsupported.
|
||||
5. **Preserve-attribute split (protocol 2.22.0)** landed on `feat/preserve-attr-split`: per-attribute `-p/-t/-o/-g` + `--no-*` negations, `-a` = `-rlptgoD`, and the 2.21.0 → 2.22.0 wire bump.
|
||||
6. **Rsync-parity wave (protocol 2.23.0)** on `feat/rsync-parity`: rsync short options/clustering/attached values (`-r`/`-b`/`-L`/`-B`, `-av`, `-aAX`, `-B1000`, `-essh`, `-MOPT`), `-c` checksum quick-check, `--checksum-choice`/`--compress-choice` validation and seed randomization, rsync timeout/max-alloc defaults, temp-dir confinement + `EXDEV` fallback, ownership/mapping parity (numeric-ids modifier, map ranges/`*`/empty-FROM, `--chown`+map conflicts, fake-super resolved-owner record), verbatim symlink storage with rsync `--safe-links`/`--munge-links`, socket recreation under `--specials`, `--chmod` 3.4.1 semantics, and delete scoping + `--max-delete` partial/exit-25. Wire: appended delete-manifest synchronized-directory section and `STATUS_DELETE_LIMIT`.
|
||||
7. **Parity-completion wave (protocol 2.24.0 → 2.26.0)** on `feat/parity-completion`: per-directory delete plans (`STATUS_DELETE_PLAN`) for `--delete-during`/`--delete-delay`; receiver `STATUS_STATS` counters feeding `--stats`/`--progress` and `--out-format %b/%c/%C`, plus `-n --delete` lines; `lz4`/`zlib`/`zlibx` compression and `md4`/`sha1`/`none` checksums with `auto` negotiation (default `xxh128`/`zstd`); general `-R`/`--no-implied-dirs`/`-d`; the full filter grammar (`merge`/`dir-merge`/`hide`/`show`/`protect`/`risk`/`clear` + modifiers) and corrected `-F`/`-FF`; receiver-side `--chown`/map TO-name resolution; absolute basis dirs + `--link-dest` relink; receiver-side `--ignore-existing` short-circuit; `--preallocate` over `--sparse` via `fallocate(2)`; `--iconv=.`/`-`/`--no-iconv`; lone `-h` help; aliases `--ignore-non-existing`/`--protect-args`/`--msgs2stderr`; and the full `--info`/`--debug` vocabulary. `RSYNC_COMPAT.md` reclassifies the matrix to 106 ✅ / 27 ⚠️ / 23 ❌; the later rsync-parity-stats pass (`fix/parity-stats`) moves it to 107 ✅ / 25 ⚠️ / 24 ❌ (see item 8).
|
||||
8. **rsync-parity-stats pass** on `fix/parity-stats` (no wire change, `PROTOCOL_VERSION` stays `2.26.0`): `--delete-delay` now reports only entries it actually removes, while the `--max-delete` budget is charged at plan/snapshot time (`planned`, via `defer_add`) to bound the deferred list (a refilled deferred directory that survives `ENOTEMPTY` is not reported but still consumes budget); `--stats` gained the `(reg/dir/link/special)` `Number of files` breakdown and now counts only regular files actually stored for `Number of regular files transferred`/transferred size/literal data (up-to-date re-runs report 0); `Total file size` includes symlink target lengths; `--progress` prints the leading `./` root line and counts it in `to-chk` so a single-file transfer matches rsync; and `%C` uses the selected transfer checksum with `checksum_digest_file` supporting md4/sha1/none, byte-identical to rsync for every algorithm. `--out-format` reclassified ❌ (`%b`/delta-`%c` are protocol-specific). Differential + regression tests added; full suite + ASan + clang-format + cppcheck clean.
|
||||
9. **Option-parity wave (protocol 2.26.0 → 2.27.0, on `fix/parity-options`):**
|
||||
`--bwlimit` now ports rsync 3.4.1's units/quantization and paces like its
|
||||
leaky bucket; `--ignore-errors` reproduces rsync's default (an I/O error
|
||||
skips deletion unless the flag is set; the readable tree still transfers and
|
||||
the run exits 23) across every delete timing; the `--info` categories with a
|
||||
FastSync event (`name`/`flist`/`del`/`remove`/`nonreg`/`progress`) emit
|
||||
rsync's line format, with real-run `deleting`/`*deleting` lines carried over
|
||||
the new trailing config bool `report_deletes` (golden wire updated by
|
||||
`tests/test_config.c`). Two residuals were reclassified **divergent**: `-M`
|
||||
over daemon/TCP (no argv channel in FastSync's binary config handshake;
|
||||
rsync-daemon differential pins the rsync behavior) and receiver-side
|
||||
`protect`/`risk` re-derivation for destination-only entries (would need a
|
||||
receiver filter engine; differential pins the divergence). 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
|
||||
`--ignore-errors`, rsync-daemon `-M`, filter-protect pin).
|
||||
|
||||
10. **rsync-parity-fs pass** on `fix/parity-fs` (no wire change of its own; integrated
|
||||
on top of the 2.27.0 options wave): recursive transfers now recreate empty source directories (and
|
||||
`-m/--prune-empty-dirs` still suppresses them), a directory entry replaces a
|
||||
blocking destination regular file, and `-R --no-implied-dirs --files-from`
|
||||
places a listed file under a missing implied parent with default attributes
|
||||
instead of refusing (real rsync 3.4.1 parity, differential-tested). `--iconv`
|
||||
now reproduces rsync's push direction (destination charset = the spec's REMOTE
|
||||
half; a server `--iconv` overrides), and `-T/--temp-dir` relative semantics are
|
||||
confirmed identical while the absolute-path confinement is a deliberate
|
||||
divergence. The basis-dir options, `--delay-updates` and `--dry-run` were
|
||||
reclassified to ❌ after a differential test reproduced each exact residual
|
||||
(basis content verification, fixed staging-name collision, and dry-run
|
||||
would-delete over-report). `--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
|
||||
it is pinned by the `TestFuzzy` threshold suite rather than a byte-level rsync
|
||||
differential. The parity-review pass then moved `--delete-delay` to ⚠️ (the
|
||||
plan-time `--max-delete` charge and non-recursive deferred removal differ from
|
||||
rsync when a snapshotted entry fails removal). Differential-gate allowlist
|
||||
entries `min_size`/`empty_dirs_recursive`/`dirs_plain` were removed. The
|
||||
integrated stats+options+fs branch stands at **111 ✅ / 13 ⚠️ / 33 ❌ = 157**;
|
||||
full suite + ASan + clang-format + cppcheck clean.
|
||||
|
||||
11. **No-wire parity track 1** on `feat/parity-2.28` (no protocol change):
|
||||
`-n --delete` now sends the same filter-excluded + size-pruned protected
|
||||
prefixes and synchronized-directory scope as a real run (dry-run would-delete
|
||||
matches rsync for source-derived protections; the destination-only exclude
|
||||
residual and readdir ordering remain); `--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 deferred-list
|
||||
cap (only partial-delete ordering remains); and `--info=name2` emits `NAME is
|
||||
uptodate` plus the leading `./` root name line for `--info=name` (only the
|
||||
root-line trigger condition and receiver-side `skip` wording remain). Matrix
|
||||
now **111 ✅ / 14 ⚠️ / 32 ❌ = 157**; differential + unit tests added in
|
||||
`test_features.py`, `test_option_parity.py`, `test_delete_plan.c`,
|
||||
`test_delete_delay_budget_parity.py`, `test_delete_timing_parity.py`.
|
||||
12. **No-wire parity track 2b** on `feat/parity-2.28` (no protocol change):
|
||||
`--progress`/`-P`/`--info=progress` (when not `--quiet`) now run an opt-in
|
||||
paths-only metadata pre-count (no file reads/hashing) that supplies rsync's
|
||||
full file-list total for the `to-chk` denominator and the directory names,
|
||||
and emits per-directory/symlink/special name lines, in both the sequential
|
||||
and `--threads` paths. `--delete-during`/`--delete-delay` reuse their
|
||||
keep-set pre-scan instead of a second walk; non-progress runs are
|
||||
unaffected. Differential tests (`progress`/`progress_threads` over a new
|
||||
`multidir` corpus) match rsync's name set and `to-chk` denominator on a
|
||||
fresh transfer, and the single-file byte-identical test still passes;
|
||||
emission order (rsync's sorted depth-first vs FastSync's readdir/BFS stream)
|
||||
plus re-run over-naming (unconditional `./`, ancestor dirs named with a
|
||||
transferred child, and no quick-check for symlinks/empty dirs) remain the
|
||||
caveats, so the row stays ⚠️ and the matrix is unchanged at
|
||||
**111 ✅ / 14 ⚠️ / 32 ❌ = 157**.
|
||||
7. **Parity-completion wave (protocol 2.24.0 → 2.26.0)** on `feat/parity-completion`: per-directory delete plans (`STATUS_DELETE_PLAN`) for `--delete-during`/`--delete-delay`; receiver `STATUS_STATS` counters feeding `--stats`/`--progress` and `--out-format %b/%c/%C`, plus `-n --delete` lines; `lz4`/`zlib`/`zlibx` compression and `md4`/`sha1`/`none` checksums with `auto` negotiation (default `xxh128`/`zstd`); general `-R`/`--no-implied-dirs`/`-d`; the full filter grammar (`merge`/`dir-merge`/`hide`/`show`/`protect`/`risk`/`clear` + modifiers) and corrected `-F`/`-FF`; receiver-side `--chown`/map TO-name resolution; absolute basis dirs + `--link-dest` relink; receiver-side `--ignore-existing` short-circuit; `--preallocate` over `--sparse` via `fallocate(2)`; `--iconv=.`/`-`/`--no-iconv`; lone `-h` help; aliases `--ignore-non-existing`/`--protect-args`/`--msgs2stderr`; and the full `--info`/`--debug` vocabulary. `RSYNC_COMPAT.md` reclassifies the matrix to 106 ✅ / 27 ⚠️ / 23 ❌.
|
||||
|
||||
## Next steps
|
||||
1. **Merge PR #284** (`dev` -> `main`) once reviewed (protected branch).
|
||||
|
||||
@@ -117,12 +117,9 @@ matrix is classified as parity, caveat, or divergent in
|
||||
per-type `Number of files` breakdown is not reproduced. `--progress` prints
|
||||
rsync-style per-file blocks (without rsync's leading `./` line).
|
||||
- Codecs match rsync 3.4.1: `zstd`/`lz4`/`zlib`/`zlibx` compression and
|
||||
`xxh128`/`xxh3`/`xxh64`/`md5`/`md4`/`sha1`/`none` checksums. `auto` honors
|
||||
`RSYNC_COMPRESS_LIST`/`RSYNC_CHECKSUM_LIST` and otherwise follows rsync's
|
||||
compiled-in order. An omitted `--compress-level` uses the codec's rsync
|
||||
default (zstd 3, zlib/zlibx 6, lz4 ignored); `zlib`/`zlibx` share the
|
||||
literal-only zlib path (rsync's zlibx semantics), and the transfer checksum is
|
||||
not separately selectable.
|
||||
`xxh128`/`xxh3`/`xxh64`/`md5`/`md4`/`sha1`/`none` checksums, negotiated with
|
||||
`auto`; `zlibx` behaves as `zlib`, and the transfer checksum is not separately
|
||||
selectable.
|
||||
|
||||
The detailed flag matrix is maintained in
|
||||
[`RSYNC_COMPAT.md`](RSYNC_COMPAT.md). It reports each row as **parity**,
|
||||
@@ -508,8 +505,8 @@ features without changing the meaning of ordinary compatibility options.
|
||||
|---|---|
|
||||
| `-j`, `--threads[=N]` | Enable the multithreaded scanner/loader/sender pipeline. `N` (1–256) sets the parallel scanner worker count; bare `-j`/`--threads` uses the default. |
|
||||
| `-z [level]`, `--compress [level]` | Enable streaming compression (default `zstd`), levels 1-22. |
|
||||
| `--compress-level <n>` | Set the compression level (1-22). Omitted, each codec uses its rsync default: zstd 3, zlib/zlibx 6, lz4 ignored. |
|
||||
| `--zc <alg>` | Alias for `--compress-choice`. FastSync supports `zstd` (default), `lz4`, `zlib`, `zlibx`, `none`, and `auto`; `zlib`/`zlibx` share the same literal-only zlib path. |
|
||||
| `--compress-level <n>` | Set the compression level. |
|
||||
| `--zc <alg>` | Alias for `--compress-choice`. FastSync supports `zstd` (default), `lz4`, `zlib`, `zlibx`, `none`, and `auto`; `zlibx` behaves as `zlib`. |
|
||||
| `--zl <n>` | Alias for `--compress-level`. |
|
||||
| `--skip-compress <list>` | Skip compression for `/`- or `,`-separated suffixes; defaults to rsync 3.4.1's built-in list. Incompatible with `--chunk-serialization`. |
|
||||
| `--compress-threads <n>` | Use `n` zstd compression workers. Requires compression and a zstd build with threaded support; the setting affects sender CPU work only. |
|
||||
@@ -792,7 +789,7 @@ before the module list, before authentication, and the connecting peer address
|
||||
|
||||
## Protocol and Security
|
||||
|
||||
FastSync protocol version `2.27.0` is shared by the client and server. The
|
||||
FastSync protocol version `2.26.0` is shared by the client and server. The
|
||||
current protocol is sender-driven and includes configuration negotiation,
|
||||
including the maximum allocation limit, incremental checks, checksums,
|
||||
manifests, keep-alives, abort handling, per-file remove-source results, and
|
||||
|
||||
+46
-74
@@ -6,10 +6,10 @@ This document maps rsync's full feature set to FastSync's current implementation
|
||||
|
||||
| Status | Count | Description |
|
||||
|--------|-------|-------------|
|
||||
| ✅ Parity | 113 | Reproduces rsync's semantics for this option's scope |
|
||||
| ⚠️ Caveat | 12 | 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 |
|
||||
| **Total** | **157** | One row per rsync option/feature group; a row may name several spellings |
|
||||
| ✅ Parity | 106 | Reproduces rsync's semantics for this option's scope |
|
||||
| ⚠️ Caveat | 27 | Wired and tested, but carries a documented behavioral difference from rsync (named in the row and/or the wave notes) |
|
||||
| ❌ Divergent | 23 | 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** | **156** | 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
|
||||
"parsed". A ✅ row matches rsync for the option's scope. A ⚠️ row is real and
|
||||
@@ -21,28 +21,6 @@ the batch container, `--fake-super`'s xattr format, `--copy-as` credential
|
||||
switching), or impossible (`-N`/`--crtimes`). The counts are derived from the
|
||||
rows below; update them together with the table.
|
||||
|
||||
**Option wave (protocol 2.26.0 → 2.27.0).** A differential pass against rsync
|
||||
3.4.1 over the remaining option caveats. `--bwlimit` now parses rsync's units
|
||||
exactly and paces like rsync's leaky bucket; `--ignore-errors` reproduces
|
||||
rsync's default (an I/O error skips deletion unless the flag is set, while the
|
||||
readable tree still transfers and the run exits 23); the `--info` categories
|
||||
that map to a FastSync event (`name`, `flist`, `del`, `remove`, `nonreg`,
|
||||
`progress`) now emit rsync's line format, including real-run `deleting PATH` /
|
||||
`*deleting` lines carried over a new `report_deletes` wire bool; and two
|
||||
genuinely non-interoperable residuals are reclassified divergent (`-M` over a
|
||||
daemon/TCP connection, which FastSync's binary config handshake has no argv
|
||||
channel for, and receiver-side `protect`/`risk` re-derivation for
|
||||
destination-only entries, which would need a receiver filter engine). After the
|
||||
combined `fix/parity-stats` + `fix/parity-options` + `fix/parity-fs` passes (and
|
||||
the later `fix/parity-review` correction that moved `--delete-delay` to ⚠️) the
|
||||
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 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 3a (no-wire, on `feat/parity-2.28`).** Three codec refinements, none of which change the wire layout (`PROTOCOL_VERSION` stays 2.28.0): (1) an omitted `--compress-level` now resolves to rsync 3.4.1's per-codec default (zstd 3, zlib/zlibx 6, lz4 ignored) and an explicit level is clamped per codec (zstd 1-22, zlib/zlibx 1-9), verified against `rsync --debug=NSTR1`; (2) `auto` now honors `RSYNC_COMPRESS_LIST`/`RSYNC_CHECKSUM_LIST` (rsync's whitespace-separated preference syntax, unknown names skipped, first supported wins, all-unknown is exit 4) before the compiled-in order, with an explicit `--zc`/`--cc` still winning; and (3) `--compress-choice=zlibx` is reclassified out of the caveat list — FastSync's zlib stream already carries only the literal/delta bytes (rsync's zlibx semantics) and is observably identical to `--zc=zlib`, so the zlib/zlibx aliasing is only an implementation detail. The matrix is now **113 ✅ / 12 ⚠️ / 32 ❌ = 157**.
|
||||
|
||||
**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
|
||||
output, codec breadth, general `-R`/`-d`, the full filter grammar, receiver-side
|
||||
@@ -73,7 +51,7 @@ Every one of those has an entry below with its remaining caveats.
|
||||
| `-q`, `--quiet` | Suppress non-error messages | ✅ Parity | Suppresses client output while preserving errors |
|
||||
| `--help` | Show help | ✅ Parity | Prints usage and exits. A lone `-h` with no other transfer arguments also prints help (protocol 2.26.0), matching the rsync idiom; `-h` alongside a transfer keeps its rsync meaning of `--human-readable` (see that row) |
|
||||
| `-V`, `--version` | Print version | ✅ Parity | |
|
||||
| `--info=FLAGS` | Fine-grained info verbosity | ⚠️ Caveat | Accepts rsync 3.4.1's full `--info` vocabulary — `backup`, `copy`, `del`, `flist`, `misc`, `mount`, `name`, `nonreg`, `progress`, `remove`, `skip`, `stats`, `symsafe`, `all`, `none` — with optional level suffixes (`--info=stats2`), so a valid rsync invocation is never rejected up front. Protocol 2.27.0 wires the categories that map to a real FastSync event, matching rsync's line format: `name` prints the updated entry names (with the ` -> target` link suffix), `flist` prints `sending incremental file list`, `del` prints `deleting PATH` (or `*deleting PATH` under `-i`/`--out-format`) for both dry-run would-delete and real deletions (real runs carry the removed paths over the new `report_deletes` wire bool), `remove` prints `sender removed PATH`, `nonreg` prints `skipping non-regular file "NAME"`, `progress` drives the per-file progress output, and `copy`/`misc`/`skip`/`stats` keep their existing channels. `none` suppresses info output, explicit flags override `--verbose`, and a genuinely unknown name is still rejected by name (matching rsync). **Fixed (no-wire):** `--info=name2` (and higher) also prints rsync's `NAME is uptodate` lines for entries the receiver already has, and `--info=name` emits the leading transfer-root `./` name line before the first transferred entry (the marker rides in the existing `info_level` bitset; differential tests vs rsync 3.4.1). **Caveat:** the root `./` line is emitted before the first transferred name rather than keyed off rsync's root-attribute-change decision, so a pre-existing root that rsync leaves untouched can differ; the categories with no client-observable event stay accepted-but-silent — `symsafe`, `mount`, and `backup` (the backup happens on the receiver, which FastSync's protocol does not echo back); and `skip` maps to FastSync's sender-side skip logging rather than rsync's receiver-side "not creating new file" lines |
|
||||
| `--info=FLAGS` | Fine-grained info verbosity | ⚠️ Caveat | Protocol 2.26.0 accepts rsync 3.4.1's full `--info` vocabulary — `backup`, `copy`, `del`, `flist`, `misc`, `mount`, `name`, `nonreg`, `progress`, `remove`, `skip`, `stats`, `symsafe`, `all`, `none` — with optional level suffixes (`--info=stats2`), so a valid rsync invocation is never rejected up front. The categories that map to a FastSync channel emit (`copy`, `name`, `misc`, `skip`, `stats`); the remaining rsync categories are accepted silently, with no output. `none` suppresses info output, explicit flags override `--verbose`, and a genuinely unknown name is still rejected by name (matching rsync). **Caveat:** many accepted rsync categories produce no output (e.g. `del`, `flist`, `remove`, `progress`, `symsafe`, `mount`, `nonreg`, `backup`), so e.g. `--info=progress` is accepted for CLI compatibility only; `name` maps to the `copy` channel rather than rsync's per-file name output |
|
||||
| `--debug=FLAGS` | Fine-grained debug verbosity | ⚠️ Caveat | Protocol 2.26.0 accepts rsync 3.4.1's full `--debug` vocabulary with optional level suffixes. FastSync emits for its own channels (`io`, `proto`, `pack`, `util`, plus the aliases `hl`/`owner`); the rsync-only categories (`acl`, `filter`, `send`, ...) are accepted silently. `--debug=help` lists the flags; a genuinely unknown name is rejected by name. **Caveat:** most accepted rsync categories produce no output (e.g. `acl`, `filter`, `send`, `flist`, `del`, `deltasum`, `hash`, `recv`, `time`), so they are accepted for CLI compatibility only |
|
||||
| `--stderr=MODE` | Change stderr output mode | ❌ Divergent | `errors` (default) and `all` are supported; `client` is rejected with a clear error (`--stderr=client is not supported`) because FastSync has no rsync client-message channel — the rejection itself is the documented behavior (Phase 7 Wave B decision). The modes that exist work; the missing rsync channel cannot be emulated without a wire change |
|
||||
| `--msgs2stderr`, `--no-msgs2stderr` | Deprecated `--stderr` aliases | ⚠️ Caveat | `--msgs2stderr` maps to `--stderr=all` (supported, matching rsync). `--no-msgs2stderr` is rsync's spelling of `--stderr=client`, which FastSync has no client-message channel for, so it maps to the errors-only default instead of reproducing rsync's client mode. See `--stderr=MODE` |
|
||||
@@ -86,12 +64,12 @@ Every one of those has an entry below with its remaining caveats.
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--stats` | Give transfer stats | ⚠️ Caveat | Prints transfer statistics. Protocol 2.25.0 populates the receiver-only counters the sender cannot observe (`Matched data`, `Number of deleted files`) from the receiver's `STATUS_STATS` report; the sender tracks the scanned file list per type so `Number of files` carries rsync's `(reg: X, dir: Y, link: Z, special: W)` breakdown (directories come from the scanner's captured directory list, present for `-a`/`-t`/`-p`), `Number of regular files transferred` excludes symlinks/specials and up-to-date files, `Total file size` includes symlink target lengths, and `Total transferred file size` counts only transferred files. **Protocol 2.28.0 extends `STATUS_STATS`** with receiver-observed `literal_bytes` and the four `created_*` counters: `Number of created files` now carries rsync's `(reg/dir/link/special)` breakdown (the receiver reports which destination entries it newly created, including implicitly-created parent directories below the transfer root) and `Literal data` is exact for a delta transfer (the receiver counts the literal fragments it stored, not the whole source size) — all differential-tested in the sequential and `--threads` paths against rsync 3.4.1 for fresh-create, update and delta shapes. **Remaining divergences:** a recursive scan that preserves no directory attribute (`-r` without `-t`/`-p`) captures no directory entries, so the `dir:` category is then omitted from `Number of files`; rsync's per-type breakdown on `Number of deleted files` is not reproduced; and `Total bytes sent`/`received` are FastSync wire bytes framed differently from rsync's, so they are not numerically comparable |
|
||||
| `--stats` | Give transfer stats | ⚠️ Caveat | Prints transfer statistics. Protocol 2.25.0 populates the receiver-only counters the sender cannot observe: `Matched data` (a delta basis's reused bytes) and `Number of deleted files` come from the receiver's `STATUS_STATS` report, and the protocol-independent lines (regular files transferred, total/transferred file size, literal data, matched data, deleted files, file-list size) match rsync exactly in both the sequential and `--threads` paths. **Remaining divergence:** rsync prints `Number of files` and `Number of created files` with a per-type breakdown (`(reg: X, dir: Y, link: Z)`); FastSync prints the bare transferred-entry count because its scanner does not put directory entries in the transfer list and the sender cannot tell which entries the receiver newly created. `Total bytes sent`/`received` are FastSync wire bytes and are not numerically comparable to rsync's |
|
||||
| `-h`, `--human-readable` | Human-readable numbers | ✅ Parity | Formats transfer byte and rate counts using rsync's **decimal** (base-1000) units, matching rsync `-h` (e.g. `1.23M`), not binary units. **A lone `-h` with no transfer arguments prints help instead** (protocol 2.26.0), matching the rsync idiom; `-h` alongside a transfer remains human-readable |
|
||||
| `-i`, `--itemize-changes` | Per-file change summary | ✅ Parity | Prints rsync-style `>f+++++++++` lines to stdout only for files actually sent (also under `-j`/`--threads`); unchanged files print nothing, matching single-`-i` behavior |
|
||||
| `--progress` | Show progress | ⚠️ Caveat | Protocol 2.25.0 prints rsync-style per-file progress blocks (percent, transferred/total bytes, rate, elapsed, `(xfr#N, to-chk=M/T)`) fed by the receiver's `STATUS_STATS`, in both the sequential and `--threads` send paths. FastSync also prints rsync's leading `./` transfer-root line and, when progress is requested (`--progress`/`-P`/`--info=progress`) and not `--quiet`, runs a **paths-only metadata pre-scan** (no file reads, no hashing) that supplies rsync's file-list total `T` for the `to-chk` denominator and the directory names; `--delete-during`/`--delete-delay` reuse their existing keep-set pre-scan instead of walking twice, and non-progress runs are untouched. Per-directory name lines are emitted (trailing `/`), and symlink (` -> target`) and special entries are named too, so a **fresh multi-directory tree's name set and `to-chk` denominator match rsync 3.4.1** (differential test, sequential and `--threads`) and a **single-file transfer's name lines and deterministic frames remain byte-identical** to rsync. **Remaining divergences:** rsync emits entries in sorted depth-first order while FastSync streams them in the scanner's readdir/BFS order, so the interleaving and the `to-chk` numerator differ (the denominator matches); the leading `./` root line is emitted unconditionally rather than keyed off rsync's root-attribute-change decision, and an ancestor directory line is emitted whenever a child transfers (rsync suppresses it when the directory itself is unchanged); on a re-run, entries without a quick-check (symlinks, empty directories) are still named where rsync stays silent; and the rate/ETA are wall-clock dependent |
|
||||
| `--progress` | Show progress | ⚠️ Caveat | Protocol 2.25.0 prints rsync-style per-file progress blocks (percent, transferred/total bytes, rate, elapsed, `(xfr#N, to-chk=M/T)`) fed by the receiver's `STATUS_STATS`, in both the sequential and `--threads` send paths; the first frame for a sub-32 KiB file is byte-identical to rsync. **Remaining divergences:** FastSync does not print rsync's leading `./` whole-transfer line, its `to-chk` total differs by the source-root entry (the scanner does not emit the root directory as a transfer entry), and the rate/ETA are wall-clock dependent, so only the first frame is pinned against rsync |
|
||||
| `-P` | Same as --partial --progress | ✅ Parity | Parses to `--partial` + `--progress`. The independent `--partial` retention semantics are rsync parity: an interrupted write retains the already-written temp at the destination (best-effort) so a later `--append`/`--append-verify` can resume. Progress presentation is owned by the `--progress` row; there is no separate `-P` divergence |
|
||||
| `--out-format=FORMAT` | Custom output format | ❌ Divergent | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%c` `%C` `%i` `%M` `%o` `%U` `%G` `%t` `%%`. `%C` now uses the negotiated transfer algorithm (`--checksum-choice`, default `xxh128`, seed 0) and renders every algorithm exactly like rsync — xxh128 high-then-low, xxh64/xxh3 big-endian, md5/md4/sha1 standard hex, `none` a blank 2-char column — differential-tested across all algorithms. `%f`/`%n`/`%l`/`%i`/`%M`/`%U`/`%G`/`%B` also match. **Reclassified because `%b`/`%c` are protocol-specific and cannot match:** a differential against rsync 3.4.1 shows whole-file `%c = 16` for both, but rsync whole-file `%b = filesize + 27 + transfer-digest-bytes` (39 for a 0-byte file; 43/35/47 for xxh128/xxh64/sha1 on a 12-byte file) while FastSync `%b` counts its own framing; in delta mode rsync `%c = 16 + 6·ceil(filesize/block_size)` (verified at block sizes 512/700/1024/2048) while FastSync counts its own signature handshake, and rsync `%b` is its token stream. FastSync's wire bytes are a different quantity, so exact `%b`/delta-`%c` equality is impossible |
|
||||
| `--out-format=FORMAT` | Custom output format | ⚠️ Caveat | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%c` `%C` `%i` `%M` `%o` `%U` `%G` `%t` `%%`. Protocol 2.25.0 adds the wire counters: `%C` is the whole-file digest (default `xxh128`, seed 0), so `%C %l %n` matches rsync byte-for-byte for a whole-file transfer. **Remaining divergences:** `%b` counts FastSync's own wire bytes (framing and checksum trailer), not rsync's protocol-specific count, so the two are not numerically equal; `%c` matches rsync's 16-byte block-sum header for whole-file transfers but differs in delta mode (each counts its own handshake bytes). **Also:** when `--checksum-choice=xxh64` is selected explicitly, `%C` still prints an xxh128 digest rather than the selected xxh64 (`change_list.c:208-220`) |
|
||||
| `--log-file=FILE` | Log to file | ✅ Parity | `log_file` config field |
|
||||
| `--log-file-format=FMT` | Log format | ✅ Parity | Requires `--log-file`; writes one template line per transferred file using the same token set as `--out-format` (including `%b` as the wire byte count) |
|
||||
| `--8-bit-output`, `-8` | Leave high-bit chars unescaped | ✅ Parity | Applies to displayed paths and protocol debug output |
|
||||
@@ -103,7 +81,7 @@ Every one of those has an entry below with its remaining caveats.
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--exclude-from=FILE` | Read exclude patterns from file | ✅ Parity | Reads patterns from file |
|
||||
| `--include-from=FILE` | Read include patterns from file | ✅ Parity | Reads patterns from file |
|
||||
| `--filter=RULE` | Add file-filtering rule | ❌ 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 | ⚠️ Caveat | The short `-f` **is** bound to `--filter` (the old FastSync sendfile conflict is gone; sendfile is long-only `--sendfile`), and `-f RULE`, `-f=RULE`, `--filter=RULE` and the two-argument form all parse. Protocol 2.26.0 implements rsync's filter grammar: `+`/`-`, `include`/`exclude`, a leading `/` anchor (to the transfer root or a `.rsync-filter` file's directory), a trailing `/` dir-only rule, and the `merge`/`.`, `dir-merge`/`:`, `hide`/`H`, `show`/`S`, `protect`/`P`, `risk`/`R` and `clear`/`!` words, including the `:`/`.` modifiers. First match wins; the filter layer is independent of `--exclude`/`--include`. **Remaining divergence:** the receiver-mirror protection a `protect`/`risk` rule produces is derived from the sender's source traversal, so a rule that would match only a destination-only entry is not re-derived on the receiver; destination-only deletion protection continues to come from the ordinary sender-derived protected-prefix mechanism |
|
||||
| `--files-from=FILE` | Read source file list from file | ✅ Parity | Entries are paths relative to the source root (leading `./` stripped, `..`/absolute rejected at parse time, blank lines ignored; NUL-delimited with `-0`). A listed regular file is transferred; a listed directory transfers its whole subtree (FastSync recursion is always on). Non-listed paths are pruned by the scanner; the delete manifest is scoped to the listed directory subtrees. A listed entry that does not exist is a hard error unless `--ignore-missing-args`/`--delete-missing-args` is given. **An empty list is a zero-transfer success (exit 0), matching rsync 3.4.1** — the earlier claim that rsync reports "no source files specified" was wrong. Scalability note: `file_list_affects` is O(list size) per scanned entry, so a very large list against a huge tree is quadratic (the documented bound) |
|
||||
| `-0`, `--from0` | Delimit *-from files with NULs | ✅ Parity | `--files-from` entries become NUL-delimited; the flag may appear before or after `--files-from` on the command line. NUL mode preserves entry bytes exactly (trailing CR/LF are part of the name; only newline mode trims them) |
|
||||
| `--max-size=SIZE` | Skip files larger than SIZE | ✅ Parity | `max_size` in scanner |
|
||||
@@ -123,8 +101,8 @@ Every one of those has an entry below with its remaining caveats.
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-r`, `--recursive` | Recurse into directories | ✅ Parity | Default behavior |
|
||||
| `-R`, `--relative` | Use relative path names | ✅ Parity | Protocol 2.26.0 implements rsync's general `-R` path semantics: without a cut the source argument is mirrored in full below the destination root; a `/./` cut in the source argument (`src/./foo`) makes everything after the cut the destination prefix, so the layout matches rsync's relative reconstruction; and `--files-from` entries land under their bare relative path. The delete manifest derives from the sent (relative) paths and is scoped to the transferred prefix subtree, so `--delete` cannot remove destination content outside that prefix (a blocker fix). Works single-threaded and under `-j`/`--threads` |
|
||||
| `--no-implied-dirs` | Don't send implied dirs with -R | ✅ Parity | With `-R`, rsync creates the ancestor directories implied by a listed path and, with `--no-implied-dirs`, omits their attributes from the transfer so they keep the destination's own state (or are created with default attributes when absent). Protocol 2.26.0 matches this: without `--files-from` the implied-dir walk applies per-attribute metadata only to explicitly transferred directories, and with `-R --files-from` a listed file whose parent is not itself listed is placed normally — the missing implied parent is created with default attributes (not the source's) and the file transfers with `rc 0`, exactly like rsync 3.4.1 (a differential test verifies the modes and mtimes with and without the flag). Works single-threaded and under `-j`/`--threads` |
|
||||
| `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ✅ Parity | Protocol 2.26.0 implements rsync's one-level `-d` listing for `dir`, `dir/` and `.`: the source's immediate contents are transferred (files with content, directories as explicit entries), matching rsync's destination tree in a differential test. `--dirs --files-from` transfers exactly the listed items — a listed directory is created empty and a listed file with content — under the same `-R` layout rules. A plain recursive scan also recreates empty source directories now: the scanner emits a payload-less directory entry (with metadata) for every traversed directory that produced no transferred or descended child, unless `-m/--prune-empty-dirs` suppresses it or the run is `--files-from`/`--list-only` (a directory emptied by filtering is recreated too, matching rsync). Directory entries cross as `STATUS_MKDIR` and appear in the delete manifest, so `--delete` prunes correctly and an empty listed directory survives; an incoming directory replaces a destination regular file (rsync removes the non-directory and creates the directory), verified differentially. Directory times are applied at the end of the transfer; modes/ownership follow the per-attribute policy. Under `--delay-updates` directories are created immediately while only regular files are staged, exactly as rsync does |
|
||||
| `--no-implied-dirs` | Don't send implied dirs with -R | ✅ Parity | With `-R`, rsync creates the ancestor directories implied by a listed path and, with `--no-implied-dirs`, omits them from the transfer so the destination directories keep the destination's own mode/mtime. Protocol 2.26.0 matches this: the implied-dir walk applies the transfer's per-attribute metadata only to explicitly transferred directories, and a differential test verifies the modes and mtimes of the implied parents against rsync with and without the flag. Works single-threaded and under `-j`/`--threads` |
|
||||
| `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ⚠️ Caveat | Protocol 2.26.0 implements rsync's one-level `-d` listing for `dir`, `dir/` and `.`: the source's immediate contents are transferred (files with content, directories as explicit entries), matching rsync's destination tree in a differential test. `--dirs --files-from` transfers exactly the listed items — a listed directory is created empty and a listed file with content — under the same `-R` layout rules. Directory entries cross as `STATUS_MKDIR` and appear in the delete manifest, so `--delete` prunes correctly and an empty listed directory survives. Directory times are applied at the end of the transfer; modes/ownership follow the per-attribute policy. **Remaining divergence:** a plain recursive `-a` scan still does not create empty source directories (directory entries are record-only unless `-d`/`--files-from` explicitly lists a directory), and under `--delay-updates` directories are created immediately while only regular files are staged (see the recursive-empty-directory residual in the completion-wave section) |
|
||||
| `--mkpath` | Create missing path components | ✅ Parity | Wire option (client → server). At connection start the server creates the client's destination root directory (and any missing leading components below its own authorized root) when `--mkpath` is set, failing the connection cleanly if it cannot. Without `--mkpath` a destination root that does not exist yet is rejected up front (rsync semantics), so the flag is the only way to transfer into a not-yet-created destination directory. Creation is confined by the same secure mkdir walk as file writes (`O_NOFOLLOW`, no `..`) |
|
||||
| `--inc-recursive`, `--no-inc-recursive` | Incremental recursion mode | ❌ Divergent | rsync's man-page-only scanning-mode switch (and its short aliases). FastSync always performs a single full recursive scan, so both spellings are rejected as unknown options rather than accepted as a no-op; there is no incremental-recursion engine to toggle. A genuine implementation would be a scan-architecture change with no benefit for FastSync's push model |
|
||||
|
||||
@@ -144,12 +122,12 @@ Every one of those has an entry below with its remaining caveats.
|
||||
|
||||
| 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. **Remaining divergences:** the `*deleting` line ordering can differ from rsync's delete-during walk, and a filtered dry-run can over-report what the real commit would remove |
|
||||
| `-b`, `--backup` | Make backups of overwritten files | ✅ Parity | Backup before overwrite |
|
||||
| `--backup-dir=DIR` | Backup directory hierarchy | ✅ Parity | `backup_dir` config field |
|
||||
| `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Parity | `suffix` config field |
|
||||
| `--delay-updates` | Put updated files in place at end | ❌ Divergent | Successfully received files are staged under a private 0700 `.fastsync-stage` dir inside the receive root and atomically renamed into their final destinations only after the whole transfer (manifest/delete handling included) succeeds, just before the success/outcome frame is sent. The delete walker deliberately skips the staging dir at the receive root, so `--delete` removes genuine extras but never the staged files (deletion runs before publication; rsync's delete-after ordering is not implemented). `--existing`/`--ignore-existing`/`--update` decide against the final destination path at stage time; `--backup` moves the old file aside at publication, and **`--force` is honored at publication** (protocol 2.23.0): a staged regular file or symlink may replace a destination directory that blocks it. Incompatible with `--inplace` and with `--backup-dir=.fastsync-stage` (the internal staging name is reserved; both are rejected). The staging dir name is fixed, so two simultaneous delayed transfers to the same destination root are serialized with an exclusive advisory lock held for the whole transfer: the second session fails cleanly instead of corrupting the first. Aborting or failing before publication installs nothing and removes the staging tree; a crash between stage and publish leaves staged leftovers that the next delayed run wipes at start (process death releases the lock). A stage→publish failure aborts the transfer (best-effort cleanup of the not-yet-published staged files; already-published files are not rolled back). **Reclassified Divergent (differential evidence):** the staging name is fixed and a delayed run wipes a pre-existing destination tree of that name at start even without `--delete`, whereas rsync uses its own internal temp name and leaves a genuine destination entry named `.fastsync-stage` untouched (`test_delay_updates_staging_name_collision_residual`); deletion also runs before publication while rsync's `--delay-updates` implies `--delete-after`. Works in single-threaded and `-j`/`--threads` modes |
|
||||
| `-T`, `--temp-dir=DIR` | Create temporary files in DIR | ❌ Divergent | `--temp-dir` with the rsync short `-T` (the timeout alias moved to long-only `--timeout`). A **relative** dir matches rsync exactly: it is resolved below the receive/destination root and must already exist (differentially verified: `rsync -a --temp-dir=scratch src/ dst/` and FastSync produce identical trees and an empty scratch dir). **Reclassified as a deliberate divergence because an absolute `--temp-dir` is rejected by the receiver** — it is resolved verbatim by rsync standalone (which will use `/tmp` or any other absolute directory, including one outside the destination), but FastSync's security-reviewed receiver confines the scratch dir to the authorized receive root and rejects any absolute path or one containing `..`. A differential test confirms rsync exits 0 using an absolute scratch dir while FastSync refuses before writing anything into it (the scratch dir stays empty). Its daemon mode also confines relative to the module, but standalone rsync's absolute-temp-dir behavior is not reproduced because it would let a client place receiver scratch files outside the sandbox. Temp copies use a unique name in the scratch dir and are atomically renamed into place; **on `EXDEV` (scratch dir and destination on different filesystems, reachable via a confined relative symlink) the receiver falls back to a non-atomic copy instead of aborting**, matching rsync. `--inplace` and `--partial-dir` writes bypass the scratch dir |
|
||||
| `--delay-updates` | Put updated files in place at end | ⚠️ Caveat | Successfully received files are staged under a private 0700 `.fastsync-stage` dir inside the receive root and atomically renamed into their final destinations only after the whole transfer (manifest/delete handling included) succeeds, just before the success/outcome frame is sent. The delete walker deliberately skips the staging dir at the receive root, so `--delete` removes genuine extras but never the staged files (deletion runs before publication; rsync's delete-after ordering is not implemented). `--existing`/`--ignore-existing`/`--update` decide against the final destination path at stage time; `--backup` moves the old file aside at publication, and **`--force` is honored at publication** (protocol 2.23.0): a staged regular file or symlink may replace a destination directory that blocks it. Incompatible with `--inplace` and with `--backup-dir=.fastsync-stage` (the internal staging name is reserved; both are rejected). The staging dir name is fixed, so two simultaneous delayed transfers to the same destination root are serialized with an exclusive advisory lock held for the whole transfer: the second session fails cleanly instead of corrupting the first. Aborting or failing before publication installs nothing and removes the staging tree; a crash between stage and publish leaves staged leftovers that the next delayed run wipes at start (process death releases the lock). A stage→publish failure aborts the transfer (best-effort cleanup of the not-yet-published staged files; already-published files are not rolled back). Works in single-threaded and `-j`/`--threads` modes |
|
||||
| `-T`, `--temp-dir=DIR` | Create temporary files in DIR | ⚠️ Caveat | `--temp-dir` with the rsync short `-T` (the timeout alias moved to long-only `--timeout`). **Protocol 2.23.0 receiver policy: the scratch dir is confined to the receive root — a relative dir is resolved below it, and an absolute path or one containing `..` is rejected by the receiver** (an absolute/foreign-filesystem scratch dir was the divergence; rsync's standalone mode would follow an absolute `--temp-dir`, while its daemon also confines). Temp copies use a unique name there and are atomically renamed into place. **On `EXDEV` (scratch dir and destination on different filesystems) the receiver falls back to a non-atomic copy instead of aborting the transfer**, matching rsync. `--inplace` and `--partial-dir` writes bypass the scratch dir |
|
||||
| `--partial` | Keep partially transferred files | ✅ Parity | On a failed/interrupted write the already-written temp file is retained at the destination path (best-effort rename instead of unlink) so a later `--append`/`--append-verify` run can resume it. Retention never runs when no data was actually written or under `--ignore-existing`/`--existing` (the destination is not ours to overwrite), and it only ever renames the already-written temp. A failed rename falls back to the normal unlink |
|
||||
| `--partial-dir=DIR` | Keep partial files in DIR | ✅ Parity | With `--partial`, the working file is written under the confined partial directory (a relative dir below the receive root) and atomically renamed into place once complete, so an interrupted transfer leaves a resumable copy there and completed transfers do not linger under it. `--inplace` bypasses the partial dir (rsync parity). Requires `--partial` |
|
||||
|
||||
@@ -160,13 +138,13 @@ Every one of those has an entry below with its remaining caveats.
|
||||
| `--delete` | Delete extraneous files from dest | ⚠️ Caveat | `use_delete` config field. Deletion is always derived from the transmitted keep-set manifest of the paths the sender sent/keeps (never from unchecked input), runs through the symlink-safe walker bounded by `MAX_SERVER_DELETE_COUNT`, and skips the `.fastsync-stage` staging dir under `--delay-updates`. FastSync's default timing when no timing flag is given is **delete-after** (extras are removed only once the whole transfer succeeded) — intentionally NOT rsync's `--del`/delete-during default, to preserve FastSync's commit-style safety. By default the destination mirror of a path the source scan pruned (filter/exclude/size rules) is **protected** from deletion — matching rsync, which does not delete excluded files under `--delete`; `--delete-excluded` opts back into deleting them (see below). Deletion is scoped to the **synchronized directories** sent in the manifest (protocol 2.23.0), so a `--files-from` subset no longer deletes untransmitted paths outside the listed directory subtrees. The walk is bounded: a client `--max-delete=NUM` (or the 100000-entry server bound) makes it **partial** — entries up to the bound are removed, the rest are skipped, and the client exits **25** (`RERR_PARTIAL`), matching rsync, rather than failing the transfer. Extraneous destination symlinks are unlinked by name (never followed); a directory still holding a kept/protected entry is left behind rather than failing |
|
||||
| `--delete-before` | Delete before transfer | ⚠️ Caveat | Implies `--delete`. The sender runs a full source pre-scan (paths only) and transmits the keep-set manifest BEFORE any file data; the receiver validates it, removes every destination entry not listed (bounded walk, staging-dir skip, protected prefixes honored), then acks `STATUS_OK`. The sender only starts streaming after the deletion committed, or aborts if the receiver reported a deletion error. By definition the deletions already happened when a later transfer phase fails — rsync's delete-before is destructive the same way; a subsequent failure does not restore the removed files. Divergence: the keep-set is the pre-scan snapshot, so a file that appears on the source between the pre-scan and the data pass is still transferred but was not protected from deletion |
|
||||
| `--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. **Remaining divergence:** exact ordering/abort boundaries can differ from rsync's generator, and `-d` falls back to the end commit. **Also:** the reported "Number of deleted files" can be inflated because a directory snapshotted into the delete plan that later fails to delete (ENOTEMPTY) is still counted (`delete_plan.c:583-593`, `866`, `891`) |
|
||||
| `--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) |
|
||||
| `--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 | ⚠️ Caveat | Sender-side, client-only config field. rsync suppresses `--delete` when the transfer had I/O errors; FastSync's equivalent is a source-scan I/O error (an unreadable directory, e.g. EACCES). By default the scan aborts the run so no deletion happens. With `--ignore-errors` the scan continues past the unreadable directory, the readable tree is transferred, deletion still runs (the unreadable directory's mirror is treated as an extra), and the run exits 23 (`RERR_PARTIAL`), matching rsync. **Remaining divergence / uncertainty:** the EACCES differential is not exercised in CI because the runner is root (mode 000 is still readable), so this rests on source inspection plus the setpriv integration test |
|
||||
| `--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) |
|
||||
| `-m`, `--prune-empty-dirs` | Prune empty dir chains | ✅ Parity | `-m`/`--prune-empty-dirs` (Phase 7 Wave A freed the rsync short `-m`; FastSync multithreading is now `-j`/`--threads`). A recursive transfer now recreates empty source directories by default (rsync parity); this flag suppresses that emission, so an empty directory (physically empty, or emptied by filtering) is not created and a true empty-directory chain is removed by `--delete`, matching rsync's `-m`. It also affects the `--dirs` explicit directory-entry generator: a plain `-d <empty-dir>` run omits the empty source directory's entry, so nothing is created at the destination (no `STATUS_MKDIR`, no `-i`/`--out-format` change line, and an existing empty mirror becomes an extra that `--delete` prunes). Explicitly `--files-from`-listed directories always pass through (documented `--files-from` behavior; `--files-from` runs never emit implicit empty directories). A directory that still holds an excluded-but-protected file survives, matching the `--delete-excluded` default |
|
||||
| `-m`, `--prune-empty-dirs` | Prune empty dir chains | ✅ Parity | `-m`/`--prune-empty-dirs` (Phase 7 Wave A freed the rsync short `-m`; FastSync multithreading is now `-j`/`--threads`). FastSync's recursive transfer records directory times but never CREATES an empty directory (a `STATUS_DIR_TIMES` entry is record-only, and `--dirs` empty entries are pruned by this flag), so empty directories are inherently never transferred (which is rsync's `-m` behavior) and truly-empty destination directory chains are removed by `--delete` regardless of this flag. The flag's additional real effect is on the `--dirs` explicit directory-entry generator: a plain `-d <empty-dir>` run omits the empty source directory's entry, so nothing is created at the destination (no `STATUS_MKDIR`, no `-i`/`--out-format` change line, and an existing empty mirror becomes an extra that `--delete` prunes). Explicitly `--files-from`-listed directories always pass through (documented `--files-from` behavior). A directory that still holds an excluded-but-protected file survives, matching the `--delete-excluded` default |
|
||||
|
||||
**Deletion-timing implementation notes (Phase 3):** the delete flags above are
|
||||
real. Two new config booleans (`delete_during`, `delete_delay`) join the already
|
||||
@@ -310,7 +288,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
||||
| `--write-devices` | Write to devices as files | ❌ Divergent | Writes only into an existing char/block node under the confined receive root (`O_NOFOLLOW` + `O_NONBLOCK`); a missing, symlinked, FIFO-with-no-reader, non-device, or otherwise unusable destination is skipped with a warning rather than allowed or aborted. Deliberate confinement divergence from rsync's more permissive behavior |
|
||||
| `-U`, `--atimes` | Preserve access times | ✅ Parity | Captures the source access time (from the scanner's pre-read stat, so it is not clobbered by reading the file for transfer) and transmits it over the wire; the receiver restores it together with the mtime via `futimens`/`utimensat`. Implies metadata transmission (the times travel inside the shared metadata payload), but does not enable ownership application (that stays opt-in via the identity flags). Wire: `atime` fields on the metadata frame + a `preserve_atimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** |
|
||||
| `-N`, `--crtimes` | Preserve create times | ❌ Divergent | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Divergent** entry (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
|
||||
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Parity | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory (an empty source directory is created by the separate `STATUS_MKDIR` entry the scanner now emits, and `-m/--prune-empty-dirs` suppresses that; the trailing dir-time simply re-applies the metadata). The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Parity | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||
| `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Parity | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||
| `--super` | Receiver attempts super-user activities | ❌ Divergent | Safe-subset privilege model. `--super` permits the receiver to attempt already-confined super-user activities (ownership application, char/block device-node creation, `--write-devices`); `--no-super` forbids them even for root; `auto` keeps the historical best-effort attempt. **FastSync never elevates** — no `setuid`/`seteuid`/`setgid` — and `--super` never bypasses the confinement floor, so it diverges from rsync's real elevation. A server `--no-super` veto forces it off for every connection; a privileged standalone listener defaults off without `--allow-super`; daemon modules opt in with `client owner = yes` |
|
||||
| `--fake-super` | Store/recover privileged attrs via xattrs | ❌ Divergent | Records the resolved `uid:gid:mode:mtime_sec:mtime_nsec` in a reserved `user.fastsync.stat` xattr and immediately replays mode/times fd-relative, but **never performs a real `chown`** (the owner is recorded for a later privileged restore). The on-disk key and format are FastSync-native, not rsync's `user.rsync.%stat%`, so recordings are not interoperable with rsync — the same class as the native auth and batch formats. Implies metadata transmission; incompatible with `-s` |
|
||||
@@ -651,19 +629,19 @@ targets verbatim, matching rsync.
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--checksum` | Skip based on checksum | ✅ Parity | `-c`/`--checksum` compares per-file whole-file content digests to skip unchanged files. **As of protocol 2.23.0 the short `-c` implies the checksum quick-check**, so a plain `-c` run verifies content rather than only affecting the `--incremental` handshake. The digest algorithm is `xxh128` by default (protocol 2.26.0's negotiated default) and is selectable via `--checksum-choice`/`--cc` (`xxh128`/`xxh3`/`xxh64`/`xxhash`/`md5`/`md4`/`sha1`/`none`/`auto`, plus rsync's two-name form) and `--checksum-seed=NUM` (see those rows) |
|
||||
| `--checksum-choice=STR`, `--cc=STR` | Choose checksum algorithm | ⚠️ Caveat | Real algorithm selection for the per-file whole-file digest used by the `--incremental`/`--checksum` handshake and basis-dir verification. **Protocol 2.26.0 accepts rsync 3.4.1's full set** — `xxh128` (the negotiated default), `xxh3`, `xxh64`, `xxhash`, `md5`, `md4`, `sha1`, `none`, `auto`, and the two-name `transfer,pre-transfer` form — with rsync's exit-4 rejection of an unknown name and of `none` on the transfer side when `--checksum` is on. `--cc=ALG` and space forms both parse. The algorithm id and seed cross the wire; the receiver hashes its old file with the same algorithm+seed and the per-file `STATUS_CHECK` handshake carries a bounded digest pinned to the negotiated length. `checksum_digest_file` now streams **every** supported algorithm (md4 via the self-contained RFC 1320 code, sha1/md5 via EVP, none as an empty digest), so the streaming path matches its contract, and `--out-format %C` uses the selected **transfer** half of a two-name choice and renders each algorithm byte-for-byte like rsync (xxh128 high-then-low, xxh64/xxh3 big-endian, md5/md4/sha1 standard hex, none a blank 2-char column) — differential-tested across all algorithms. **Remaining divergence:** rsync uses this choice for the block checksum on the wire too, while FastSync selects only the whole-file comparison digest and keeps the delta BLOCK strong checksum at xxHash32. `auto` now consults `RSYNC_CHECKSUM_LIST` (rsync's whitespace-separated preference list; unknown names skipped, first supported wins, all-unknown is exit 4) before the compiled-in order; because both peers run the identical build this deterministic resolution needs no rsync peer probe, and an explicit `--cc` still wins. The list is differential-tested through `--out-format %C` (byte-identical digests to rsync for md5/sha1/xxh3) |
|
||||
| `--compare-dest=DIR` | Compare dest files relative to DIR | ❌ Divergent | DIR is a receiver-side basis; protocol 2.26.0 uses an absolute path verbatim (rsync semantics) and resolves a relative path below the destination root (`..` components are rejected, `//` collapsed and trailing `/` dropped). On the receiver's per-file check (implies `--incremental`) an exact match = same size + mtime (unless `--size-only`; `-I` disables matching) **and** equal xxHash64 of the sender's file; a match suppresses the data transfer. compare-dest never copies: it only skips a file the destination does **not** already hold (sparse destination, rsync parity), and is consulted before the normal delta/full paths. Repeatable; searched in command-line order, first match wins. **Reclassified Divergent (differential evidence):** FastSync verifies a basis hit's content with xxHash64 while rsync's `--size-only` quick check trusts size (and mtime) alone, so with a same-size/different-content basis rsync skips/links the *wrong* basis content while FastSync transfers the source — a deliberate safety-stricter behavior that cannot match rsync (see `test_basis_dir_size_only_content_residual` in `tests/integration/test_parity_quickwins.py`). Attribute-only differences on a match are also not re-applied (data is skipped so the sender never sends metadata). Sizing: FastSync's whole-file payload limit is 256 MiB on **every** transfer path (not basis-specific); rsync applies basis dirs to arbitrary sizes, so FastSync refuses a basis run whose source contains a larger file up front with a clear error before any transfer. Wire: a basis-count field is always present on the config frame (protocol 2.9.0, so clients and servers must both be 2.9.0) |
|
||||
| `--copy-dest=DIR` | Include copies of unchanged files | ❌ Divergent | Same basis rules as `--compare-dest`, but an exact match materializes a **local copy** of the DIR file into the destination (via the normal atomic temp+rename store path, so `--existing`/`--ignore-existing`/`--update`/`--backup`/`--delay-updates` all still apply) instead of transferring data. Repeatable; command-line order = priority. Content is xxHash64-verified before the copy. **Reclassified Divergent** for the same basis-hit verification divergence as `--compare-dest`: rsync's `--size-only` size/quick-check match rings a same-size/different-content file as unchanged and copies the wrong basis bytes, while FastSync's xxHash verification transfers the source (differential test `test_basis_dir_size_only_content_residual`); a basis-hit also keeps whatever metadata the copy derived from the basis rather than rsync's "copy + fix attributes" in attribute-only cases. Requires `--incremental` (implied); incompatible with `-s`. Wire: protocol 2.9.0 |
|
||||
| `--link-dest=DIR` | Hardlink to files when unchanged | ❌ Divergent | Same basis rules as `--copy-dest`, but an exact match installs an atomic **hard link** to the DIR file (temp hard link + rename) so no data or disk space is used; where the link is impossible (basis on another filesystem, filesystem refuses links) it falls back cleanly to a byte-identical local copy, never a corrupt/partial file. `--delay-updates` stages the link and publishes by rename, so the final entry stays a real hard link. Repeatable (searched in command-line order, first match wins). Content is xxHash64-verified before linking. **Reclassified Divergent** for the shared basis-hit divergence: with `--size-only` a same-size/different-content basis is linked by rsync (installing wrong content) but FastSync detects the xxHash mismatch and transfers the source (differential test `test_basis_dir_size_only_content_residual`). Other inherent caveats: protocol 2.26.0 re-links an already up-to-date destination file to the basis; a link keeps the basis inode's own mode/uid/gid and mtime — metadata is never written through the shared inode (that would mutate the basis file), so a later `--inplace` run that rewrites such a destination path **will mutate the basis snapshot** through the shared inode (use `--copy-dest` when the destination must stay independently writable); a `--remove-source-files` source satisfied by a basis dir is treated as skipped and therefore **retained** (never removed); basis dirs are excluded from `--delete`. Requires `--incremental` (implied); incompatible with `-s`. Wire: protocol 2.9.0 |
|
||||
| `-y`, `--fuzzy`, `--no-fuzzy` | Find similar file for basis | ❌ Divergent | `-y/--fuzzy` is a pure bandwidth optimization on the existing receiver-driven delta path: when a file must be transferred and the destination holds no usable content at the exact path (file absent, or the destination file is outside the delta engine's size bounds), the receiver searches the SAME destination directory for an existing regular file whose basename is similar to the incoming name and uses it as the delta basis, so the sender transmits only the differences instead of the whole file. The output is always byte-exact regardless of which (or whether any) basis is chosen. Decision location: the receiver performs the candidate search inside `receive_incremental_check` and sends the normal `STATUS_DELTA_SIGNATURE`; the sender never learns the basis was a different file, so no new frame type or sender logic was needed — only the config frame grew a `fuzzy` boolean, so `PROTOCOL_VERSION` was bumped **2.8.0 → 2.9.0** (peers must match). Similarity heuristic (deterministic, simpler than rsync's deliberately-fuzzy matching, and documented precisely): candidates are the target's sibling entries in its destination directory, opened `O_NOFOLLOW`/`AT_SYMLINK_NOFOLLOW` under the confined root (symlinks never followed; nothing outside the destination root is ever read or hashed); dotfiles, directories, the target's own name, and the `.fastsync-stage`/temp scratch names are excluded; like the ordinary delta path, the block signature the receiver transmits is derived from on-disk content it may not otherwise send, so a negotiated `--fuzzy` run exposes the destination's sibling files (at block granularity) to the sender as a known-plaintext oracle — the same information class as the normal delta handshake over the file being replaced; the size gate is the delta engine's own bounds (both files ≥ 16 KiB, ≤ `--delta-max`, ratio ≤ 10×) rather than rsync's ~1.5× size window; protocol 2.26.0 uses a name-distance/suffix heuristic modelled on rsync's plus an exact size+mtime pass, and reads a single best candidate; the exact tie-break order can still differ from rsync's; the directory scan is capped at 4096 entries so a pathological directory cannot stall a transfer. When fuzzy applies: only to files the receiver would otherwise send whole — the destination's own file is always preferred as the delta basis when it exists and fits the delta size bounds, so fuzzy does NOT replace an existing-but-different destination basis; FastSync's 10× delta size-ratio bound means an existing destination file that is too far away in size still lets the fuzzy search run. When no similar candidate exists the transfer falls back to the normal whole-file transfer. rsync-divergence note: rsync's own matching uses a fuzzy name/size rule set; FastSync implements the closest safe deterministic approximation above. Because FastSync's delta machinery is off by default (rsync's is on), `--fuzzy` implies `--incremental` + `--delta` (unless `--whole-file`/`-W` or an explicit `--no-delta` switched delta off, in which case fuzzy is inert — matching rsync where `--whole-file` makes fuzzy irrelevant). Unlike the basis-dir options, `--fuzzy` honors an explicit `--no-incremental` (it does not force the handshake back on); an explicit `--no-incremental` also suppresses the delta implication so no invalid `--delta requires --incremental` config results. `--no-fuzzy` negates it. All surrounding semantics are untouched: a fuzzy-reconstructed file is stored as a normal file, so `--remove-source-files`, itemize/`-i`, `--stats`, `--backup`, `--delay-updates`, `--existing`/`--ignore-existing`/`--update` behave exactly as for a whole-file transfer (the fuzzy delta does not skip the file). **Reclassified Divergent:** because the output is always byte-exact, the residual is the candidate-selection heuristic itself — a deterministic name-distance/suffix rule with a 10× size-ratio window (vs rsync's ~1.5× window), not rsync's deliberately fuzzy matcher, so the chosen basis (and thus the wire bytes) can differ from rsync even though the final tree cannot. The Integration fuzzy suite (`TestFuzzy`) pins FastSync's thresholds (exact-size+mtime pass, name-distance rejection, 10× unsuitable-destination fallback); a bit-identical basis choice is not achievable without porting rsync's matcher |
|
||||
| `--checksum-choice=STR`, `--cc=STR` | Choose checksum algorithm | ⚠️ Caveat | Real algorithm selection for the per-file whole-file digest used by the `--incremental`/`--checksum` handshake and basis-dir verification. **Protocol 2.26.0 accepts rsync 3.4.1's full set** — `xxh128` (the negotiated default), `xxh3`, `xxh64`, `xxhash`, `md5`, `md4`, `sha1`, `none`, `auto`, and the two-name `transfer,pre-transfer` form — with rsync's exit-4 rejection of an unknown name and of `none` on the transfer side when `--checksum` is on. `--cc=ALG` and space forms both parse. The algorithm id and seed cross the wire; the receiver hashes its old file with the same algorithm+seed and the per-file `STATUS_CHECK` handshake carries a bounded digest pinned to the negotiated length. **Remaining divergences:** rsync uses this choice for the transfer checksum on the wire as well, while FastSync selects only the whole-file comparison digest and keeps the delta BLOCK strong checksum at xxHash32; the `RSYNC_CHECKSUM_LIST` environment variable is not consulted; and `auto` always resolves deterministically to the first supported entry in rsync's preference order rather than probing the peer |
|
||||
| `--compare-dest=DIR` | Compare dest files relative to DIR | ⚠️ Caveat | DIR is a receiver-side basis; protocol 2.26.0 uses an absolute path verbatim (rsync semantics) and resolves a relative path below the destination root (`..` components are rejected, `//` collapsed and trailing `/` dropped). On the receiver's per-file check (implies `--incremental`) an exact match = same size + mtime (unless `--size-only`; `-I` disables matching) **and** equal xxHash64 of the sender's file; a match suppresses the data transfer. compare-dest never copies: it only skips a file the destination does **not** already hold (sparse destination, rsync parity), and is consulted before the normal delta/full paths. Repeatable; searched in command-line order, first match wins. Divergences: when the destination already holds a *different* version rsync deletes it but FastSync instead transfers the data (keeps the mirror complete; never deletes without `--delete`); attribute-only differences on a match are not re-applied (data is skipped so the sender never sends metadata); content is verified by xxHash64, stricter than rsync's default quick check. Sizing: FastSync's whole-file payload limit is 256 MiB on **every** transfer path (not basis-specific); rsync applies basis dirs to arbitrary sizes, so FastSync refuses a basis run whose source contains a larger file up front with a clear error before any transfer. Wire: a basis-count field is always present on the config frame (protocol 2.9.0, so clients and servers must both be 2.9.0) |
|
||||
| `--copy-dest=DIR` | Include copies of unchanged files | ⚠️ Caveat | Same basis rules as `--compare-dest`, but an exact match materializes a **local copy** of the DIR file into the destination (via the normal atomic temp+rename store path, so `--existing`/`--ignore-existing`/`--update`/`--backup`/`--delay-updates` all still apply) instead of transferring data. Repeatable; command-line order = priority. Content is xxHash64-verified before the copy. Divergences: a basis-hit destination keeps the basis file's own mode/uid/gid and mtime (the sender sends no metadata on a skip), so with `--size-only` its mtime can differ from the source and attribute-only differences are copied with the basis attributes rather than rsync's "copy + fix attributes". Requires `--incremental` (implied); incompatible with `-s`. Wire: protocol 2.9.0 |
|
||||
| `--link-dest=DIR` | Hardlink to files when unchanged | ⚠️ Caveat | Same basis rules as `--copy-dest`, but an exact match installs an atomic **hard link** to the DIR file (temp hard link + rename) so no data or disk space is used; where the link is impossible (basis on another filesystem, filesystem refuses links) it falls back cleanly to a byte-identical local copy, never a corrupt/partial file. `--delay-updates` stages the link and publishes by rename, so the final entry stays a real hard link. Repeatable (searched in command-line order, first match wins). Content is xxHash64-verified before linking. Divergences and caveats: protocol 2.26.0 re-links an already up-to-date destination file to the basis (the relink path installs the hard link when the content matches); a link keeps the basis inode's own mode/uid/gid and mtime — metadata is never written through the shared inode (that would mutate the basis file), so a later `--inplace` run that rewrites such a destination path **will mutate the basis snapshot** through the shared inode (use `--copy-dest` when the destination must stay independently writable); with `--size-only` the linked mtime can differ from the source; a `--remove-source-files` source satisfied by a basis dir is treated as skipped and therefore **retained** (never removed); basis dirs are excluded from `--delete`. Requires `--incremental` (implied); incompatible with `-s`. Wire: protocol 2.9.0 |
|
||||
| `-y`, `--fuzzy`, `--no-fuzzy` | Find similar file for basis | ⚠️ Caveat | `-y/--fuzzy` is a pure bandwidth optimization on the existing receiver-driven delta path: when a file must be transferred and the destination holds no usable content at the exact path (file absent, or the destination file is outside the delta engine's size bounds), the receiver searches the SAME destination directory for an existing regular file whose basename is similar to the incoming name and uses it as the delta basis, so the sender transmits only the differences instead of the whole file. The output is always byte-exact regardless of which (or whether any) basis is chosen. Decision location: the receiver performs the candidate search inside `receive_incremental_check` and sends the normal `STATUS_DELTA_SIGNATURE`; the sender never learns the basis was a different file, so no new frame type or sender logic was needed — only the config frame grew a `fuzzy` boolean, so `PROTOCOL_VERSION` was bumped **2.8.0 → 2.9.0** (peers must match). Similarity heuristic (deterministic, simpler than rsync's deliberately-fuzzy matching, and documented precisely): candidates are the target's sibling entries in its destination directory, opened `O_NOFOLLOW`/`AT_SYMLINK_NOFOLLOW` under the confined root (symlinks never followed; nothing outside the destination root is ever read or hashed); dotfiles, directories, the target's own name, and the `.fastsync-stage`/temp scratch names are excluded; like the ordinary delta path, the block signature the receiver transmits is derived from on-disk content it may not otherwise send, so a negotiated `--fuzzy` run exposes the destination's sibling files (at block granularity) to the sender as a known-plaintext oracle — the same information class as the normal delta handshake over the file being replaced; the size gate is the delta engine's own bounds (both files ≥ 16 KiB, ≤ `--delta-max`, ratio ≤ 10×) rather than rsync's ~1.5× size window; protocol 2.26.0 uses a name-distance/suffix heuristic modelled on rsync's plus an exact size+mtime pass, and reads a single best candidate; the exact tie-break order can still differ from rsync's; the directory scan is capped at 4096 entries so a pathological directory cannot stall a transfer. When fuzzy applies: only to files the receiver would otherwise send whole — the destination's own file is always preferred as the delta basis when it exists and fits the delta size bounds, so fuzzy does NOT replace an existing-but-different destination basis; FastSync's 10× delta size-ratio bound means an existing destination file that is too far away in size still lets the fuzzy search run. When no similar candidate exists the transfer falls back to the normal whole-file transfer. rsync-divergence note: rsync's own matching uses a fuzzy name/size rule set; FastSync implements the closest safe deterministic approximation above. Because FastSync's delta machinery is off by default (rsync's is on), `--fuzzy` implies `--incremental` + `--delta` (unless `--whole-file`/`-W` or an explicit `--no-delta` switched delta off, in which case fuzzy is inert — matching rsync where `--whole-file` makes fuzzy irrelevant). Unlike the basis-dir options, `--fuzzy` honors an explicit `--no-incremental` (it does not force the handshake back on); an explicit `--no-incremental` also suppresses the delta implication so no invalid `--delta requires --incremental` config results. `--no-fuzzy` negates it. All surrounding semantics are untouched: a fuzzy-reconstructed file is stored as a normal file, so `--remove-source-files`, itemize/`-i`, `--stats`, `--backup`, `--delay-updates`, `--existing`/`--ignore-existing`/`--update` behave exactly as for a whole-file transfer (the fuzzy delta does not skip the file) |
|
||||
|
||||
## 12. Compression
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-z`, `--compress` | Compress file data | ✅ Parity | Streaming compression. **Protocol 2.26.0 implements rsync 3.4.1's codec set** (`zstd` default, `lz4`, `zlib`, `zlibx`, `none`), selectable via `--compress-choice`/`--zc` and negotiated with `auto`. `-z` is the compression short form; `-c` is rsync's `--checksum`. `--skip-compress` applies rsync 3.4.1's default suffix list when no list is given. **Track 3a closes the codec caveats:** `zlibx` is no longer a divergence — FastSync's zlib stream already carries only the delta/token (literal) bytes, which is exactly rsync's zlibx semantics, so `--zc=zlib` and `--zc=zlibx` land the same tree/stdout/exit (differential `test_compress_codec_matches_rsync_bytes`) and the zlib/zlibx aliasing is only an implementation detail. Each codec now uses rsync's own default `--compress-level` (zstd 3, zlib/zlibx 6, lz4 ignored) and `auto` consults `RSYNC_COMPRESS_LIST` before the compiled-in order; the deterministic same-build resolution needs no peer probe |
|
||||
| `--compress-choice=STR`, `--zc=STR` | Choose compression algorithm | ✅ Parity | Protocol 2.26.0 accepts rsync 3.4.1's compiled-in choices — `zstd` (default), `lz4`, `zlib`, `zlibx`, `none`, `auto` — and rejects an unknown name with exit 4 like rsync. The negotiated codec id crosses the wire (`compression_algo`), so the receiver decodes with the sender's codec. `--zc` is the alias. `auto` now resolves through `RSYNC_COMPRESS_LIST` (whitespace-separated; unknown names skipped, first supported wins, all-unknown is exit 4) and then the compiled-in order, and an explicit `--zc` wins; the deterministic same-build resolution needs no peer probe. `zlib`/`zlibx` share FastSync's literal-only zlib path, which is rsync's zlibx behavior and is observably identical for both, so `zlibx` is not a divergence (the aliasing is an implementation detail) |
|
||||
| `--compress-level=NUM`, `--zl=NUM` | Set compression level | ✅ Parity | Accepted range 1-22. When omitted, rsync 3.4.1's **per-codec default** applies: zstd 3 (`ZSTD_CLEVEL_DEFAULT`), zlib/zlibx 6 (`Z_DEFAULT_COMPRESSION` resolved), lz4 ignored (no tunable level; FastSync keeps a positive gate value and `lz4_compress` ignores it, so the bytes match rsync). An explicit level is clamped per codec like rsync's `init_compression_level()`: zstd 1-22, zlib/zlibx 1-9, lz4 ignored. Verified against `rsync --debug=NSTR1`, which reports the same effective level per codec |
|
||||
| `-z`, `--compress` | Compress file data | ⚠️ Caveat | Streaming compression. **Protocol 2.26.0 implements rsync 3.4.1's codec set** (`zstd` default, `lz4`, `zlib`, `zlibx`, `none`), selectable via `--compress-choice`/`--zc` and negotiated with `auto`. `-z` is the compression short form; `-c` is rsync's `--checksum`. `--skip-compress` applies rsync 3.4.1's default suffix list when no list is given. **Remaining codec divergence:** `zlibx` is treated as `zlib`, per-codec level defaults are not mirrored, and `auto` does not probe the peer |
|
||||
| `--compress-choice=STR`, `--zc=STR` | Choose compression algorithm | ⚠️ Caveat | Protocol 2.26.0 accepts rsync 3.4.1's compiled-in choices — `zstd` (default), `lz4`, `zlib`, `zlibx`, `none`, `auto` — and rejects an unknown name with exit 4 like rsync. The negotiated codec id crosses the wire (`compression_algo`), so the receiver decodes with the sender's codec. `--zc` is the alias. **Remaining divergences:** `zlibx` behaves as `zlib` (there is no separate zlibx path), the per-codec compression-level defaults differ from rsync's, and `auto` always resolves to the first supported entry in rsync's preference order rather than probing the peer (no `RSYNC_COMPRESS_LIST` handling) |
|
||||
| `--compress-level=NUM`, `--zl=NUM` | Set compression level | ✅ Parity | 1-22, default 5 |
|
||||
| `--compress-threads=NUM` | Set compression threads | ✅ Parity | `compression_threads` config field (client-only; does not cross the wire). Sets the number of worker threads used by the zstd compression pool to NUM (1..64; 0/garbage/oversized rejected up front). Accepted in both `--compress-threads=NUM` and two-argument `--compress-threads NUM` forms. Composes with `-z`/compression; under the `-j`/`--threads` multithreaded pipeline it parallelizes compressed chunk encoding. See test_tcp.py `-z --compress-threads=2` and test_client_cli.c |
|
||||
| `--skip-compress=LIST` | Skip compress for suffixes | ✅ Parity | Comma-separated (or `/`-separated, as in rsync) case-insensitive suffix list; a leading dot is optional; an empty list skips none. **When the option is omitted, rsync 3.4.1's built-in default suffix list applies** (`3g2 3gp 7z aac … zip zst`); an explicit list replaces that default entirely, matching rsync. A user-supplied list is a client-side compression choice; incompatible with FastSync chunk serialization (`-s`) |
|
||||
|
||||
@@ -681,8 +659,8 @@ targets verbatim, matching rsync.
|
||||
| `--address=ADDRESS` | Bind address for outgoing socket | ✅ Parity | Binds the outgoing client socket to a local source address before `connect()` (resolved with the same `-4`/`-6` family hints as the destination). Local socket concern: never crosses the wire |
|
||||
| `-4`, `--ipv4` | Prefer IPv4 | ✅ Parity | Forces `AF_INET` in the `getaddrinfo` hints for client destination/source resolution and the server bind (see the Phase 5, Wave B note). Mutually exclusive with `-6` |
|
||||
| `-6`, `--ipv6` | Prefer IPv6 | ✅ Parity | Forces `AF_INET6` in the `getaddrinfo` hints for client destination/source resolution and the server bind. Mutually exclusive with `-4` |
|
||||
| `--remote-option=OPT`, `-M` | Send an option only to the remote side | ❌ Divergent | Each value is appended to the remote server invocation over SSH as an individually single-quote-escaped shell word in `ssh_build_remote_command()`. Values are validated (non-empty, no control characters) and shell metacharacters cannot break out of the quoting (`;`, `&`, `\|`, <code>`</code>, `$`, `(`, `)`, quotes are neutralized), so a value cannot inject an arbitrary remote command and a subsequent `--` on the client line cannot be turned into one. The short `-M` form (`-M OPT`, `-M=OPT`, and rsync-style attached `-MOPT`) is available, matching rsync; metadata mode moved to long-only `--preserve`. **Reclassified because the daemon/TCP case cannot be reproduced:** `-M` is only meaningful for the SSH transport (`user@host:path`); a daemon (`host::module/path`) or local TCP destination **rejects** it, whereas rsync forwards it to its own remote process on every transport. A differential test starts a real rsync daemon and shows `-M--totally-bogus` reaching the remote parser (`unknown option`) while a valid `-M--safe-links` is accepted. FastSync's daemon handshake is a fixed binary config frame with no per-connection argv channel; adding one would let a client set arbitrary server-side options (the same class of divergence as the native daemon config/auth), so the safe subset stays SSH-only |
|
||||
| `--bwlimit=RATE` | Limit I/O bandwidth | ✅ Parity | A faithful port of rsync 3.4.1's `parse_size_arg(bwlimit_arg, 'K', "bwlimit", 512, -1, True)`: a bare value is KiB/s, `K`/`M`/`G`/`T`/`P` are binary suffixes, `KB`/`MB` are decimal, `KiB`/`MiB` are binary, decimals are accepted and quantized to whole KiB exactly like rsync's `(size + 512) / 1024`, `0` (or an empty value) means "no limit", and any other value below the 512-byte floor is rejected. The token bucket's burst capacity is ~100 ms of bandwidth, matching the point at which rsync's leaky bucket starts sleeping, so a throttled transfer paces like rsync (4 MiB at `--bwlimit=1024`/`2048` matches rsync within ~4%). Differential-tested: the accept/reject matrix and the wall-clock rate both match rsync 3.4.1. The limit is a local I/O concern and is not negotiated on the wire |
|
||||
| `--remote-option=OPT`, `-M` | Send an option only to the remote side | ⚠️ Caveat | Each value is appended to the remote server invocation over SSH as an individually single-quote-escaped shell word in `ssh_build_remote_command()`. Values are validated (non-empty, no control characters) and shell metacharacters cannot break out of the quoting (`;`, `&`, `\|`, <code>`</code>, `$`, `(`, `)`, quotes are neutralized), so a value cannot inject an arbitrary remote command and a subsequent `--` on the client line cannot be turned into one. The short `-M` form (`-M OPT`, `-M=OPT`, and rsync-style attached `-MOPT`) is available, matching rsync; metadata mode moved to long-only `--preserve`. **Divergence:** `-M` is only meaningful for the SSH transport (`user@host:path`); a daemon (`host::module/path`) or local TCP destination **rejects** it (there is no remote command line to append to), whereas rsync applies it to its own remote process on every transport. The options never cross the binary config frame |
|
||||
| `--bwlimit=RATE` | Limit I/O bandwidth | ⚠️ Caveat | Token-bucket throttling of the transfer I/O (Kibibytes/second). **Divergence:** FastSync accepts only a positive integer; rsync additionally accepts `0` (no limit) and decimal/suffixed rates (`1.5`, `1.5m`, `100K`), so those rsync spellings are rejected. The limit is a local I/O concern and is not negotiated on the wire |
|
||||
|
||||
## 14. Daemon Mode
|
||||
|
||||
@@ -755,8 +733,8 @@ modes or links.
|
||||
| `--stop-after=MINS` | Stop after N minutes | ✅ Parity | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
|
||||
| `--stop-at=TIME` | Stop at specified time | ✅ Parity | Deadline transfer stop (client-only, never serialized). Protocol 2.26.0 accepts rsync's full date/time grammar (`2030-12-31T23:59`, `2030/12/31T23:59`, `2030-12-31`, `12-31`, `14:00`, `:59`, `1`) in addition to FastSync's `HH:MM[:SS]` and `now+N[smhd]`; a past time stops immediately. Everything already transferred is kept and an early stop suppresses the late `--delete` keep-set so unscanned source mirrors survive. Works single-threaded and under `-j`/`--threads` |
|
||||
| `--fsync` | Fsync every written file before publication | ✅ Parity | |
|
||||
| `--protocol=NUM` | Force older protocol version | ❌ Divergent | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.27.0) with no downgrade/backward-compat code paths, so `--protocol=2.27.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.26.0`/`2.25.0`/`2.24.0`/`2.23.0`/`2.22.0`/`2.21.0`/`2.20.0`/`2.19.0`/`2.18.0`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
|
||||
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Parity | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, matching rsync's rule that the spec "stays the same whether you're pushing or pulling": on a PUSH the destination end's charset is the spec's REMOTE half, so the default receiver writes the wire bytes verbatim, and only a server started with its own `--iconv` (the daemon `charset` analog) declares a different destination charset and converts REMOTE→that LOCAL (rsync push parity, differential-tested with and without a server `--iconv`). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front; protocol 2.26.0 additionally accepts `--iconv=.` (the locale's default charset for both directions), `--iconv=-` and `--no-iconv` (disable conversion). Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
|
||||
| `--protocol=NUM` | Force older protocol version | ❌ Divergent | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.26.0) with no downgrade/backward-compat code paths, so `--protocol=2.26.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.25.0`/`2.24.0`/`2.23.0`/`2.22.0`/`2.21.0`/`2.20.0`/`2.19.0`/`2.18.0`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
|
||||
| `--iconv=CONVERT_SPEC` | Charset conversion | ⚠️ Caveat | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front; protocol 2.26.0 additionally accepts `--iconv=.` (the locale's default charset for both directions), `--iconv=-` and `--no-iconv` (disable conversion). Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
|
||||
| `--checksum-seed=NUM` | Set checksum seed | ✅ Parity | Sets the seed for FastSync's whole-file xxHash digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). **As of protocol 2.23.0 a seed of `0` — the default when the flag is unset — is randomized per transfer and the chosen seed is sent to the receiver**, exactly like rsync, so two runs against different content do not share a predictable seed; an explicit non-zero seed is used verbatim, so an explicit seed deterministically reproduces every computed digest on BOTH endpoints (the seed crosses in the config frame). `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta` |
|
||||
| `--secluded-args`, `-s` | Use protocol to send args | ❌ Divergent | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
|
||||
| `--protect-args` | Old name of --secluded-args | ❌ Divergent | Accepted for CLI compatibility as a documented no-op; the same rationale as `--secluded-args`/`-s` (FastSync's remote SSH argv is already built injection-safe, so there is no argument-leak to close) |
|
||||
@@ -870,7 +848,7 @@ These are the hardest compatibility items because they require durable formats o
|
||||
|
||||
**Phase 6, Wave A (stop deadline) shipping note:** `--stop-after=MINS` and `--stop-at=TIME` are client-only sender stop deadlines. `--stop-after` takes a positive minute count (0/negative/garbage rejected); `--stop-at` takes `HH:MM`, `HH:MM:SS`, or `now+N[smhd]` (a past time stops immediately, a garbage spec is rejected at parse time). The deadline is computed once at the start of the transfer (CLOCK_MONOTONIC for `--stop-after`, wall clock via `time()` for `--stop-at`) and checked at every chunk boundary in both the single-threaded `send_files` loop and the multithreaded `send_chunks_multithreaded` path, and inside the scanner loops so a busy scan itself stops. When it fires, the transfer stops ELEGANTLY: the in-flight chunk completes, the existing completion tail runs (summary, `disconnect`), and the run returns 0 — exactly like rsync's clean early stop. Because the deadline is client-only and never crosses the wire config frame, no PROTOCOL_VERSION bump is required. The safety-critical interaction is with `--delete`: FastSync streams while scanning, so a deadline can cut the source scan short and yield a PARTIAL keep-set manifest; committing that would make the receiver delete destination mirrors of source files not yet scanned. So the sender tracks `scan_stopped_early` and, when it is true on the late/delete-after (`--delete`/`--delete-after`/`--delete-delay`) path, SUPPRESSES the keep-set manifest (logs a warning) so no deletion happens from an incomplete set — this is the safe direction (preserves data; the delete simply does not run). `--delete-before`/`--delete-during` are unaffected: their complete pre-scan runs before any data and ignores the deadline (a stop can be exceeded by that pre-scan). Under `-j`/`--threads` the stop is symmetric and the scanner thread's still-in-progress manifest appends can never race the tail because the tail does not read the manifest on the early-stop path.
|
||||
|
||||
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives its charset and the wire charset: the sender opens LOCAL→REMOTE and converts every transmitted filename; on a push the receiver's destination charset is the spec's REMOTE half, so it writes the wire bytes verbatim, unless the server was started with its own `--iconv` naming a different LOCAL charset (then it opens REMOTE→that LOCAL). Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→destination, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. The wire charset always comes from the sender's REMOTE half; a server whose local charset differs from the client's REMOTE must declare it with its own `--iconv` (the daemon `charset` analog). Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
|
||||
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
|
||||
|
||||
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: the current `PROTOCOL_VERSION` (2.26.0 as of the parity-completion wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.25.0`, `2.24.0`, `2.23.0`, `2.22.0`, `2.21.0`, `2.20.0`, `2.19.0`, `2.18.0`, `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18, SCRAM daemon auth 2.19, packed metadata 2.20, error-detail/dry-run 2.21, preserve-attribute split 2.22, rsync-parity wave 2.23) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
||||
|
||||
@@ -901,7 +879,7 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
||||
|
||||
**Wave D — Times superstructure & arg-protection no-ops (✅ implemented, `--secluded-args` ❌).** `-O`/`--omit-dir-times` and `-J`/`--omit-link-times` are now **real modifiers** (both `🔄 → ✅ Implemented`), reversing the old "never preserves directory/symlink times" divergence:
|
||||
|
||||
- **Directory times.** The recursive scanner captures every traversed source directory's metadata (mtime, plus atime under `-U`) into a per-transfer list — two paths are covered: the sequential `DirectoryScanner` captures each opened directory (including the transfer root), and the parallel scanner captures both the root in `parallel_scanner_create_with_options` and each worker's subdirectories in `open_next_directory` (appends are guarded by a mutex shared with the sender's pipeline context). The sender transmits them in trailing `STATUS_DIR_TIMES` frames (each: int count + count × (wire path, metadata) pairs) sent **after all file data and after the optional delete manifest**, just before `STATUS_FINISHED`. A tree larger than `MAX_MANIFEST_ENTRIES` (1 048 576) directories is chunked into repeated frames, each within the receiver's per-frame bound. A dir-time entry is RECORD-ONLY (`file->dir_time_only`): `file_save_to_disk_full` returns `FILE_SAVE_SKIPPED` without creating anything (the directory's creation, when it is empty, is now carried by a separate `STATUS_MKDIR` entry the scanner emits for every directory that produced no transferred child, and `-m/--prune-empty-dirs` suppresses that). The receiver accumulates received directory metadata in a `DirTimeList` and applies it only at the very end — after the entire stream, after the commit-style `--delete` deletion, and after `--delay-updates` publication — because creating or removing a child bumps the parent's mtime. Application is fd-relative/walk-confined (`file_open_secure_parent` + `utimensat(..., AT_SYMLINK_NOFOLLOW)`) and best-effort per entry: an absent path (an intentionally uncreated empty dir) is skipped QUIETLY and only a real existing directory is stamped. `-O` (config boolean, already on the wire) makes the receiver skip the whole set. The single-threaded sink applies in `receiver_send_success_frame`; the `-j`/`--threads` sink accumulates in `write_thread` and server.c applies after both threads join and the deletion commits.
|
||||
- **Directory times.** The recursive scanner captures every traversed source directory's metadata (mtime, plus atime under `-U`) into a per-transfer list — two paths are covered: the sequential `DirectoryScanner` captures each opened directory (including the transfer root), and the parallel scanner captures both the root in `parallel_scanner_create_with_options` and each worker's subdirectories in `open_next_directory` (appends are guarded by a mutex shared with the sender's pipeline context). The sender transmits them in trailing `STATUS_DIR_TIMES` frames (each: int count + count × (wire path, metadata) pairs) sent **after all file data and after the optional delete manifest**, just before `STATUS_FINISHED`. A tree larger than `MAX_MANIFEST_ENTRIES` (1 048 576) directories is chunked into repeated frames, each within the receiver's per-frame bound. A dir-time entry is RECORD-ONLY (`file->dir_time_only`): `file_save_to_disk_full` returns `FILE_SAVE_SKIPPED` without creating anything, so a source directory that was empty (or pruned by `-m/--prune-empty-dirs`) is never resurrected. The receiver accumulates received directory metadata in a `DirTimeList` and applies it only at the very end — after the entire stream, after the commit-style `--delete` deletion, and after `--delay-updates` publication — because creating or removing a child bumps the parent's mtime. Application is fd-relative/walk-confined (`file_open_secure_parent` + `utimensat(..., AT_SYMLINK_NOFOLLOW)`) and best-effort per entry: an absent path (an intentionally uncreated empty dir) is skipped QUIETLY and only a real existing directory is stamped. `-O` (config boolean, already on the wire) makes the receiver skip the whole set. The single-threaded sink applies in `receiver_send_success_frame`; the `-j`/`--threads` sink accumulates in `write_thread` and server.c applies after both threads join and the deletion commits.
|
||||
- **Symlink times/owner/mode.** `STATUS_SYMLINK` already carried metadata; the receiver now applies it with no-follow primitives only: `utimensat(..., AT_SYMLINK_NOFOLLOW)`, best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` (honest no-op where unsupported, e.g. Linux), and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)` via a new `identity_apply_ownership_link` that shares the identity resolver with the fd path. `-J` suppresses only the timestamps; ownership stays governed by the identity opt-in (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`) exactly like regular files. A symlink has no children, so this is applied immediately at creation.
|
||||
- **Wire:** the shared `STATUS_DIR_TIMES` frame (and metadata on `STATUS_MKDIR` for `--dirs` entries) is a frame-sequence change, so `PROTOCOL_VERSION` was bumped **2.16.0 → 2.17.0**; every version-sensitive test (`--protocol` accepted/rejected values) was updated. The config-frame layout itself is unchanged (the omit booleans already crossed). Non-metadata and `--no-preserve` transfers send no `STATUS_DIR_TIMES` frame and no directory metadata, keeping them byte-identical.
|
||||
|
||||
@@ -915,7 +893,7 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
||||
|
||||
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
|
||||
|
||||
**Honest status after the parity-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.26.0).** ✅ Parity 106 / ⚠️ Caveat 27 / ❌ Divergent 23 = 156 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 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.
|
||||
|
||||
@@ -1113,11 +1091,10 @@ wire protocol three times (full rationale in `src/shared/config.h`):
|
||||
`sha1`, `none`, `auto`, plus the two-name `transfer,pre-transfer` form.
|
||||
Unknown names and `none` on the transfer side under `--checksum` exit 4 like
|
||||
rsync.
|
||||
- **Codec residuals:** the transfer checksum is not independently selectable
|
||||
(only the whole-file comparison digest is); `zlib`/`zlibx` share FastSync's
|
||||
literal-only zlib path (rsync's zlibx behavior, observably identical for both,
|
||||
so `zlibx` is not a divergence). Per-codec compression-level defaults and
|
||||
`RSYNC_COMPRESS_LIST`/`RSYNC_CHECKSUM_LIST` are implemented (track 3a).
|
||||
- **Remaining codec residuals:** `zlibx` behaves as `zlib`; the transfer
|
||||
checksum is not independently selectable (only the whole-file comparison
|
||||
digest is); per-codec level defaults differ; and `RSYNC_CHECKSUM_LIST`/
|
||||
`RSYNC_COMPRESS_LIST` are not consulted.
|
||||
|
||||
### Selection, paths, and filters
|
||||
|
||||
@@ -1146,14 +1123,10 @@ wire protocol three times (full rationale in `src/shared/config.h`):
|
||||
|
||||
These remain after the wave; the individual rows carry the precise wording.
|
||||
|
||||
- **`--stats`** now reproduces rsync's `(reg/dir/link/special)` breakdown on
|
||||
`Number of files`, the regular-transferred count and the size totals, but
|
||||
`Number of created files` is the transferred-regular count without a type
|
||||
breakdown and wire-byte totals differ; **`--progress`** now prints the leading
|
||||
`./` line and includes the root in `to-chk` (single-file output is
|
||||
byte-identical), but a multi-directory `to-chk` denominator and per-directory
|
||||
name lines still differ; **`--out-format`** `%C` matches for every algorithm,
|
||||
but `%b`/`%c` count FastSync wire bytes (protocol-specific, hence ❌).
|
||||
- **`--stats`** lacks rsync's `(reg/dir/link)` breakdown on `Number of files`
|
||||
and `Number of created files`; **`--progress`** omits the leading `./` line
|
||||
and its `to-chk` total differs by the root entry; **`--out-format`** `%b`/`%c`
|
||||
count FastSync wire bytes.
|
||||
- **`-n --delete`** ordering can differ from rsync's delete-during walk and a
|
||||
filtered dry-run can over-report.
|
||||
- **Delete timing:** the default `--delete` remains delete-after rather than
|
||||
@@ -1163,14 +1136,13 @@ These remain after the wave; the individual rows carry the precise wording.
|
||||
sender-derived). `--ignore-errors` exits 23 but its EACCES differential is not
|
||||
exercised in CI.
|
||||
- **`--delay-updates`** uses a fixed staging name with an advisory lock and
|
||||
deletes before publication; **`--temp-dir`** rejects absolute/foreign paths
|
||||
(deliberately confined, see the row); **`--remote-option`** is SSH-only.
|
||||
**`--iconv`** now matches rsync's push direction (destination charset = the
|
||||
spec's REMOTE half; a server `--iconv` overrides it).
|
||||
deletes before publication; **`--temp-dir`** rejects absolute/foreign paths;
|
||||
**`--remote-option`** is SSH-only; **`--iconv`** keeps the receiver
|
||||
half-swap/charset-declaration caveat.
|
||||
- **Basis dirs** do not re-apply attributes on a match, keep the
|
||||
`--size-only` mtime caveat, and share the 256 MiB whole-file cap; **`--fuzzy`**
|
||||
has a different tie-break order; and **`--bwlimit`** rejects rsync's
|
||||
`0`/decimal/suffixed rates.
|
||||
has a different tie-break order; recursive transfers still do not create empty
|
||||
directories; and **`--bwlimit`** rejects rsync's `0`/decimal/suffixed rates.
|
||||
- **`--inc-recursive`/`--no-inc-recursive`** are not implemented (rejected).
|
||||
|
||||
### Intentional divergences (explicit ❌ rows)
|
||||
|
||||
@@ -8,6 +8,3 @@ markers =
|
||||
daemon_detach: real double-fork backgrounding path (--daemon without
|
||||
--no-detach); slower/fragile, so it runs in the full suite but not the
|
||||
fast PR gate
|
||||
parity: differential rsync-parity case (full set; runs on push to
|
||||
dev/main)
|
||||
parity_ci: fast differential rsync-parity subset (runs on the PR gate)
|
||||
|
||||
+21
-120
@@ -1,6 +1,5 @@
|
||||
#include "change_list.h"
|
||||
#include "checksum.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
@@ -71,16 +70,7 @@ static bool strbuf_append(StrBuf* buf, const char* text) {
|
||||
|
||||
bool change_list_enabled(const Config* config) {
|
||||
return config != NULL && (config->itemize_changes || config->out_format != NULL ||
|
||||
(config->log_file != NULL && config->log_file_format != NULL) ||
|
||||
(config->info_level & LOG_INFO_NAME) != 0);
|
||||
}
|
||||
|
||||
/* Emitted once, lazily, ahead of the first --info=name entry: rsync prints the
|
||||
* transfer-root `./` name line when the root directory is (re)created. */
|
||||
static bool name_root_printed = false;
|
||||
|
||||
void change_reset_name_root(void) {
|
||||
name_root_printed = false;
|
||||
(config->log_file != NULL && config->log_file_format != NULL));
|
||||
}
|
||||
|
||||
/* ---- Itemize code ---- */
|
||||
@@ -202,55 +192,27 @@ char* change_render_itemize(const Config* config, const ChangeEvent* event) {
|
||||
return line.data;
|
||||
}
|
||||
|
||||
/* rsync's `--info=name` line for an updated entry: the transfer-relative name
|
||||
* (trailing slash for directories) plus the ` -> target` / ` => target` link
|
||||
* suffix. `--info=name` does not alter an itemize/out-format run. */
|
||||
static char* change_render_name(const ChangeEvent* event) {
|
||||
StrBuf line = {0};
|
||||
bool ok = append_name(&line, event) && append_link_suffix(&line, event);
|
||||
if (!ok) {
|
||||
strbuf_free(&line);
|
||||
return NULL;
|
||||
}
|
||||
if (line.data == NULL) {
|
||||
line.data = str_dup("");
|
||||
if (!line.data)
|
||||
return NULL;
|
||||
}
|
||||
return line.data;
|
||||
}
|
||||
|
||||
/* rsync's `--info=name2` line for an unchanged entry: `NAME is uptodate`. */
|
||||
static char* change_render_name_uptodate(const ChangeEvent* event) {
|
||||
char* name = change_render_name(event);
|
||||
if (name == NULL)
|
||||
return NULL;
|
||||
size_t length = strlen(name);
|
||||
char* line = malloc(length + sizeof(" is uptodate"));
|
||||
if (line == NULL) {
|
||||
free(name);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(line, name, length);
|
||||
memcpy(line + length, " is uptodate", sizeof(" is uptodate"));
|
||||
free(name);
|
||||
return line;
|
||||
}
|
||||
|
||||
/* ---- --out-format / --log-file-format ---- */
|
||||
|
||||
/* rsync 3.4.1's `%C` uses the negotiated TRANSFER checksum (the first name of a
|
||||
* two-name "transfer,pre-transfer" --checksum-choice), not the pre-transfer
|
||||
* whole-file digest FastSync compares against on the wire. The default "auto"
|
||||
* resolves to xxh128, so an explicit selection and the default both render the
|
||||
* selected algorithm's digest. */
|
||||
/* rsync 3.4.1's `%C` uses the negotiated transfer checksum; with the default
|
||||
* "auto" choice on both ends that is xxh128. FastSync's internal XXH64 default
|
||||
* is not an rsync algorithm, so map it to xxh128 for parity. */
|
||||
static ChecksumAlgo out_format_checksum_algo(const Config* config) {
|
||||
return (ChecksumAlgo)config->checksum_transfer_algo;
|
||||
switch ((ChecksumAlgo)config->checksum_algo) {
|
||||
case CHECKSUM_ALGO_MD5:
|
||||
return CHECKSUM_ALGO_MD5;
|
||||
case CHECKSUM_ALGO_XXH3:
|
||||
return CHECKSUM_ALGO_XXH3;
|
||||
case CHECKSUM_ALGO_XXH128:
|
||||
return CHECKSUM_ALGO_XXH128;
|
||||
case CHECKSUM_ALGO_XXH64:
|
||||
default:
|
||||
return CHECKSUM_ALGO_XXH128;
|
||||
}
|
||||
}
|
||||
|
||||
/* Render a digest as rsync's sum_as_hex: xxh128 prints the HIGH 64-bit half
|
||||
* before the low half, and xxh64/xxh3 print their 64-bit value big-endian; every
|
||||
* other algorithm prints its bytes in order. */
|
||||
/* Render a digest as rsync's sum_as_hex: for xxh128 the HIGH 64-bit half is
|
||||
* printed before the low half; every other algorithm prints its bytes in order. */
|
||||
static void digest_to_hex(ChecksumAlgo algo, const uint8_t* digest, size_t len, char* out) {
|
||||
if (algo == CHECKSUM_ALGO_XXH128 && len == 16) {
|
||||
uint64_t low = 0;
|
||||
@@ -260,12 +222,6 @@ static void digest_to_hex(ChecksumAlgo algo, const uint8_t* digest, size_t len,
|
||||
snprintf(out, len * 2 + 1, "%016llx%016llx", (unsigned long long)high, (unsigned long long)low);
|
||||
return;
|
||||
}
|
||||
if ((algo == CHECKSUM_ALGO_XXH64 || algo == CHECKSUM_ALGO_XXH3) && len == 8) {
|
||||
uint64_t value = 0;
|
||||
memcpy(&value, digest, sizeof(value));
|
||||
snprintf(out, len * 2 + 1, "%016llx", (unsigned long long)value);
|
||||
return;
|
||||
}
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
out[i * 2] = hex[(digest[i] >> 4) & 0xf];
|
||||
@@ -304,9 +260,6 @@ static void fill_event_checksum(const Config* config, const File* file, ChangeEv
|
||||
if (file->path == NULL)
|
||||
return;
|
||||
ChecksumAlgo algo = out_format_checksum_algo(config);
|
||||
/* rsync renders `--checksum-choice=none` as a blank 2-character column. */
|
||||
if (algo == CHECKSUM_ALGO_NONE)
|
||||
return;
|
||||
uint8_t digest[CHECKSUM_MAX_DIGEST_LEN];
|
||||
size_t len = 0;
|
||||
/* rsync's %C is the transfer checksum, which is always seeded with 0 (it is
|
||||
@@ -376,10 +329,9 @@ char* change_render_format(const char* format, const Config* config, const Chang
|
||||
if (event->checksum_known) {
|
||||
ok = strbuf_append(&line, event->checksum);
|
||||
} else {
|
||||
/* rsync pads a non-regular / untransferred / `none` entry with spaces;
|
||||
`none` renders as a blank 2-character column. */
|
||||
/* rsync pads a non-regular / untransferred entry with spaces. */
|
||||
ChecksumAlgo algo = out_format_checksum_algo(config);
|
||||
int width = algo == CHECKSUM_ALGO_NONE ? 2 : checksum_digest_len(algo) * 2;
|
||||
int width = checksum_digest_len(algo) * 2;
|
||||
for (int i = 0; i < width && ok; i++)
|
||||
ok = strbuf_append_char(&line, ' ');
|
||||
}
|
||||
@@ -486,24 +438,10 @@ static void print_escaped_line(FILE* stream, const char* line, bool eight_bit_ou
|
||||
void change_emit(const Config* config, const ChangeEvent* event) {
|
||||
if (event == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
if (event->decision == CHANGE_UP_TO_DATE)
|
||||
return;
|
||||
bool to_stdout = config->itemize_changes || config->out_format != NULL;
|
||||
bool to_log = config->log_file != NULL && config->log_file_format != NULL;
|
||||
bool progress_active = config->show_progress || (config->info_level & LOG_INFO_PROGRESS);
|
||||
if (event->decision == CHANGE_UP_TO_DATE) {
|
||||
/* --info=name2 prints `NAME is uptodate` for entries the receiver already
|
||||
had. An itemize/out-format run reports them through its own format (or
|
||||
not at all), the progress stream has no frame for them, and neither the
|
||||
itemize nor the log-file stream previously reported an up-to-date entry,
|
||||
so nothing else here changes. */
|
||||
if (!to_stdout && (config->info_level & LOG_INFO_NAME_UPTODATE) != 0 && !progress_active) {
|
||||
char* line = change_render_name_uptodate(event);
|
||||
if (line != NULL) {
|
||||
print_escaped_line(stdout, line, config->eight_bit_output);
|
||||
free(line);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (to_stdout) {
|
||||
char* line = config->out_format != NULL
|
||||
? change_render_format(config->out_format, config, event)
|
||||
@@ -512,20 +450,6 @@ void change_emit(const Config* config, const ChangeEvent* event) {
|
||||
print_escaped_line(stdout, line, config->eight_bit_output);
|
||||
free(line);
|
||||
}
|
||||
} else if ((config->info_level & LOG_INFO_NAME) != 0 && !progress_active) {
|
||||
/* --info=name without -i/--out-format: print the updated entry's name. The
|
||||
--progress path owns the name line when progress output is active (it
|
||||
emits the same names before the progress frames), so do not duplicate.
|
||||
The transfer-root `./` line precedes the first such name. */
|
||||
if (!name_root_printed) {
|
||||
name_root_printed = true;
|
||||
fputs("./\n", stdout);
|
||||
}
|
||||
char* line = change_render_name(event);
|
||||
if (line != NULL) {
|
||||
print_escaped_line(stdout, line, config->eight_bit_output);
|
||||
free(line);
|
||||
}
|
||||
}
|
||||
if (to_log) {
|
||||
char* line = change_render_format(config->log_file_format, config, event);
|
||||
@@ -686,29 +610,6 @@ void change_emit_file_sent(const Config* config, const File* file) {
|
||||
change_emit_file_sent_bytes(config, file, payload, 0);
|
||||
}
|
||||
|
||||
void change_emit_file_uptodate(const Config* config, const File* file) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
ChangeEvent event;
|
||||
memset(&event, 0, sizeof(event));
|
||||
event.decision = CHANGE_UP_TO_DATE;
|
||||
event.is_directory = false;
|
||||
event.is_symlink = file->is_symlink;
|
||||
event.is_special = file->is_special;
|
||||
event.is_hardlink = file->link_group != 0 && !file->link_first;
|
||||
event.symlink_target = file->symlink_target;
|
||||
event.hardlink_target = file->hardlink_target;
|
||||
event.size = file->data != NULL ? file->data->size : 0;
|
||||
event.dest = file->dest_state;
|
||||
char* name = NULL;
|
||||
char* path = NULL;
|
||||
fill_event_from_file(config, file, &event, &name, &path);
|
||||
if (name != NULL && path != NULL)
|
||||
change_emit(config, &event);
|
||||
free(name);
|
||||
free(path);
|
||||
}
|
||||
|
||||
void change_emit_dir_sent(const Config* config, const File* file) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
|
||||
@@ -102,13 +102,4 @@ void change_emit_file_sent(const Config* config, const File* file);
|
||||
/* Build and emit a CHANGE_SENT event for an explicit directory entry (-d). */
|
||||
void change_emit_dir_sent(const Config* config, const File* file);
|
||||
|
||||
/* Build and emit a CHANGE_UP_TO_DATE event for a file the receiver already had.
|
||||
* With --info=name2 it renders rsync's "NAME is uptodate" line (no output
|
||||
* otherwise). */
|
||||
void change_emit_file_uptodate(const Config* config, const File* file);
|
||||
|
||||
/* Reset the lazy transfer-root `./` line emitted ahead of the first
|
||||
* --info=name entry. Call once at the start of a transfer. */
|
||||
void change_reset_name_root(void);
|
||||
|
||||
#endif
|
||||
|
||||
+27
-221
@@ -23,7 +23,6 @@
|
||||
#include <langinfo.h>
|
||||
#include <limits.h>
|
||||
#include <locale.h>
|
||||
#include <math.h>
|
||||
#include <time.h>
|
||||
#include <signal.h>
|
||||
#include <stdbool.h>
|
||||
@@ -161,16 +160,10 @@ static int set_compression_choice(Config* config, const char* value) {
|
||||
return -1;
|
||||
}
|
||||
int algo;
|
||||
if (strcasecmp(value, "auto") == 0) {
|
||||
algo = compression_choice_resolve();
|
||||
if (algo < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_COMPRESS_LIST names no supported compression algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (strcasecmp(value, "auto") == 0)
|
||||
algo = (int)compression_negotiate_default();
|
||||
else
|
||||
algo = compression_algo_from_name(value);
|
||||
}
|
||||
if (algo < 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--compress-choice '%s' is not a supported algorithm; FastSync supports zstd, "
|
||||
@@ -234,25 +227,16 @@ static int set_checksum_choice(Config* config, const char* value) {
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
int negotiated = -1;
|
||||
if (rc1 == 1 || rc2 == 1) {
|
||||
negotiated = checksum_choice_resolve();
|
||||
if (negotiated < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_CHECKSUM_LIST names no supported checksum algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
ChecksumAlgo negotiated = checksum_negotiate_default();
|
||||
if (rc1 == 1)
|
||||
transfer = negotiated;
|
||||
transfer = (int)negotiated;
|
||||
if (!name2)
|
||||
pre = transfer;
|
||||
else if (rc2 == 1)
|
||||
pre = negotiated;
|
||||
pre = (int)negotiated;
|
||||
|
||||
config->checksum_algo = pre;
|
||||
config->checksum_transfer_algo = transfer;
|
||||
config->checksum_choice_set = true;
|
||||
/* rsync: "none" for the transfer checksum forces --whole-file. */
|
||||
if (transfer == (int)CHECKSUM_ALGO_NONE)
|
||||
config->whole_file = true;
|
||||
@@ -517,10 +501,7 @@ static bool is_accepted_debug_category(const char* name) {
|
||||
|
||||
static bool is_accepted_info_category(const char* name) {
|
||||
static const char* const categories[] = {
|
||||
"backup",
|
||||
"mount",
|
||||
"syms",
|
||||
"symsafe",
|
||||
"backup", "del", "flist", "mount", "nonreg", "progress", "remove", "syms", "symsafe",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(categories) / sizeof(categories[0]); i++) {
|
||||
if (strcmp(name, categories[i]) == 0)
|
||||
@@ -627,36 +608,14 @@ static int parse_info_flags(const char* value, Config* config) {
|
||||
free(flags);
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(name, "copy") == 0)
|
||||
if (strcmp(name, "copy") == 0 || strcmp(name, "name") == 0)
|
||||
flag = LOG_INFO_COPY;
|
||||
else if (strcmp(name, "name") == 0) {
|
||||
/* name level 2 adds rsync's "is uptodate" lines. */
|
||||
if (level == 0)
|
||||
parsed &= ~(uint32_t)(LOG_INFO_NAME | LOG_INFO_NAME_UPTODATE);
|
||||
else {
|
||||
parsed |= LOG_INFO_NAME;
|
||||
if (level >= 2)
|
||||
parsed |= LOG_INFO_NAME_UPTODATE;
|
||||
else
|
||||
parsed &= ~(uint32_t)LOG_INFO_NAME_UPTODATE;
|
||||
}
|
||||
continue;
|
||||
} else if (strcmp(name, "misc") == 0)
|
||||
else if (strcmp(name, "misc") == 0)
|
||||
flag = LOG_INFO_MISC;
|
||||
else if (strcmp(name, "skip") == 0)
|
||||
flag = LOG_INFO_SKIP;
|
||||
else if (strcmp(name, "stats") == 0)
|
||||
flag = LOG_INFO_STATS;
|
||||
else if (strcmp(name, "del") == 0)
|
||||
flag = LOG_INFO_DEL;
|
||||
else if (strcmp(name, "remove") == 0)
|
||||
flag = LOG_INFO_REMOVE;
|
||||
else if (strcmp(name, "flist") == 0)
|
||||
flag = LOG_INFO_FLIST;
|
||||
else if (strcmp(name, "nonreg") == 0)
|
||||
flag = LOG_INFO_NONREG;
|
||||
else if (strcmp(name, "progress") == 0)
|
||||
flag = LOG_INFO_PROGRESS;
|
||||
else if (is_accepted_info_category(name))
|
||||
continue;
|
||||
else {
|
||||
@@ -1479,8 +1438,6 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
|
||||
ctx->exit_code = -1;
|
||||
return true;
|
||||
}
|
||||
if (entry->offset == offsetof(Config, compression_level))
|
||||
config->compression_level_set = true;
|
||||
if (entry->offset == offsetof(Config, chmod_spec)) {
|
||||
mode_t ignored;
|
||||
if (!chmod_apply(0, config->chmod_spec, &ignored)) {
|
||||
@@ -1765,7 +1722,6 @@ static bool cli_handle_transfer_flags(CliParseCtx* ctx) {
|
||||
return true;
|
||||
}
|
||||
config->compression_level = (int)level;
|
||||
config->compression_level_set = true;
|
||||
log_info_message(LOG_INFO_MISC, "Set Compression level to %ld", level);
|
||||
ctx->i++;
|
||||
}
|
||||
@@ -1863,130 +1819,22 @@ static int set_log_file_option(Config* config, const char* log_path) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Faithful port of rsync 3.4.1's `parse_size_arg(bwlimit_arg, 'K', "bwlimit",
|
||||
* 512, -1, True)`: a default KiB suffix, binary (1024) multipliers unless a
|
||||
* `b`/`B` decimal suffix or explicit `iB` is given, an optional decimal
|
||||
* fraction, the P/T/G/M/K suffixes, and the special rules that a value of 0
|
||||
* means "no limit" while any other value below 512 bytes is rejected. The
|
||||
* parsed byte count is then quantized to whole KiB exactly like rsync's
|
||||
* `bwlimit = (size + 512) / 1024`. Returns 0 on success, -1 on a parse error. */
|
||||
static int parse_bwlimit_value(const char* value, unsigned long long* bytes_per_sec_out) {
|
||||
if (!value || !bytes_per_sec_out)
|
||||
return -1;
|
||||
const char* arg = value;
|
||||
int reps;
|
||||
long long mult;
|
||||
while (*arg >= '0' && *arg <= '9')
|
||||
arg++;
|
||||
if (*arg != '\0' && (*arg == '.' || *arg == localeconv()->decimal_point[0]))
|
||||
for (arg++; *arg >= '0' && *arg <= '9'; arg++) {
|
||||
}
|
||||
|
||||
char suffix = *arg && *arg != '+' && *arg != '-' ? *arg++ : 'K';
|
||||
switch (suffix) {
|
||||
case 'b':
|
||||
case 'B':
|
||||
reps = 0;
|
||||
break;
|
||||
case 'k':
|
||||
case 'K':
|
||||
reps = 1;
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
reps = 2;
|
||||
break;
|
||||
case 'g':
|
||||
case 'G':
|
||||
reps = 3;
|
||||
break;
|
||||
case 't':
|
||||
case 'T':
|
||||
reps = 4;
|
||||
break;
|
||||
case 'p':
|
||||
case 'P':
|
||||
reps = 5;
|
||||
break;
|
||||
default:
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is invalid", value);
|
||||
return -1;
|
||||
}
|
||||
if (*arg == 'b' || *arg == 'B') {
|
||||
mult = 1000;
|
||||
arg++;
|
||||
} else if (*arg == '\0' || *arg == '+' || *arg == '-') {
|
||||
mult = 1024;
|
||||
} else if ((arg[0] == 'i' || arg[0] == 'I') && (arg[1] == 'b' || arg[1] == 'B')) {
|
||||
mult = 1024;
|
||||
arg += 2;
|
||||
} else {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is invalid", value);
|
||||
return -1;
|
||||
}
|
||||
|
||||
long long base = 1;
|
||||
for (int i = 0; i < reps; i++) {
|
||||
if (base > LLONG_MAX / mult) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is too large", value);
|
||||
return -1;
|
||||
}
|
||||
base *= mult;
|
||||
}
|
||||
/* rsync multiplies the numeric prefix (atof) by mult^reps in a signed
|
||||
* ssize_t, which is undefined on overflow. Scale in double and range-check
|
||||
* before converting, so a huge value is rejected as "too large" (where
|
||||
* rsync's overflow happens to land on a negative result) without invoking
|
||||
* signed-overflow UB. */
|
||||
double scaled = (double)base * strtod(value, NULL);
|
||||
/* (double)LLONG_MAX rounds up to 2^63, which is itself out of range for the
|
||||
* cast, so reject at >= that bound; LLONG_MIN == -2^63 is exactly
|
||||
* representable and thus castable, so the lower bound stays strict. */
|
||||
if (!isfinite(scaled) || scaled >= (double)LLONG_MAX || scaled < (double)LLONG_MIN) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is too large", value);
|
||||
return -1;
|
||||
}
|
||||
long long size = (long long)scaled;
|
||||
if ((*arg == '+' || *arg == '-') && arg[1] == '1' && arg != value) {
|
||||
/* The only form accepted here is "+1"/"-1" (a longer number leaves a
|
||||
trailing byte and is rejected below), so apply the delta directly and
|
||||
guard the one overflow direction. */
|
||||
if (*arg == '+') {
|
||||
if (size == LLONG_MAX) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is too large", value);
|
||||
return -1;
|
||||
}
|
||||
size += 1;
|
||||
} else {
|
||||
size -= 1;
|
||||
}
|
||||
arg += 2;
|
||||
}
|
||||
if (*arg != '\0' || size < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is %s", value, size < 0 ? "too large" : "invalid");
|
||||
return -1;
|
||||
}
|
||||
if (size != 0 && size < 512) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is too small (min: 512 or 0 for unlimited)", value);
|
||||
return -1;
|
||||
}
|
||||
long long kib = size == 0 ? 0 : (size + 512) / 1024;
|
||||
if (kib > (long long)(ULLONG_MAX / 1024)) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit=%s is too large", value);
|
||||
return -1;
|
||||
}
|
||||
*bytes_per_sec_out = (unsigned long long)kib * 1024;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Apply a --bwlimit value using rsync 3.4.1's units/semantics. Returns 0 on
|
||||
* success, -1 on error. */
|
||||
/* Apply a --bwlimit value (kilobytes per second). Returns 0 on success, -1 on
|
||||
* error. */
|
||||
static int set_bwlimit_option(const char* value) {
|
||||
unsigned long long bytes_per_sec;
|
||||
if (parse_bwlimit_value(value, &bytes_per_sec) != 0)
|
||||
unsigned long long kbps;
|
||||
if (parse_ull_arg(value, &kbps, "--bwlimit") != 0)
|
||||
return -1;
|
||||
io_set_bwlimit(bytes_per_sec);
|
||||
log_info_message(LOG_INFO_MISC, "Set bandwidth limit to %llu KB/s", bytes_per_sec / 1024);
|
||||
if (kbps == 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit must be a positive integer");
|
||||
return -1;
|
||||
}
|
||||
if (kbps > ULLONG_MAX / 1024) {
|
||||
log_message(LOG_LEVEL_ERROR, "--bwlimit value too large");
|
||||
return -1;
|
||||
}
|
||||
io_set_bwlimit(kbps * 1024);
|
||||
log_info_message(LOG_INFO_MISC, "Set bandwidth limit to %llu KB/s", kbps);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -2568,43 +2416,8 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
config->use_compression = (algo != (int)COMPRESSION_ALGO_NONE);
|
||||
}
|
||||
}
|
||||
/* A bare -z (no --compress-choice) resolves like rsync's "auto": the
|
||||
* RSYNC_COMPRESS_LIST preference list first, then the compiled-in order. A
|
||||
* list that names no supported codec is rsync's failed negotiation (exit 4). */
|
||||
if (config->use_compression && !config->compress_choice) {
|
||||
int resolved = compression_choice_resolve();
|
||||
if (resolved < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_COMPRESS_LIST names no supported compression algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
config->compression_algo = resolved;
|
||||
if (resolved == (int)COMPRESSION_ALGO_NONE)
|
||||
config->use_compression = false;
|
||||
}
|
||||
/* Apply rsync's per-codec compression level: an explicit --compress-level is
|
||||
* clamped to the codec's range, otherwise the codec's own default is used. */
|
||||
if (config->use_compression) {
|
||||
CompressionAlgo algo = (CompressionAlgo)config->compression_algo;
|
||||
config->compression_level = config->compression_level_set
|
||||
? compression_clamp_level(algo, config->compression_level)
|
||||
: compression_default_level(algo);
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Client compression: %s (level %d)",
|
||||
compression_algo_name(algo), config->compression_level);
|
||||
}
|
||||
/* The negotiated checksum is always resolved (rsync negotiates one for the
|
||||
* delta strong sum even without --checksum): RSYNC_CHECKSUM_LIST first, then
|
||||
* the compiled-in order. An explicit --checksum-choice already set it. */
|
||||
if (!config->checksum_choice_set) {
|
||||
int resolved = checksum_choice_resolve();
|
||||
if (resolved < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_CHECKSUM_LIST names no supported checksum algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
config->checksum_algo = resolved;
|
||||
config->checksum_transfer_algo = resolved;
|
||||
}
|
||||
if (config->use_compression && config->compression_algo == (int)COMPRESSION_ALGO_NONE)
|
||||
config->compression_algo = (int)compression_negotiate_default();
|
||||
/* rsync parity: "none" as the pre-transfer checksum cannot be combined with
|
||||
* --checksum (exit 4). The check runs here because --checksum may appear on
|
||||
* either side of --checksum-choice. */
|
||||
@@ -2736,15 +2549,8 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
}
|
||||
}
|
||||
}
|
||||
/* --info=del on a real --delete run asks the receiver to report the paths it
|
||||
actually removed; the report rides the STATUS_STATS path list, so the wire
|
||||
stats frame must be negotiated too. */
|
||||
config->report_deletes = config->use_delete && !config->dry_run &&
|
||||
((config->info_level & LOG_INFO_DEL) != 0 || config->itemize_changes ||
|
||||
config->out_format != NULL);
|
||||
config->report_stats = config->stats || config->show_progress ||
|
||||
(config->info_level & LOG_INFO_PROGRESS) || format_needs_wire ||
|
||||
config->report_deletes || (config->dry_run && config->use_delete);
|
||||
config->report_stats = config->stats || config->show_progress || format_needs_wire ||
|
||||
(config->dry_run && config->use_delete);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+199
-693
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,6 @@ void client_set_abort_armed(bool armed);
|
||||
* config_delete() once the call returns). */
|
||||
int send_files(Config* config);
|
||||
int send_files_multithreaded(Config** config);
|
||||
/* rsync's --ignore-errors deletion gate: with no I/O error during the scan the
|
||||
* deletion phase always proceeds; with one it is suppressed unless
|
||||
* `--ignore-errors` was given. Exposed so the decision can be unit-tested
|
||||
* without a privileged (mode-000) source directory. See client_send.c. */
|
||||
bool ignore_errors_allows_delete(const Config* config, bool had_io_error);
|
||||
|
||||
/* Phase 6 residual-batch (client-only). See client_send.c. */
|
||||
int write_batch_from_source(const Config* config, const char* batch_path);
|
||||
int apply_batch_to_dest(const Config* config, const char* batch_path, const char* dest_root);
|
||||
|
||||
+10
-95
@@ -432,19 +432,6 @@ static void scanner_record_protected(DirectoryScanner* scanner, const char* fs_p
|
||||
scanner->failed = true;
|
||||
}
|
||||
|
||||
/* rsync's `--info=nonreg` line for a non-regular entry that is not being
|
||||
* preserved: `skipping non-regular file "NAME"`. The name is the path relative
|
||||
* to the transfer root, so it matches rsync's displayed name. */
|
||||
static void scanner_note_nonreg(const ScannerOptions* options, const char* fs_path) {
|
||||
if (!options || !options->note_nonreg || !fs_path)
|
||||
return;
|
||||
const char* rel = utils_strip_transfer_root(fs_path, options->send_directory);
|
||||
char* escaped = output_escape(rel, options->eight_bit_output);
|
||||
printf("skipping non-regular file \"%s\"\n", escaped ? escaped : rel);
|
||||
free(escaped);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
/* A user-selection exclusion (--filter/-C/per-dir or --exclude/--include). */
|
||||
static void scanner_record_excluded(DirectoryScanner* scanner, const char* fs_path) {
|
||||
scanner_record_protected(scanner, fs_path, scanner->options.excluded_paths);
|
||||
@@ -843,8 +830,7 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
const char* fs_path, bool relative_mode,
|
||||
const char* relative_prefix, bool preserve_atimes,
|
||||
bool preserve_crtimes, bool preserve_xattrs,
|
||||
bool preserve_acls, bool no_implied_dirs,
|
||||
const FileListSet* file_list) {
|
||||
bool preserve_acls) {
|
||||
if (!dir_entries || !root_path || !fs_path)
|
||||
return true;
|
||||
struct stat st;
|
||||
@@ -853,13 +839,6 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
char* rel = scanner_path_relative(root_path, fs_path);
|
||||
if (!rel)
|
||||
return true;
|
||||
/* --no-implied-dirs: an implied parent directory (not listed, and not under
|
||||
a listed directory) keeps the destination's own/default attributes, so its
|
||||
source metadata is not transmitted. */
|
||||
if (no_implied_dirs && file_list && !file_list_dir_in_scope(file_list, rel)) {
|
||||
free(rel);
|
||||
return true;
|
||||
}
|
||||
if (relative_mode && rel[0] == '\0') {
|
||||
/* -R + --files-from: the transfer root itself has no bare relative wire
|
||||
path (matches the -R scan, which never emits the root). */
|
||||
@@ -923,43 +902,6 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Recursive scan: emit a payload-less directory entry for the directory that
|
||||
* just finished scanning. rsync creates every source directory at the
|
||||
* destination; FastSync otherwise creates one only implicitly through a
|
||||
* transferred child, so a directory emptied on the transfer side (physically
|
||||
* empty, or all of its entries filtered out) would never appear. The transfer
|
||||
* root is skipped (it maps to the receive root, which already exists), as are
|
||||
* --files-from (only listed items and their implied parents transfer),
|
||||
* --list-only (directory lines are emitted by the caller) and
|
||||
* -m/--prune-empty-dirs. Returns false on allocation failure. */
|
||||
static bool scanner_emit_empty_dir(DirectoryScanner* scanner, ArrayList* chunk_data) {
|
||||
if (!scanner->current_path || !scanner->current_rel || scanner->current_rel[0] == '\0')
|
||||
return true;
|
||||
struct stat st;
|
||||
if (lstat(scanner->current_path, &st) != 0 || !S_ISDIR(st.st_mode))
|
||||
return true;
|
||||
File* dir = scanner_build_dir_file(scanner->current_path, &st, &scanner->options);
|
||||
if (!dir)
|
||||
return false;
|
||||
if (scanner->relative_mode) {
|
||||
dir->send_path = str_dup(scanner->current_rel);
|
||||
} else if (scanner->options.relative_prefix) {
|
||||
dir->send_path =
|
||||
scanner_prefix_send_path(scanner->options.relative_prefix, scanner->current_rel);
|
||||
}
|
||||
if ((scanner->relative_mode || scanner->options.relative_prefix) && !dir->send_path) {
|
||||
file_destroy(dir);
|
||||
return false;
|
||||
}
|
||||
if (scanner->options.preserve_xattrs || scanner->options.preserve_acls)
|
||||
dir->xattrs = xattr_capture_path(scanner->current_path, scanner->options.preserve_acls);
|
||||
if (!array_list_add(chunk_data, dir)) {
|
||||
file_destroy(dir);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Open the next queued directory and set up its filter context. Returns 1 when
|
||||
a directory is open, 0 when the queue is exhausted, and -1 on a fatal error.
|
||||
A directory that cannot be opened is an I/O error: it is recorded on the
|
||||
@@ -983,7 +925,6 @@ static int open_next_directory(DirectoryScanner* scanner) {
|
||||
* the directory that enqueued them. */
|
||||
const FilterNode* inherited = scanner->at_seed_dir ? scanner->seed_node : de->context;
|
||||
scanner->at_seed_dir = false;
|
||||
scanner->current_dir_produced = false;
|
||||
free(de);
|
||||
|
||||
free(scanner->current_rel);
|
||||
@@ -1013,18 +954,11 @@ static int open_next_directory(DirectoryScanner* scanner) {
|
||||
scanner->current_rel = NULL;
|
||||
free(scanner->current_path);
|
||||
scanner->current_path = NULL;
|
||||
if (is_root_seed) {
|
||||
/* The transfer ROOT being unreadable is always fatal: an empty keep-set
|
||||
would delete the whole destination. Mark the scan as errored so the
|
||||
client can report the partial-transfer exit code (rsync's 23). */
|
||||
scanner->root_io_error = true;
|
||||
if (!scanner->options.ignore_io_errors || is_root_seed) {
|
||||
scanner->failed = true;
|
||||
return -1;
|
||||
}
|
||||
/* A subdirectory that cannot be opened is always skipped (rsync continues
|
||||
with a partial transfer), whether or not --ignore-errors is set. The
|
||||
error is recorded so the client exits 23; --ignore-errors only changes
|
||||
what the deletion phase does with the recorded error. */
|
||||
/* --ignore-errors: record the I/O error and keep scanning the rest. */
|
||||
continue;
|
||||
}
|
||||
if (open_directory_filter_context(scanner, inherited) != 0) {
|
||||
@@ -1051,8 +985,7 @@ static int open_next_directory(DirectoryScanner* scanner) {
|
||||
scanner->options.dir_entries, scanner->options.dir_entries_mutex, scanner->root_path,
|
||||
scanner->current_path, scanner->relative_mode, scanner->options.relative_prefix,
|
||||
scanner->options.preserve_atimes, scanner->options.preserve_crtimes,
|
||||
scanner->options.preserve_xattrs, scanner->options.preserve_acls,
|
||||
scanner->options.no_implied_dirs, scanner->options.file_list)) {
|
||||
scanner->options.preserve_xattrs, scanner->options.preserve_acls)) {
|
||||
closedir(scanner->current_dir);
|
||||
scanner->current_dir = NULL;
|
||||
free(scanner->current_path);
|
||||
@@ -1417,22 +1350,10 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
|
||||
const struct dirent* entry = readdir(scanner->current_dir);
|
||||
if (entry == NULL) {
|
||||
/* The directory is exhausted: if nothing was transferred or descended
|
||||
from it, recreate it at the destination as an explicit entry. */
|
||||
if (scanner->options.emit_empty_dirs && !scanner->current_dir_produced &&
|
||||
!scanner->options.prune_empty_dirs && !scanner->options.list_dirs &&
|
||||
scanner->options.file_list == NULL) {
|
||||
if (!scanner_emit_empty_dir(scanner, chunk_data))
|
||||
scanner->failed = true;
|
||||
}
|
||||
closedir(scanner->current_dir);
|
||||
scanner->current_dir = NULL;
|
||||
free(scanner->current_path);
|
||||
scanner->current_path = NULL;
|
||||
if (scanner->failed) {
|
||||
array_list_delete(chunk_data);
|
||||
return NULL;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1567,7 +1488,6 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
scanner->failed = true;
|
||||
break;
|
||||
}
|
||||
scanner->current_dir_produced = true;
|
||||
free(cur_path);
|
||||
continue;
|
||||
}
|
||||
@@ -1582,7 +1502,6 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
scanner->current_dir_produced = true;
|
||||
int next_depth = scanner->current_depth + 1;
|
||||
if (scanner->options.max_depth <= 0 || next_depth < scanner->options.max_depth) {
|
||||
DirEntry* de = dir_entry_create(cur_path, next_depth, scanner->current_node);
|
||||
@@ -1635,7 +1554,6 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
scanner->options.preserve_specials,
|
||||
scanner->options.copy_devices, file, &stats);
|
||||
if (special == SCANNER_SPECIAL_SKIP) {
|
||||
scanner_note_nonreg(&scanner->options, file->path);
|
||||
free(rel_copy);
|
||||
file_destroy(file);
|
||||
continue;
|
||||
@@ -1659,7 +1577,6 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
scanner->failed = true;
|
||||
break;
|
||||
}
|
||||
scanner->current_dir_produced = true;
|
||||
chunk_data_size += file->data->size;
|
||||
if (chunk_data_size > scanner->options.chunk_size) {
|
||||
free(rel_copy);
|
||||
@@ -1687,7 +1604,7 @@ bool directory_scanner_failed(const DirectoryScanner* scanner) {
|
||||
}
|
||||
|
||||
bool directory_scanner_had_io_error(const DirectoryScanner* scanner) {
|
||||
return scanner != NULL && (scanner->io_error || scanner->root_io_error);
|
||||
return scanner != NULL && scanner->io_error;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
@@ -2057,7 +1974,6 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
ScannerSpecial special = scanner_prepare_special(
|
||||
options->preserve_devices, options->preserve_specials, options->copy_devices, file, &st);
|
||||
if (special == SCANNER_SPECIAL_SKIP) {
|
||||
scanner_note_nonreg(ps->options, file->path);
|
||||
free(rel);
|
||||
file_destroy(file);
|
||||
return;
|
||||
@@ -2218,7 +2134,6 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
|
||||
return NULL;
|
||||
}
|
||||
ps->allocation_session = allocation_session;
|
||||
ps->options = options;
|
||||
|
||||
ArrayList* root_files = array_list_create(file_destroy);
|
||||
ArrayList* subdirs = array_list_create(free);
|
||||
@@ -2287,11 +2202,11 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
|
||||
transfer root itself (it hands the root's immediate subdirectories to
|
||||
workers), so capture the root's directory time here. */
|
||||
if (options->capture_dir_times &&
|
||||
!scanner_capture_dir_time(
|
||||
options->dir_entries, options->dir_entries_mutex, root_directory, root_directory,
|
||||
options->relative && options->file_list != NULL, options->relative_prefix,
|
||||
options->preserve_atimes, options->preserve_crtimes, options->preserve_xattrs,
|
||||
options->preserve_acls, options->no_implied_dirs, options->file_list)) {
|
||||
!scanner_capture_dir_time(options->dir_entries, options->dir_entries_mutex, root_directory,
|
||||
root_directory, options->relative && options->file_list != NULL,
|
||||
options->relative_prefix, options->preserve_atimes,
|
||||
options->preserve_crtimes, options->preserve_xattrs,
|
||||
options->preserve_acls)) {
|
||||
array_list_delete(root_files);
|
||||
array_list_delete(subdirs);
|
||||
parallel_scanner_destroy(ps);
|
||||
|
||||
+3
-33
@@ -121,18 +121,9 @@ typedef struct {
|
||||
* it) and to emit its plan after the data stream, when no file frame would
|
||||
* otherwise trigger it. Guarded by `excluded_mutex`. */
|
||||
ArrayList* plan_dirs;
|
||||
/* --ignore-errors: an unreadable subdirectory no longer aborts the scan (it
|
||||
* is always skipped so the rest of the tree transfers); this flag is kept so
|
||||
* the client can distinguish the option state when deciding deletion policy.
|
||||
* Client-only. */
|
||||
/* --ignore-errors: an unreadable directory during the scan is recorded as an
|
||||
* I/O error and skipped instead of aborting the scan. Client-only. */
|
||||
bool ignore_io_errors;
|
||||
/* --info=nonreg: print rsync's `skipping non-regular file "NAME"` line for a
|
||||
* non-regular entry that is not being preserved. Client-only. */
|
||||
bool note_nonreg;
|
||||
/* Source root and 8-bit-output policy used to render a `--info=nonreg` name
|
||||
* relative to the transfer root. Borrowed read-only. */
|
||||
const char* send_directory;
|
||||
bool eight_bit_output;
|
||||
/* --ignore-missing-args (implied by --delete-missing-args): an explicitly
|
||||
* --files-from-listed entry that does not exist under the source is skipped
|
||||
* instead of failing (the --dirs generator is the only scanner path that
|
||||
@@ -160,17 +151,6 @@ typedef struct {
|
||||
bool capture_dir_times;
|
||||
ArrayList* dir_entries;
|
||||
mtx_t* dir_entries_mutex;
|
||||
/* Recreate empty source directories on a recursive transfer: emit a
|
||||
* payload-less directory entry for every traversed directory that produced
|
||||
* no transferred/descended child. Off by default so low-level scanner users
|
||||
* (unit helpers, --list-only) see only the historical file list; the real
|
||||
* sender sets it in prepare_scanner. */
|
||||
bool emit_empty_dirs;
|
||||
/* --no-implied-dirs with -R + --files-from: a directory that is only an
|
||||
* implied parent of a listed entry (not itself listed, nor below a listed
|
||||
* directory) must not carry source metadata; it is created with default
|
||||
* attributes at the destination, matching rsync. */
|
||||
bool no_implied_dirs;
|
||||
} ScannerOptions;
|
||||
|
||||
/* Internal per-scanner filter state. FilterNode chains represent the ordered
|
||||
@@ -188,11 +168,6 @@ typedef struct {
|
||||
int current_depth;
|
||||
dev_t root_dev;
|
||||
bool failed;
|
||||
/* Recursive scan: whether the open directory yielded any transferred or
|
||||
descended entry. When it did not, closing it emits a directory entry so
|
||||
the empty source directory is recreated at the destination (rsync
|
||||
parity). */
|
||||
bool current_dir_produced;
|
||||
/* Phase 2 (files-from / filter layer). */
|
||||
char* root_path; /* transfer root (fs path) for rel computation */
|
||||
char* current_rel; /* rel path of the open directory ("" == root) */
|
||||
@@ -211,10 +186,6 @@ typedef struct {
|
||||
--ignore-errors the scan continues past it and the caller decides what to
|
||||
do; `failed` is reserved for fatal errors that always abort the scan. */
|
||||
bool io_error;
|
||||
/* The transfer ROOT could not be opened. It is always fatal, even under
|
||||
--ignore-errors, but the client still maps it to rsync's partial-transfer
|
||||
exit (23) rather than a generic failure. */
|
||||
bool root_io_error;
|
||||
} DirectoryScanner;
|
||||
|
||||
typedef struct {
|
||||
@@ -234,8 +205,7 @@ typedef struct {
|
||||
int completed;
|
||||
Chunk* initial_chunk;
|
||||
ProtocolSession* allocation_session;
|
||||
FilterNode* root_filter_node; /* root .rsync-filter context (owned by ps) */
|
||||
const ScannerOptions* options; /* borrowed scan options (--info=nonreg output) */
|
||||
FilterNode* root_filter_node; /* root .rsync-filter context (owned by ps) */
|
||||
} ParallelScanner;
|
||||
|
||||
DirectoryScanner* directory_scanner_create(const char* root_directory, bool use_metadata,
|
||||
|
||||
+5
-8
@@ -90,8 +90,8 @@ void print_usage(void) {
|
||||
printf(" entry's destination mirror receiver-side. Independent of\n");
|
||||
printf(" --delete (it does not imply --delete; a non-empty directory\n");
|
||||
printf(" mirror is removed only with --force or --delete)\n");
|
||||
printf(" -m, --prune-empty-dirs Do not create empty directories (a recursive transfer\n");
|
||||
printf(" otherwise recreates them, like rsync)\n");
|
||||
printf(" -m, --prune-empty-dirs Do not transfer empty directory entries (--dirs mode);\n");
|
||||
printf(" recursive transfers never send empty dirs\n");
|
||||
printf(" Note: each timing flag implies --delete. Combining a timing flag with\n");
|
||||
printf(" --no-delete (in either order) is rejected as a config error.\n");
|
||||
printf(" --ignore-existing Skip files that already exist on receiver\n");
|
||||
@@ -103,9 +103,8 @@ void print_usage(void) {
|
||||
printf(" -R, --relative With --files-from, preserve each listed entry's relative path\n");
|
||||
printf(" below the destination root instead of mirroring the full\n");
|
||||
printf(" source path (no effect without --files-from)\n");
|
||||
printf(" --no-implied-dirs With -R, do not apply the source metadata of a listed file's\n");
|
||||
printf(" implied parent directories (they are still created with\n");
|
||||
printf(" default attributes)\n");
|
||||
printf(" --no-implied-dirs With -R --files-from, refuse to place a listed file whose\n");
|
||||
printf(" parent directory is not itself listed\n");
|
||||
printf(" --mkpath Create the destination root directory on the server when it\n");
|
||||
printf(" does not exist yet\n");
|
||||
printf(" --exclude <pattern>, --exclude=<pattern> Exclude files matching pattern\n");
|
||||
@@ -244,9 +243,7 @@ void print_usage(void) {
|
||||
printf(" reusable digest is sent (keep the file mode 0600)\n");
|
||||
printf(" --no-motd Suppress display of the daemon's MOTD (the server\n");
|
||||
printf(" still sends it; the client just does not show it)\n");
|
||||
printf(" --bwlimit=RATE Limit socket I/O bandwidth (default unit KiB/s,\n");
|
||||
printf(" rsync-style: 0 = no limit; K/M/G/T/P suffixes are\n");
|
||||
printf(" binary, KB/MB decimal, KiB/MiB binary; decimals allowed)\n");
|
||||
printf(" --bwlimit <KB/s> Bandwidth limit in kilobytes per second\n");
|
||||
printf(" --tls Enable TLS encryption\n");
|
||||
printf(" --cert <path> TLS certificate file (PEM)\n");
|
||||
printf(" --key <path> TLS private key file (PEM)\n");
|
||||
|
||||
+13
-69
@@ -61,18 +61,13 @@ bool receiver_send_final_success(int fd, const Config* config, const ReceiverOut
|
||||
}
|
||||
|
||||
bool receiver_send_stats_frame(int fd, const Config* config, const ReceiverStats* stats,
|
||||
const struct ArrayList* would_delete,
|
||||
const struct ArrayList* deleted_paths) {
|
||||
const struct ArrayList* would_delete) {
|
||||
if (!config->report_stats)
|
||||
return true;
|
||||
ReceiverStats local;
|
||||
memset(&local, 0, sizeof(local));
|
||||
const ReceiverStats* out = stats ? stats : &local;
|
||||
/* The path list carries the dry-run would-delete set for a -n run and the
|
||||
actually-removed set for a real --info=del run. */
|
||||
const struct ArrayList* paths =
|
||||
config->dry_run ? would_delete : (config->report_deletes ? deleted_paths : NULL);
|
||||
size_t count = paths ? (size_t)paths->size : 0;
|
||||
size_t count = would_delete ? (size_t)would_delete->size : 0;
|
||||
if (count > (size_t)MAX_MANIFEST_ENTRIES)
|
||||
count = MAX_MANIFEST_ENTRIES;
|
||||
ReceiverStats record = *out;
|
||||
@@ -81,7 +76,7 @@ bool receiver_send_stats_frame(int fd, const Config* config, const ReceiverStats
|
||||
!send_int(fd, (int)count))
|
||||
return false;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const char* path = (const char*)paths->items[i];
|
||||
const char* path = (const char*)would_delete->items[i];
|
||||
if (!send_wire_str(fd, path ? path : ""))
|
||||
return false;
|
||||
}
|
||||
@@ -95,25 +90,6 @@ static void receiver_tally_deleted(const ReceiverSink* sink, size_t deleted) {
|
||||
sink->stats->deleted_files += deleted;
|
||||
}
|
||||
|
||||
/* Observer for --info=del: record each truly-removed destination-relative path
|
||||
in the ArrayList passed as the observer context, so the terminal STATUS_STATS
|
||||
frame can list it. A failed append is best-effort (the deletion already
|
||||
happened; output is cosmetic). Shared by the single-threaded receiver and
|
||||
the -m pipeline's deferred commit. */
|
||||
void receiver_record_deleted_path(void* context, const char* rel_path) {
|
||||
ArrayList* paths = context;
|
||||
if (!paths || !rel_path)
|
||||
return;
|
||||
/* Bound the retained list like the keep-set manifest: only MAX_MANIFEST_ENTRIES
|
||||
paths are ever transmitted in the terminal STATUS_STATS frame, so recording
|
||||
more only grows memory. A hostile/huge deletion set is therefore capped. */
|
||||
if ((size_t)paths->size >= (size_t)MAX_MANIFEST_ENTRIES)
|
||||
return;
|
||||
char* copy = str_dup(rel_path);
|
||||
if (copy && !array_list_add(paths, copy))
|
||||
free(copy);
|
||||
}
|
||||
|
||||
static bool receiver_process_chunk(Chunk* chunk, const ReceiverSink* sink) {
|
||||
if (!chunk || !sink || !sink->store_file)
|
||||
return false;
|
||||
@@ -422,13 +398,9 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
--max-delete-capped commit still succeeds and the transfer proceeds;
|
||||
the terminal success frame reports the cap. */
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer =
|
||||
(config->report_deletes && sink->deleted_paths) ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion =
|
||||
(config->use_delete || config->delete_missing_args)
|
||||
? manifest_delete_all_observed(config, manifest, &deleted, observer,
|
||||
(void*)sink->deleted_paths)
|
||||
: DELETE_COMMIT_OK;
|
||||
DeleteCommitResult deletion = (config->use_delete || config->delete_missing_args)
|
||||
? manifest_delete_all_counted(config, manifest, &deleted)
|
||||
: DELETE_COMMIT_OK;
|
||||
receiver_tally_deleted(sink, deleted);
|
||||
delete_manifest_free(manifest);
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
@@ -463,12 +435,8 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
if (!plan_session) {
|
||||
if (!plan_session)
|
||||
plan_session = delete_plan_session_create(config);
|
||||
if (plan_session && config->report_deletes && sink->deleted_paths)
|
||||
delete_plan_session_set_delete_observer(plan_session, receiver_record_deleted_path,
|
||||
(void*)sink->deleted_paths);
|
||||
}
|
||||
if (!plan_session || delete_plan_session_receive(plan_session, config, file_descriptor) != 0)
|
||||
goto fail;
|
||||
if (delete_plan_session_limit_reached(plan_session) && !delete_limit_noted &&
|
||||
@@ -511,10 +479,8 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
deferred_manifest = NULL;
|
||||
} else {
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer =
|
||||
(config->report_deletes && sink->deleted_paths) ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion = manifest_delete_all_observed(
|
||||
config, deferred_manifest, &deleted, observer, (void*)sink->deleted_paths);
|
||||
DeleteCommitResult deletion =
|
||||
manifest_delete_all_counted(config, deferred_manifest, &deleted);
|
||||
receiver_tally_deleted(sink, deleted);
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
@@ -532,9 +498,6 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
hands the session to its caller instead, which commits after the disk
|
||||
writer drained. */
|
||||
if (plan_session) {
|
||||
if (config->report_deletes && sink->deleted_paths)
|
||||
delete_plan_session_set_delete_observer(plan_session, receiver_record_deleted_path,
|
||||
(void*)sink->deleted_paths);
|
||||
if (pending_plans) {
|
||||
*pending_plans = plan_session;
|
||||
plan_session = NULL;
|
||||
@@ -606,15 +569,11 @@ typedef struct {
|
||||
--delete would-delete path list collected while processing the manifest. */
|
||||
ReceiverStats stats;
|
||||
ArrayList* would_delete;
|
||||
/* --info=del: actually-removed paths collected during the delete commit. */
|
||||
ArrayList* deleted_paths;
|
||||
} ReceiverSaveContext;
|
||||
|
||||
static bool receiver_save_file(File* file, void* context_pointer) {
|
||||
ReceiverSaveContext* context = context_pointer;
|
||||
FileSaveResult result = FILE_SAVE_ERROR;
|
||||
bool created = false;
|
||||
unsigned created_dirs = 0;
|
||||
if (context->config->dry_run) {
|
||||
/* Defense in depth: a dry-run receiver mutates nothing even if a data
|
||||
frame reaches the sink (the sender is not supposed to send one). */
|
||||
@@ -624,17 +583,12 @@ static bool receiver_save_file(File* file, void* context_pointer) {
|
||||
--remove-source-files sender keeps its source. */
|
||||
result = FILE_SAVE_SKIPPED;
|
||||
} else {
|
||||
result = file_save_to_disk_full_ex(context->config->receive_root_directory, file,
|
||||
context->config, &created, &created_dirs);
|
||||
result = file_save_to_disk_full(context->config->receive_root_directory, file, context->config);
|
||||
}
|
||||
/* Wire-stats tally: bytes reconstructed from the basis file (delta matches)
|
||||
count as matched data in the end-of-transfer report. */
|
||||
if (result != FILE_SAVE_ERROR && file->matched_bytes > 0)
|
||||
context->stats.matched_data += file->matched_bytes;
|
||||
/* Protocol 2.28.0: receiver-observed literal bytes and the created-entry
|
||||
breakdown (regular/dir/link/special) for the `--stats` report. */
|
||||
if (result == FILE_SAVE_WRITTEN)
|
||||
receiver_stats_note_saved(&context->stats, file, created, created_dirs);
|
||||
/* A directory's metadata is deferred, never applied inline: collect it now
|
||||
and apply it at the end. -O/--omit-dir-times and --preserve_perms/-times
|
||||
are honored by dir_metadata_list_apply's caller (see
|
||||
@@ -667,8 +621,7 @@ static void receiver_note_delete_limit(void* context_pointer) {
|
||||
static bool receiver_send_success_frame(int fd, void* context_pointer) {
|
||||
ReceiverSaveContext* context = context_pointer;
|
||||
Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK;
|
||||
if (!receiver_send_stats_frame(fd, context->config, &context->stats, context->would_delete,
|
||||
context->deleted_paths))
|
||||
if (!receiver_send_stats_frame(fd, context->config, &context->stats, context->would_delete))
|
||||
return false;
|
||||
/* Server-contacting --dry-run: nothing was staged or written, so there is
|
||||
nothing to publish and no directory times to stamp. */
|
||||
@@ -698,15 +651,8 @@ int receiver_receive_files(Config* config, int file_descriptor) {
|
||||
ReceiverSaveContext context = {.config = config, .outcomes = {0}};
|
||||
dir_time_list_init(&context.dir_times);
|
||||
context.would_delete = array_list_create(free);
|
||||
/* report_deletes (--info=del / -i / --out-format under --delete) is the only
|
||||
reason to retain the actually-removed paths; a plain --delete must not
|
||||
str_dup every removal. NULL is handled by every consumer. */
|
||||
context.deleted_paths = config->report_deletes ? array_list_create(free) : NULL;
|
||||
if (!context.would_delete || (config->report_deletes && !context.deleted_paths)) {
|
||||
array_list_delete(context.would_delete);
|
||||
array_list_delete(context.deleted_paths);
|
||||
if (!context.would_delete)
|
||||
return -1;
|
||||
}
|
||||
ReceiverSink sink = {receiver_save_file,
|
||||
&context,
|
||||
true,
|
||||
@@ -714,14 +660,12 @@ int receiver_receive_files(Config* config, int file_descriptor) {
|
||||
receiver_send_success_frame,
|
||||
receiver_note_delete_limit,
|
||||
&context.stats,
|
||||
context.would_delete,
|
||||
context.deleted_paths};
|
||||
context.would_delete};
|
||||
int ret = receiver_process(config, file_descriptor, &sink);
|
||||
if (ret != 0 && config->delay_updates && config->delay_context)
|
||||
delay_updates_cleanup(config->delay_context);
|
||||
receiver_outcomes_destroy(&context.outcomes);
|
||||
dir_time_list_free(&context.dir_times);
|
||||
array_list_delete(context.would_delete);
|
||||
array_list_delete(context.deleted_paths);
|
||||
return ret;
|
||||
}
|
||||
|
||||
+1
-11
@@ -46,20 +46,11 @@ typedef struct {
|
||||
carries the -n/--dry-run --delete path list. */
|
||||
ReceiverStats* stats;
|
||||
struct ArrayList* would_delete;
|
||||
/* When --info=del requested it, receiver-owned strings for every path the
|
||||
deletion commit ACTUALLY removed, sent in the terminal STATUS_STATS frame's
|
||||
path list so the sender can print rsync's `deleting PATH` lines. */
|
||||
struct ArrayList* deleted_paths;
|
||||
} ReceiverSink;
|
||||
|
||||
bool receiver_outcomes_append(ReceiverOutcomes* outcomes, unsigned char code);
|
||||
void receiver_outcomes_destroy(ReceiverOutcomes* outcomes);
|
||||
|
||||
/* DeletePathObserver implementation for --info=del: `context` is an ArrayList*
|
||||
that receives owned copies of every truly-removed destination-relative path.
|
||||
Shared by the single-threaded receiver and the -m pipeline's deferred commit. */
|
||||
void receiver_record_deleted_path(void* context, const char* rel_path);
|
||||
|
||||
/* Send the terminal success frame. `final_status` is usually STATUS_OK, or
|
||||
STATUS_DELETE_LIMIT when a --max-delete commit was capped. */
|
||||
bool receiver_send_final_success(int fd, const Config* config, const ReceiverOutcomes* outcomes,
|
||||
@@ -69,8 +60,7 @@ bool receiver_send_final_success(int fd, const Config* config, const ReceiverOut
|
||||
non-NULL, a count and that many wire strings) when the wire config requested
|
||||
report_stats. A no-op otherwise. */
|
||||
bool receiver_send_stats_frame(int fd, const Config* config, const ReceiverStats* stats,
|
||||
const struct ArrayList* would_delete,
|
||||
const struct ArrayList* deleted_paths);
|
||||
const struct ArrayList* would_delete);
|
||||
|
||||
int receiver_process(Config* config, int file_descriptor, const ReceiverSink* sink);
|
||||
/* receiver_process with an escape hatch for the commit-style (late) deletion:
|
||||
|
||||
@@ -31,7 +31,6 @@ PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue*
|
||||
context->delete_limit_reached = false;
|
||||
memset(&context->stats, 0, sizeof(context->stats));
|
||||
context->would_delete = NULL;
|
||||
context->deleted_paths = NULL;
|
||||
atomic_init(&context->cancelled, false);
|
||||
int init = 0;
|
||||
if (mtx_init(&context->mutex, mtx_plain) != thrd_success)
|
||||
@@ -47,15 +46,6 @@ PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue*
|
||||
context->would_delete = array_list_create(free);
|
||||
if (!context->would_delete)
|
||||
goto fail;
|
||||
/* The actually-removed path list is only needed to render rsync's
|
||||
`deleting PATH` lines, which the client requests via report_deletes
|
||||
(--info=del / -i / --out-format under --delete). A plain --delete run must
|
||||
not allocate it or observe every removal. */
|
||||
if (config->report_deletes) {
|
||||
context->deleted_paths = array_list_create(free);
|
||||
if (!context->deleted_paths)
|
||||
goto fail;
|
||||
}
|
||||
return context;
|
||||
|
||||
fail:
|
||||
@@ -66,12 +56,6 @@ fail:
|
||||
cnd_destroy(&context->condition_not_full);
|
||||
if (init >= 1)
|
||||
mtx_destroy(&context->mutex);
|
||||
/* Free every list that was already created before the failing allocation:
|
||||
`context` itself is freed below, so they would otherwise leak. */
|
||||
if (context->would_delete)
|
||||
array_list_delete(context->would_delete);
|
||||
if (context->deleted_paths)
|
||||
array_list_delete(context->deleted_paths);
|
||||
free(context);
|
||||
return NULL;
|
||||
}
|
||||
@@ -87,8 +71,6 @@ void pipeline_context_receiver_destroy(PipelineContextReceiver* context) {
|
||||
dir_time_list_free(&context->dir_times);
|
||||
if (context->would_delete)
|
||||
array_list_delete(context->would_delete);
|
||||
if (context->deleted_paths)
|
||||
array_list_delete(context->deleted_paths);
|
||||
mtx_destroy(&context->mutex);
|
||||
cnd_destroy(&context->condition_not_full);
|
||||
cnd_destroy(&context->condition_not_empty);
|
||||
@@ -202,8 +184,7 @@ int receive_thread(void* pipeline_context) {
|
||||
NULL,
|
||||
receiver_pipeline_note_delete_limit,
|
||||
&context->stats,
|
||||
context->would_delete,
|
||||
context->deleted_paths};
|
||||
context->would_delete};
|
||||
if (receiver_process_pending((Config*)config, file_descriptor, &sink, &context->deferred_manifest,
|
||||
&context->deferred_plans) != 0) {
|
||||
receiver_thread_fail(context);
|
||||
@@ -247,23 +228,12 @@ int write_thread(void* pipeline_context) {
|
||||
}
|
||||
size_t file_bytes = file->data ? file->data->size : 0;
|
||||
FileSaveResult result = FILE_SAVE_SKIPPED;
|
||||
bool created = false;
|
||||
unsigned created_dirs = 0;
|
||||
/* Server-contacting --dry-run: never write. The receiver thread does not
|
||||
enqueue anything on the dry-run path, but this keeps the writer thread
|
||||
provably mutation-free if a data frame ever reached it. */
|
||||
bool dry_run = context->config->dry_run;
|
||||
if (save_to_disk && !dry_run) {
|
||||
result =
|
||||
file_save_to_disk_full_ex(root_directory, file, context->config, &created, &created_dirs);
|
||||
if (result == FILE_SAVE_WRITTEN) {
|
||||
/* Protocol 2.28.0: fold the receiver-observed literal bytes and the
|
||||
created-entry type into the shared stats block under its mutex (the
|
||||
receive thread also writes stats.matched_data). */
|
||||
mtx_lock(&context->mutex);
|
||||
receiver_stats_note_saved(&context->stats, file, created, created_dirs);
|
||||
mtx_unlock(&context->mutex);
|
||||
}
|
||||
result = file_save_to_disk_full(root_directory, file, context->config);
|
||||
if (result == FILE_SAVE_ERROR) {
|
||||
file_destroy(file);
|
||||
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
||||
|
||||
@@ -61,9 +61,6 @@ typedef struct PipelineContextReceiver {
|
||||
/* -n/--dry-run --delete would-delete path list, collected by receive_thread
|
||||
and reported in the STATUS_STATS frame. */
|
||||
struct ArrayList* would_delete;
|
||||
/* --info=del actually-removed path list, collected by the deferred delete
|
||||
commit in server.c and reported in the STATUS_STATS frame. */
|
||||
struct ArrayList* deleted_paths;
|
||||
} PipelineContextReceiver;
|
||||
|
||||
PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue* queue_receiver,
|
||||
|
||||
+3
-7
@@ -956,9 +956,8 @@ void handler(int file_descriptor) {
|
||||
server-contacting --dry-run deletes nothing (no manifest is sent). */
|
||||
if (context->deferred_manifest) {
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer = config->report_deletes ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion = manifest_delete_all_observed(
|
||||
config, context->deferred_manifest, &deleted, observer, (void*)context->deleted_paths);
|
||||
DeleteCommitResult deletion =
|
||||
manifest_delete_all_counted(config, context->deferred_manifest, &deleted);
|
||||
context->stats.deleted_files += deleted;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
transfer_ok = false;
|
||||
@@ -976,9 +975,6 @@ void handler(int file_descriptor) {
|
||||
if (context->deferred_plans) {
|
||||
/* Defence in depth (the enclosing block already excludes dry-run): a
|
||||
-n run never commits a deletion. */
|
||||
if (config->report_deletes)
|
||||
delete_plan_session_set_delete_observer(
|
||||
context->deferred_plans, receiver_record_deleted_path, (void*)context->deleted_paths);
|
||||
DeleteCommitResult deletion =
|
||||
config->dry_run ? DELETE_COMMIT_OK
|
||||
: delete_plan_session_commit(context->deferred_plans, config);
|
||||
@@ -1014,7 +1010,7 @@ void handler(int file_descriptor) {
|
||||
/* Emit the optional wire-stats record first (protocol 2.25.0), then the
|
||||
success/outcome frame, exactly like the single-threaded receiver. */
|
||||
if (!receiver_send_stats_frame(file_descriptor, config, &context->stats,
|
||||
context->would_delete, context->deleted_paths) ||
|
||||
context->would_delete) ||
|
||||
!receiver_send_final_success(file_descriptor, config, &context->outcomes, final_status))
|
||||
transfer_ok = false;
|
||||
} else {
|
||||
|
||||
+10
-15
@@ -168,14 +168,9 @@ bool charset_spec_valid_direction(const char* from_charset, const char* to_chars
|
||||
return direction_probe_valid(from_charset, to_charset);
|
||||
}
|
||||
|
||||
/* The receiver's conversion is wire charset -> destination charset. rsync's
|
||||
* CONVERT_SPEC is LOCAL,REMOTE and "stays the same whether you're pushing or
|
||||
* pulling", so for a PUSH (FastSync's only direction) the destination end's
|
||||
* charset is the spec's REMOTE half: the client converts LOCAL -> REMOTE on the
|
||||
* sender and the receiver writes the wire bytes verbatim. Only a server that
|
||||
* declares its OWN --iconv (the daemon "charset" analog) has a different local
|
||||
* charset, and then it is that spec's LOCAL half and the receiver converts
|
||||
* wire -> server-local. A dedicated pre-ack check so an impossible direction is
|
||||
/* The receiver's real conversion is wire(client REMOTE) -> server-local (the
|
||||
* server's own --iconv LOCAL half, or the client's LOCAL half when the server
|
||||
* has no --iconv). A dedicated pre-ack check so an impossible direction is
|
||||
* rejected before the connection instead of refusing mid-transfer. */
|
||||
bool charset_wire_receiver_spec_valid(const char* spec, const char* server_spec) {
|
||||
if (!spec)
|
||||
@@ -185,7 +180,7 @@ bool charset_wire_receiver_spec_valid(const char* spec, const char* server_spec)
|
||||
if (charset_spec_parse(spec, &local, &remote) != 0)
|
||||
return false;
|
||||
const char* wire = remote;
|
||||
const char* target_local = remote;
|
||||
const char* target_local = local;
|
||||
char* server_local = NULL;
|
||||
char* server_remote = NULL;
|
||||
if (server_spec) {
|
||||
@@ -307,13 +302,13 @@ bool charset_wire_init_receiver(const char* spec, const char* server_spec) {
|
||||
char* remote;
|
||||
if (charset_spec_parse(spec, &local, &remote) != 0)
|
||||
return false;
|
||||
/* The wire charset is the client spec's REMOTE half (rsync's LOCAL,REMOTE
|
||||
* spec stays the same push or pull, so on a push the destination end's
|
||||
* charset is REMOTE and the receiver writes the wire bytes verbatim). Only a
|
||||
* server started with its own --iconv declares a different local charset (the
|
||||
* server halves above never travel), and then it is that spec's LOCAL half. */
|
||||
/* The wire charset is the client spec's REMOTE half; the local charset is
|
||||
* the client spec's LOCAL half unless the server was itself started with
|
||||
* --iconv naming a different local charset (the server halves above never
|
||||
* travel, so the server's own flag is the only way its local charset can
|
||||
* differ from what the client assumed). */
|
||||
const char* wire = remote;
|
||||
const char* target_local = remote;
|
||||
const char* target_local = local;
|
||||
char* server_local = NULL;
|
||||
char* server_remote = NULL;
|
||||
if (server_spec) {
|
||||
|
||||
@@ -57,9 +57,8 @@ void charset_conversion_close(void* conversion);
|
||||
/* Process-wide wire conversion. charset_wire_init_sender (client side) opens
|
||||
* LOCAL->REMOTE; charset_wire_init_receiver (server side) opens
|
||||
* wire(REMOTE)->server-local. server_spec is the server's own --iconv, whose
|
||||
* LOCAL half overrides the destination charset; NULL means the destination
|
||||
* charset is the client spec's REMOTE half (rsync's push semantics: the wire
|
||||
* bytes are written verbatim). Both return false on an unsupported spec.
|
||||
* LOCAL half may override the local charset the client assumed; NULL reuses
|
||||
* the client spec's LOCAL half. Both return false on an unsupported spec.
|
||||
* The state is freed with charset_wire_free. */
|
||||
bool charset_wire_init_sender(const char* spec);
|
||||
bool charset_wire_init_receiver(const char* spec, const char* server_spec);
|
||||
@@ -67,9 +66,9 @@ void charset_wire_free(void);
|
||||
bool charset_wire_active(void);
|
||||
|
||||
/* Pre-ack receiver-direction sanity (see charset_wire_init_receiver): true
|
||||
* when the exact wire->destination conversion the receiver will use (client
|
||||
* spec's REMOTE half into the server's own LOCAL half, or REMOTE->REMOTE when
|
||||
* the server has no --iconv) opens and produces NUL-free output. */
|
||||
* when the exact wire->server-local conversion the receiver will use (client
|
||||
* spec's REMOTE half into the server's own LOCAL half, or the client's LOCAL
|
||||
* half when the server has no --iconv) opens and produces NUL-free output. */
|
||||
bool charset_wire_receiver_spec_valid(const char* spec, const char* server_spec);
|
||||
|
||||
/* Convert a path across the wire in the process direction. Returns a malloc'd
|
||||
|
||||
+3
-42
@@ -1,5 +1,4 @@
|
||||
#include "checksum.h"
|
||||
#include "utils.h"
|
||||
#include <fcntl.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <string.h>
|
||||
@@ -224,25 +223,17 @@ bool checksum_digest_file(ChecksumAlgo algo, uint64_t seed, const char* path, ui
|
||||
if (fd < 0)
|
||||
return false;
|
||||
|
||||
if (algo == CHECKSUM_ALGO_NONE) {
|
||||
/* No checksum requested: nothing to read; an empty digest succeeds. */
|
||||
close(fd);
|
||||
*out_len = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t buffer[64 * 1024];
|
||||
bool ok = false;
|
||||
|
||||
if (algo == CHECKSUM_ALGO_MD5 || algo == CHECKSUM_ALGO_SHA1) {
|
||||
const EVP_MD* md = algo == CHECKSUM_ALGO_MD5 ? EVP_md5() : EVP_sha1();
|
||||
if (algo == CHECKSUM_ALGO_MD5) {
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
if (!ctx) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
unsigned int digest_len = 0;
|
||||
if (EVP_DigestInit_ex(ctx, md, NULL) == 1) {
|
||||
if (EVP_DigestInit_ex(ctx, EVP_md5(), NULL) == 1) {
|
||||
ok = true;
|
||||
ssize_t got;
|
||||
while ((got = read(fd, buffer, sizeof(buffer))) > 0) {
|
||||
@@ -263,23 +254,6 @@ bool checksum_digest_file(ChecksumAlgo algo, uint64_t seed, const char* path, ui
|
||||
return ok;
|
||||
}
|
||||
|
||||
if (algo == CHECKSUM_ALGO_MD4) {
|
||||
Md4Ctx ctx;
|
||||
md4_init(&ctx);
|
||||
ok = true;
|
||||
ssize_t got;
|
||||
while ((got = read(fd, buffer, sizeof(buffer))) > 0)
|
||||
md4_update(&ctx, buffer, (size_t)got);
|
||||
if (got < 0)
|
||||
ok = false;
|
||||
if (ok) {
|
||||
md4_final(&ctx, out);
|
||||
*out_len = 16;
|
||||
}
|
||||
close(fd);
|
||||
return ok;
|
||||
}
|
||||
|
||||
XXH64_state_t xxh64;
|
||||
XXH3_state_t* xxh3 = NULL;
|
||||
if (algo == CHECKSUM_ALGO_XXH64) {
|
||||
@@ -397,7 +371,7 @@ uint8_t checksum_digest_len(ChecksumAlgo algo) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static ChecksumAlgo compiled_checksum_preference_first(void) {
|
||||
ChecksumAlgo checksum_negotiate_default(void) {
|
||||
/* rsync 3.4.1 default preference order; every entry is compiled in, so this
|
||||
* resolves to xxh128. */
|
||||
static const ChecksumAlgo preference[] = {
|
||||
@@ -410,16 +384,3 @@ static ChecksumAlgo compiled_checksum_preference_first(void) {
|
||||
}
|
||||
return CHECKSUM_ALGO_XXH64;
|
||||
}
|
||||
|
||||
int checksum_choice_resolve(void) {
|
||||
bool specified = false;
|
||||
int env = env_choice_first("RSYNC_CHECKSUM_LIST", checksum_algo_from_name, &specified);
|
||||
if (specified)
|
||||
return env; /* -1 = the list named no supported checksum */
|
||||
return (int)compiled_checksum_preference_first();
|
||||
}
|
||||
|
||||
ChecksumAlgo checksum_negotiate_default(void) {
|
||||
int resolved = checksum_choice_resolve();
|
||||
return resolved >= 0 ? (ChecksumAlgo)resolved : compiled_checksum_preference_first();
|
||||
}
|
||||
|
||||
@@ -72,11 +72,4 @@ uint8_t checksum_digest_len(ChecksumAlgo algo);
|
||||
* xxh128 xxh3 xxh64 md5 md4 sha1 none). Used to resolve "auto". */
|
||||
ChecksumAlgo checksum_negotiate_default(void);
|
||||
|
||||
/* Resolve "auto" the way rsync does: the first supported name in
|
||||
* RSYNC_CHECKSUM_LIST (whitespace-separated, client half ends at '&'), then the
|
||||
* compiled-in preference order when the variable is unset/blank. Returns -1
|
||||
* when the variable is set but names no supported checksum (rsync's failed
|
||||
* negotiation), otherwise a valid ChecksumAlgo id. */
|
||||
int checksum_choice_resolve(void);
|
||||
|
||||
#endif /* CHECKSUM_H */
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include "data.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
#include <limits.h>
|
||||
#include <lz4.h>
|
||||
#include <stdatomic.h>
|
||||
@@ -120,7 +119,7 @@ bool compression_algo_enabled(CompressionAlgo algo) {
|
||||
return algo != COMPRESSION_ALGO_NONE;
|
||||
}
|
||||
|
||||
static CompressionAlgo compiled_preference_first(void) {
|
||||
CompressionAlgo compression_negotiate_default(void) {
|
||||
/* rsync 3.4.1 default preference order; every entry is compiled in, so this
|
||||
* resolves to zstd. */
|
||||
static const CompressionAlgo preference[] = {
|
||||
@@ -134,57 +133,6 @@ static CompressionAlgo compiled_preference_first(void) {
|
||||
return COMPRESSION_ALGO_ZSTD;
|
||||
}
|
||||
|
||||
int compression_choice_resolve(void) {
|
||||
bool specified = false;
|
||||
int env = env_choice_first("RSYNC_COMPRESS_LIST", compression_algo_from_name, &specified);
|
||||
if (specified)
|
||||
return env; /* -1 = the list named no supported codec */
|
||||
return (int)compiled_preference_first();
|
||||
}
|
||||
|
||||
CompressionAlgo compression_negotiate_default(void) {
|
||||
int resolved = compression_choice_resolve();
|
||||
return resolved >= 0 ? (CompressionAlgo)resolved : compiled_preference_first();
|
||||
}
|
||||
|
||||
int compression_default_level(CompressionAlgo algo) {
|
||||
switch (algo) {
|
||||
case COMPRESSION_ALGO_ZSTD:
|
||||
return ZSTD_CLEVEL_DEFAULT;
|
||||
case COMPRESSION_ALGO_ZLIB:
|
||||
case COMPRESSION_ALGO_ZLIBX:
|
||||
return 6; /* rsync resolves zlib's Z_DEFAULT_COMPRESSION (-1) to 6 */
|
||||
case COMPRESSION_ALGO_LZ4:
|
||||
return 1; /* rsync lz4 level is 0/ignored; positive keeps the gate on */
|
||||
case COMPRESSION_ALGO_NONE:
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int compression_clamp_level(CompressionAlgo algo, int level) {
|
||||
switch (algo) {
|
||||
case COMPRESSION_ALGO_ZSTD:
|
||||
if (level < 1)
|
||||
return 1;
|
||||
if (level > 22)
|
||||
return 22;
|
||||
return level;
|
||||
case COMPRESSION_ALGO_ZLIB:
|
||||
case COMPRESSION_ALGO_ZLIBX:
|
||||
if (level < 1)
|
||||
return 1;
|
||||
if (level > 9)
|
||||
return 9;
|
||||
return level;
|
||||
case COMPRESSION_ALGO_LZ4:
|
||||
return 1; /* ignored by lz4_compress; keeps the "compress" gate on */
|
||||
case COMPRESSION_ALGO_NONE:
|
||||
return 0;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
void compression_set_algo(CompressionAlgo algo) {
|
||||
if (compression_algo_valid((int)algo))
|
||||
atomic_store(&g_compression_algo, (int)algo);
|
||||
|
||||
@@ -35,26 +35,6 @@ bool compression_algo_valid(int algo);
|
||||
* "auto". */
|
||||
CompressionAlgo compression_negotiate_default(void);
|
||||
|
||||
/* Resolve "auto" the way rsync does: the first supported name in
|
||||
* RSYNC_COMPRESS_LIST (whitespace-separated, client half ends at '&'), then the
|
||||
* compiled-in preference order when the variable is unset/blank. Returns -1
|
||||
* when the variable is set but names no supported codec (rsync's failed
|
||||
* negotiation), otherwise a valid CompressionAlgo id. */
|
||||
int compression_choice_resolve(void);
|
||||
|
||||
/* rsync 3.4.1's per-codec default level, applied when the user did not pass
|
||||
* --compress-level/--zl. zstd uses ZSTD_CLEVEL_DEFAULT (3) and zlib/zlibx the
|
||||
* resolved Z_DEFAULT_COMPRESSION (6). lz4 has no tunable level in rsync
|
||||
* (always the default acceleration); FastSync returns a positive placeholder so
|
||||
* its "level > 0" compression gate stays engaged, and lz4_compress ignores the
|
||||
* value, so the output is identical to rsync's. none is 0. */
|
||||
int compression_default_level(CompressionAlgo algo);
|
||||
|
||||
/* Clamp an explicit --compress-level to the codec's accepted range the way
|
||||
* rsync's init_compression_level() does: zstd 1..22, zlib/zlibx 1..9, lz4
|
||||
* ignored (fixed positive placeholder), none 0. */
|
||||
int compression_clamp_level(CompressionAlgo algo, int level);
|
||||
|
||||
/* True when the algorithm actually compresses (i.e. is not NONE). */
|
||||
bool compression_algo_enabled(CompressionAlgo algo);
|
||||
|
||||
|
||||
+1
-4
@@ -70,8 +70,6 @@ static void config_set_defaults(Config* config) {
|
||||
config->ignore_missing_args = false;
|
||||
config->checksum_transfer_algo = CHECKSUM_ALGO_DEFAULT;
|
||||
config->cli_exit_code = 0;
|
||||
config->compression_level_set = false;
|
||||
config->checksum_choice_set = false;
|
||||
config->filters = NULL;
|
||||
config->files_from = NULL;
|
||||
config->files_from_set = NULL;
|
||||
@@ -208,8 +206,7 @@ static bool validate_received_config(const Config* config) {
|
||||
valid_wire_bool(config->preserve_perms) && valid_wire_bool(config->preserve_times) &&
|
||||
valid_wire_bool(config->preserve_owner) && valid_wire_bool(config->preserve_group) &&
|
||||
valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) &&
|
||||
valid_wire_bool(config->fake_super) && valid_wire_bool(config->report_dest_info) &&
|
||||
valid_wire_bool(config->report_stats) && valid_wire_bool(config->report_deletes) &&
|
||||
valid_wire_bool(config->fake_super) &&
|
||||
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
|
||||
(!config->use_compression ||
|
||||
(config->compression_level >= 1 && config->compression_level <= 22)) &&
|
||||
|
||||
+6
-43
@@ -82,7 +82,7 @@ typedef struct {
|
||||
typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode;
|
||||
|
||||
/* ===========================================================================
|
||||
* Config wire-field table (single source of truth for protocol 2.28.0).
|
||||
* Config wire-field table (single source of truth for protocol 2.26.0).
|
||||
*
|
||||
* Every field below crosses the wire. The table is the ONLY place a
|
||||
* serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare
|
||||
@@ -259,18 +259,9 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
||||
* for -n/--dry-run --delete, the destination-relative paths it WOULD have
|
||||
* deleted. It is set by the client only when --stats, --progress/-P, an
|
||||
* --out-format token needs a wire counter (%b/%c), or a dry-run carries
|
||||
* --delete; the transfer decision itself is unchanged.
|
||||
*
|
||||
* --info wave (protocol 2.27.0). report_deletes tells the receiver to include
|
||||
* the destination-relative paths it ACTUALLY removed in its terminal
|
||||
* STATUS_STATS record (the same path-list field the dry-run would-delete report
|
||||
* uses), so the sender can print rsync's `deleting PATH`/`*deleting` lines for a
|
||||
* real (non-dry-run) deletion. It is set when --delete is active and any of
|
||||
* --info=del, -i/--itemize-changes or --out-format requests per-file change
|
||||
* output; the transfer decision itself is unchanged. */
|
||||
* --delete; the transfer decision itself is unchanged. */
|
||||
#define CONFIG_WIRE_OUTPUT_FIELDS(X) \
|
||||
X(report_dest_info, bool, false, BOOL) \
|
||||
X(report_stats, bool, false, BOOL) X(report_deletes, bool, false, BOOL)
|
||||
X(report_dest_info, bool, false, BOOL) X(report_stats, bool, false, BOOL)
|
||||
|
||||
/* Codec-negotiation wave (protocol 2.26.0). compression_algo is the concrete
|
||||
* codec the client selected for this transfer (a CompressionAlgo id) and is the
|
||||
@@ -419,12 +410,6 @@ typedef struct Config {
|
||||
* for an unsupported checksum/compress algorithm) so main() can mirror it. */
|
||||
int checksum_transfer_algo;
|
||||
int cli_exit_code;
|
||||
/* Client-only "the user explicitly chose" bits. They let the per-codec
|
||||
* default level / checksum list be applied only when the corresponding
|
||||
* rsync option was omitted (an explicit --compress-level / --checksum-choice
|
||||
* always wins). Never serialized. */
|
||||
bool compression_level_set;
|
||||
bool checksum_choice_set;
|
||||
|
||||
// Issue #129: Advanced file selection. These fields are CLIENT-ONLY: they are
|
||||
// never serialized to the wire (the receiver must not learn them).
|
||||
@@ -439,10 +424,8 @@ typedef struct Config {
|
||||
* repeated -F adds --filter='- .rsync-filter' so they are excluded too. */
|
||||
int per_dir_filter_count;
|
||||
bool one_file_system; /* -x/--one-file-system: do not cross filesystem boundaries */
|
||||
/* --no-implied-dirs: client-only. With -R, do not transfer the source
|
||||
* metadata of the parent directories implied by a listed path; an unlisted
|
||||
* implied parent is still created (with default attributes) so the listed
|
||||
* file can be placed, matching rsync. */
|
||||
/* --no-implied-dirs: client-only. With -R + --files-from, refuse to place a
|
||||
* listed file whose ancestor directory is not itself explicitly listed. */
|
||||
bool no_implied_dirs;
|
||||
/* -d/--dirs: client-only. Transfer the directory entries named by the
|
||||
* source argument / --files-from list without recursing into contents. */
|
||||
@@ -1005,27 +988,7 @@ typedef struct Config {
|
||||
* boundary, and the strict same-version handshake (config_receive rejects a
|
||||
* mismatched version before parsing anything else) keeps mixed deployments from
|
||||
* ever reaching that state. */
|
||||
/* (7) --info=del report (protocol 2.27.0): the config frame gains one trailing
|
||||
* bool, report_deletes, appended after report_stats. When set, the receiver
|
||||
* lists the paths it actually removed in the terminal STATUS_STATS path list
|
||||
* (the same count-delimited list the -n/--dry-run would-delete report uses), so
|
||||
* the sender can print rsync's `deleting PATH` lines for a real deletion. No
|
||||
* change to the fixed STATUS_STATS record itself; only a new trailing config
|
||||
* bool, which still requires the version bump for the strict lockstep. */
|
||||
/* (8) --stats receiver-observed counters (protocol 2.28.0): the fixed
|
||||
* STATUS_STATS record grows from three counters to eight. The receiver now
|
||||
* reports the bytes it literally stored (`literal_data`) and the count of
|
||||
* destination entries it newly CREATED, split by type
|
||||
* (reg/dir/link/special), so the sender can print rsync's exact
|
||||
* `Number of created files: N (reg: X, dir: Y, link: Z, special: W)` line and
|
||||
* an exact `Literal data` total even for delta transfers. The config-frame
|
||||
* LAYOUT is unchanged (no new config field), but the STATUS_STATS body grows,
|
||||
* so a 2.27 peer that does not consume the five new fixed-width counters would
|
||||
* desynchronize on the trailing would-delete path list; the strict
|
||||
* same-version handshake (config_receive rejects a mismatched version before
|
||||
* parsing anything else) keeps mixed deployments from ever reaching that
|
||||
* state. */
|
||||
#define PROTOCOL_VERSION "2.28.0"
|
||||
#define PROTOCOL_VERSION "2.26.0"
|
||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||
#define MAX_BASIS_DIRS 64
|
||||
|
||||
+24
-121
@@ -412,17 +412,6 @@ struct DeletePlanSession {
|
||||
bool dry_run;
|
||||
size_t max_delete;
|
||||
size_t deleted;
|
||||
/* Removals charged against --max-delete. The budget is charged on ACTUAL
|
||||
removals (an unlink/rmdir that succeeded), matching rsync: a snapshotted
|
||||
entry that fails removal consumes nothing, so a later extra is still
|
||||
deleted. `planned` and `deleted` advance together for the inline paths and
|
||||
`apply_missing`; `deleted` is the reported count. */
|
||||
size_t planned;
|
||||
/* Hard bound on the deferred snapshot list. Because the budget is no longer
|
||||
charged at snapshot time, this independent cap keeps a huge destination
|
||||
from growing the list without limit (it matches the receiver's overall
|
||||
deletion bound). */
|
||||
size_t defer_cap;
|
||||
size_t skipped;
|
||||
bool limit_hit;
|
||||
bool limit_logged;
|
||||
@@ -432,16 +421,8 @@ struct DeletePlanSession {
|
||||
ArrayList* size_skipped;
|
||||
ArrayList* missing;
|
||||
ArrayList* deferred;
|
||||
DeletePathObserver observer;
|
||||
void* observer_context;
|
||||
};
|
||||
|
||||
/* Report one path the session truly removed (no-op without an observer). */
|
||||
static void notify_deleted(DeletePlanSession* session, const char* rel) {
|
||||
if (session && session->observer && rel)
|
||||
session->observer(session->observer_context, rel);
|
||||
}
|
||||
|
||||
DeletePlanSession* delete_plan_session_create(const Config* config) {
|
||||
if (!config)
|
||||
return NULL;
|
||||
@@ -454,7 +435,6 @@ DeletePlanSession* delete_plan_session_create(const Config* config) {
|
||||
config->max_delete >= 0 && (size_t)config->max_delete < DELETE_PLAN_SERVER_LIMIT;
|
||||
session->max_delete =
|
||||
user_limited ? (size_t)config->max_delete : (size_t)DELETE_PLAN_SERVER_LIMIT;
|
||||
session->defer_cap = DELETE_PLAN_SERVER_LIMIT;
|
||||
session->protected_prefixes = array_list_create(free);
|
||||
session->size_skipped = array_list_create(free);
|
||||
session->missing = array_list_create(free);
|
||||
@@ -585,7 +565,7 @@ static bool build_plan_skips(const Config* config, const DeletePlanSession* sess
|
||||
}
|
||||
|
||||
static bool budget_available(const DeletePlanSession* session) {
|
||||
return session->planned < session->max_delete;
|
||||
return session->deleted < session->max_delete;
|
||||
}
|
||||
|
||||
static void note_skipped(DeletePlanSession* session) {
|
||||
@@ -599,15 +579,8 @@ static void log_deleted(const char* rel) {
|
||||
free(escaped);
|
||||
}
|
||||
|
||||
/* Append a snapshot path for --delete-delay. The budget is NOT charged here:
|
||||
* the remover charges --max-delete only when a path is actually unlinked (see
|
||||
* apply_deferred_path), so a snapshotted entry that survives ENOTEMPTY cannot
|
||||
* deny budget to a later extra. The independent `defer_cap` bounds the list. */
|
||||
/* Append a snapshot path for --delete-delay. */
|
||||
static bool defer_add(DeletePlanSession* session, const char* rel) {
|
||||
if ((size_t)session->deferred->size >= session->defer_cap) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
char* copy = str_dup(rel);
|
||||
if (!copy)
|
||||
return false;
|
||||
@@ -615,6 +588,7 @@ static bool defer_add(DeletePlanSession* session, const char* rel) {
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
session->deleted++;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -647,21 +621,19 @@ static bool process_extra_dir(int dirfd, const char* name, const char* child_rel
|
||||
return false;
|
||||
if (survives)
|
||||
return true;
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
if (session->defer && !force_now) {
|
||||
if (!defer_add(session, child_rel))
|
||||
return false;
|
||||
*removed = true;
|
||||
return true;
|
||||
}
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
if (unlinkat(dirfd, name, AT_REMOVEDIR) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
log_deleted(child_rel);
|
||||
notify_deleted(session, child_rel);
|
||||
*removed = true;
|
||||
return true;
|
||||
}
|
||||
@@ -676,18 +648,16 @@ static bool process_extra_dir(int dirfd, const char* name, const char* child_rel
|
||||
|
||||
static bool process_extra_file(int dirfd, const char* name, const char* child_rel, bool force_now,
|
||||
DeletePlanSession* session) {
|
||||
if (session->defer && !force_now) {
|
||||
return defer_add(session, child_rel);
|
||||
}
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
if (session->defer && !force_now) {
|
||||
return defer_add(session, child_rel);
|
||||
}
|
||||
if (unlinkat(dirfd, name, 0) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
log_deleted(child_rel);
|
||||
notify_deleted(session, child_rel);
|
||||
} else if (errno != ENOENT) {
|
||||
return false;
|
||||
}
|
||||
@@ -798,15 +768,13 @@ static bool apply_missing(DeletePlanSession* session, const Config* config) {
|
||||
return true;
|
||||
DeleteManifest manifest = {
|
||||
.keeps = NULL, .protected = NULL, .missing = session->missing, .dirs = NULL};
|
||||
size_t remaining = budget_available(session) ? session->max_delete - session->planned : 0;
|
||||
size_t remaining = budget_available(session) ? session->max_delete - session->deleted : 0;
|
||||
size_t deleted = 0;
|
||||
size_t skipped = 0;
|
||||
bool limit = false;
|
||||
bool ok = manifest_delete_missing_args_limited_observed(config, &manifest, remaining, &deleted,
|
||||
&skipped, &limit, session->observer,
|
||||
session->observer_context);
|
||||
bool ok = manifest_delete_missing_args_limited(config, &manifest, remaining, &deleted, &skipped,
|
||||
&limit);
|
||||
session->deleted += deleted;
|
||||
session->planned += deleted;
|
||||
session->skipped += skipped;
|
||||
if (limit)
|
||||
session->limit_hit = true;
|
||||
@@ -869,10 +837,9 @@ int delete_plan_session_receive(DeletePlanSession* session, const Config* config
|
||||
}
|
||||
|
||||
/* Apply one snapshotted --delete-delay path (post-order: children precede their
|
||||
* parent directory). A directory that is still present is re-scanned so content
|
||||
* created after the plan is removed too; every actual removal charges
|
||||
* --max-delete. */
|
||||
* parent directory). */
|
||||
static bool apply_deferred_path(DeletePlanSession* session, const Config* config, const char* rel) {
|
||||
(void)session;
|
||||
char* full = path_cat(config->receive_root_directory, rel);
|
||||
if (!full)
|
||||
return false;
|
||||
@@ -885,86 +852,22 @@ static bool apply_deferred_path(DeletePlanSession* session, const Config* config
|
||||
}
|
||||
struct stat st;
|
||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
bool absent = errno == ENOENT || errno == ENOTDIR;
|
||||
bool absent = errno == ENOENT;
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return absent;
|
||||
}
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return true;
|
||||
}
|
||||
int dirfd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (dirfd < 0) {
|
||||
bool absent = errno == ENOENT || errno == ENOTDIR;
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return absent;
|
||||
}
|
||||
PlanSkips skips;
|
||||
if (!build_plan_skips(config, session, &skips)) {
|
||||
close(dirfd);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
bool survives = false;
|
||||
bool ok = process_children(dirfd, rel, NULL, NULL, false, true, &skips, session, &survives);
|
||||
free(skips.entries);
|
||||
close(dirfd);
|
||||
if (!ok) {
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
if (!survives) {
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
} else if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
log_deleted(rel);
|
||||
notify_deleted(session, rel);
|
||||
} else if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) {
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return true;
|
||||
}
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return true;
|
||||
}
|
||||
if (unlinkat(parent_fd, leaf, 0) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
int rc;
|
||||
if (S_ISDIR(st.st_mode))
|
||||
rc = unlinkat(parent_fd, leaf, AT_REMOVEDIR);
|
||||
else
|
||||
rc = unlinkat(parent_fd, leaf, 0);
|
||||
bool ok = rc == 0 || errno == ENOENT || errno == ENOTEMPTY || errno == EEXIST;
|
||||
if (rc == 0)
|
||||
log_deleted(rel);
|
||||
notify_deleted(session, rel);
|
||||
} else if (errno != ENOENT) {
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return true;
|
||||
}
|
||||
|
||||
void delete_plan_session_set_delete_observer(DeletePlanSession* session,
|
||||
DeletePathObserver observer, void* context) {
|
||||
if (!session)
|
||||
return;
|
||||
session->observer = observer;
|
||||
session->observer_context = context;
|
||||
return ok;
|
||||
}
|
||||
|
||||
DeleteCommitResult delete_plan_session_commit(DeletePlanSession* session, const Config* config) {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "config.h"
|
||||
#include "file_receive.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Per-directory delete plans (protocol 2.24.0).
|
||||
@@ -77,16 +76,8 @@ int delete_plan_session_receive(DeletePlanSession* session, const Config* config
|
||||
DeleteCommitResult delete_plan_session_commit(DeletePlanSession* session, const Config* config);
|
||||
/* True once the shared --max-delete budget stopped part of a deletion. */
|
||||
bool delete_plan_session_limit_reached(const DeletePlanSession* session);
|
||||
/* Number of destination entries the session actually removed, for the
|
||||
end-of-transfer stats. For --delete-delay this excludes a snapshotted entry
|
||||
that survived (e.g. a refilled directory that failed ENOTEMPTY), even though
|
||||
that entry already consumed --max-delete budget at snapshot time. */
|
||||
/* Number of destination entries the session's plans removed (or, for
|
||||
--delete-delay, snapshotted for removal), for the end-of-transfer stats. */
|
||||
size_t delete_plan_session_deleted(const DeletePlanSession* session);
|
||||
/* Install an observer invoked for every destination-relative path the session
|
||||
truly removes (including the deferred --delete-delay commit), so the receiver
|
||||
can report rsync's `deleting PATH` lines through the terminal STATUS_STATS
|
||||
record. Pass NULL/0 to clear. */
|
||||
void delete_plan_session_set_delete_observer(DeletePlanSession* session,
|
||||
DeletePathObserver observer, void* context);
|
||||
|
||||
#endif
|
||||
|
||||
+21
-175
@@ -187,7 +187,6 @@ File* file_create(const char* path) {
|
||||
file->xattrs = NULL;
|
||||
file->dest_state = (OutputDestState){0};
|
||||
file->matched_bytes = 0;
|
||||
file->literal_bytes = 0;
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -615,103 +614,6 @@ static int open_dir_beneath_root(const char* resolved, const char* root) {
|
||||
}
|
||||
|
||||
int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) {
|
||||
return file_open_secure_parent_counted(path, leaf_out, create_dirs, NULL, NULL);
|
||||
}
|
||||
|
||||
/* The logical transfer root expressed in the same coordinate as the secure
|
||||
* parent walk's `rel_buf` (relative to the authorized root, with a leading
|
||||
* '/'), used as the floor at or below which a created directory is a real
|
||||
* file-list entry. The on-disk transfer root is the receive root joined to the
|
||||
* wire path; the mirror scaffolding above it (the absolute source path below
|
||||
* the destination root) is not an rsync entry. Returns an allocated string or
|
||||
* NULL (count every created component). */
|
||||
static char* transfer_root_floor(const Config* config) {
|
||||
if (!config || !config->send_directory || config->send_directory[0] == '\0')
|
||||
return NULL;
|
||||
const char* spec = config->send_directory;
|
||||
const char* after = spec;
|
||||
if (spec[0] == '.' && spec[1] == '/') {
|
||||
after = spec + 2;
|
||||
} else {
|
||||
const char* cut = strstr(spec, "/./");
|
||||
if (cut)
|
||||
after = cut + 3;
|
||||
}
|
||||
while (*after == '/')
|
||||
after++;
|
||||
char* wire_root = str_dup(after);
|
||||
if (!wire_root)
|
||||
return NULL;
|
||||
size_t wlen = strlen(wire_root);
|
||||
while (wlen > 0 && wire_root[wlen - 1] == '/')
|
||||
wire_root[--wlen] = '\0';
|
||||
if (wlen == 0) {
|
||||
free(wire_root);
|
||||
return NULL;
|
||||
}
|
||||
char* disk_root = config->receive_root_directory
|
||||
? path_cat(config->receive_root_directory, wire_root)
|
||||
: str_dup(wire_root);
|
||||
free(wire_root);
|
||||
if (!disk_root)
|
||||
return NULL;
|
||||
const char* root_path = utils_get_authorized_root_path();
|
||||
const char* floor = disk_root;
|
||||
if (root_path && root_path[0] == '/') {
|
||||
size_t rl = strlen(root_path);
|
||||
while (rl > 0 && root_path[rl - 1] == '/')
|
||||
rl--;
|
||||
if (strncmp(disk_root, root_path, rl) == 0 && (disk_root[rl] == '/' || disk_root[rl] == '\0'))
|
||||
floor = disk_root + rl;
|
||||
}
|
||||
while (*floor == '/')
|
||||
floor++;
|
||||
char* out = str_dup(floor);
|
||||
free(disk_root);
|
||||
if (!out)
|
||||
return NULL;
|
||||
if (out[0] == '\0') {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* A created parent component counts toward `Number of created files` only when
|
||||
* its receive-root-relative path is at or below the logical transfer root
|
||||
* (`count_floor`). The transfer root itself corresponds to rsync's `.` entry
|
||||
* (created on a fresh destination, pre-existing otherwise); the mirror
|
||||
* scaffolding above it is FastSync's absolute-path layout, not an rsync entry. */
|
||||
static bool created_dir_counts(const char* count_floor, const char* rel_buf,
|
||||
const char* component) {
|
||||
if (!count_floor)
|
||||
return true;
|
||||
char candidate[PATH_MAX];
|
||||
int n = snprintf(candidate, sizeof(candidate), "%s/%s", rel_buf, component);
|
||||
if (n < 0 || (size_t)n >= sizeof(candidate))
|
||||
return false;
|
||||
const char* cand = candidate;
|
||||
while (*cand == '/')
|
||||
cand++;
|
||||
size_t fl = strlen(count_floor);
|
||||
if (strncmp(cand, count_floor, fl) != 0)
|
||||
return false;
|
||||
return cand[fl] == '\0' || cand[fl] == '/';
|
||||
}
|
||||
|
||||
/* Public wrapper for the receiver's created-directory accounting: the logical
|
||||
* transfer root expressed receive-root-relative, or NULL when the wire paths
|
||||
* carry no mirror scaffolding above it (--relative and --files-from, whose
|
||||
* paths are already relative to the transfer root). The caller frees a
|
||||
* non-NULL result. */
|
||||
char* file_transfer_root_floor(const Config* config) {
|
||||
if (!config || config->relative || config->files_from_set != NULL)
|
||||
return NULL;
|
||||
return transfer_root_floor(config);
|
||||
}
|
||||
|
||||
int file_open_secure_parent_counted(const char* path, char** leaf_out, bool create_dirs,
|
||||
unsigned* dirs_created, const char* count_floor) {
|
||||
char* copy = str_dup(path);
|
||||
if (!copy)
|
||||
return -1;
|
||||
@@ -772,14 +674,6 @@ int file_open_secure_parent_counted(const char* path, char** leaf_out, bool crea
|
||||
if (next < 0 && create_dirs && errno == ENOENT) {
|
||||
bool created = mkdirat(fd, component, (mode_t)(0777 & ~(mode_t)file_process_umask())) == 0;
|
||||
if (created || errno == EEXIST) {
|
||||
/* Protocol 2.28.0: only directories the logical file list would
|
||||
create count toward `Number of created files`; the mirror
|
||||
scaffolding above the transfer root (e.g. the absolute source path
|
||||
under the destination root) is not an rsync entry. `count_floor`
|
||||
is a receive-root-relative prefix that must be reached before a
|
||||
created component is counted. */
|
||||
if (created && dirs_created && created_dir_counts(count_floor, rel_buf, component))
|
||||
(*dirs_created)++;
|
||||
/* P7 Wave E: --copy-as owns EVERY entry, including the intermediate
|
||||
directories this walk creates implicitly. Its target ids are a
|
||||
global policy, so they are available here without per-entry source
|
||||
@@ -913,25 +807,6 @@ bool file_ensure_directory_secure(const char* path) {
|
||||
} else if (errno == EEXIST) {
|
||||
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
} else if (dir_fd < 0 && errno == ENOTDIR) {
|
||||
/* rsync replaces a destination non-directory (regular file) with an
|
||||
incoming directory. Confined to the already-opened secure parent fd:
|
||||
the leaf is unlinked by name (never followed) and only a non-directory
|
||||
is ever removed, so this cannot escape the authorized root or remove a
|
||||
pre-existing directory tree. A symlink is left alone (openat with
|
||||
O_NOFOLLOW reports ELOOP, which takes no branch here), since replacing
|
||||
it is not required for FastSync's transferred directories and keeps
|
||||
--keep-dirlinks semantics untouched. */
|
||||
struct stat leaf_st;
|
||||
if (fstatat(parent_fd, leaf, &leaf_st, AT_SYMLINK_NOFOLLOW) == 0 && !S_ISDIR(leaf_st.st_mode) &&
|
||||
!S_ISLNK(leaf_st.st_mode)) {
|
||||
if (unlinkat(parent_fd, leaf, 0) == 0) {
|
||||
if (mkdirat(parent_fd, leaf, (mode_t)(0777 & ~(mode_t)file_process_umask())) == 0)
|
||||
created = true;
|
||||
/* On failure dir_fd stays < 0 below, so the caller still sees it. */
|
||||
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
}
|
||||
}
|
||||
bool ok = dir_fd >= 0;
|
||||
/* --copy-as owns a directory this call just created (the final component;
|
||||
@@ -1132,14 +1007,15 @@ static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXat
|
||||
}
|
||||
}
|
||||
|
||||
static bool
|
||||
file_to_disk_secure_impl(const char* path, const void* data, unsigned long long data_size,
|
||||
bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool update, bool no_replace, bool use_fsync,
|
||||
const char* temp_dir, const FileXattrList* xattrs, bool fake_super,
|
||||
bool keep_partial, unsigned* dirs_created, const char* count_floor) {
|
||||
static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
unsigned long long data_size, bool inplace, bool sparse,
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool update, bool no_replace,
|
||||
bool use_fsync, const char* temp_dir,
|
||||
const FileXattrList* xattrs, bool fake_super,
|
||||
bool keep_partial) {
|
||||
char* leaf = NULL;
|
||||
int dirfd = file_open_secure_parent_counted(path, &leaf, true, dirs_created, count_floor);
|
||||
int dirfd = file_open_secure_parent(path, &leaf, true);
|
||||
if (dirfd < 0)
|
||||
return false;
|
||||
int fd = -1;
|
||||
@@ -1430,7 +1306,7 @@ file_to_disk_secure_impl(const char* path, const void* data, unsigned long long
|
||||
"non-atomic copy into the destination directory");
|
||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||
policy, update, no_replace, use_fsync, NULL, xattrs, fake_super,
|
||||
keep_partial, dirs_created, count_floor);
|
||||
keep_partial);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -1439,8 +1315,7 @@ bool file_to_disk_secure(const char* path, const void* data, unsigned long long
|
||||
bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, const char* temp_dir) {
|
||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||
policy, false, false, false, temp_dir, NULL, false, false, NULL,
|
||||
NULL);
|
||||
policy, false, false, false, temp_dir, NULL, false, false);
|
||||
}
|
||||
|
||||
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
|
||||
@@ -1448,8 +1323,7 @@ bool file_to_disk_secure_update(const char* path, const void* data, unsigned lon
|
||||
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||
const char* temp_dir) {
|
||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||
policy, true, false, false, temp_dir, NULL, false, false, NULL,
|
||||
NULL);
|
||||
policy, true, false, false, temp_dir, NULL, false, false);
|
||||
}
|
||||
|
||||
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
|
||||
@@ -1457,8 +1331,7 @@ bool file_to_disk_secure_with_fsync(const char* path, const void* data,
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool use_fsync, const char* temp_dir) {
|
||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||
policy, false, false, use_fsync, temp_dir, NULL, false, false,
|
||||
NULL, NULL);
|
||||
policy, false, false, use_fsync, temp_dir, NULL, false, false);
|
||||
}
|
||||
|
||||
bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
||||
@@ -1466,8 +1339,7 @@ bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
||||
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||
const char* temp_dir) {
|
||||
return file_to_disk_secure_impl(path, data, data_size, false, sparse, preallocate, metadata,
|
||||
policy, false, true, false, temp_dir, NULL, false, false, NULL,
|
||||
NULL);
|
||||
policy, false, true, false, temp_dir, NULL, false, false);
|
||||
}
|
||||
|
||||
/* Receiver write-path variant that also applies the per-file xattrs (-X/-A)
|
||||
@@ -1480,21 +1352,9 @@ bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long
|
||||
const FileMetadata* metadata, FileAttrPolicy policy, bool update,
|
||||
bool no_replace, bool use_fsync, const FileXattrList* xattrs,
|
||||
bool fake_super, bool keep_partial, const char* temp_dir) {
|
||||
return file_to_disk_secure_attrs_counted(path, data, data_size, inplace, sparse, preallocate,
|
||||
metadata, policy, update, no_replace, use_fsync, xattrs,
|
||||
fake_super, keep_partial, temp_dir, NULL, NULL);
|
||||
}
|
||||
|
||||
bool file_to_disk_secure_attrs_counted(const char* path, const void* data,
|
||||
unsigned long long data_size, bool inplace, bool sparse,
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool update, bool no_replace,
|
||||
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
||||
bool keep_partial, const char* temp_dir,
|
||||
unsigned* dirs_created, const char* count_floor) {
|
||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||
policy, update, no_replace, use_fsync, temp_dir, xattrs,
|
||||
fake_super, keep_partial, dirs_created, count_floor);
|
||||
fake_super, keep_partial);
|
||||
}
|
||||
|
||||
/* Atomic --link-dest install. The destination is replaced (via a temporary
|
||||
@@ -1517,12 +1377,11 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool use_fsync,
|
||||
const FileXattrList* xattrs, bool fake_super,
|
||||
const char* temp_dir, unsigned* dirs_created,
|
||||
const char* count_floor) {
|
||||
const char* temp_dir) {
|
||||
if (!path || !basis_path)
|
||||
return false;
|
||||
char* leaf = NULL;
|
||||
int dirfd = file_open_secure_parent_counted(path, &leaf, true, dirs_created, count_floor);
|
||||
int dirfd = file_open_secure_parent(path, &leaf, true);
|
||||
if (dirfd < 0)
|
||||
return false;
|
||||
|
||||
@@ -1605,9 +1464,9 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
|
||||
free(leaf);
|
||||
/* The basis file could not be linked in (missing, cross-device, refused
|
||||
by the filesystem). Write a byte-identical local copy instead. */
|
||||
return file_to_disk_secure_attrs_counted(
|
||||
path, data, data_size, false, false, preallocate, metadata, policy, false, false, use_fsync,
|
||||
xattrs, fake_super, false, temp_dir, dirs_created, count_floor);
|
||||
return file_to_disk_secure_attrs(path, data, data_size, false, false, preallocate, metadata,
|
||||
policy, false, false, use_fsync, xattrs, fake_super, false,
|
||||
temp_dir);
|
||||
}
|
||||
|
||||
if (scratch_dirfd >= 0)
|
||||
@@ -1622,7 +1481,7 @@ bool file_to_disk_secure_link(const char* path, const char* basis_path, const vo
|
||||
const FileMetadata* metadata, FileAttrPolicy policy, bool use_fsync,
|
||||
const char* temp_dir) {
|
||||
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
|
||||
policy, use_fsync, NULL, false, temp_dir, NULL, NULL);
|
||||
policy, use_fsync, NULL, false, temp_dir);
|
||||
}
|
||||
|
||||
bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, const void* data,
|
||||
@@ -1630,21 +1489,8 @@ bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, co
|
||||
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
||||
const char* temp_dir) {
|
||||
return file_to_disk_secure_link_attrs_counted(path, basis_path, data, data_size, preallocate,
|
||||
metadata, policy, use_fsync, xattrs, fake_super,
|
||||
temp_dir, NULL, NULL);
|
||||
}
|
||||
|
||||
bool file_to_disk_secure_link_attrs_counted(const char* path, const char* basis_path,
|
||||
const void* data, unsigned long long data_size,
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool use_fsync,
|
||||
const FileXattrList* xattrs, bool fake_super,
|
||||
const char* temp_dir, unsigned* dirs_created,
|
||||
const char* count_floor) {
|
||||
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
|
||||
policy, use_fsync, xattrs, fake_super, temp_dir,
|
||||
dirs_created, count_floor);
|
||||
policy, use_fsync, xattrs, fake_super, temp_dir);
|
||||
}
|
||||
|
||||
bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
|
||||
|
||||
@@ -87,11 +87,6 @@ bool file_path_exists_secure(const char* path);
|
||||
bool file_stat_secure(const char* path, struct stat* st);
|
||||
bool file_destination_is_newer_secure(const char* path, const FileMetadata* metadata);
|
||||
int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs);
|
||||
/* Protocol 2.28.0 variant: also increments *dirs_created for every missing
|
||||
* parent directory this walk creates that lies strictly below `count_floor`
|
||||
* (a receive-root-relative path, or NULL to count all of them). */
|
||||
int file_open_secure_parent_counted(const char* path, char** leaf_out, bool create_dirs,
|
||||
unsigned* dirs_created, const char* count_floor);
|
||||
bool file_ensure_directory_secure(const char* path);
|
||||
bool file_directory_exists_secure(const char* path);
|
||||
bool file_rename_secure(const char* old_path, const char* new_path);
|
||||
@@ -163,27 +158,5 @@ bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, co
|
||||
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
||||
const char* temp_dir);
|
||||
/* Protocol 2.28.0 receiver-stat variants: like the two above but additionally
|
||||
* report through `dirs_created` (when non-NULL) how many parent directories the
|
||||
* confined secure walk had to create that lie strictly below `count_floor` (a
|
||||
* receive-root-relative prefix, or NULL for all). Used to reproduce rsync's
|
||||
* `Number of created files` directory count on a fresh destination. */
|
||||
bool file_to_disk_secure_attrs_counted(const char* path, const void* data,
|
||||
unsigned long long data_size, bool inplace, bool sparse,
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool update, bool no_replace,
|
||||
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
||||
bool keep_partial, const char* temp_dir,
|
||||
unsigned* dirs_created, const char* count_floor);
|
||||
bool file_to_disk_secure_link_attrs_counted(const char* path, const char* basis_path,
|
||||
const void* data, unsigned long long data_size,
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
FileAttrPolicy policy, bool use_fsync,
|
||||
const FileXattrList* xattrs, bool fake_super,
|
||||
const char* temp_dir, unsigned* dirs_created,
|
||||
const char* count_floor);
|
||||
/* The logical transfer root expressed receive-root-relative, or NULL when the
|
||||
* wire paths carry no mirror scaffolding above it. Caller frees non-NULL. */
|
||||
char* file_transfer_root_floor(const Config* config);
|
||||
|
||||
#endif
|
||||
|
||||
+25
-141
@@ -37,12 +37,7 @@
|
||||
#define MANIFEST_ENTRY_OVERHEAD (sizeof(char*) + 16)
|
||||
|
||||
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) {
|
||||
return file_save_to_disk_full_ex(root_directory, file, config, NULL, NULL) != FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
||||
const Config* config) {
|
||||
return file_save_to_disk_full_ex(root_directory, file, config, NULL, NULL);
|
||||
return file_save_to_disk_full(root_directory, file, config) != FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
/* --delay-updates receiver path: write the file into a private staging tree
|
||||
@@ -213,14 +208,13 @@ static FileSaveResult hardlink_sibling_absent_first(const char* destination_path
|
||||
--existing/--ignore-existing/--update policies are decided against the final
|
||||
destination like every normal write. */
|
||||
static FileSaveResult file_save_hardlink_sibling(const char* root_directory, const File* file,
|
||||
const Config* config, bool* created) {
|
||||
const Config* config) {
|
||||
Config* cfg = (Config*)config;
|
||||
if (!root_directory || !file || !file->path || !file->hardlink_target)
|
||||
return FILE_SAVE_ERROR;
|
||||
char* destination_path = path_cat(root_directory, file->path);
|
||||
if (!destination_path)
|
||||
return FILE_SAVE_ERROR;
|
||||
bool existed = file_path_exists_secure(destination_path);
|
||||
|
||||
if (cfg->existing && !file_path_exists_secure(destination_path)) {
|
||||
free(destination_path);
|
||||
@@ -284,8 +278,6 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
|
||||
free(staged_first);
|
||||
free(staged_sibling);
|
||||
free(destination_path);
|
||||
if (ok && created && !existed)
|
||||
*created = true;
|
||||
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
@@ -332,8 +324,6 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
|
||||
free(content);
|
||||
free(first_disk);
|
||||
free(destination_path);
|
||||
if (ok && created && !existed)
|
||||
*created = true;
|
||||
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
@@ -370,7 +360,7 @@ bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode) {
|
||||
* non-device entry must carry an empty rdev.
|
||||
*/
|
||||
static FileSaveResult file_save_special_to_disk(const char* root_directory, const File* file,
|
||||
const Config* config, bool* created) {
|
||||
const Config* config) {
|
||||
/* The empty-path and structural checks stay unconditional; the redundant
|
||||
".." list-path re-check is skipped under --trust-sender exactly like the
|
||||
receive layer (confinement is deferred to the secure parent walk below,
|
||||
@@ -422,7 +412,6 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
||||
char* destination = path_cat(root_directory, file->path);
|
||||
if (!destination)
|
||||
return FILE_SAVE_ERROR;
|
||||
bool existed = file_path_exists_secure(destination);
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(destination, &leaf, true);
|
||||
if (parent_fd < 0) {
|
||||
@@ -543,8 +532,6 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
||||
free(destination);
|
||||
/* A failed required --copy-as ownership marks the node as failed; every other
|
||||
* identity policy stays best-effort. */
|
||||
if (owner_ok && created && !existed)
|
||||
*created = true;
|
||||
return owner_ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
@@ -623,13 +610,8 @@ static FileSaveResult file_save_write_device(const char* root_directory, const F
|
||||
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_SKIPPED;
|
||||
}
|
||||
|
||||
FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File* file,
|
||||
const Config* config, bool* created,
|
||||
unsigned* created_dirs) {
|
||||
if (created)
|
||||
*created = false;
|
||||
if (created_dirs)
|
||||
*created_dirs = 0;
|
||||
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
||||
const Config* config) {
|
||||
/* Central no-mutation guard: a server-contacting --dry-run (or a local batch
|
||||
apply that somehow carries dry_run) must never touch the destination, no
|
||||
matter which caller reached this primitive. The per-caller guards remain,
|
||||
@@ -676,7 +658,7 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
/* Device/special node (--devices/--specials): recreate the node instead of
|
||||
writing content (privilege-gated, confined, rdev-validated). */
|
||||
if (file->is_special)
|
||||
return file_save_special_to_disk(root_directory, file, config, created);
|
||||
return file_save_special_to_disk(root_directory, file, config);
|
||||
/* --write-devices: write straight into an existing device node. Writing
|
||||
into a device is a super-user activity, so --no-super must suppress it just
|
||||
like device-node creation; the default AUTO/--super attempt it (the wide
|
||||
@@ -707,7 +689,6 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
char* dir_path = path_cat(root_directory, file->path);
|
||||
if (!dir_path)
|
||||
return FILE_SAVE_ERROR;
|
||||
bool dir_existed = file_path_exists_secure(dir_path);
|
||||
bool ok = file_ensure_directory_secure(dir_path);
|
||||
/* P7 Wave E: apply the negotiated ownership to the directory ITSELF (not
|
||||
just the files inside it). --copy-as and every explicit identity policy
|
||||
@@ -731,8 +712,6 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
free(leaf);
|
||||
}
|
||||
free(dir_path);
|
||||
if (ok && created && !dir_existed)
|
||||
*created = true;
|
||||
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
@@ -749,7 +728,6 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
char* link_path = path_cat(root_directory, file->path);
|
||||
if (!link_path)
|
||||
return FILE_SAVE_ERROR;
|
||||
bool link_existed = file_path_exists_secure(link_path);
|
||||
/* The link value is stored verbatim (rsync -l parity: absolute and
|
||||
".."-bearing targets are preserved; the scanner's --safe-links /
|
||||
--copy-unsafe-links decide which links are sent at all). --munge-links
|
||||
@@ -790,8 +768,6 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
ok = file_restore_symlink_metadata(link_path, file->metadata, link_policy,
|
||||
config->omit_link_times);
|
||||
}
|
||||
if (ok && created && !link_existed)
|
||||
*created = true;
|
||||
free(link_path);
|
||||
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
@@ -801,7 +777,7 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
byte-identical copy of) the group's first member. Handled entirely here,
|
||||
before the normal data-write paths (which would create an empty file). */
|
||||
if (file->link_group != 0 && !file->link_first && file->hardlink_target != NULL) {
|
||||
return file_save_hardlink_sibling(root_directory, file, config, created);
|
||||
return file_save_hardlink_sibling(root_directory, file, config);
|
||||
}
|
||||
|
||||
/* These options arrive from the client. --backup-dir, --partial-dir and
|
||||
@@ -832,18 +808,12 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
free(disk_path);
|
||||
return FILE_SAVE_ERROR;
|
||||
}
|
||||
/* Snapshot the final destination's existence BEFORE any backup/force/partial
|
||||
step can move or remove it, so the receiver can report rsync's
|
||||
`Number of created files` (protocol 2.28.0). */
|
||||
bool dest_existed = file_path_exists_secure(destination_path);
|
||||
|
||||
/* --delay-updates diverts the whole write into the staging tree; the rest of
|
||||
this function is the immediate-install path. */
|
||||
if (config && config->delay_updates) {
|
||||
FileSaveResult result =
|
||||
file_stage_delayed_update(root_directory, destination_path, file, (Config*)config);
|
||||
if (result == FILE_SAVE_WRITTEN && created && !dest_existed)
|
||||
*created = true;
|
||||
free(confined_backup);
|
||||
free(confined_partial);
|
||||
free(destination_path);
|
||||
@@ -969,23 +939,19 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
existing/ignore-existing/update/backup preamble above has already made the
|
||||
policy decision. */
|
||||
bool ok;
|
||||
char* count_floor = file_transfer_root_floor(config);
|
||||
if (config && file->basis_link) {
|
||||
ok = file_to_disk_secure_link_attrs_counted(
|
||||
ok = file_to_disk_secure_link_attrs(
|
||||
disk_path, file->basis_link, file->data->data, file->data->size, config->preallocate,
|
||||
metadata, policy, config->use_fsync, file->xattrs, config->fake_super, confined_temp,
|
||||
created_dirs, count_floor);
|
||||
metadata, policy, config->use_fsync, file->xattrs, config->fake_super, confined_temp);
|
||||
} else {
|
||||
/* The plain no-replace / update / with-fsync engines, plus per-file xattr
|
||||
(-X/-A) and --fake-super application on the written fd. */
|
||||
ok = file_to_disk_secure_attrs_counted(
|
||||
ok = file_to_disk_secure_attrs(
|
||||
disk_path, file->data->data, file->data->size, inplace, sparse,
|
||||
config && config->preallocate, metadata, policy, config && config->update,
|
||||
config && config->ignore_existing, config && config->use_fsync, file->xattrs,
|
||||
config ? config->fake_super : false, config ? config->partial : false, confined_temp,
|
||||
created_dirs, count_floor);
|
||||
config ? config->fake_super : false, config ? config->partial : false, confined_temp);
|
||||
}
|
||||
free(count_floor);
|
||||
free(confined_temp);
|
||||
confined_temp = NULL;
|
||||
if (!ok)
|
||||
@@ -1006,8 +972,6 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
|
||||
free(confined_partial);
|
||||
free(destination_path);
|
||||
free(disk_path);
|
||||
if (created && !dest_existed)
|
||||
*created = true;
|
||||
return FILE_SAVE_WRITTEN;
|
||||
|
||||
fail:
|
||||
@@ -1020,30 +984,6 @@ fail:
|
||||
return FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
void receiver_stats_note_saved(ReceiverStats* stats, const File* file, bool created,
|
||||
unsigned created_dirs) {
|
||||
if (!stats || !file)
|
||||
return;
|
||||
bool is_sibling = file->link_group != 0 && !file->link_first;
|
||||
if (!file->is_dir && !file->is_symlink && !file->is_special && !is_sibling) {
|
||||
unsigned long long literal = file->literal_bytes;
|
||||
if (literal == 0 && file->matched_bytes == 0)
|
||||
literal = file->data ? file->data->size : 0;
|
||||
stats->literal_bytes += literal;
|
||||
}
|
||||
stats->created_dir += created_dirs;
|
||||
if (!created)
|
||||
return;
|
||||
if (file->is_dir)
|
||||
stats->created_dir++;
|
||||
else if (file->is_symlink)
|
||||
stats->created_link++;
|
||||
else if (file->is_special)
|
||||
stats->created_special++;
|
||||
else
|
||||
stats->created_reg++;
|
||||
}
|
||||
|
||||
/* Receive a file's xattr block (when the config enables xattr transport) and
|
||||
* attach it to `file`. Returns false on a malformed/oversized frame. */
|
||||
static bool receive_file_xattrs(File* file, int fd, const Config* config) {
|
||||
@@ -1154,15 +1094,11 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
|
||||
return NULL;
|
||||
}
|
||||
/* Wire-stats tally: bytes taken straight from the basis file (matched
|
||||
delta blocks) and bytes shipped literally (protocol 2.28.0). Computed
|
||||
before the delta is destroyed. */
|
||||
delta blocks). Computed before the delta is destroyed. */
|
||||
unsigned long long matched = 0;
|
||||
unsigned long long literal = 0;
|
||||
for (uint32_t k = 0; k < delta->instruction_count; k++) {
|
||||
if (delta->instructions[k].type == DELTA_INSTR_BLOCK_MATCH)
|
||||
matched += delta->instructions[k].match.length;
|
||||
else if (delta->instructions[k].type == DELTA_INSTR_LITERAL)
|
||||
literal += delta->instructions[k].literal.length;
|
||||
}
|
||||
void* new_data = delta_apply(old_data, old_size, delta, config->delta_block_size);
|
||||
delta_destroy(delta);
|
||||
@@ -1183,7 +1119,6 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
|
||||
return NULL;
|
||||
}
|
||||
file->matched_bytes = matched;
|
||||
file->literal_bytes = literal;
|
||||
|
||||
if (config->use_metadata) {
|
||||
int meta_ok = 1;
|
||||
@@ -3260,9 +3195,8 @@ char* file_receive_basis_delete_relative(const Config* config, const char* path)
|
||||
alternate basis directories are never destination content and are skipped at
|
||||
any depth. Returns true unless a traversal/unlink error aborted the walk;
|
||||
the budget's limit_hit/skipped fields report a cap-stopped run. */
|
||||
static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget, DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget) {
|
||||
if (!config || !manifest || !manifest->keeps)
|
||||
return false;
|
||||
fprintf(stderr, "Deleting files not in manifest...\n");
|
||||
@@ -3326,9 +3260,9 @@ static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest
|
||||
remaining = budget->max_delete - budget->deleted;
|
||||
size_t deleted = 0;
|
||||
size_t skipped = 0;
|
||||
DeleteWalkResult result = delete_extras_limited_observed(
|
||||
config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips, used,
|
||||
&deleted, &skipped, observer, observer_context);
|
||||
DeleteWalkResult result =
|
||||
delete_extras_limited(config->receive_root_directory, manifest->keeps, manifest->dirs,
|
||||
remaining, skips, used, &deleted, &skipped);
|
||||
if (owned_prefixes) {
|
||||
for (int i = 0; i < config->basis_count; i++)
|
||||
free(owned_prefixes[i]);
|
||||
@@ -3348,31 +3282,6 @@ static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget) {
|
||||
return delete_extras_budgeted_observed(config, manifest, budget, NULL, NULL);
|
||||
}
|
||||
|
||||
/* Prefixes every observed path with a fixed subtree root, so a nested walk
|
||||
(a recursively removed missing-arg directory) reports receive-root-relative
|
||||
names like the rest of the delete output. */
|
||||
typedef struct {
|
||||
DeletePathObserver inner;
|
||||
void* inner_context;
|
||||
const char* prefix;
|
||||
} PrefixedDeleteObserver;
|
||||
|
||||
static void prefixed_delete_observer(void* context, const char* rel) {
|
||||
PrefixedDeleteObserver* prefixed = context;
|
||||
if (!prefixed->inner || !rel)
|
||||
return;
|
||||
char* joined = path_cat((char*)prefixed->prefix, rel);
|
||||
if (joined) {
|
||||
prefixed->inner(prefixed->inner_context, joined);
|
||||
free(joined);
|
||||
}
|
||||
}
|
||||
|
||||
/* --delete-missing-args exact-path deletions: each destination mirror in
|
||||
manifest->missing is an explicit user request, so it is removed even when the
|
||||
ordinary extras walk (with its protected prefixes) would leave it alone. The
|
||||
@@ -3386,10 +3295,8 @@ static void prefixed_delete_observer(void* context, const char* rel) {
|
||||
--max-delete budget: once it is exhausted the remaining requests are skipped
|
||||
and counted. Returns false only on a genuine error (a confinement failure on
|
||||
a validated path or an I/O error), which fails the run. */
|
||||
static bool delete_missing_args_budgeted_observed(const Config* config, DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget) {
|
||||
if (!config || !manifest)
|
||||
return false;
|
||||
if (!manifest->missing || manifest->missing->size == 0)
|
||||
@@ -3506,12 +3413,9 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa
|
||||
budget->deleted >= budget->max_delete ? 0 : budget->max_delete - budget->deleted;
|
||||
size_t contents_deleted = 0;
|
||||
size_t contents_skipped = 0;
|
||||
PrefixedDeleteObserver nested = {observer, observer_context, rel};
|
||||
DeleteWalkResult walk =
|
||||
no_keeps ? delete_extras_limited_observed(full, no_keeps, NULL, remaining, NULL, 0,
|
||||
&contents_deleted, &contents_skipped,
|
||||
observer ? prefixed_delete_observer : NULL,
|
||||
observer ? &nested : NULL)
|
||||
no_keeps ? delete_extras_limited(full, no_keeps, NULL, remaining, NULL, 0,
|
||||
&contents_deleted, &contents_skipped)
|
||||
: DELETE_WALK_ERROR;
|
||||
if (no_keeps)
|
||||
array_list_delete(no_keeps);
|
||||
@@ -3551,8 +3455,6 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa
|
||||
}
|
||||
if (removed) {
|
||||
budget->deleted++;
|
||||
if (observer)
|
||||
observer(observer_context, rel);
|
||||
char* escaped = output_escape(rel, log_get_8_bit_output());
|
||||
fprintf(stderr, " Deleted: %s\n", escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
@@ -3639,25 +3541,15 @@ bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) {
|
||||
bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest) {
|
||||
DeleteBudgetState budget = {
|
||||
.max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
return delete_missing_args_budgeted_observed(config, manifest, &budget, NULL, NULL);
|
||||
return delete_missing_args_budgeted(config, manifest, &budget);
|
||||
}
|
||||
|
||||
bool manifest_delete_missing_args_limited(const Config* config, DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted, size_t* skipped,
|
||||
bool* limit_hit) {
|
||||
return manifest_delete_missing_args_limited_observed(config, manifest, max_delete, deleted,
|
||||
skipped, limit_hit, NULL, NULL);
|
||||
}
|
||||
|
||||
bool manifest_delete_missing_args_limited_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted,
|
||||
size_t* skipped, bool* limit_hit,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
DeleteBudgetState budget = {
|
||||
.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
bool ok =
|
||||
delete_missing_args_budgeted_observed(config, manifest, &budget, observer, observer_context);
|
||||
bool ok = delete_missing_args_budgeted(config, manifest, &budget);
|
||||
if (deleted)
|
||||
*deleted = budget.deleted;
|
||||
if (skipped)
|
||||
@@ -3680,12 +3572,6 @@ DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* man
|
||||
|
||||
DeleteCommitResult manifest_delete_all_counted(const Config* config, DeleteManifest* manifest,
|
||||
size_t* deleted) {
|
||||
return manifest_delete_all_observed(config, manifest, deleted, NULL, NULL);
|
||||
}
|
||||
|
||||
DeleteCommitResult manifest_delete_all_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t* deleted, DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
if (deleted)
|
||||
*deleted = 0;
|
||||
if (!config || !manifest)
|
||||
@@ -3704,11 +3590,9 @@ DeleteCommitResult manifest_delete_all_observed(const Config* config, DeleteMani
|
||||
.deleted = 0,
|
||||
.skipped = 0,
|
||||
.limit_hit = false};
|
||||
if (config->delete_missing_args &&
|
||||
!delete_missing_args_budgeted_observed(config, manifest, &budget, observer, observer_context))
|
||||
if (config->delete_missing_args && !delete_missing_args_budgeted(config, manifest, &budget))
|
||||
return DELETE_COMMIT_ERROR;
|
||||
if (config->use_delete &&
|
||||
!delete_extras_budgeted_observed(config, manifest, &budget, observer, observer_context))
|
||||
if (config->use_delete && !delete_extras_budgeted(config, manifest, &budget))
|
||||
return DELETE_COMMIT_ERROR;
|
||||
if (deleted)
|
||||
*deleted = budget.deleted;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "file_types.h"
|
||||
#include "utils.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Server-side file receive/save path. */
|
||||
@@ -127,13 +126,6 @@ bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest
|
||||
bool manifest_delete_missing_args_limited(const Config* config, DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted, size_t* skipped,
|
||||
bool* limit_hit);
|
||||
/* Observer-aware form of manifest_delete_missing_args_limited: `observer` (may
|
||||
be NULL) is invoked for every destination-relative path truly removed. */
|
||||
bool manifest_delete_missing_args_limited_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted,
|
||||
size_t* skipped, bool* limit_hit,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context);
|
||||
/* Outcome of committing a delete manifest. LIMIT_REACHED reports rsync's
|
||||
partial --max-delete result: the budget allowed some deletions and the rest
|
||||
were skipped (the run still stores all file data but the client exits 25). */
|
||||
@@ -154,11 +146,6 @@ DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* man
|
||||
removed (for the end-of-transfer wire stats). `deleted` may be NULL. */
|
||||
DeleteCommitResult manifest_delete_all_counted(const Config* config, DeleteManifest* manifest,
|
||||
size_t* deleted);
|
||||
/* Observer-aware form of manifest_delete_all_counted: `observer` (may be NULL)
|
||||
is invoked for every destination-relative path truly removed. */
|
||||
DeleteCommitResult manifest_delete_all_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t* deleted, DeletePathObserver observer,
|
||||
void* observer_context);
|
||||
|
||||
/* -n/--dry-run --delete would-delete reporting: walk the destination exactly as
|
||||
the delete pass would and append (strdup'd) destination-relative paths that
|
||||
@@ -179,22 +166,6 @@ typedef enum { FILE_SAVE_ERROR = 0, FILE_SAVE_WRITTEN = 1, FILE_SAVE_SKIPPED = 2
|
||||
|
||||
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
||||
const Config* config);
|
||||
/* Protocol 2.28.0 variant: also reports through `created` (when non-NULL)
|
||||
* whether the destination entry did not exist before this save, and through
|
||||
* `created_dirs` how many parent directories the confined walk created, so the
|
||||
* receiver can build rsync's `Number of created files` breakdown. The plain
|
||||
* file_save_to_disk_full() is this with both out-params NULL. */
|
||||
FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File* file,
|
||||
const Config* config, bool* created,
|
||||
unsigned* created_dirs);
|
||||
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config);
|
||||
|
||||
/* Protocol 2.28.0 receiver counter accumulator: fold one successfully saved
|
||||
* entry into `stats`, adding its receiver-observed literal bytes and, when
|
||||
* `created`, the matching created-by-type counter (regular file / symlink /
|
||||
* special) plus `created_dirs` implicitly-created parent directories.
|
||||
* Non-first hardlink siblings contribute no literal bytes. */
|
||||
void receiver_stats_note_saved(ReceiverStats* stats, const File* file, bool created,
|
||||
unsigned created_dirs);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -97,11 +97,6 @@ typedef struct {
|
||||
* basis file (matched delta blocks) for this entry. 0 when the file was sent
|
||||
* whole. Accumulated into ReceiverStats.matched_data by the receiver sink. */
|
||||
unsigned long long matched_bytes;
|
||||
/* Receiver-only (protocol 2.28.0) wire-stats tally: the literal delta fragment
|
||||
* bytes this entry carried (DELTA_INSTR_LITERAL). 0 when the file was sent
|
||||
* whole; the sink then falls back to the whole payload size. Accumulated
|
||||
* into ReceiverStats.literal_bytes. */
|
||||
unsigned long long literal_bytes;
|
||||
} File;
|
||||
|
||||
/* The path that should be sent on the wire and used for the receiver-side
|
||||
|
||||
+13
-15
@@ -105,27 +105,25 @@ bool format_dest_state_receive(int fd, OutputDestState* state) {
|
||||
bool format_stats_send(int fd, const ReceiverStats* stats) {
|
||||
if (!stats)
|
||||
return false;
|
||||
unsigned long long fields[8] = {
|
||||
stats->matched_data, stats->deleted_files, stats->would_delete_count, stats->literal_bytes,
|
||||
stats->created_reg, stats->created_dir, stats->created_link, stats->created_special,
|
||||
};
|
||||
return send_n_data(fd, fields, sizeof(fields));
|
||||
unsigned long long matched = stats->matched_data;
|
||||
unsigned long long deleted = stats->deleted_files;
|
||||
unsigned long long would = stats->would_delete_count;
|
||||
return send_n_data(fd, &matched, sizeof(matched)) && send_n_data(fd, &deleted, sizeof(deleted)) &&
|
||||
send_n_data(fd, &would, sizeof(would));
|
||||
}
|
||||
|
||||
bool format_stats_receive(int fd, ReceiverStats* stats) {
|
||||
if (!stats)
|
||||
return false;
|
||||
unsigned long long fields[8] = {0};
|
||||
if (!receive_n_data(fd, fields, sizeof(fields)))
|
||||
unsigned long long matched = 0;
|
||||
unsigned long long deleted = 0;
|
||||
unsigned long long would = 0;
|
||||
if (!receive_n_data(fd, &matched, sizeof(matched)) ||
|
||||
!receive_n_data(fd, &deleted, sizeof(deleted)) || !receive_n_data(fd, &would, sizeof(would)))
|
||||
return false;
|
||||
memset(stats, 0, sizeof(*stats));
|
||||
stats->matched_data = fields[0];
|
||||
stats->deleted_files = fields[1];
|
||||
stats->would_delete_count = fields[2];
|
||||
stats->literal_bytes = fields[3];
|
||||
stats->created_reg = fields[4];
|
||||
stats->created_dir = fields[5];
|
||||
stats->created_link = fields[6];
|
||||
stats->created_special = fields[7];
|
||||
stats->matched_data = matched;
|
||||
stats->deleted_files = deleted;
|
||||
stats->would_delete_count = would;
|
||||
return true;
|
||||
}
|
||||
|
||||
+4
-39
@@ -57,26 +57,14 @@ bool format_dest_state_send(int fd, const OutputDestState* state);
|
||||
bool format_dest_state_receive(int fd, OutputDestState* state);
|
||||
|
||||
/* End-of-transfer receiver counters reported through STATUS_STATS (protocol
|
||||
* 2.25.0, extended in 2.28.0) when the wire config carries report_stats.
|
||||
* `would_delete_count` is the number of destination-relative paths the receiver
|
||||
* would have deleted in a -n/--dry-run --delete run; that many wire strings
|
||||
* immediately follow the fixed record (sent/read by the caller).
|
||||
*
|
||||
* Protocol 2.28.0 adds the receiver-observed counters the sender cannot see:
|
||||
* `literal_bytes` is the file data the receiver actually stored literally
|
||||
* (whole files plus the literal fragments of a delta) and the four `created_*`
|
||||
* counters split the destination entries the receiver newly created by type,
|
||||
* reproducing rsync's `Number of created files` breakdown and an exact
|
||||
* `Literal data` for a delta run. */
|
||||
* 2.25.0) when the wire config carries report_stats. `would_delete_count` is
|
||||
* the number of destination-relative paths the receiver would have deleted in a
|
||||
* -n/--dry-run --delete run; that many wire strings immediately follow the
|
||||
* fixed record (sent/read by the caller). */
|
||||
typedef struct {
|
||||
unsigned long long matched_data;
|
||||
unsigned long long deleted_files;
|
||||
unsigned long long would_delete_count;
|
||||
unsigned long long literal_bytes;
|
||||
unsigned long long created_reg;
|
||||
unsigned long long created_dir;
|
||||
unsigned long long created_link;
|
||||
unsigned long long created_special;
|
||||
} ReceiverStats;
|
||||
|
||||
/* Fixed-width STATUS_STATS counter record. The status frame and the optional
|
||||
@@ -85,27 +73,4 @@ typedef struct {
|
||||
bool format_stats_send(int fd, const ReceiverStats* stats);
|
||||
bool format_stats_receive(int fd, ReceiverStats* stats);
|
||||
|
||||
/* Sender-side file-list accounting for rsync's `--stats` block. Filled while
|
||||
* the scan/send loops walk each entry: the flist counters describe every
|
||||
* scanned source entry (transferred or skipped), while the transferred/literal
|
||||
* counters describe only the regular files the receiver actually stored. The
|
||||
* type split lets the client print rsync's `Number of files` breakdown; the
|
||||
* receiver-only counters (matched data, deleted, created) come from
|
||||
* STATUS_STATS. */
|
||||
typedef struct {
|
||||
unsigned long long flist_reg;
|
||||
unsigned long long flist_dir;
|
||||
unsigned long long flist_link;
|
||||
unsigned long long flist_special;
|
||||
unsigned long long total_file_size; /* sum of entry sizes (link target len) */
|
||||
unsigned long long transferred_regular; /* regular files actually stored */
|
||||
unsigned long long transferred_file_size; /* source size of those files */
|
||||
/* Whole-file accuracy: the `--stats` "Literal data" row. The sender counts
|
||||
* the source size of every stored file, so a whole-file transfer matches
|
||||
* rsync. A delta run actually ships only the literal fragments of the diff
|
||||
* (the rest is matched/copied), so here the value is an upper bound, not
|
||||
* rsync's literal-byte total; see RSYNC_COMPAT.md's `--stats` row. */
|
||||
unsigned long long literal_data;
|
||||
} TransferStats;
|
||||
|
||||
#endif
|
||||
|
||||
+1
-20
@@ -21,26 +21,7 @@ typedef enum {
|
||||
LOG_INFO_MISC = 1u << 1,
|
||||
LOG_INFO_SKIP = 1u << 2,
|
||||
LOG_INFO_STATS = 1u << 3,
|
||||
/* rsync categories that map to a FastSync event (emitted in rsync's line
|
||||
* format): del (deletions), remove (sender-side source removal), name
|
||||
* (transferred entry names), flist (file-list header), nonreg (skipped
|
||||
* non-regular files), progress (per-file progress). rsync's `backup`
|
||||
* category is accepted for CLI parity but stays silent: the receiver does the
|
||||
* backing-up and FastSync has no backup event to report from the sender. */
|
||||
LOG_INFO_DEL = 1u << 4,
|
||||
LOG_INFO_REMOVE = 1u << 5,
|
||||
LOG_INFO_NAME = 1u << 6,
|
||||
LOG_INFO_FLIST = 1u << 7,
|
||||
LOG_INFO_NONREG = 1u << 8,
|
||||
LOG_INFO_PROGRESS = 1u << 9,
|
||||
/* Marker for `--info=name2` and higher: also print rsync's
|
||||
"NAME is uptodate" line for entries the receiver already has. It rides in
|
||||
the info_level bitset (there is no separate Config field) and is never set
|
||||
by --info=all (which selects level 1). */
|
||||
LOG_INFO_NAME_UPTODATE = 1u << 10,
|
||||
LOG_INFO_ALL = LOG_INFO_COPY | LOG_INFO_MISC | LOG_INFO_SKIP | LOG_INFO_STATS | LOG_INFO_DEL |
|
||||
LOG_INFO_REMOVE | LOG_INFO_NAME | LOG_INFO_FLIST | LOG_INFO_NONREG |
|
||||
LOG_INFO_PROGRESS,
|
||||
LOG_INFO_ALL = LOG_INFO_COPY | LOG_INFO_MISC | LOG_INFO_SKIP | LOG_INFO_STATS,
|
||||
} LogInfoFlag;
|
||||
|
||||
void log_message(LogLevel log_level, const char* message, ...);
|
||||
|
||||
@@ -38,12 +38,10 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que
|
||||
context->remove_source_files = NULL;
|
||||
context->early_delete = false;
|
||||
context->delete_plans = NULL;
|
||||
context->delete_suppressed = false;
|
||||
context->scan_stopped_early = false;
|
||||
context->total_files = 0;
|
||||
context->progress_bytes = 0;
|
||||
context->total_bytes = 0;
|
||||
memset(&context->stats, 0, sizeof(context->stats));
|
||||
context->sender_done = false;
|
||||
atomic_init(&context->cancelled, false);
|
||||
protocol_session_init(&context->allocation_session, -1, -1);
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include "config.h"
|
||||
#include "delete_plan.h"
|
||||
#include "file.h"
|
||||
#include "format.h"
|
||||
#include "protocol.h"
|
||||
#include "queue.h"
|
||||
#include "stop_condition.h"
|
||||
@@ -83,22 +82,10 @@ typedef struct {
|
||||
thread transmits the root plan before any data and the remaining plans
|
||||
alongside the chunks. Set once before the worker threads start. */
|
||||
DeletePlanSender* delete_plans;
|
||||
/* A scan I/O error without --ignore-errors suppressed deletion: the prebuilt
|
||||
keep-set/plans were dropped, and the streaming scanner must not build a
|
||||
fresh manifest or re-send the per-directory plans. Set once before the
|
||||
worker threads start. */
|
||||
bool delete_suppressed;
|
||||
mtx_t mutex_progress;
|
||||
int total_files;
|
||||
unsigned long long progress_bytes;
|
||||
unsigned long long total_bytes;
|
||||
/* Per-type flist / transferred accounting for the rsync --stats breakdown and
|
||||
the progress `to-chk` denominator. Owned by the sender thread: it is the
|
||||
only writer (the entry/transfer notes in send_chunks_multithreaded) and it
|
||||
reads the totals in its completion tail, so no lock is needed. This is NOT
|
||||
guarded by mutex_progress (which covers total_files/progress_bytes/
|
||||
total_bytes/sender_done). */
|
||||
TransferStats stats;
|
||||
bool sender_done;
|
||||
atomic_bool cancelled;
|
||||
ProtocolSession allocation_session;
|
||||
|
||||
+3
-24
@@ -183,28 +183,12 @@ void io_set_bwlimit(unsigned long long bytes_per_sec) {
|
||||
mtx_unlock(&bw_mutex);
|
||||
}
|
||||
|
||||
unsigned long long io_get_bwlimit(void) {
|
||||
return global_bwlimit();
|
||||
}
|
||||
|
||||
/* rsync's throttle (io.c sleep_for_bwlimit) sleeps once its unslept debt
|
||||
* reaches ~100 ms of bandwidth, so its effective initial burst is about 0.1 s
|
||||
* worth of bytes, not a full second. FastSync models the same with a token
|
||||
* bucket whose capacity is bwlimit/10, so a throttled run paces like rsync
|
||||
* instead of sending a full second's worth up front. */
|
||||
static long long bw_burst_capacity(unsigned long long bwlimit) {
|
||||
if (bwlimit == 0)
|
||||
return 0;
|
||||
long long burst = (long long)(bwlimit / 10);
|
||||
return burst > 0 ? burst : 1;
|
||||
}
|
||||
|
||||
void protocol_session_set_bwlimit(ProtocolSession* session, unsigned long long bytes_per_sec) {
|
||||
if (!session)
|
||||
return;
|
||||
session->bwlimit =
|
||||
bytes_per_sec > (unsigned long long)LLONG_MAX ? (unsigned long long)LLONG_MAX : bytes_per_sec;
|
||||
session->bw_tokens = bw_burst_capacity(session->bwlimit);
|
||||
session->bw_tokens = (long long)session->bwlimit;
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
session->bw_last_refill_sec = now.tv_sec;
|
||||
@@ -238,9 +222,8 @@ static void bw_throttle_session(ProtocolSession* session, size_t bytes_written)
|
||||
|
||||
long long tokens_to_add = (long long)((double)session->bwlimit * elapsed_ns / 1000000000.0);
|
||||
session->bw_tokens += tokens_to_add;
|
||||
long long burst = bw_burst_capacity(session->bwlimit);
|
||||
if (session->bw_tokens > burst)
|
||||
session->bw_tokens = burst;
|
||||
if (session->bw_tokens > (long long)session->bwlimit)
|
||||
session->bw_tokens = (long long)session->bwlimit;
|
||||
|
||||
session->bw_tokens -= bytes_written;
|
||||
|
||||
@@ -251,11 +234,7 @@ static void bw_throttle_session(ProtocolSession* session, size_t bytes_written)
|
||||
poll(NULL, 0, (int)(deficit_us / 1000));
|
||||
else
|
||||
usleep((useconds_t)deficit_us);
|
||||
/* Reset the bucket AFTER the sleep: crediting the sleep duration as elapsed
|
||||
refill time would cancel half the throttle (the next call would see the
|
||||
whole sleep as refill and immediately grant a fresh burst). */
|
||||
session->bw_tokens = 0;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
session->bw_last_refill_sec = now.tv_sec;
|
||||
session->bw_last_refill_nsec = now.tv_nsec;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,6 @@ enum NET_STATUS {
|
||||
|
||||
void io_set_fds(int read_fd, int write_fd);
|
||||
void io_set_bwlimit(unsigned long long bytes_per_sec);
|
||||
unsigned long long io_get_bwlimit(void);
|
||||
void io_set_ssl(SSL* ssl);
|
||||
SSL* io_get_ssl(void);
|
||||
|
||||
|
||||
+7
-89
@@ -2,7 +2,6 @@
|
||||
#include "array_list.h"
|
||||
#include "log.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
@@ -53,31 +52,6 @@ bool path_is_within_root(const char* root, const char* path) {
|
||||
return strncmp(root, path, root_len) == 0 && (path[root_len] == '\0' || path[root_len] == '/');
|
||||
}
|
||||
|
||||
/* Borrowed transfer-relative view of `path`: strip any leading '/' and then a
|
||||
* `root` prefix (its own leading/trailing slashes tolerated), returning a
|
||||
* pointer into `path`. Non-allocating, so it is safe on the hot scan/print
|
||||
* paths. A NULL/empty root, or a path not under `root`, leaves only the
|
||||
* leading-slash strip. `path` must be NUL-terminated and live in the caller. */
|
||||
const char* utils_strip_transfer_root(const char* path, const char* root) {
|
||||
if (path == NULL)
|
||||
return NULL;
|
||||
const char* rel = path;
|
||||
while (*rel == '/')
|
||||
rel++;
|
||||
if (root == NULL)
|
||||
return rel;
|
||||
while (*root == '/')
|
||||
root++;
|
||||
size_t root_len = strlen(root);
|
||||
while (root_len > 0 && root[root_len - 1] == '/')
|
||||
root_len--;
|
||||
if (root_len == 0)
|
||||
return rel;
|
||||
if (strncmp(rel, root, root_len) == 0 && (rel[root_len] == '/' || rel[root_len] == '\0'))
|
||||
return rel + root_len + (rel[root_len] == '/' ? 1 : 0);
|
||||
return rel;
|
||||
}
|
||||
|
||||
/* Open the destination root directory itself, confined to the authorized root.
|
||||
* NOTE (do not merge with file_open_secure_parent): this walk opens dest_root
|
||||
* (a directory that must already exist) and returns its fd, whereas
|
||||
@@ -143,47 +117,6 @@ char* str_dup(const char* string) {
|
||||
return new_string;
|
||||
}
|
||||
|
||||
int env_choice_first(const char* env_name, int (*resolve)(const char*), bool* specified) {
|
||||
if (specified)
|
||||
*specified = false;
|
||||
if (!env_name || !resolve)
|
||||
return -1;
|
||||
const char* env = getenv(env_name);
|
||||
if (!env)
|
||||
return -1;
|
||||
|
||||
bool saw_nonblank = false;
|
||||
const char* p = env;
|
||||
while (*p) {
|
||||
if (*p == '&')
|
||||
break;
|
||||
if (isspace((unsigned char)*p)) {
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
saw_nonblank = true;
|
||||
char token[64];
|
||||
size_t len = 0;
|
||||
while (*p && *p != '&' && !isspace((unsigned char)*p)) {
|
||||
if (len < sizeof(token) - 1)
|
||||
token[len++] = *p;
|
||||
p++;
|
||||
}
|
||||
token[len] = '\0';
|
||||
if (len > 0) {
|
||||
int id = resolve(token);
|
||||
if (id >= 0) {
|
||||
if (specified)
|
||||
*specified = true;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (specified)
|
||||
*specified = saw_nonblank;
|
||||
return -1;
|
||||
}
|
||||
|
||||
#define STR_HASH_SET_MIN_CAPACITY 16
|
||||
|
||||
static size_t str_hash_set_hash(const char* key, size_t len) {
|
||||
@@ -683,8 +616,7 @@ static bool is_synced_dir(const PathIndex* dirs, const char* rel) {
|
||||
static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
||||
const PathIndex* dirs, DeleteBudget* budget,
|
||||
const DeleteSkipEntry* skips, int skip_count, bool parent_deletable,
|
||||
bool* all_removed, DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
bool* all_removed) {
|
||||
/* openat(dirfd, ".") opens an independent file description: a dup() would
|
||||
share dirfd's file offset and a prior pass could leave the stream drained. */
|
||||
int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
@@ -734,7 +666,7 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!delete_extras_fd(childfd, child_rel, keep, dirs, budget, skips, skip_count, deletable,
|
||||
&child_all_removed, observer, observer_context))
|
||||
&child_all_removed))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
@@ -761,8 +693,6 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k
|
||||
local_survives = true;
|
||||
} else {
|
||||
budget->deleted++;
|
||||
if (observer)
|
||||
observer(observer_context, child_rel);
|
||||
}
|
||||
} else {
|
||||
local_survives = true;
|
||||
@@ -783,8 +713,6 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k
|
||||
local_survives = true;
|
||||
} else {
|
||||
budget->deleted++;
|
||||
if (observer)
|
||||
observer(observer_context, child_rel);
|
||||
char* escaped_path = output_escape(child_rel, log_get_8_bit_output());
|
||||
fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
@@ -939,12 +867,10 @@ bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||
return ok;
|
||||
}
|
||||
|
||||
DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
size_t* deleted_out, size_t* skipped_out,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
size_t* deleted_out, size_t* skipped_out) {
|
||||
if (deleted_out)
|
||||
*deleted_out = 0;
|
||||
if (skipped_out)
|
||||
@@ -985,7 +911,7 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr
|
||||
DeleteBudget budget = {.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
bool all_removed = false;
|
||||
bool ok = delete_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &budget, skips,
|
||||
skip_count, false, &all_removed, observer, observer_context);
|
||||
skip_count, false, &all_removed);
|
||||
if (close(rootfd) != 0)
|
||||
ok = false;
|
||||
path_index_free(&keep);
|
||||
@@ -1000,14 +926,6 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr
|
||||
return budget.limit_hit ? DELETE_WALK_LIMIT_REACHED : DELETE_WALK_OK;
|
||||
}
|
||||
|
||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
size_t* deleted_out, size_t* skipped_out) {
|
||||
return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips,
|
||||
skip_count, deleted_out, skipped_out, NULL, NULL);
|
||||
}
|
||||
|
||||
bool delete_extras(const char* dest_root, const ArrayList* manifest) {
|
||||
return delete_extras_limited(dest_root, manifest, NULL, SIZE_MAX, NULL, 0, NULL, NULL) ==
|
||||
DELETE_WALK_OK;
|
||||
|
||||
@@ -81,17 +81,6 @@ bool path_index_has_descendant(const PathIndex* index, const char* path);
|
||||
|
||||
char* str_dup(const char* string);
|
||||
char* output_escape(const char* string, bool eight_bit_output);
|
||||
|
||||
/* Resolve the first supported name from a rsync algorithm-preference
|
||||
* environment variable (RSYNC_COMPRESS_LIST / RSYNC_CHECKSUM_LIST). `resolve`
|
||||
* maps a case-insensitive name to an algorithm id (>= 0) or -1 for an unknown
|
||||
* name. rsync's syntax is a whitespace-separated list (comma/colon are NOT
|
||||
* separators); the client-side half ends at '&'. Unknown entries are skipped
|
||||
* and the first resolvable one wins. *specified is set true when the variable
|
||||
* holds at least one non-blank character. Returns the first resolvable id, or
|
||||
* -1 when the variable is unset/blank or names no supported algorithm. */
|
||||
int env_choice_first(const char* env_name, int (*resolve)(const char*), bool* specified);
|
||||
|
||||
/* Upper bound on one line/token read from a local list file (--files-from,
|
||||
* --exclude-from/--include-from, .rsync-filter). Mirrors MAX_STRING_SIZE and
|
||||
* stops a hostile multi-gigabyte line from forcing unbounded allocation. */
|
||||
@@ -149,20 +138,6 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
size_t* deleted_out, size_t* skipped_out);
|
||||
|
||||
/* Optional per-deletion observer: called for each destination-relative path
|
||||
actually removed (a file, symlink, or directory), in removal order, so the
|
||||
receiver can stream rsync's `--info=del`/`--info=remove` lines. */
|
||||
typedef void (*DeletePathObserver)(void* context, const char* rel_path);
|
||||
|
||||
/* `delete_extras_limited_observed` is delete_extras_limited with an optional
|
||||
observer; the observer is invoked only for entries truly removed. */
|
||||
DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
size_t* deleted_out, size_t* skipped_out,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context);
|
||||
/* Read-only companion to delete_extras_limited: walk the destination exactly as
|
||||
the delete pass would and APPEND (strdup'd) destination-relative paths that
|
||||
WOULD be removed, without touching disk. Used for -n/--dry-run --delete
|
||||
@@ -202,11 +177,6 @@ const char* utils_get_authorized_root_path(void);
|
||||
* callers guarantee this); this is containment by string, not by resolved
|
||||
* symlinks. Shared by the utils and file secure-walk root confinement. */
|
||||
bool path_is_within_root(const char* root, const char* path);
|
||||
/* Non-allocating transfer-relative view of `path`: strip any leading '/' and
|
||||
* then a `root` prefix (leading/trailing slashes tolerated), returning a
|
||||
* borrowed pointer into `path`. A NULL/empty root, or a path not under
|
||||
* `root`, yields just the leading-slash strip. `path`/`root` must stay alive. */
|
||||
const char* utils_strip_transfer_root(const char* path, const char* root);
|
||||
/* True when `path` contains a ".." component. This is a purely lexical
|
||||
* dot-dot check: an absolute path is NOT rejected here, because default
|
||||
* (non-relative) transfers legitimately put the sender's absolute source path
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
OLDDEST
|
||||
@@ -1 +0,0 @@
|
||||
NEWCONTENT
|
||||
@@ -1 +0,0 @@
|
||||
NEWCONTENT
|
||||
@@ -1,80 +0,0 @@
|
||||
# Integration tests
|
||||
|
||||
The integration suite drives the built `build/server` and `build/client`
|
||||
against local corpora. Unit tests live in `tests/` (the custom C framework);
|
||||
the Python suite here covers the full transfer pipeline, transports, features,
|
||||
and rsync parity.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Full suite (excludes privilege-dependent tests on CI runners)
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
|
||||
|
||||
# Fast PR subset only
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m ci
|
||||
```
|
||||
|
||||
The tests expect `build/server` and `build/client` (configure/build with CMake
|
||||
first); `common.py` derives `BUILD_DIR` from the repository root.
|
||||
|
||||
## Differential rsync-parity gate
|
||||
|
||||
`test_differential_parity.py` runs the **same** transfer with real
|
||||
`rsync 3.4.1` and with FastSync over separate destinations, then compares:
|
||||
|
||||
- the destination trees — relative paths, file content hashes, symlink
|
||||
targets, modes (where the case is about perms), and hard-link grouping;
|
||||
- the normalized stdout for output-oriented flags (`-i`,
|
||||
`--out-format=...`, `--stats`), after stripping volatile fields
|
||||
(timings, rates, wire byte counts) and directory-only itemize lines that
|
||||
FastSync's recursive scanner documents as absent.
|
||||
|
||||
FastSync mirrors the absolute source path under its receive root (see
|
||||
`get_dest_received_dir`); the harness normalizes that layout (and the
|
||||
`-R`/`--files-from` layouts) before comparing.
|
||||
|
||||
```bash
|
||||
# Fast subset that guards the ✅ surface on pull requests
|
||||
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity_ci
|
||||
|
||||
# Full differential case table (`_CASES`): every row is marked `parity`, and a
|
||||
# case with an allowlisted residual in `parity_caveats.py` is included too.
|
||||
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity
|
||||
```
|
||||
|
||||
`-m parity` selects only the `_CASES` table in this module. Differential
|
||||
coverage for options outside that table (`--temp-dir`, `--delay-updates`,
|
||||
`--dry-run`, `--fuzzy`, the basis-dir options, `-M` over daemon/TCP, and
|
||||
receiver filter-protect) lives in dedicated modules (`test_option_parity.py`,
|
||||
`test_parity_blockers.py`, `test_parity_quickwins.py`, ...) and is not part of
|
||||
this gate. The suite skips cleanly when `rsync` is not installed.
|
||||
|
||||
## Allowlist (`parity_caveats.py`)
|
||||
|
||||
`parity_caveats.py` is the single data-driven allowlist of known differences.
|
||||
Each entry maps a case id to the aspects that may differ (`tree`, `stdout`,
|
||||
`extra`, `rc`) and cites the governing row in `RSYNC_COMPAT.md`:
|
||||
|
||||
```python
|
||||
CAVEATS = {
|
||||
# no known residuals at present -- the burn-down reached zero
|
||||
# "some_case_id": {"tree": "documented residual ... ref: RSYNC_COMPAT.md ..."},
|
||||
}
|
||||
```
|
||||
|
||||
A differential mismatch in an aspect that is **not** listed fails the gate with
|
||||
a readable tree/stdout diff.
|
||||
|
||||
If a case is allowlisted but now matches rsync, the gate emits a loud warning
|
||||
naming the stale entry — that is the parity burn-down signal. Run with
|
||||
`FASTSYNC_PARITY_STRICT=1` to make stale entries fail instead (the full CI
|
||||
parity job sets this). To add a residual:
|
||||
|
||||
1. Reproduce it with `-m parity` and read the failure's tree/stdout diff.
|
||||
2. Confirm it is a documented `⚠️`/`❌` residual (or get the `✅` row
|
||||
reclassified) and cite the row.
|
||||
3. Add the case id and aspect(s) to `CAVEATS`, keeping the reason concise.
|
||||
|
||||
Do not allowlist an undocumented divergence from a `✅` row — fix it or get the
|
||||
row reclassified first.
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Data-driven allowlist for the differential rsync-parity gate.
|
||||
|
||||
Every entry maps a case id (see ``test_differential_parity.py``) to the aspects
|
||||
that are *known* to differ from ``rsync 3.4.1`` and the documented reason. A
|
||||
differential mismatch in an aspect that is **not** listed here fails the gate.
|
||||
|
||||
Aspect keys
|
||||
-----------
|
||||
``tree`` destination tree differs (paths, file hashes, symlink targets,
|
||||
modes, hardlink grouping)
|
||||
``stdout`` normalized output for ``-i`` / ``--stats`` / ``--out-format``
|
||||
``extra`` a case-specific assertion differs (basis/inode checks, ...)
|
||||
``rc`` exit status differs
|
||||
|
||||
Burn-down
|
||||
---------
|
||||
If a case is listed here but now matches rsync, the gate emits a loud
|
||||
``pytest`` warning naming the stale entry: delete the entry (and, when the
|
||||
underlying row in ``RSYNC_COMPAT.md`` is now parity, update that row). Set
|
||||
``FASTSYNC_PARITY_STRICT=1`` to turn stale entries into failures in CI.
|
||||
|
||||
Keep the values concise but cite the governing row so the entry can be
|
||||
re-triaged when the row moves.
|
||||
"""
|
||||
|
||||
# case id -> {aspect: "reason (ref: RSYNC_COMPAT.md ...)"}
|
||||
CAVEATS = {}
|
||||
|
||||
# Accepted aspect names (guards against typos in this file).
|
||||
ASPECTS = ("tree", "stdout", "extra", "rc")
|
||||
|
||||
|
||||
def caveat_for(case_id: str) -> dict:
|
||||
return CAVEATS.get(case_id, {})
|
||||
@@ -1,571 +0,0 @@
|
||||
"""Differential rsync-parity harness.
|
||||
|
||||
Runs the SAME transfer with real ``rsync`` and with FastSync over separate
|
||||
destinations and compares the resulting trees and (optionally) normalized
|
||||
stdout. ``test_differential_parity.py`` drives this module with a table of
|
||||
cases; ``parity_caveats.py`` is the data-driven allowlist of documented
|
||||
residuals.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
FastSync mirrors the *absolute* source path below its receive root, while
|
||||
rsync copies the source contents directly into the destination. ``Case.layout``
|
||||
tells the harness which pair of directory roots to compare:
|
||||
|
||||
* ``MIRROR`` -- rsync ``DEST/`` vs FastSync ``DEST/<abs-src>/`` (the common
|
||||
case; matches ``common.get_dest_received_dir``).
|
||||
* ``MIRROR_ABS`` -- ``rsync -R`` without a cut lays the full absolute path
|
||||
under the destination, so rsync ``DEST/<abs-src>/`` is compared against the
|
||||
same FastSync mirror path.
|
||||
* ``RELATIVE`` -- ``rsync -R --files-from`` lays bare relative paths under the
|
||||
destination and FastSync does the same, so both destination roots compare
|
||||
directly.
|
||||
|
||||
Only ``tests/integration/common.py`` is used to reach the build products and the
|
||||
server manager; the harness never duplicates that plumbing.
|
||||
"""
|
||||
import difflib
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Optional, Tuple
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import ( # noqa: E402 (path bootstrap above)
|
||||
TEST_DATA_DIR,
|
||||
clean_dir,
|
||||
get_dest_received_dir,
|
||||
run_client,
|
||||
)
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
|
||||
# Comparison layouts (see module docstring).
|
||||
MIRROR = "mirror"
|
||||
MIRROR_ABS = "mirror_abs"
|
||||
RELATIVE = "relative"
|
||||
|
||||
# stdout comparators.
|
||||
STDOUT_NONE = None
|
||||
STDOUT_ITEMIZE = "itemize"
|
||||
STDOUT_OUTFMT = "outfmt"
|
||||
STDOUT_STATS = "stats"
|
||||
STDOUT_PROGRESS = "progress"
|
||||
|
||||
# rsync --stats lines that are protocol-independent and must match exactly.
|
||||
# `Number of files` and `Number of created files` carry rsync's per-type
|
||||
# breakdown; protocol 2.28.0 reports the receiver-created split over
|
||||
# STATUS_STATS. Deliberately excluded: Total bytes sent/received (protocol
|
||||
# framing differs, see the `--stats` row in RSYNC_COMPAT.md).
|
||||
STATS_KEYS = (
|
||||
"Number of files",
|
||||
"Number of created files",
|
||||
"Number of deleted files",
|
||||
"Number of regular files transferred",
|
||||
"Total file size",
|
||||
"Total transferred file size",
|
||||
"Literal data",
|
||||
"Matched data",
|
||||
"File list size",
|
||||
)
|
||||
|
||||
_ITEMIZE_RE = re.compile(r"^(<|>|c|h|\.|\*)[fdLDS][.+\-][.+\-][.+\-][.+\-]")
|
||||
_PROGRESS_TOTAL_RE = re.compile(r"to-chk=\d+/(\d+)")
|
||||
_PROGRESS_XFR_RE = re.compile(r"xfr#(\d+)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
"""One differential scenario: a corpus, a flag set, and how to compare."""
|
||||
|
||||
id: str
|
||||
corpus: str
|
||||
flags: List[str]
|
||||
fastsync_flags: Optional[List[str]] = None
|
||||
layout: str = MIRROR
|
||||
server_args: Tuple[str, ...] = ("--allow-super",)
|
||||
seed: Optional[Callable] = None
|
||||
stdout: Optional[str] = STDOUT_NONE
|
||||
compare_modes: bool = False
|
||||
compare_hardlinks: bool = False
|
||||
ignore_paths: Tuple[str, ...] = ()
|
||||
extra_check: Optional[Callable] = None
|
||||
files_from: Optional[Tuple[str, ...]] = None
|
||||
# rsync receives ``src + "/"``; FastSync mirrors the path it is given, so a
|
||||
# trailing-slash-sensitive case must hand FastSync the same form.
|
||||
fs_src_suffix: str = ""
|
||||
# Some cases have an unspecified result (e.g. which extras survive a
|
||||
# partial --max-delete abort): assert the case-specific invariants via
|
||||
# extra_check and skip the exact-tree comparison.
|
||||
compare_tree: bool = True
|
||||
ci: bool = False
|
||||
ref: str = ""
|
||||
|
||||
def fs_flags(self) -> List[str]:
|
||||
return list(self.flags if self.fastsync_flags is None else self.fastsync_flags)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Corpora
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Deterministic mtimes so quick-check decisions are reproducible.
|
||||
_SRC_MTIME = 1_600_000_000
|
||||
|
||||
|
||||
def _write(path: str, data: bytes, mode: Optional[int] = None) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
os.utime(path, (_SRC_MTIME, _SRC_MTIME))
|
||||
if mode is not None:
|
||||
os.chmod(path, mode)
|
||||
|
||||
|
||||
def _set_mode(path: str, mode: int) -> None:
|
||||
os.chmod(path, mode)
|
||||
|
||||
|
||||
def corpus_basic(root: str) -> None:
|
||||
"""Regular files + nested dirs (dirs are implied by their files)."""
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "a.txt"), b"hello world\n")
|
||||
_write(os.path.join(root, "sub", "b.bin"),
|
||||
bytes((i * 7) & 0xFF for i in range(5000)))
|
||||
_write(os.path.join(root, "sub", "deep", "c.txt"), "w\u00f6rld\n".encode())
|
||||
|
||||
|
||||
def corpus_unicode(root: str) -> None:
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "uni \u00f1\u6587.txt"), b"unicode\n")
|
||||
_write(os.path.join(root, "sub", "sp ace \u00e9.dat"), b"spaced\n")
|
||||
_set_mode(os.path.join(root, "sub"), 0o750)
|
||||
|
||||
|
||||
def corpus_links(root: str) -> None:
|
||||
corpus_basic(root)
|
||||
os.symlink("a.txt", os.path.join(root, "rel_link"))
|
||||
os.symlink("/etc/hostname", os.path.join(root, "abs_link"))
|
||||
os.symlink("nowhere/target", os.path.join(root, "broken_link"))
|
||||
|
||||
|
||||
def corpus_hardlinks(root: str) -> None:
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "h1.txt"), b"hardlinked payload\n")
|
||||
os.link(os.path.join(root, "h1.txt"), os.path.join(root, "h2.txt"))
|
||||
_write(os.path.join(root, "other.txt"), b"other\n")
|
||||
|
||||
|
||||
def corpus_sparse(root: str) -> None:
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "small.txt"), b"small\n")
|
||||
sparse = os.path.join(root, "sparse.bin")
|
||||
with open(sparse, "wb") as fh:
|
||||
fh.seek(1024 * 1024 - 1)
|
||||
fh.write(b"\0")
|
||||
os.utime(sparse, (_SRC_MTIME, _SRC_MTIME))
|
||||
|
||||
|
||||
def corpus_filters(root: str) -> None:
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "keep.txt"), b"keep\n")
|
||||
_write(os.path.join(root, "drop.log"), b"log\n")
|
||||
_write(os.path.join(root, "sub", "keep2.txt"), b"keep2\n")
|
||||
_write(os.path.join(root, "sub", "drop2.log"), b"log2\n")
|
||||
_write(os.path.join(root, "sub", "data.bin"), b"bin\n")
|
||||
|
||||
|
||||
def corpus_empty_dir(root: str) -> None:
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "keep.txt"), b"keep\n")
|
||||
os.makedirs(os.path.join(root, "emptydir"), exist_ok=True)
|
||||
os.utime(os.path.join(root, "emptydir"), (_SRC_MTIME, _SRC_MTIME))
|
||||
_write(os.path.join(root, "nonempty", "f.txt"), b"f\n")
|
||||
|
||||
|
||||
def corpus_multidir(root: str) -> None:
|
||||
"""Multi-directory tree for the --progress file-list naming/denominator.
|
||||
|
||||
Nested files, a directory-only branch, an empty directory and a symlink
|
||||
exercise every file-list entry type rsync counts in `to-chk` but FastSync's
|
||||
streaming scanner never emits as a transfer entry.
|
||||
"""
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "a.txt"), b"alpha\n")
|
||||
_write(os.path.join(root, "b.txt"), b"bravo\n")
|
||||
_write(os.path.join(root, "sub1", "c.txt"), b"charlie\n")
|
||||
_write(os.path.join(root, "sub1", "deep", "d.txt"), b"delta\n")
|
||||
_write(os.path.join(root, "sub2", "e.txt"), b"echo\n")
|
||||
os.symlink("a.txt", os.path.join(root, "link1"))
|
||||
os.makedirs(os.path.join(root, "emptydir"), exist_ok=True)
|
||||
os.utime(os.path.join(root, "emptydir"), (_SRC_MTIME, _SRC_MTIME))
|
||||
|
||||
|
||||
def corpus_relative(root: str) -> None:
|
||||
"""Tree for the -R/--files-from cases."""
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "a.txt"), b"a\n")
|
||||
_write(os.path.join(root, "b.txt"), b"b\n")
|
||||
_write(os.path.join(root, "sub", "x.txt"), b"x\n")
|
||||
_write(os.path.join(root, "sub", "y.txt"), b"y\n")
|
||||
os.makedirs(os.path.join(root, "dir1"), exist_ok=True)
|
||||
os.utime(os.path.join(root, "dir1"), (_SRC_MTIME, _SRC_MTIME))
|
||||
_write(os.path.join(root, "dir1", "keep.txt"), b"keep\n")
|
||||
|
||||
|
||||
def corpus_iconv(root: str) -> None:
|
||||
"""Latin-1 (ISO-8859-1) encoded filenames, matching the --iconv direction."""
|
||||
clean_dir(root)
|
||||
for rel, data in ((b"caf\xe9.txt", b"caf\xe9\n"),
|
||||
(os.path.join(b"sub", b"\xfcber.txt"), b"\xfcber\n")):
|
||||
full = os.path.join(os.fsencode(root), rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(data)
|
||||
os.utime(full, (_SRC_MTIME, _SRC_MTIME))
|
||||
|
||||
|
||||
CORPORA: Dict[str, Callable[[str], None]] = {
|
||||
"basic": corpus_basic,
|
||||
"unicode": corpus_unicode,
|
||||
"links": corpus_links,
|
||||
"hardlinks": corpus_hardlinks,
|
||||
"sparse": corpus_sparse,
|
||||
"filters": corpus_filters,
|
||||
"empty_dir": corpus_empty_dir,
|
||||
"multidir": corpus_multidir,
|
||||
"relative": corpus_relative,
|
||||
"iconv": corpus_iconv,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tree snapshotting / comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def snapshot(root: str, compare_modes: bool = False) -> Dict[str, tuple]:
|
||||
"""Map relative path -> descriptor for every entry below ``root``.
|
||||
|
||||
Files hash their contents with SHA-256 (structural comparison, so differing
|
||||
quick-check metadata cannot mask a payload difference). Symlinks record
|
||||
their target. Empty directories are included (as ``("dir", ...)``) so the
|
||||
recursive-empty-directory residual is observable.
|
||||
"""
|
||||
out: Dict[str, tuple] = {}
|
||||
if not os.path.isdir(root):
|
||||
return out
|
||||
|
||||
def describe(path: str) -> Optional[tuple]:
|
||||
st = os.lstat(path)
|
||||
if os.path.islink(path):
|
||||
return ("link", os.readlink(path))
|
||||
if os.path.isdir(path):
|
||||
mode = oct(st.st_mode & 0o7777) if compare_modes else None
|
||||
return ("dir", mode)
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
h.update(chunk)
|
||||
mode = oct(st.st_mode & 0o7777) if compare_modes else None
|
||||
return ("file", h.hexdigest()[:16], mode)
|
||||
|
||||
# The comparison root itself is not part of the tree diff: a no-transfer
|
||||
# result legitimately leaves FastSync's mirror directory absent while rsync
|
||||
# leaves an existing (empty) destination root.
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
dirnames.sort()
|
||||
for name in sorted(dirnames):
|
||||
p = os.path.join(dirpath, name)
|
||||
rel = os.path.relpath(p, root)
|
||||
if os.path.islink(p):
|
||||
out[rel] = ("link", os.readlink(p))
|
||||
dirnames.remove(name)
|
||||
else:
|
||||
out[rel] = describe(p)
|
||||
for name in sorted(filenames):
|
||||
p = os.path.join(dirpath, name)
|
||||
out[os.path.relpath(p, root)] = describe(p)
|
||||
return out
|
||||
|
||||
|
||||
def _hardlink_groups(root: str) -> Dict[str, str]:
|
||||
"""Assign a stable group letter to each inode shared by >1 regular file."""
|
||||
inodes: Dict[tuple, List[str]] = {}
|
||||
for dirpath, _dirs, filenames in os.walk(root, followlinks=False):
|
||||
for name in filenames:
|
||||
p = os.path.join(dirpath, name)
|
||||
if os.path.islink(p):
|
||||
continue
|
||||
st = os.lstat(p)
|
||||
if st.st_nlink > 1:
|
||||
inodes.setdefault((st.st_dev, st.st_ino), []).append(
|
||||
os.path.relpath(p, root))
|
||||
groups: Dict[str, str] = {}
|
||||
for i, (_key, members) in enumerate(sorted(inodes.items())):
|
||||
for rel in members:
|
||||
groups[rel] = chr(ord("A") + i)
|
||||
return groups
|
||||
|
||||
|
||||
def _drop_ignored(tree: Dict[str, tuple], ignore_paths) -> Dict[str, tuple]:
|
||||
if not ignore_paths:
|
||||
return tree
|
||||
out = {}
|
||||
for rel, desc in tree.items():
|
||||
if any(rel == ig or rel.startswith(ig.rstrip("/") + "/") for ig in ignore_paths):
|
||||
continue
|
||||
out[rel] = desc
|
||||
return out
|
||||
|
||||
|
||||
def tree_diff(rsync_root: str, fs_root: str, case: Case) -> List[str]:
|
||||
"""Return a list of human-readable differences (empty when identical)."""
|
||||
rtree = _drop_ignored(snapshot(rsync_root, case.compare_modes), case.ignore_paths)
|
||||
ftree = _drop_ignored(snapshot(fs_root, case.compare_modes), case.ignore_paths)
|
||||
if case.compare_hardlinks:
|
||||
rgroups = _hardlink_groups(rsync_root)
|
||||
fgroups = _hardlink_groups(fs_root)
|
||||
else:
|
||||
rgroups = fgroups = {}
|
||||
diffs: List[str] = []
|
||||
for rel in sorted(set(rtree) | set(ftree)):
|
||||
r = rtree.get(rel)
|
||||
f = ftree.get(rel)
|
||||
if r == f:
|
||||
continue
|
||||
if r is None:
|
||||
diffs.append(f"+ fastsync-only: {rel!r} {f}")
|
||||
elif f is None:
|
||||
diffs.append(f"- rsync-only: {rel!r} {r}")
|
||||
else:
|
||||
diffs.append(f"~ differs: {rel!r} rsync={r} fastsync={f}")
|
||||
if case.compare_hardlinks:
|
||||
for rel in sorted(set(rgroups) | set(fgroups)):
|
||||
if rgroups.get(rel) != fgroups.get(rel):
|
||||
diffs.append(
|
||||
f"~ hardlink group: {rel!r} rsync={rgroups.get(rel)} "
|
||||
f"fastsync={fgroups.get(rel)}")
|
||||
return diffs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stdout normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_bytes(text: str) -> str:
|
||||
m = re.match(r"([\d,]+)", text.strip())
|
||||
return m.group(1).replace(",", "") if m else text.strip()
|
||||
|
||||
|
||||
def normalize_stdout(text: str, mode: Optional[str]) -> object:
|
||||
if mode == STDOUT_ITEMIZE:
|
||||
lines = []
|
||||
for line in (text or "").splitlines():
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("*deleting"):
|
||||
lines.append(line)
|
||||
continue
|
||||
if not _ITEMIZE_RE.match(line):
|
||||
continue
|
||||
# Directories are not transfer entries in FastSync's recursive
|
||||
# scanner, so rsync's `cd+++++++++ name/` lines have no counterpart
|
||||
# (documented recursive-empty-dir residual). Compare file/link
|
||||
# itemization only.
|
||||
if line.rsplit(" ", 1)[-1].endswith("/"):
|
||||
continue
|
||||
lines.append(line)
|
||||
return sorted(lines)
|
||||
if mode == STDOUT_OUTFMT:
|
||||
lines = []
|
||||
for line in (text or "").splitlines():
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
# Directory entries are emitted by rsync but not by FastSync's
|
||||
# recursive scanner (documented residual). Tokens are either
|
||||
# `%n %l` (path first) or `%i %n` (path last); drop a line when
|
||||
# either end-token is a directory path.
|
||||
first = line.split(" ", 1)[0]
|
||||
last = line.rsplit(" ", 1)[-1]
|
||||
if first.endswith("/") or last.endswith("/"):
|
||||
continue
|
||||
lines.append(line)
|
||||
return sorted(lines)
|
||||
if mode == STDOUT_STATS:
|
||||
found = {}
|
||||
for line in (text or "").splitlines():
|
||||
for key in STATS_KEYS:
|
||||
if line.startswith(key + ":"):
|
||||
found[key] = _parse_bytes(line.split(":", 1)[1])
|
||||
return found
|
||||
if mode == STDOUT_PROGRESS:
|
||||
# rsync prints the file-list entries in sorted depth-first order while
|
||||
# FastSync's streaming scan emits them in readdir/BFS order; only the
|
||||
# entry set and deterministic fields are compared. The transfer-root
|
||||
# `./` line's trigger condition is a separate documented residual, and
|
||||
# the per-frame rate/elapsed/xfr#/to-chk numerator are wall-clock- or
|
||||
# order-dependent, so only the `to-chk` denominator and the name set are
|
||||
# asserted.
|
||||
names = []
|
||||
totals = set()
|
||||
max_xfr = 0
|
||||
for line in (text or "").splitlines():
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
if "%" in line:
|
||||
m = _PROGRESS_TOTAL_RE.search(line)
|
||||
if m:
|
||||
totals.add(int(m.group(1)))
|
||||
mx = _PROGRESS_XFR_RE.search(line)
|
||||
if mx:
|
||||
max_xfr = max(max_xfr, int(mx.group(1)))
|
||||
continue
|
||||
if line == "sending incremental file list":
|
||||
continue
|
||||
if line.startswith("created directory "):
|
||||
continue
|
||||
if line == "./":
|
||||
continue
|
||||
names.append(line)
|
||||
return {"names": sorted(names), "total": sorted(totals), "xfr": max_xfr}
|
||||
# raw
|
||||
return sorted(l.rstrip() for l in (text or "").splitlines() if l.strip())
|
||||
|
||||
|
||||
def stdout_diff(rsync_out: str, fs_out: str, mode: Optional[str]) -> List[str]:
|
||||
r = normalize_stdout(rsync_out, mode)
|
||||
f = normalize_stdout(fs_out, mode)
|
||||
if r == f:
|
||||
return []
|
||||
if mode == STDOUT_STATS:
|
||||
return [f"stats rsync={r}", f"stats fastsync={f}"]
|
||||
if mode == STDOUT_PROGRESS:
|
||||
return [f"progress rsync={r}", f"progress fastsync={f}"]
|
||||
return list(difflib.unified_diff(
|
||||
[str(x) for x in r], [str(x) for x in f],
|
||||
fromfile="rsync", tofile="fastsync", lineterm=""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Running one case
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_rsync(src: str, rdst: str, flags: List[str]) -> subprocess.CompletedProcess:
|
||||
args = [RSYNC] + list(flags) + [src + "/", rdst + "/"]
|
||||
return subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
env=dict(os.environ, LC_ALL="C"), timeout=180)
|
||||
|
||||
|
||||
def run_fastsync(src: str, fdst: str, flags: List[str], port: int):
|
||||
return run_client(src, fdst, flags=list(flags), port=port)
|
||||
|
||||
|
||||
def run_differential( # noqa: PLR0913 (explicit scenario parameters)
|
||||
src: str,
|
||||
rdst: str,
|
||||
fdst: str,
|
||||
rs_flags: List[str],
|
||||
fs_flags: List[str],
|
||||
server,
|
||||
layout: str = MIRROR,
|
||||
seed: Optional[Callable] = None,
|
||||
stdout: Optional[str] = STDOUT_NONE,
|
||||
compare_modes: bool = False,
|
||||
compare_hardlinks: bool = False,
|
||||
ignore_paths: Tuple[str, ...] = (),
|
||||
extra_check: Optional[Callable] = None,
|
||||
files_from: Optional[Tuple[str, ...]] = None,
|
||||
fs_src_suffix: str = "",
|
||||
compare_tree: bool = True,
|
||||
) -> Dict[str, object]:
|
||||
"""Run one rsync/FastSync pair and return the diff aspects.
|
||||
|
||||
Returned dict keys: ``rsync_rc``, ``fastsync_rc``, ``rsync_stderr``,
|
||||
``fastsync_stderr``, ``tree``, ``stdout``, ``extra``.
|
||||
"""
|
||||
clean_dir(rdst)
|
||||
clean_dir(fdst)
|
||||
abs_src = os.path.abspath(src)
|
||||
rel = abs_src.lstrip(os.sep)
|
||||
if layout == RELATIVE:
|
||||
rroot, froot = rdst, fdst
|
||||
elif layout == MIRROR_ABS:
|
||||
rroot, froot = os.path.join(rdst, rel), get_dest_received_dir(fdst, src)
|
||||
else:
|
||||
rroot, froot = rdst, get_dest_received_dir(fdst, src)
|
||||
# rsync's destination root always exists (clean_dir created it). FastSync's
|
||||
# logical transfer root is the mirror path below the destination argument,
|
||||
# so pre-create it too: `Number of created files` counts the root only when
|
||||
# it is genuinely absent, and the two tools must start from the same state.
|
||||
os.makedirs(froot, exist_ok=True)
|
||||
if seed:
|
||||
seed(src, rroot, froot)
|
||||
|
||||
rs_flags = list(rs_flags)
|
||||
fs_flags = list(fs_flags)
|
||||
if files_from is not None:
|
||||
list_path = os.path.join(TEST_DATA_DIR, "parity_" +
|
||||
os.path.basename(src) + ".list")
|
||||
write_list(list_path, files_from)
|
||||
rs_flags.append(f"--files-from={list_path}")
|
||||
fs_flags.append(f"--files-from={list_path}")
|
||||
|
||||
rs = run_rsync(src, rdst, rs_flags)
|
||||
fs_result, _ = run_fastsync(src + fs_src_suffix, fdst, fs_flags, server.port)
|
||||
|
||||
class _View:
|
||||
"""Adapter so tree_diff/extra_check keep the Case-shaped interface."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.compare_modes = compare_modes
|
||||
self.compare_hardlinks = compare_hardlinks
|
||||
self.ignore_paths = ignore_paths
|
||||
|
||||
result = {
|
||||
"rsync_rc": rs.returncode,
|
||||
"fastsync_rc": fs_result.returncode,
|
||||
"rsync_stderr": rs.stderr,
|
||||
"fastsync_stderr": fs_result.stderr or fs_result.stdout,
|
||||
"tree": tree_diff(rroot, froot, _View()) if compare_tree else [],
|
||||
"stdout": [],
|
||||
"extra": [],
|
||||
}
|
||||
if stdout is not None:
|
||||
result["stdout"] = stdout_diff(rs.stdout, fs_result.stdout, stdout)
|
||||
if extra_check:
|
||||
result["extra"] = list(extra_check(src, rroot, froot, rs, fs_result) or [])
|
||||
return result
|
||||
|
||||
|
||||
def execute_case(case: Case, server) -> Dict[str, object]:
|
||||
"""Run a table-driven case and return the diff aspects."""
|
||||
tag = case.id
|
||||
src = os.path.join(TEST_DATA_DIR, f"parity_{tag}_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, f"parity_{tag}_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, f"parity_{tag}_fdst")
|
||||
CORPORA[case.corpus](src)
|
||||
return run_differential(
|
||||
src, rdst, fdst,
|
||||
case.flags, case.fs_flags(), server,
|
||||
layout=case.layout, seed=case.seed, stdout=case.stdout,
|
||||
compare_modes=case.compare_modes, compare_hardlinks=case.compare_hardlinks,
|
||||
ignore_paths=case.ignore_paths, extra_check=case.extra_check,
|
||||
files_from=case.files_from, fs_src_suffix=case.fs_src_suffix,
|
||||
compare_tree=case.compare_tree,
|
||||
)
|
||||
|
||||
|
||||
def write_list(path: str, entries) -> str:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
for e in entries:
|
||||
fh.write(e + "\n")
|
||||
return path
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Differential tests for the client CLI's codec defaults and env lists.
|
||||
|
||||
Track 3a of the rsync-parity plan pins two rsync 3.4.1 behaviors that are
|
||||
resolved entirely on the client:
|
||||
|
||||
* the per-codec default ``--compress-level`` (zstd 3, zlib/zlibx 6, lz4
|
||||
ignored) applied when the user omits ``--compress-level``/``--zl``, with an
|
||||
explicit level clamped to the codec's range; and
|
||||
* the ``RSYNC_COMPRESS_LIST`` / ``RSYNC_CHECKSUM_LIST`` preference lists that
|
||||
rsync's ``auto`` consults before its compiled-in order (whitespace-separated,
|
||||
unknown names skipped, first supported wins, all-unknown is exit 4).
|
||||
|
||||
The rsync side is observed through ``--debug=NSTR1``; FastSync publishes its
|
||||
resolved codec/level through ``--debug=util``. The checksum side is confirmed
|
||||
byte-for-byte through ``--out-format %C``. The rsync-based tests skip cleanly
|
||||
when rsync is not installed.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
TEST_DATA_DIR,
|
||||
run_client,
|
||||
clean_dir,
|
||||
get_dest_received_dir,
|
||||
)
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
||||
|
||||
CODEC_ROOT = os.path.join(TEST_DATA_DIR, "cli_differential")
|
||||
|
||||
_COMPRESS_RE = re.compile(r"compress(?:ion)?: (\w+) \(level (-?\d+)\)")
|
||||
|
||||
|
||||
def _rsync(args):
|
||||
env = dict(os.environ, LC_ALL="C")
|
||||
return subprocess.run([RSYNC] + args, capture_output=True, text=True, env=env, timeout=120)
|
||||
|
||||
|
||||
def _scratch(tag):
|
||||
path = os.path.join(CODEC_ROOT, tag)
|
||||
clean_dir(path)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _make_corpus(root):
|
||||
clean_dir(root)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(os.path.join(root, "big.bin"), "wb") as fh:
|
||||
fh.write(b"FastSync codec payload " * 4096)
|
||||
with open(os.path.join(root, "small.txt"), "wb") as fh:
|
||||
fh.write(b"hello codec world\n" * 32)
|
||||
return root
|
||||
|
||||
|
||||
def _rsync_compress_level(choice, level):
|
||||
src = _make_corpus(_scratch(f"lvl_src_{choice}_{level}"))
|
||||
dst = _scratch(f"lvl_rsync_{choice}_{level}")
|
||||
args = ["-a", "-z", f"--zc={choice}"]
|
||||
if level is not None:
|
||||
args.append(f"--zl={level}")
|
||||
args += ["--debug=NSTR1", src + "/", dst + "/"]
|
||||
result = _rsync(args)
|
||||
assert result.returncode == 0, result.stderr
|
||||
match = _COMPRESS_RE.search(result.stdout + result.stderr)
|
||||
assert match, (result.stdout, result.stderr)
|
||||
return match.group(1), int(match.group(2))
|
||||
|
||||
|
||||
def _fastsync_compress_level(choice, level, shared_server):
|
||||
src = _make_corpus(_scratch(f"lvl_src_fs_{choice}_{level}"))
|
||||
dst = _scratch(f"lvl_fs_{choice}_{level}")
|
||||
args = ["-a", "-z", f"--zc={choice}"]
|
||||
if level is not None:
|
||||
args.append(f"--zl={level}")
|
||||
args += ["-v", "--debug=util"]
|
||||
result, _ = run_client(src, dst, flags=args, port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
match = _COMPRESS_RE.search(result.stdout)
|
||||
assert match, result.stdout[:500]
|
||||
return match.group(1), int(match.group(2))
|
||||
|
||||
|
||||
class TestPerCodecCompressionLevelDefaults:
|
||||
"""``--compress-level`` defaults and clamping match rsync per codec."""
|
||||
|
||||
# FastSync uses a positive lz4 placeholder because its "level > 0" gate
|
||||
# enables compression; lz4_compress ignores the value, so rsync's level 0
|
||||
# and FastSync's level 1 produce the same bytes.
|
||||
CASES = [
|
||||
("zstd", None, 3),
|
||||
("zlib", None, 6),
|
||||
("zlibx", None, 6),
|
||||
("lz4", None, 1),
|
||||
("zstd", 10, 10),
|
||||
("zlib", 15, 9),
|
||||
("zlib", 3, 3),
|
||||
("lz4", 15, 1),
|
||||
]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("choice,level,fs_level", CASES)
|
||||
def test_level_matches_rsync(self, choice, level, fs_level, shared_server):
|
||||
rsync_algo, rsync_level = _rsync_compress_level(choice, level)
|
||||
fs_algo, fs_level_actual = _fastsync_compress_level(choice, level, shared_server)
|
||||
assert rsync_algo == choice
|
||||
assert fs_algo == choice
|
||||
if choice == "lz4":
|
||||
assert rsync_level == 0 and fs_level_actual > 0
|
||||
else:
|
||||
assert rsync_level == fs_level
|
||||
assert fs_level_actual == fs_level
|
||||
|
||||
|
||||
class TestEnvPreferenceLists:
|
||||
"""``RSYNC_COMPRESS_LIST`` / ``RSYNC_CHECKSUM_LIST`` drive auto like rsync."""
|
||||
|
||||
# (env value, expected codec, rsync level, FastSync level)
|
||||
COMPRESS_CASES = [
|
||||
("zlib lz4", "zlib", 6, 6),
|
||||
("lz4 zstd", "lz4", 0, 1),
|
||||
("bogus zstd zlib", "zstd", 3, 3),
|
||||
(" ", "zstd", 3, 3),
|
||||
]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("env,algo,rsync_level,fs_level", COMPRESS_CASES)
|
||||
def test_compress_list_matches_rsync(self, env, algo, rsync_level, fs_level, shared_server,
|
||||
monkeypatch):
|
||||
monkeypatch.setenv("RSYNC_COMPRESS_LIST", env)
|
||||
src = _make_corpus(_scratch(f"envc_src_{algo}"))
|
||||
rdst = _scratch(f"envc_rsync_{algo}")
|
||||
rs = _rsync(["-a", "-z", "--debug=NSTR1", src + "/", rdst + "/"])
|
||||
assert rs.returncode == 0, rs.stderr
|
||||
rm = _COMPRESS_RE.search(rs.stdout + rs.stderr)
|
||||
assert rm, (rs.stdout, rs.stderr)
|
||||
assert rm.group(1) == algo
|
||||
assert int(rm.group(2)) == rsync_level
|
||||
|
||||
fdst = _scratch(f"envc_fs_{algo}")
|
||||
result, _ = run_client(src, fdst, flags=["-a", "-z", "-v", "--debug=util"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
fm = _COMPRESS_RE.search(result.stdout)
|
||||
assert fm, result.stdout[:500]
|
||||
assert fm.group(1) == algo
|
||||
assert int(fm.group(2)) == fs_level
|
||||
received = get_dest_received_dir(fdst, src)
|
||||
assert _tree_bytes(received) == _tree_bytes(src)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("env,algo", [("md5", "md5"), ("sha1", "sha1"), ("xxh3 md5", "xxh3")])
|
||||
def test_checksum_list_matches_rsync(self, env, algo, shared_server, monkeypatch):
|
||||
monkeypatch.setenv("RSYNC_CHECKSUM_LIST", env)
|
||||
src = _make_corpus(_scratch(f"envcc_src_{algo}"))
|
||||
rdst = _scratch(f"envcc_rsync_{algo}")
|
||||
rs = _rsync(["-a", "--checksum", "--out-format=%C %n", src + "/", rdst + "/"])
|
||||
assert rs.returncode == 0, rs.stderr
|
||||
rs_digests = _digests(rs.stdout)
|
||||
|
||||
fdst = _scratch(f"envcc_fs_{algo}")
|
||||
result, _ = run_client(src, fdst, flags=["-a", "--checksum", "--out-format=%C %n"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert _digests(result.stdout) == rs_digests
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_all_unknown_lists_fail_like_rsync(self, shared_server, monkeypatch):
|
||||
src = _make_corpus(_scratch("envbad_src"))
|
||||
monkeypatch.setenv("RSYNC_COMPRESS_LIST", "bogus")
|
||||
rs = _rsync(["-a", "-z", src + "/", _scratch("envbad_rsync_c") + "/"])
|
||||
assert rs.returncode == 4, rs.stderr
|
||||
result, _ = run_client(src, _scratch("envbad_fs_c"), flags=["-a", "-z"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 4, (result.stderr or result.stdout)[:200]
|
||||
|
||||
monkeypatch.setenv("RSYNC_CHECKSUM_LIST", "bogus")
|
||||
rs = _rsync(["-a", src + "/", _scratch("envbad_rsync_s") + "/"])
|
||||
assert rs.returncode == 4, rs.stderr
|
||||
result, _ = run_client(src, _scratch("envbad_fs_s"), flags=["-a"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 4, (result.stderr or result.stdout)[:200]
|
||||
|
||||
|
||||
def _tree_bytes(root):
|
||||
out = {}
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for name in files:
|
||||
path = os.path.join(dirpath, name)
|
||||
with open(path, "rb") as fh:
|
||||
out[os.path.relpath(path, root)] = fh.read()
|
||||
return out
|
||||
|
||||
|
||||
def _digests(output):
|
||||
out = {}
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[0]:
|
||||
out[parts[1]] = parts[0]
|
||||
return out
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Differential coverage for ``--delete-delay`` + ``--max-delete`` with a
|
||||
refilled deferred directory.
|
||||
|
||||
FastSync snapshots a directory's extras at plan time (``defer_add``) but charges
|
||||
``--max-delete`` only when a path is actually removed, and its deferred commit
|
||||
re-scans a queued directory and removes content created after the plan -- the
|
||||
same rules as rsync. These tests run both tools on the same fixture and assert
|
||||
both sides remove the late content (recursively) and bound the deletion with
|
||||
``--max-delete`` identically.
|
||||
|
||||
They are not part of the fast PR gate because the rsync side needs a wide
|
||||
real-time injection window (a throttled transfer), while the FastSync side uses
|
||||
the existing byte-deterministic slicing proxy.
|
||||
|
||||
The refilled directory sits at the transfer ROOT, whose delete plan is always
|
||||
processed before any subdirectory's, so the budget is deterministically charged
|
||||
to the refilled entry; the second extra lives under ``b`` and is skipped.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import ( # noqa: E402
|
||||
TEST_DATA_DIR,
|
||||
ServerManager,
|
||||
clean_dir,
|
||||
get_dest_received_dir,
|
||||
run_client,
|
||||
)
|
||||
from test_delete_timing_parity import _SlicingProxy # noqa: E402
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
||||
|
||||
BIG_BYTES = 8 * 1024 * 1024
|
||||
MID_TRANSFER_BYTES = 256 * 1024
|
||||
PROXY_THROTTLE = 0.001
|
||||
# rsync is driven locally, so the refill is injected on a wall-clock delay while
|
||||
# a throttled ~8 s transfer is in flight. 1.5 s is safely after rsync's plan
|
||||
# scan (t=0) and well before the deferred commit at the end.
|
||||
RSYNC_BWLIMIT = 1024 # 1 MiB/s
|
||||
RSYNC_INJECT_DELAY = 1.5
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
def _seed_source(tag):
|
||||
source = os.path.join(TEST_DATA_DIR, f"ddb_{tag}_src")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "a", "keep.bin"), b"B" * BIG_BYTES)
|
||||
_write(os.path.join(source, "b", "keep.txt"), b"keep\n")
|
||||
return source
|
||||
|
||||
|
||||
def _seed_fastsync(tag):
|
||||
"""FastSync mirrors the absolute source path under its receive root, so the
|
||||
extras live below ``received``."""
|
||||
source = _seed_source(tag)
|
||||
dest = os.path.join(TEST_DATA_DIR, f"ddb_{tag}_dst")
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
os.makedirs(os.path.join(received, "xdir"), exist_ok=True)
|
||||
os.makedirs(os.path.join(received, "b", "ydir"), exist_ok=True)
|
||||
return source, dest, received
|
||||
|
||||
|
||||
def _seed_rsync(tag):
|
||||
"""rsync mirrors the source contents directly into the destination, so the
|
||||
extras are flat under ``rsync_dst``."""
|
||||
source = _seed_source(tag)
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, f"ddb_{tag}_dst")
|
||||
clean_dir(rsync_dst)
|
||||
os.makedirs(os.path.join(rsync_dst, "xdir"), exist_ok=True)
|
||||
os.makedirs(os.path.join(rsync_dst, "b", "ydir"), exist_ok=True)
|
||||
return source, rsync_dst
|
||||
|
||||
|
||||
def _deleted_count(text):
|
||||
for line in text.splitlines():
|
||||
if line.startswith("Number of deleted files:"):
|
||||
return int(line.split(":", 1)[1].split()[0])
|
||||
return None
|
||||
|
||||
|
||||
def _rsync(args, timeout=120):
|
||||
env = dict(os.environ, LC_ALL="C")
|
||||
return subprocess.run([RSYNC] + args, capture_output=True, text=True, env=env, timeout=timeout)
|
||||
|
||||
|
||||
class TestDeleteDelayRefilledDirVsRsync:
|
||||
"""Both tools charge --max-delete on actual removals and recurse."""
|
||||
|
||||
def _fastsync_refilled(self, tag, max_delete=None):
|
||||
"""Run FastSync with the refill injected deterministically by the proxy
|
||||
hook (fired once the receiver has processed the plan frames)."""
|
||||
source, dest, received = _seed_fastsync(tag)
|
||||
late = os.path.join(received, "xdir", "new.txt")
|
||||
|
||||
def hook():
|
||||
_write(late, b"created mid-transfer\n")
|
||||
|
||||
flags = ["--delete-delay", "--incremental", "--ignore-times", "--stats"]
|
||||
if max_delete is not None:
|
||||
flags.append(f"--max-delete={max_delete}")
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
proxy = _SlicingProxy(server.port, hook=hook, hook_after=MID_TRANSFER_BYTES,
|
||||
throttle=PROXY_THROTTLE, wait_for_reply=True)
|
||||
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
||||
proxy.finish()
|
||||
assert proxy.hook_called.is_set(), "refill hook never fired"
|
||||
return result, received, late
|
||||
|
||||
@requires_rsync
|
||||
def test_max_delete_budget_bound_matches(self):
|
||||
# --- FastSync: the one actual removal is the late file; dirs survive ---
|
||||
result, received, late = self._fastsync_refilled("budget_fs", max_delete=1)
|
||||
assert result.returncode == 25, (result.stderr or result.stdout)[:300]
|
||||
assert _deleted_count(result.stdout) == 1, result.stdout
|
||||
assert not os.path.exists(late), "FastSync kept the late content of a queued dir"
|
||||
assert os.path.isdir(os.path.join(received, "xdir"))
|
||||
assert os.path.isdir(os.path.join(received, "b", "ydir")), (
|
||||
"FastSync did not bound the deletion with --max-delete=1"
|
||||
)
|
||||
|
||||
# --- rsync: same budget rule and recursive removal ---
|
||||
source, rsync_dst = _seed_rsync("budget_rsync")
|
||||
|
||||
def inject():
|
||||
time.sleep(RSYNC_INJECT_DELAY)
|
||||
_write(os.path.join(rsync_dst, "xdir", "new.txt"), b"created mid-transfer\n")
|
||||
|
||||
t = threading.Thread(target=inject)
|
||||
t.start()
|
||||
rsync_result = _rsync(
|
||||
["-a", "--delete-delay", "--max-delete=1", "--stats",
|
||||
f"--bwlimit={RSYNC_BWLIMIT}", source + "/", rsync_dst + "/"]
|
||||
)
|
||||
t.join()
|
||||
assert rsync_result.returncode == 25, rsync_result.stderr
|
||||
assert _deleted_count(rsync_result.stdout) == _deleted_count(result.stdout)
|
||||
assert not os.path.exists(os.path.join(rsync_dst, "xdir", "new.txt")), (
|
||||
"rsync kept late content inside a queued directory"
|
||||
)
|
||||
assert os.path.isdir(os.path.join(rsync_dst, "b", "ydir")), (
|
||||
"rsync did not bound the deletion with --max-delete=1"
|
||||
)
|
||||
assert os.path.isdir(os.path.join(received, "b", "ydir"))
|
||||
|
||||
@requires_rsync
|
||||
def test_refilled_extra_dir_recursive_removal_matches(self):
|
||||
"""Without --max-delete both tools remove the refilled extra directory
|
||||
(and its late content)."""
|
||||
result, received, late = self._fastsync_refilled("recur_fs")
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert not os.path.exists(late), "FastSync kept the refilled directory's late content"
|
||||
assert not os.path.isdir(os.path.join(received, "xdir"))
|
||||
|
||||
source, rsync_dst = _seed_rsync("recur_rsync")
|
||||
|
||||
def inject():
|
||||
time.sleep(RSYNC_INJECT_DELAY)
|
||||
_write(os.path.join(rsync_dst, "xdir", "new.txt"), b"created mid-transfer\n")
|
||||
|
||||
t = threading.Thread(target=inject)
|
||||
t.start()
|
||||
rsync_result = _rsync(
|
||||
["-a", "--delete-delay", "--stats", f"--bwlimit={RSYNC_BWLIMIT}",
|
||||
source + "/", rsync_dst + "/"]
|
||||
)
|
||||
t.join()
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
assert not os.path.exists(os.path.join(rsync_dst, "xdir")), (
|
||||
"rsync did not recursively remove the refilled extra directory"
|
||||
)
|
||||
@@ -229,13 +229,10 @@ class TestDeleteTimingFinalStateParity:
|
||||
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
||||
@requires_rsync
|
||||
def test_success_final_state_matches_rsync(self, timing):
|
||||
# Worker-safe names: xdist may run both parametrizations concurrently, so
|
||||
# the timing is part of every fixture path.
|
||||
label = timing.lstrip("-")
|
||||
# Build the rsync fixture from the same seed so both sides start equal.
|
||||
source, dest, received = _seed_pair(f"parity_rsync_{label}")
|
||||
source, dest, received = _seed_pair("parity_rsync")
|
||||
source2 = source
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, f"dtp_rsync_{label}_dst")
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, "dtp_parity_rsync_dst")
|
||||
clean_dir(rsync_dst)
|
||||
# rsync mirrors src/ into dst/; seed the same extra.
|
||||
_write(os.path.join(rsync_dst, "d", "old_extra"), b"stale extra\n")
|
||||
@@ -261,8 +258,7 @@ class TestDeleteTimingTypeConflictParity:
|
||||
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
||||
@requires_rsync
|
||||
def test_type_conflicts_match_rsync(self, timing):
|
||||
label = timing.lstrip("-")
|
||||
source = os.path.join(TEST_DATA_DIR, f"dtc_{label}_src")
|
||||
source = os.path.join(TEST_DATA_DIR, "dtc_src")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "foo"), b"now a file\n")
|
||||
_write(os.path.join(source, "bar", "inner.txt"), b"now a dir\n")
|
||||
@@ -272,13 +268,13 @@ class TestDeleteTimingTypeConflictParity:
|
||||
_write(os.path.join(root, "foo", "inner.txt"), b"was a dir\n")
|
||||
_write(os.path.join(root, "bar"), b"was a file\n")
|
||||
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, f"dtc_{label}_rsync_dst")
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, "dtc_rsync_dst")
|
||||
seed_dest(rsync_dst)
|
||||
rsync_result = _rsync(["-a", timing, source + "/", rsync_dst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_tree = _tree(rsync_dst)
|
||||
|
||||
dest = os.path.join(TEST_DATA_DIR, f"dtc_{label}_dst")
|
||||
dest = os.path.join(TEST_DATA_DIR, "dtc_dst")
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
seed_dest(received)
|
||||
@@ -296,7 +292,7 @@ class TestDeleteTimingFailure:
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_during_removes_delay_preserves_on_failure(self, mt):
|
||||
source, dest, received = _seed_pair(f"failure_mt{int(mt)}", big=True)
|
||||
source, dest, received = _seed_pair("failure", big=True)
|
||||
extra = os.path.join(received, "d", "old_extra")
|
||||
assert os.path.exists(extra)
|
||||
with ServerManager() as server:
|
||||
@@ -317,99 +313,13 @@ class TestDeleteTimingFailure:
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteDelayDeletedCount:
|
||||
"""The reported deleted count must reflect entries actually removed."""
|
||||
|
||||
def test_refilled_deferred_dir_is_recursively_removed_and_counted(self):
|
||||
"""A directory snapshotted into a --delete-delay plan that is refilled
|
||||
before the commit is re-scanned and removed recursively (rsync parity):
|
||||
the late file and the directory are both counted as deleted."""
|
||||
source = os.path.join(TEST_DATA_DIR, "ddc_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "ddc_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
_write(os.path.join(source, "d", "keep.txt"), b"kept payload\n")
|
||||
_write(os.path.join(source, "d", "big.bin"), b"B" * BIG_BYTES)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
extra_dir = os.path.join(received, "d", "extradir")
|
||||
os.makedirs(extra_dir, exist_ok=True)
|
||||
|
||||
def hook():
|
||||
# Runs while big.bin is in flight, after d's delete plan was processed.
|
||||
_write(os.path.join(extra_dir, "new.txt"), b"created mid-transfer\n")
|
||||
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
proxy = _SlicingProxy(server.port, hook=hook, hook_after=MID_TRANSFER_BYTES,
|
||||
throttle=PROXY_THROTTLE, wait_for_reply=True)
|
||||
flags = ["--delete-delay", "--incremental", "--ignore-times", "--stats"]
|
||||
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
||||
proxy.finish()
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
assert proxy.hook_called.is_set(), "hook never fired"
|
||||
assert not os.path.exists(os.path.join(extra_dir, "new.txt")), "late file survived"
|
||||
assert not os.path.isdir(extra_dir), "refilled extra dir survived"
|
||||
deleted = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("Number of deleted files:"):
|
||||
deleted = int(line.split(":", 1)[1].split()[0])
|
||||
assert deleted == 2, (deleted, result.stdout)
|
||||
|
||||
|
||||
class TestDeleteDelayMaxDeleteParity:
|
||||
"""--max-delete with --delete-delay: a partial deletion still reports the
|
||||
number of entries actually removed, matching rsync (the exact surviving set
|
||||
can differ; only the count is compared)."""
|
||||
|
||||
@requires_rsync
|
||||
def test_max_delete_count_matches_rsync(self):
|
||||
source = os.path.join(TEST_DATA_DIR, "ddm_src")
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, "ddm_rsync_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(rsync_dst)
|
||||
_write(os.path.join(source, "d", "keep.txt"), b"keep\n")
|
||||
for i in range(1, 6):
|
||||
_write(os.path.join(rsync_dst, "d", f"e{i}.txt"), f"extra{i}\n".encode())
|
||||
|
||||
rsync_result = _rsync(["-a", "--delete-delay", "--max-delete=2", "--stats",
|
||||
source + "/", rsync_dst + "/"])
|
||||
# rsync exits 25 ("the --max-delete limit stopped deletions").
|
||||
assert rsync_result.returncode == 25, rsync_result.stderr
|
||||
rsync_count = _deleted_count(rsync_result.stdout)
|
||||
assert rsync_count == 2, rsync_result.stdout
|
||||
|
||||
dest = os.path.join(TEST_DATA_DIR, "ddm_dst")
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
for i in range(1, 6):
|
||||
_write(os.path.join(received, "d", f"e{i}.txt"), f"extra{i}\n".encode())
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(
|
||||
source, dest,
|
||||
flags=["--delete-delay", "--max-delete=2", "--stats"],
|
||||
port=server.port,
|
||||
)
|
||||
# A capped --max-delete commit is a successful transfer that both tools
|
||||
# report with exit 25.
|
||||
assert result.returncode == 25, (result.stderr or result.stdout)[:300]
|
||||
assert _deleted_count(result.stdout) == rsync_count, result.stdout
|
||||
|
||||
|
||||
def _deleted_count(text):
|
||||
for line in text.splitlines():
|
||||
if line.startswith("Number of deleted files:"):
|
||||
return int(line.split(":", 1)[1].split()[0])
|
||||
return None
|
||||
|
||||
|
||||
class TestDeleteDelayVsAfterSnapshot:
|
||||
"""A destination entry created after its directory's scan survives under
|
||||
--delete-delay but is removed by --delete-after's fresh end scan."""
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_late_created_extra_survives_delay_not_after(self, mt):
|
||||
source, dest, received = _seed_pair(f"latecreate_mt{int(mt)}", big=True)
|
||||
source, dest, received = _seed_pair("latecreate", big=True)
|
||||
old_extra = os.path.join(received, "d", "old_extra")
|
||||
new_extra = os.path.join(received, "d", "new_extra")
|
||||
with ServerManager() as server:
|
||||
@@ -446,67 +356,3 @@ class TestDeleteDelayVsAfterSnapshot:
|
||||
f"{timing} (mt={mt}): new_extra present="
|
||||
f"{os.path.exists(new_extra)}, expected survives={new_survives}"
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteAfterThreadsKeepSet:
|
||||
"""Regression: -m/--threads with the default delete-after timing (plain
|
||||
--delete) must still transmit the keep-set manifest and remove destination
|
||||
extras. PipelineContextSender.delete_suppressed was left uninitialized, so a
|
||||
garbage true silently skipped the manifest under --threads."""
|
||||
|
||||
@pytest.mark.parametrize("delete_flag", ["--delete", "--delete-after"])
|
||||
def test_threads_delete_after_sends_keep_set(self, delete_flag):
|
||||
source, dest, received = _seed_pair("mtkeep")
|
||||
extra = os.path.join(received, "d", "old_extra")
|
||||
assert os.path.exists(extra)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=["--threads", delete_flag],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert not os.path.exists(extra), (
|
||||
f"{delete_flag} --threads did not remove an extra: keep-set manifest was suppressed"
|
||||
)
|
||||
|
||||
class TestDeleteDelayMaxDeleteRefilledDir:
|
||||
"""--delete-delay charges the --max-delete budget on ACTUAL removals: the
|
||||
refilled directory's late content is removed first (consuming the one slot),
|
||||
so the directory itself and a later extra are skipped, matching rsync.
|
||||
|
||||
The refilled directory is at the destination ROOT (its plan is always sent
|
||||
first) and the skipped extra is under a separate source directory, so the
|
||||
ordering that decides the budget charge is deterministic -- not readdir
|
||||
order. The refill is injected through the byte-barrier proxy so it is
|
||||
causally after the plan frame."""
|
||||
|
||||
def test_budget_charged_on_actual_removal(self):
|
||||
source = os.path.join(TEST_DATA_DIR, "ddmb_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "ddmb_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
_write(os.path.join(source, "a", "keep.bin"), b"B" * BIG_BYTES)
|
||||
_write(os.path.join(source, "b", "keep.txt"), b"keep\n")
|
||||
received = get_dest_received_dir(dest, source)
|
||||
refilled_dir = os.path.join(received, "xdir")
|
||||
os.makedirs(refilled_dir, exist_ok=True)
|
||||
later_dir = os.path.join(received, "b", "ydir")
|
||||
os.makedirs(later_dir, exist_ok=True)
|
||||
|
||||
def hook():
|
||||
_write(os.path.join(refilled_dir, "new.txt"), b"created mid-transfer\n")
|
||||
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
proxy = _SlicingProxy(server.port, hook=hook, hook_after=MID_TRANSFER_BYTES,
|
||||
throttle=PROXY_THROTTLE, wait_for_reply=True)
|
||||
flags = ["--delete-delay", "--max-delete=1", "--incremental", "--ignore-times", "--stats"]
|
||||
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
||||
proxy.finish()
|
||||
assert result.returncode == 25, (result.stderr or result.stdout)[:400]
|
||||
assert proxy.hook_called.is_set(), "hook never fired"
|
||||
# The late content consumes the single budget slot; the refilled
|
||||
# directory itself and the later extra are skipped.
|
||||
assert not os.path.exists(os.path.join(refilled_dir, "new.txt")), "late file survived"
|
||||
assert os.path.isdir(later_dir), "later extra was not skipped by the budget"
|
||||
# The one actual removal is reported.
|
||||
assert _deleted_count(result.stdout) == 1, result.stdout
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
"""Differential rsync-parity gate.
|
||||
|
||||
Runs real ``rsync 3.4.1`` and FastSync over the same corpora and flags, then
|
||||
compares the destination trees and the normalized output of the
|
||||
output-oriented flags. This is the executable counterpart of
|
||||
``RSYNC_COMPAT.md``: the fast subset (``-m parity_ci``) guards the ✅ surface on
|
||||
every pull request, and the full set (``-m parity``) burns the documented
|
||||
⚠️/❌ residuals down.
|
||||
|
||||
Known, documented differences live in ``parity_caveats.py``; anything else
|
||||
fails with a readable tree/stdout diff. A stale allowlist entry is reported
|
||||
loudly (and fails when ``FASTSYNC_PARITY_STRICT=1``).
|
||||
|
||||
Run locally::
|
||||
|
||||
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity_ci
|
||||
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import ( # noqa: E402
|
||||
ServerManager,
|
||||
TEST_DATA_DIR,
|
||||
clean_dir,
|
||||
)
|
||||
from parity_caveats import ASPECTS, caveat_for # noqa: E402
|
||||
import parity_harness as H # noqa: E402
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
||||
|
||||
# `--allow-super` matches the rest of the integration suite; `--allow-delete`
|
||||
# is needed only by the delete cases.
|
||||
SUPER = ("--allow-super",)
|
||||
DELETE = ("--allow-super", "--allow-delete")
|
||||
_OLD_MTIME = 1_500_000_000
|
||||
|
||||
parity = pytest.mark.parity
|
||||
parity_ci = pytest.mark.parity_ci
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def parity_server_factory():
|
||||
"""Lazily start one server per distinct extra-argument set, per xdist worker."""
|
||||
servers = {}
|
||||
|
||||
def get(extra):
|
||||
key = tuple(extra)
|
||||
if key not in servers:
|
||||
s = ServerManager()
|
||||
s.start(extra_args=list(extra))
|
||||
servers[key] = s
|
||||
return servers[key]
|
||||
|
||||
yield get
|
||||
for s in servers.values():
|
||||
s.stop()
|
||||
|
||||
|
||||
def _pin(path, mtime):
|
||||
os.utime(path, (mtime, mtime))
|
||||
|
||||
|
||||
def _mk(path, data, mtime=None):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
if mtime is not None:
|
||||
_pin(path, mtime)
|
||||
|
||||
|
||||
# --- destination seeds ------------------------------------------------------
|
||||
|
||||
def seed_extras(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
_mk(os.path.join(root, "extra.txt"), b"extra\n")
|
||||
_mk(os.path.join(root, "extradir", "z.txt"), b"z\n")
|
||||
|
||||
|
||||
def seed_update(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
p = os.path.join(root, "a.txt")
|
||||
_mk(p, b"destination is newer and longer\n", 2_000_000_000)
|
||||
|
||||
|
||||
def seed_ignore_existing(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
_mk(os.path.join(root, "a.txt"), b"destination-kept\n", _OLD_MTIME)
|
||||
|
||||
|
||||
def seed_append(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
_mk(os.path.join(root, "a.txt"), b"hello ", _OLD_MTIME)
|
||||
|
||||
|
||||
def seed_backup(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
_mk(os.path.join(root, "a.txt"), b"OLD-CONTENT\n", _OLD_MTIME)
|
||||
|
||||
|
||||
def seed_size_only(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
_mk(os.path.join(root, "a.txt"), b"XXXXXXXXXXX\n", _OLD_MTIME)
|
||||
|
||||
|
||||
def seed_delete_excluded(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
_mk(os.path.join(root, "drop.log"), b"stale log\n", _OLD_MTIME)
|
||||
_mk(os.path.join(root, "extra.txt"), b"extra\n", _OLD_MTIME)
|
||||
_mk(os.path.join(root, "keep.txt"), b"keep\n", _OLD_MTIME)
|
||||
|
||||
|
||||
def seed_max_delete(_src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
_mk(os.path.join(root, "extra1.txt"), b"e1\n", _OLD_MTIME)
|
||||
_mk(os.path.join(root, "extra2.txt"), b"e2\n", _OLD_MTIME)
|
||||
|
||||
|
||||
def max_delete_count_check(_src, rroot, froot, _rs, _fs):
|
||||
"""The exact survivor set is order-dependent; the count must still match."""
|
||||
r = H.snapshot(rroot)
|
||||
f = H.snapshot(froot)
|
||||
if len(r) != len(f):
|
||||
return [f"survivor count differs: rsync={len(r)} fastsync={len(f)}"]
|
||||
return []
|
||||
|
||||
|
||||
# --- case table -------------------------------------------------------------
|
||||
|
||||
_CASES = [
|
||||
# --- core archive / recursion -----------------------------------------
|
||||
H.Case("archive", "basic", ["-a"], ci=True, ref="-a/--archive"),
|
||||
H.Case("recursive", "basic", ["-r"], ci=True, ref="-r/--recursive"),
|
||||
H.Case("unicode_names", "unicode", ["-a"], ci=True, ref="-a unicode names"),
|
||||
H.Case("links_archive", "links", ["-a"], ci=True, ref="-l/--links"),
|
||||
H.Case("copy_links", "links", ["-aL"], ref="-L/--copy-links"),
|
||||
H.Case("hardlinks", "hardlinks", ["-a", "-H"], compare_hardlinks=True,
|
||||
ci=True, ref="-H/--hard-links"),
|
||||
H.Case("hardlinks_without_H", "hardlinks", ["-a"], compare_hardlinks=True,
|
||||
ref="hardlinks without -H"),
|
||||
H.Case("sparse", "sparse", ["-a", "-S"], ref="-S/--sparse"),
|
||||
|
||||
# --- compression / checksums ------------------------------------------
|
||||
H.Case("compress_zstd", "basic", ["-a", "-z"], ci=True, ref="-z/--compress"),
|
||||
H.Case("checksum", "basic", ["-a", "-c"], ref="-c/--checksum"),
|
||||
H.Case("checksum_choice_xxh64", "basic",
|
||||
["-a", "-c", "--checksum-choice=xxh64"], ref="--checksum-choice"),
|
||||
|
||||
# --- selection --------------------------------------------------------
|
||||
H.Case("exclude", "filters", ["-a", "--exclude=*.log"], ci=True,
|
||||
ref="--exclude"),
|
||||
H.Case("include_exclude", "filters",
|
||||
["-a", "--include=*.txt", "--exclude=*"], ci=True,
|
||||
ref="--include/--exclude ordering"),
|
||||
H.Case("filter_rules", "filters",
|
||||
["-a", "-f", "- *.log", "-f", "+ *.txt", "-f", "- *"],
|
||||
ref="--filter/-f grammar"),
|
||||
H.Case("max_size", "basic", ["-a", "--max-size=1000"], ref="--max-size"),
|
||||
H.Case("min_size", "basic", ["-a", "--min-size=1000"], ref="--min-size"),
|
||||
|
||||
# --- output-oriented --------------------------------------------------
|
||||
H.Case("stats", "basic", ["-a", "--stats"], stdout=H.STDOUT_STATS,
|
||||
ci=True, ref="--stats"),
|
||||
H.Case("itemize", "links", ["-a", "-i"], stdout=H.STDOUT_ITEMIZE,
|
||||
ci=True, ref="-i/--itemize-changes"),
|
||||
H.Case("out_format_n_l", "basic", ["-a", "--out-format=%n %l"],
|
||||
stdout=H.STDOUT_OUTFMT, ref="--out-format %n %l"),
|
||||
H.Case("out_format_i_n", "basic", ["-a", "--out-format=%i %n"],
|
||||
stdout=H.STDOUT_OUTFMT, ref="--out-format %i %n"),
|
||||
H.Case("progress", "multidir", ["-a", "--progress"], stdout=H.STDOUT_PROGRESS,
|
||||
ci=True, ref="--progress multi-directory file list"),
|
||||
H.Case("progress_threads", "multidir", ["-a", "--progress"],
|
||||
fastsync_flags=["-a", "--progress", "--threads"],
|
||||
stdout=H.STDOUT_PROGRESS, ci=True,
|
||||
ref="--progress multi-directory file list (--threads)"),
|
||||
|
||||
# --- transfer modifications -------------------------------------------
|
||||
H.Case("update", "basic", ["-a", "--update"], seed=seed_update,
|
||||
ref="-u/--update"),
|
||||
H.Case("ignore_existing", "basic", ["-a", "--ignore-existing"],
|
||||
seed=seed_ignore_existing, ci=True, ref="--ignore-existing"),
|
||||
H.Case("size_only", "basic",
|
||||
["-a", "--size-only"], fastsync_flags=["-a", "--incremental", "--size-only"],
|
||||
seed=seed_size_only, ref="--size-only"),
|
||||
H.Case("append", "basic", ["-a", "--append"], seed=seed_append,
|
||||
ref="--append"),
|
||||
H.Case("append_verify", "basic", ["-a", "--append-verify"], seed=seed_append,
|
||||
ref="--append-verify"),
|
||||
H.Case("backup", "basic", ["-a", "--backup"], seed=seed_backup,
|
||||
ref="--backup"),
|
||||
H.Case("chmod", "basic", ["-a", "--chmod=Fu+rwx"], compare_modes=True,
|
||||
ci=True, ref="--chmod"),
|
||||
|
||||
# --- deletion ---------------------------------------------------------
|
||||
H.Case("delete", "basic", ["-a", "--delete"], seed=seed_extras,
|
||||
server_args=DELETE, ci=True, ref="--delete"),
|
||||
H.Case("delete_before", "basic", ["-a", "--delete-before"], seed=seed_extras,
|
||||
server_args=DELETE, ref="--delete-before"),
|
||||
H.Case("delete_during", "basic", ["-a", "--delete-during"], seed=seed_extras,
|
||||
server_args=DELETE, ref="--delete-during"),
|
||||
H.Case("delete_delay", "basic", ["-a", "--delete-delay"], seed=seed_extras,
|
||||
server_args=DELETE, ref="--delete-delay"),
|
||||
H.Case("delete_after", "basic", ["-a", "--delete-after"], seed=seed_extras,
|
||||
server_args=DELETE, ref="--delete-after"),
|
||||
H.Case("delete_excluded", "filters",
|
||||
["-a", "--delete", "--delete-excluded", "--exclude=*.log"],
|
||||
seed=seed_delete_excluded, server_args=DELETE, ref="--delete-excluded"),
|
||||
H.Case("max_delete", "basic", ["-a", "--delete", "--max-delete=1"],
|
||||
seed=seed_max_delete, server_args=DELETE,
|
||||
extra_check=max_delete_count_check, compare_tree=False,
|
||||
ref="--max-delete"),
|
||||
|
||||
# --- relative / dirs --------------------------------------------------
|
||||
H.Case("relative_general", "basic", ["-a", "-R"], layout=H.MIRROR_ABS,
|
||||
compare_modes=True, ref="-R/--relative"),
|
||||
H.Case("relative_no_implied_dirs", "basic",
|
||||
["-a", "-R", "--no-implied-dirs"], layout=H.MIRROR_ABS,
|
||||
compare_modes=True, ref="--no-implied-dirs"),
|
||||
H.Case("files_from", "relative", ["--dirs", "-R"],
|
||||
files_from=("dir1", "sub/x.txt"), layout=H.RELATIVE, ci=True,
|
||||
ref="-d/--dirs + --files-from"),
|
||||
H.Case("dirs_plain", "basic", ["-d"], fs_src_suffix="/",
|
||||
ref="-d/--dirs (plain)"),
|
||||
H.Case("empty_dirs_recursive", "empty_dir", ["-a"],
|
||||
ref="recursive empty-directory residual"),
|
||||
H.Case("empty_dirs_files_from", "empty_dir", ["--dirs", "-R"],
|
||||
files_from=("emptydir",), layout=H.RELATIVE, ci=True,
|
||||
ref="-d/--dirs explicit empty directory"),
|
||||
|
||||
# --- codecs -----------------------------------------------------------
|
||||
H.Case("iconv_identity", "basic", ["-a", "--iconv=UTF-8,UTF-8"],
|
||||
ref="--iconv identity"),
|
||||
H.Case("iconv_convert", "iconv",
|
||||
["-a", "--iconv=ISO-8859-1,UTF-8"],
|
||||
server_args=("--allow-super", "--iconv=UTF-8"),
|
||||
ref="--iconv conversion (receiver declares its own charset)"),
|
||||
# rsync's spec is LOCAL,REMOTE and the destination end's charset is REMOTE
|
||||
# on a push, so a default server writes the wire (UTF-8) names verbatim.
|
||||
H.Case("iconv_default_server", "iconv",
|
||||
["-a", "--iconv=ISO-8859-1,UTF-8"],
|
||||
ref="--iconv push direction (default receiver charset = REMOTE)"),
|
||||
|
||||
# --- partial ----------------------------------------------------------
|
||||
H.Case("partial_complete", "basic", ["-a", "--partial"], ref="--partial"),
|
||||
]
|
||||
|
||||
# Cases that must always be tolerated (documented ⚠️/❌ residuals) get an
|
||||
# allowlist entry; the table below stays the exact ✅ surface.
|
||||
ALL_CASES = _CASES
|
||||
|
||||
|
||||
def _params():
|
||||
out = []
|
||||
for case in ALL_CASES:
|
||||
marks = [parity]
|
||||
if case.ci:
|
||||
marks.append(parity_ci)
|
||||
out.append(pytest.param(case, id=case.id, marks=marks))
|
||||
return out
|
||||
|
||||
|
||||
def _aspects_to_check(result):
|
||||
return {
|
||||
"tree": result["tree"],
|
||||
"stdout": result["stdout"],
|
||||
"extra": result["extra"],
|
||||
}
|
||||
|
||||
|
||||
def _assert_no_unexpected(case_id, mismatches, caveat, ref=""):
|
||||
unexpected = {a: v for a, v in mismatches.items() if v and a not in caveat}
|
||||
if unexpected:
|
||||
lines = [f"differential parity mismatch for case {case_id!r}:"]
|
||||
lines.append(f" ref: {ref or 'see RSYNC_COMPAT.md'}")
|
||||
for aspect, detail in unexpected.items():
|
||||
lines.append(f" --- {aspect} ---")
|
||||
lines.extend(" " + str(d) for d in detail)
|
||||
lines.append("If this is a documented residual, add it to "
|
||||
"tests/integration/parity_caveats.py with a RSYNC_COMPAT.md "
|
||||
"reference. Do not allowlist an undocumented divergence.")
|
||||
pytest.fail("\n".join(lines))
|
||||
|
||||
stale = [a for a in ASPECTS
|
||||
if a in caveat and a != "rc" and not mismatches.get(a)]
|
||||
if stale:
|
||||
msg = (f"stale parity allowlist entry for case {case_id!r}, aspect(s) "
|
||||
f"{stale}: FastSync now matches rsync. Remove it from "
|
||||
f"parity_caveats.py (and update RSYNC_COMPAT.md if the row moved).")
|
||||
if os.environ.get("FASTSYNC_PARITY_STRICT") == "1":
|
||||
pytest.fail(msg)
|
||||
warnings.warn(msg, stacklevel=2)
|
||||
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.parametrize("case", _params())
|
||||
def test_differential_case(case, parity_server_factory):
|
||||
server = parity_server_factory(case.server_args)
|
||||
result = H.execute_case(case, server)
|
||||
caveat = caveat_for(case.id)
|
||||
|
||||
mismatches = _aspects_to_check(result)
|
||||
if result["rsync_rc"] != result["fastsync_rc"]:
|
||||
mismatches["rc"] = [
|
||||
f"rsync rc={result['rsync_rc']} fastsync rc={result['fastsync_rc']} "
|
||||
f"(rsync stderr: {result['rsync_stderr'][:200]!r}, "
|
||||
f"fastsync stderr: {result['fastsync_stderr'][:200]!r})"]
|
||||
_assert_no_unexpected(case.id, mismatches, caveat, ref=case.ref)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-run and setup-heavy scenarios (kept as explicit tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _result_aspects(result):
|
||||
return _aspects_to_check(result)
|
||||
|
||||
|
||||
_STANDALONE_REFS = {
|
||||
"incremental_modified": "-i/--itemize-changes + incremental second run",
|
||||
"compare_dest": "--compare-dest",
|
||||
"link_dest": "--link-dest",
|
||||
"added_and_deleted": "--delete across two runs",
|
||||
"added_and_deleted_seed": "--delete across two runs",
|
||||
"one_file_system": "-x/--one-file-system",
|
||||
}
|
||||
|
||||
|
||||
def _run_and_check(case_id, result, ref=""):
|
||||
mismatches = _result_aspects(result)
|
||||
if result["rsync_rc"] != result["fastsync_rc"]:
|
||||
mismatches["rc"] = [
|
||||
f"rsync rc={result['rsync_rc']} fastsync rc={result['fastsync_rc']} "
|
||||
f"(rsync stderr: {result['rsync_stderr'][:200]!r}, "
|
||||
f"fastsync stderr: {result['fastsync_stderr'][:200]!r})"]
|
||||
_assert_no_unexpected(case_id, mismatches, caveat_for(case_id),
|
||||
ref=ref or _STANDALONE_REFS.get(case_id, ""))
|
||||
|
||||
|
||||
@requires_rsync
|
||||
@parity
|
||||
def test_incremental_modified_file(parity_server_factory):
|
||||
"""A second run sends only the modified file; destinations stay identical."""
|
||||
case_id = "incremental_modified"
|
||||
src = os.path.join(TEST_DATA_DIR, "parity_inc_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "parity_inc_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "parity_inc_fdst")
|
||||
H.CORPORA["basic"](src)
|
||||
server = parity_server_factory(SUPER)
|
||||
|
||||
# Seed both destinations with the initial content.
|
||||
H.run_differential(src, rdst, fdst, ["-a"], ["-a"], server,
|
||||
extra_check=lambda *a: [])
|
||||
with open(os.path.join(src, "a.txt"), "wb") as fh:
|
||||
fh.write(b"hello world, now modified and longer\n")
|
||||
_pin(os.path.join(src, "a.txt"), 1_650_000_000)
|
||||
|
||||
result = H.run_differential(
|
||||
src, rdst, fdst, ["-a", "-i"], ["-a", "-i", "--incremental"], server,
|
||||
stdout=H.STDOUT_ITEMIZE)
|
||||
_run_and_check(case_id, result)
|
||||
|
||||
|
||||
def _seed_basis(rel_entries):
|
||||
def seed(src, rroot, froot):
|
||||
for root in (rroot, froot):
|
||||
os.makedirs(root, exist_ok=True)
|
||||
for rel, data in rel_entries.items():
|
||||
_mk(os.path.join(root, rel), data)
|
||||
return seed
|
||||
|
||||
|
||||
@requires_rsync
|
||||
@parity
|
||||
def test_compare_dest_skips_basis(parity_server_factory):
|
||||
"""--compare-dest: a file present in the basis is not copied."""
|
||||
case_id = "compare_dest"
|
||||
src = os.path.join(TEST_DATA_DIR, "parity_cmpd_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "parity_cmpd_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "parity_cmpd_fdst")
|
||||
clean_dir(src)
|
||||
_mk(os.path.join(src, "f.txt"), b"basis-content\n")
|
||||
server = parity_server_factory(SUPER)
|
||||
rel = os.path.abspath(src).lstrip(os.sep)
|
||||
|
||||
# rsync resolves --compare-dest relative to the destination dir; FastSync
|
||||
# resolves it under the receive root and appends the mirrored source path.
|
||||
def seed(_src, rroot, froot):
|
||||
_mk(os.path.join(rroot, "basis", "f.txt"), b"basis-content\n")
|
||||
_mk(os.path.join(fdst, "basis", rel, "f.txt"), b"basis-content\n")
|
||||
|
||||
def extra(_src, rroot, froot, _rs, _fs):
|
||||
out = []
|
||||
for label, root in (("rsync", rroot), ("fastsync", froot)):
|
||||
if os.path.exists(os.path.join(root, "f.txt")):
|
||||
out.append(f"{label} copied a file that is present in the "
|
||||
f"compare basis")
|
||||
return out
|
||||
|
||||
result = H.run_differential(
|
||||
src, rdst, fdst,
|
||||
["-a", "--compare-dest=basis"],
|
||||
["-a", f"--compare-dest={os.path.join(fdst, 'basis')}", "--incremental"],
|
||||
server, seed=seed, ignore_paths=("basis",), extra_check=extra)
|
||||
_run_and_check(case_id, result)
|
||||
|
||||
|
||||
@requires_rsync
|
||||
@parity
|
||||
def test_link_dest_hardlinks_basis(parity_server_factory):
|
||||
"""--link-dest: an unchanged file is hard-linked to the basis, not copied."""
|
||||
case_id = "link_dest"
|
||||
src = os.path.join(TEST_DATA_DIR, "parity_linkd_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "parity_linkd_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "parity_linkd_fdst")
|
||||
clean_dir(src)
|
||||
_mk(os.path.join(src, "f.txt"), b"link-basis-content\n")
|
||||
server = parity_server_factory(SUPER)
|
||||
rel = os.path.abspath(src).lstrip(os.sep)
|
||||
|
||||
def seed(_src, rroot, froot):
|
||||
_mk(os.path.join(rroot, "basis", "f.txt"), b"link-basis-content\n")
|
||||
_mk(os.path.join(fdst, "basis", rel, "f.txt"), b"link-basis-content\n")
|
||||
|
||||
def extra(_src, rroot, froot, _rs, _fs):
|
||||
r_basis = os.stat(os.path.join(rroot, "basis", "f.txt")).st_ino
|
||||
f_basis = os.stat(os.path.join(fdst, "basis", rel, "f.txt")).st_ino
|
||||
out = []
|
||||
for label, root, basis in (("rsync", rroot, r_basis),
|
||||
("fastsync", froot, f_basis)):
|
||||
target = os.path.join(root, "f.txt")
|
||||
if not os.path.exists(target):
|
||||
out.append(f"{label}: f.txt missing")
|
||||
elif os.stat(target).st_ino != basis:
|
||||
out.append(f"{label}: f.txt is not hard-linked to the basis")
|
||||
return out
|
||||
|
||||
result = H.run_differential(
|
||||
src, rdst, fdst,
|
||||
["-a", "--link-dest=basis"],
|
||||
["-a", f"--link-dest={os.path.join(fdst, 'basis')}", "--incremental"],
|
||||
server, seed=seed, ignore_paths=("basis",), extra_check=extra)
|
||||
_run_and_check(case_id, result)
|
||||
|
||||
|
||||
@requires_rsync
|
||||
@parity
|
||||
def test_added_and_deleted_between_runs(parity_server_factory):
|
||||
"""A source deletion and addition sync correctly under --delete."""
|
||||
case_id = "added_and_deleted"
|
||||
src = os.path.join(TEST_DATA_DIR, "parity_addel_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "parity_addel_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "parity_addel_fdst")
|
||||
server = parity_server_factory(DELETE)
|
||||
H.CORPORA["basic"](src)
|
||||
|
||||
seed = seed_extras
|
||||
result = H.run_differential(
|
||||
src, rdst, fdst, ["-a", "--delete"], ["-a", "--delete"], server,
|
||||
seed=seed)
|
||||
_run_and_check(case_id + "_seed", result)
|
||||
|
||||
os.remove(os.path.join(src, "a.txt"))
|
||||
_mk(os.path.join(src, "added.txt"), b"added between runs\n")
|
||||
result = H.run_differential(
|
||||
src, rdst, fdst, ["-a", "--delete", "-i"],
|
||||
["-a", "--delete", "-i", "--incremental"], server,
|
||||
stdout=H.STDOUT_ITEMIZE)
|
||||
_run_and_check(case_id, result)
|
||||
|
||||
|
||||
@requires_rsync
|
||||
@parity
|
||||
def test_one_file_system(parity_server_factory):
|
||||
"""-x emits the mount-point directory but not its contents."""
|
||||
case_id = "one_file_system"
|
||||
local = os.stat(".")
|
||||
shm = "/dev/shm"
|
||||
if not os.path.isdir(shm):
|
||||
pytest.skip("/dev/shm not available")
|
||||
if os.stat(shm).st_dev == local.st_dev:
|
||||
pytest.skip("no cross-device filesystem available")
|
||||
|
||||
src = os.path.join(TEST_DATA_DIR, "parity_ofs_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "parity_ofs_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "parity_ofs_fdst")
|
||||
clean_dir(src)
|
||||
_mk(os.path.join(src, "keep.txt"), b"keep\n")
|
||||
probe = os.path.join(shm, f"fastsync_ofs_{os.getpid()}")
|
||||
shutil.rmtree(probe, ignore_errors=True)
|
||||
os.makedirs(probe)
|
||||
_mk(os.path.join(probe, "inside.txt"), b"cross\n")
|
||||
try:
|
||||
os.symlink(probe, os.path.join(src, "nested_link"))
|
||||
server = parity_server_factory(SUPER)
|
||||
result = H.run_differential(
|
||||
src, rdst, fdst,
|
||||
["-a", "--copy-links", "-x"],
|
||||
["-a", "--copy-links", "-x"], server)
|
||||
_run_and_check(case_id, result)
|
||||
finally:
|
||||
shutil.rmtree(probe, ignore_errors=True)
|
||||
|
||||
|
||||
@requires_rsync
|
||||
@parity
|
||||
def test_parity_caveats_reference_known_cases():
|
||||
"""Every allowlist entry must name a real case id and aspect."""
|
||||
from parity_caveats import CAVEATS
|
||||
known = {c.id for c in ALL_CASES} | {
|
||||
"incremental_modified", "compare_dest", "link_dest",
|
||||
"added_and_deleted", "added_and_deleted_seed", "one_file_system",
|
||||
}
|
||||
problems = []
|
||||
for case_id, entry in CAVEATS.items():
|
||||
if case_id not in known:
|
||||
problems.append(f"unknown case id in parity_caveats.py: {case_id!r}")
|
||||
for aspect in entry:
|
||||
if aspect not in ASPECTS:
|
||||
problems.append(
|
||||
f"{case_id!r}: unknown aspect {aspect!r} (expected {ASPECTS})")
|
||||
assert not problems, "\n".join(problems)
|
||||
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
|
||||
verify_transfer,
|
||||
)
|
||||
|
||||
PROTOCOL_VERSION = b"2.28.0"
|
||||
PROTOCOL_VERSION = b"2.26.0"
|
||||
STATUS_MANIFEST = 5
|
||||
STATUS_OK = 0
|
||||
|
||||
|
||||
@@ -583,55 +583,6 @@ class TestRemoteDryRun:
|
||||
assert os.path.exists(extra), f"{flags} deleted an extra in dry-run"
|
||||
assert _snapshot_tree(received) == before, f"{flags} mutated the destination"
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed")
|
||||
def test_dry_run_delete_lines_match_rsync(self):
|
||||
"""`-n --delete` lists exactly the destination extras rsync would remove.
|
||||
|
||||
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
|
||||
a --max-size-pruned source entry (always-protected prefix). Only the
|
||||
genuine destination-only extras may appear. Residual: a destination-only
|
||||
entry matching an exclude pattern is still removed (FastSync derives
|
||||
delete protection from the source scan, not a receiver filter engine);
|
||||
that divergence is pinned by TestOptionParity.
|
||||
"""
|
||||
source = os.path.join(TEST_DATA_DIR, "dryrep_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "dryrep_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "dryrep_fdst")
|
||||
clean_dir(source)
|
||||
clean_dir(rdst)
|
||||
clean_dir(fdst)
|
||||
for name, data in (("a.txt", b"new content\n"), ("keep.log", b"log\n"),
|
||||
("big.bin", b"B" * 2000)):
|
||||
with open(os.path.join(source, name), "wb") as fh:
|
||||
fh.write(data)
|
||||
os.utime(os.path.join(source, "a.txt"), (1_700_000_000, 1_700_000_000))
|
||||
received = get_dest_received_dir(fdst, source)
|
||||
os.makedirs(received, exist_ok=True)
|
||||
for root in (rdst, received):
|
||||
for name, data in (("a.txt", b"old\n"), ("keep.log", b"log\n"),
|
||||
("big.bin", b"B" * 2000), ("extra.txt", b"extra\n")):
|
||||
with open(os.path.join(root, name), "wb") as fh:
|
||||
fh.write(data)
|
||||
os.utime(os.path.join(root, name), (1_500_000_000, 1_500_000_000))
|
||||
|
||||
flags = ["-a", "-n", "-i", "--delete", "--exclude=*.log", "--max-size=1000"]
|
||||
r = subprocess.run(["rsync", "-an", "-i", "--delete", "--exclude=*.log",
|
||||
"--max-size=1000", source + "/", rdst + "/"],
|
||||
capture_output=True, text=True,
|
||||
env=dict(os.environ, LC_ALL="C"))
|
||||
assert r.returncode == 0, r.stderr
|
||||
rsync_del = sorted(l for l in r.stdout.splitlines() if l.startswith("*deleting"))
|
||||
assert rsync_del == ["*deleting extra.txt"], f"unexpected rsync set: {rsync_del}"
|
||||
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, fdst, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
fs_del = sorted(l for l in (result.stdout or "").splitlines()
|
||||
if l.startswith("*deleting"))
|
||||
assert fs_del == rsync_del, f"rsync={rsync_del}\nfastsync={fs_del}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_remote_dry_run_quiet_is_silent(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_src")
|
||||
@@ -2425,61 +2376,6 @@ class TestTempDir:
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
self._assert_clean_scratch(os.path.join(dest, "scratch"))
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed")
|
||||
def test_relative_temp_dir_matches_rsync_absolute_rejected(self):
|
||||
"""Differential: a relative --temp-dir is resolved under the destination
|
||||
by both (rsync 3.4.1 and FastSync), producing identical trees. An
|
||||
absolute --temp-dir is used verbatim by rsync standalone, but the
|
||||
receiver deliberately confines it to the receive root (security
|
||||
invariant), so FastSync rejects it without writing outside the root.
|
||||
"""
|
||||
source = self._make_source("tempdir_diff_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "tempdir_diff_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "tempdir_diff_fdst")
|
||||
clean_dir(rdst)
|
||||
clean_dir(fdst)
|
||||
os.makedirs(os.path.join(rdst, "scratch"), exist_ok=True)
|
||||
os.makedirs(os.path.join(fdst, "scratch"), exist_ok=True)
|
||||
r = subprocess.run(["rsync", "-a", "--temp-dir=scratch", source + "/", rdst + "/"],
|
||||
capture_output=True, text=True,
|
||||
env=dict(os.environ, LC_ALL="C"))
|
||||
assert r.returncode == 0, r.stderr
|
||||
with ServerManager() as server:
|
||||
server.start()
|
||||
result, _ = run_client(source, fdst, flags=["--temp-dir=scratch"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
# rsync lays the source contents directly in rdst; FastSync mirrors the
|
||||
# absolute source path below fdst. Compare the mirrored content trees
|
||||
# (the scratch dir lives at each destination root).
|
||||
rtree = sorted(os.path.relpath(os.path.join(dp, n), rdst)
|
||||
for dp, dn, fn in os.walk(rdst)
|
||||
for n in dn + fn if os.path.join(dp, n) != os.path.join(rdst, "scratch"))
|
||||
mirror = get_dest_received_dir(fdst, source)
|
||||
ftree = sorted(os.path.relpath(os.path.join(dp, n), mirror)
|
||||
for dp, dn, fn in os.walk(mirror) for n in dn + fn)
|
||||
assert rtree == ftree, f"relative temp-dir tree mismatch: {rtree} != {ftree}"
|
||||
assert _walk_tmp_files(os.path.join(rdst, "scratch")) == []
|
||||
assert _walk_tmp_files(os.path.join(fdst, "scratch")) == []
|
||||
|
||||
# Absolute temp dir: rsync accepts it; FastSync rejects it safely.
|
||||
abs_scratch = os.path.join(TEST_DATA_DIR, "tempdir_diff_abs")
|
||||
clean_dir(abs_scratch)
|
||||
rdst2 = os.path.join(TEST_DATA_DIR, "tempdir_diff_rdst2")
|
||||
clean_dir(rdst2)
|
||||
r2 = subprocess.run(["rsync", "-a", "--temp-dir=" + abs_scratch, source + "/", rdst2 + "/"],
|
||||
capture_output=True, text=True,
|
||||
env=dict(os.environ, LC_ALL="C"))
|
||||
assert r2.returncode == 0, r2.stderr
|
||||
fdst2 = os.path.join(TEST_DATA_DIR, "tempdir_diff_fdst2")
|
||||
clean_dir(fdst2)
|
||||
with ServerManager() as server:
|
||||
server.start()
|
||||
result2, _ = run_client(source, fdst2, flags=["--temp-dir", abs_scratch],
|
||||
port=server.port)
|
||||
assert result2.returncode != 0, "an absolute --temp-dir must be rejected (confined)"
|
||||
assert os.listdir(abs_scratch) == [], "receiver wrote into an unconfined temp dir"
|
||||
|
||||
def test_default_behavior_has_no_scratch_dir(self, shared_server):
|
||||
source = self._make_source("tempdir_default_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "tempdir_default_dst")
|
||||
@@ -2941,41 +2837,6 @@ class TestDelayUpdates:
|
||||
assert not os.path.isdir(os.path.join(delay_dest, self.STAGING)), \
|
||||
"staging directory left behind after a successful delayed transfer"
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed")
|
||||
def test_delay_updates_staging_name_collision_residual(self):
|
||||
"""Documented residual (RSYNC_COMPAT.md `--delay-updates` row): FastSync
|
||||
uses a fixed `.fastsync-stage` staging name and wipes a pre-existing tree
|
||||
of that name at the start of a delayed run (crash-leftover cleanup),
|
||||
even without `--delete`; rsync leaves a genuine destination entry of that
|
||||
name untouched. Pins the divergence that keeps the row Divergent."""
|
||||
source = self._make_source("delay_collide_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "delay_collide_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "delay_collide_fdst")
|
||||
clean_dir(rdst)
|
||||
clean_dir(fdst)
|
||||
for root in (rdst, fdst):
|
||||
with open(os.path.join(root, "top.txt"), "wb") as fh:
|
||||
fh.write(b"old\n")
|
||||
stage = os.path.join(root, self.STAGING)
|
||||
os.makedirs(stage, exist_ok=True)
|
||||
with open(os.path.join(stage, "keepme.txt"), "wb") as fh:
|
||||
fh.write(b"genuine user data\n")
|
||||
|
||||
r = subprocess.run(["rsync", "-a", "--delay-updates", source + "/", rdst + "/"],
|
||||
capture_output=True, text=True,
|
||||
env=dict(os.environ, LC_ALL="C"))
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert os.path.exists(os.path.join(rdst, self.STAGING, "keepme.txt")), \
|
||||
"rsync removed an unrelated destination entry named like the staging dir"
|
||||
|
||||
with ServerManager() as server:
|
||||
server.start()
|
||||
result, _ = run_client(source, fdst, flags=["--delay-updates"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert not os.path.exists(os.path.join(fdst, self.STAGING)), \
|
||||
"FastSync did not wipe the reserved staging name (residual changed)"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_delay_updates_incremental_rerun_no_leftovers(self, shared_server, mt):
|
||||
source = self._make_source("delay_rerun_src")
|
||||
@@ -3662,25 +3523,23 @@ class TestMissingArgs:
|
||||
|
||||
|
||||
class TestNoImpliedDirs:
|
||||
"""--no-implied-dirs (meaningful with -R) omits the source metadata of a
|
||||
listed path's implied parent directories but still creates those parents
|
||||
with default attributes, matching rsync 3.4.1."""
|
||||
"""--no-implied-dirs (only meaningful with -R + --files-from) refuses to
|
||||
place a listed file whose parent directory is not itself listed."""
|
||||
|
||||
def _make(self):
|
||||
return _make_relative_source("noimplied_src")
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_implied_dir_created_with_default_attrs(self, shared_server, mt):
|
||||
def test_implied_dir_only_fails_entry(self, shared_server, mt):
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "noimplied_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"a/b.txt\n") # "a" itself is not listed
|
||||
flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"implied parent directory was not created: {result.stderr[:200]}"
|
||||
assert os.path.isdir(os.path.join(dest, "a")), "implied parent 'a' was not created"
|
||||
assert _read_file(os.path.join(dest, "a", "b.txt")) == b"nested\n"
|
||||
assert result.returncode != 0, "implied parent directory was not rejected"
|
||||
assert "--no-implied-dirs" in (result.stderr or result.stdout)
|
||||
assert not os.path.exists(os.path.join(dest, "a", "b.txt"))
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_listed_dir_allows_file(self, shared_server, mt):
|
||||
@@ -3807,10 +3666,9 @@ class TestDirs:
|
||||
files.extend(os.path.relpath(os.path.join(root, n), mirror) for n in names)
|
||||
assert files == [], f"--dirs descended into contents: {files}"
|
||||
|
||||
def test_dirs_listed_dir_replaces_blocking_file(self, shared_server):
|
||||
"""rsync parity: a listed directory replaces a regular file already at
|
||||
its destination path (rsync removes the non-directory and creates the
|
||||
directory)."""
|
||||
def test_dirs_listed_dir_colliding_with_file_fails(self, shared_server):
|
||||
"""A listed directory that already exists as a regular file at the
|
||||
destination fails the transfer cleanly instead of clobbering the file."""
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "dirs_coll_dst")
|
||||
clean_dir(dest)
|
||||
@@ -3820,10 +3678,8 @@ class TestDirs:
|
||||
lst = _write_rel_list(b"dir1\n")
|
||||
result, _ = run_client(source, dest, flags=["--files-from", lst, "--dirs", "-R"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"dir entry over an existing file failed: {(result.stderr or result.stdout)[:300]}"
|
||||
assert os.path.isdir(blocker) and not os.path.islink(blocker), \
|
||||
"blocking regular file was not replaced by the incoming directory"
|
||||
assert result.returncode != 0, "dir entry over an existing file did not fail"
|
||||
assert os.path.isfile(blocker), "blocking regular file was clobbered"
|
||||
|
||||
|
||||
class TestMkpath:
|
||||
@@ -4607,11 +4463,11 @@ class TestDeletePolicy:
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@pytest.mark.setpriv
|
||||
def test_ignore_errors_keeps_deletion_active_on_scan_error(self, mt):
|
||||
"""rsync's --ignore-errors semantics: a source I/O error (unreadable
|
||||
subdirectory) makes the run continue and transfer the readable tree, but
|
||||
the default suppresses deletion ("IO error encountered -- skipping file
|
||||
deletion"); --ignore-errors lets deletion proceed. Both exit 23. Run as
|
||||
an unprivileged user so the mode-000 directory is genuinely unreadable."""
|
||||
"""A source I/O error (unreadable subdirectory) aborts the run so no
|
||||
deletion happens by default; --ignore-errors continues, still transfers
|
||||
the readable tree and still deletes, single-threaded and under -m. Run
|
||||
as an unprivileged user so the mode-000 directory is genuinely
|
||||
unreadable."""
|
||||
if os.geteuid() != 0 or shutil.which("setpriv") is None:
|
||||
pytest.skip("requires root + setpriv to drop privileges for the client")
|
||||
tag = f"ioerr_{os.getpid()}_{mt}"
|
||||
@@ -4631,15 +4487,11 @@ class TestDeletePolicy:
|
||||
try:
|
||||
os.chmod(os.path.join(source, "locked"), 0)
|
||||
|
||||
# Default: the scan continues past the unreadable dir and the
|
||||
# readable tree transfers, but deletion is skipped (exit 23).
|
||||
# Default: scan error aborts the run; nothing is deleted.
|
||||
self._write(os.path.join(received, "extra.txt"), b"extra\n")
|
||||
flags = ["--delete"] + (["--threads"] if mt else [])
|
||||
result = self._run_client_as_nobody(source, dest, server.port, flags)
|
||||
assert result.returncode == 23, \
|
||||
f"unreadable source dir should exit 23 (got {result.returncode})"
|
||||
assert os.path.exists(os.path.join(received, "top.txt")), \
|
||||
"readable tree did not transfer past the I/O error"
|
||||
assert result.returncode != 0, "unreadable source dir did not fail the run"
|
||||
assert os.path.exists(os.path.join(received, "extra.txt")), \
|
||||
"default run deleted although the scan hit an I/O error"
|
||||
|
||||
@@ -4647,8 +4499,6 @@ class TestDeletePolicy:
|
||||
self._write(os.path.join(received, "extra.txt"), b"extra\n")
|
||||
flags = ["--delete", "--ignore-errors"] + (["--threads"] if mt else [])
|
||||
result = self._run_client_as_nobody(source, dest, server.port, flags)
|
||||
assert result.returncode == 23, \
|
||||
f"--ignore-errors run should still exit 23 (got {result.returncode})"
|
||||
assert not os.path.exists(os.path.join(received, "extra.txt")), \
|
||||
f"--ignore-errors did not keep deletion active: {result.stderr[:300]}"
|
||||
assert not os.path.exists(os.path.join(received, "locked")), \
|
||||
@@ -6840,10 +6690,11 @@ class TestDirectoryAndSymlinkTimes:
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_preserve_creates_empty_source_dir(self, shared_server, mt):
|
||||
"""rsync parity: a recursive `-a` transfer recreates an empty source
|
||||
directory at the destination (the scanner emits it as an explicit
|
||||
directory entry)."""
|
||||
def test_preserve_does_not_create_empty_source_dir(self, shared_server, mt):
|
||||
"""P7 Wave D #1: a captured-but-EMPTY source directory is never created
|
||||
at the destination. The scanner records its time (it is transmitted via
|
||||
STATUS_DIR_TIMES), but the receiver treats that entry as record-only, so
|
||||
`-a` keeps the documented "empty dirs are never transferred" behavior."""
|
||||
source = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_dst")
|
||||
clean_dir(source)
|
||||
@@ -6854,8 +6705,8 @@ class TestDirectoryAndSymlinkTimes:
|
||||
flags = ["-a"] + (["--threads"] if mt else [])
|
||||
received = self._run(source, dest, flags, shared_server)
|
||||
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
|
||||
assert os.path.isdir(os.path.join(received, "empty_sub")), \
|
||||
f"-a did not recreate the empty source directory at {received}/empty_sub"
|
||||
assert not os.path.lexists(os.path.join(received, "empty_sub")), \
|
||||
f"-a created an empty source directory at {received}/empty_sub"
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@@ -6878,10 +6729,10 @@ class TestDirectoryAndSymlinkTimes:
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_collision_at_empty_dir_path_replaces_blocker(self, shared_server, mt):
|
||||
"""rsync parity: a pre-existing regular file at a source empty-dir's
|
||||
mirror path is replaced by the incoming directory (rsync removes the
|
||||
non-directory and creates the directory); the run succeeds."""
|
||||
def test_collision_at_dir_time_path_does_not_abort(self, shared_server, mt):
|
||||
"""P7 Wave D #1: a pre-existing regular file at a source-empty-dir's
|
||||
mirror path must not abort the transfer (the old mkdir failed and failed
|
||||
the run) and must not be clobbered."""
|
||||
source = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_dst")
|
||||
clean_dir(source)
|
||||
@@ -6900,8 +6751,10 @@ class TestDirectoryAndSymlinkTimes:
|
||||
assert result.returncode == 0, \
|
||||
f"-a aborted on a pre-existing file at an empty-dir path: " \
|
||||
f"{(result.stderr or result.stdout)[:400]}"
|
||||
assert os.path.isdir(blocker) and not os.path.islink(blocker), \
|
||||
"the pre-existing blocker was not replaced by the incoming directory"
|
||||
assert os.path.isfile(blocker) and not os.path.islink(blocker), \
|
||||
"the pre-existing blocker was replaced by a directory"
|
||||
with open(blocker, "rb") as fh:
|
||||
assert fh.read() == b"pre-existing blocker\n", "the blocker file was clobbered"
|
||||
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""--iconv=CONVERT_SPEC file-NAME charset conversion integration tests.
|
||||
|
||||
rsync's spec is ``--iconv=LOCAL,REMOTE`` (the order is the same push or pull).
|
||||
The sender converts each source name from LOCAL to REMOTE for the wire, and on
|
||||
a PUSH the receiver's charset is the spec's REMOTE half, so it writes the wire
|
||||
bytes verbatim (only a server with its own ``--iconv`` declares a different
|
||||
destination charset and re-converts). Content bytes are never touched.
|
||||
The client converts every source file name from LOCAL to REMOTE before it goes
|
||||
on the wire, and the receiver converts it back from REMOTE to LOCAL, so a
|
||||
source tree using one charset can be written into a destination tree using
|
||||
another (rsync compatibility; content bytes are never touched).
|
||||
"""
|
||||
import codecs
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@@ -18,11 +16,6 @@ LATIN1_NAME = b"caf\xe9.txt"
|
||||
UTF8_NAME = "caf\u00e9.txt".encode("utf-8")
|
||||
|
||||
|
||||
def _to_utf8(name_bytes):
|
||||
"""The UTF-8 encoding of a name that is stored as ISO-8859-1 bytes."""
|
||||
return codecs.encode(codecs.decode(name_bytes, "iso-8859-1"), "utf-8")
|
||||
|
||||
|
||||
def _make(tag):
|
||||
source = os.path.join(TEST_DATA_DIR, f"iconv_{tag}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"iconv_{tag}_dst")
|
||||
@@ -48,10 +41,10 @@ def _dest_file(source, dest, name):
|
||||
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_iconv_latin1_to_utf8_dest(shared_server):
|
||||
"""rsync push parity: --iconv=iso-8859-1,utf-8 converts a latin1 source name
|
||||
to the spec's REMOTE (UTF-8) on the wire and the default receiver writes it
|
||||
verbatim, so the destination name is UTF-8 (not the source's latin1)."""
|
||||
def test_iconv_latin1_roundtrip(shared_server):
|
||||
"""A source file whose name is ISO-8859-1 bytes is transferred with
|
||||
--iconv=iso-8859-1,utf-8 and lands on the destination with the ORIGINAL
|
||||
latin1 name (the wire carried it as UTF-8)."""
|
||||
source, dest = _make("latin1")
|
||||
_place_bytes(source, LATIN1_NAME)
|
||||
|
||||
@@ -60,10 +53,8 @@ def test_iconv_latin1_to_utf8_dest(shared_server):
|
||||
)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
|
||||
dst = _dest_file(source, dest, UTF8_NAME)
|
||||
assert os.path.exists(dst), f"dest UTF-8-named file not found under {dest}"
|
||||
assert not os.path.exists(_dest_file(source, dest, LATIN1_NAME)), \
|
||||
"destination kept the latin1 name instead of the wire (UTF-8) charset"
|
||||
dst = _dest_file(source, dest, LATIN1_NAME)
|
||||
assert os.path.exists(dst), f"dest latin1-named file not found under {dest}"
|
||||
|
||||
|
||||
@pytest.mark.ci
|
||||
@@ -162,7 +153,7 @@ def test_iconv_expanding_name_growth(shared_server):
|
||||
)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
|
||||
assert os.path.exists(_dest_file(source, dest, _to_utf8(name_bytes)))
|
||||
assert os.path.exists(_dest_file(source, dest, name_bytes))
|
||||
|
||||
|
||||
def test_iconv_symlink_path_and_target(shared_server):
|
||||
@@ -179,13 +170,11 @@ def test_iconv_symlink_path_and_target(shared_server):
|
||||
)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
|
||||
utf8_target = _to_utf8(target)
|
||||
utf8_link = _to_utf8(b"link\xe9")
|
||||
dst_target = _dest_file(source, dest, utf8_target)
|
||||
dst_link = _dest_file(source, dest, utf8_link)
|
||||
assert os.path.exists(dst_target), "dest UTF-8 target file missing"
|
||||
assert os.path.islink(dst_link), "dest UTF-8 symlink missing"
|
||||
assert os.readlink(dst_link) == utf8_target, "symlink target not wire-converted"
|
||||
dst_target = _dest_file(source, dest, target)
|
||||
dst_link = _dest_file(source, dest, b"link\xe9")
|
||||
assert os.path.exists(dst_target), "dest latin1 target file missing"
|
||||
assert os.path.islink(dst_link), "dest latin1 symlink missing"
|
||||
assert os.readlink(dst_link) == target, "symlink target not preserved/decoded"
|
||||
with open(dst_link, "rb") as fh:
|
||||
assert fh.read() == b"t\n"
|
||||
|
||||
@@ -209,8 +198,8 @@ def test_iconv_hardlink_path_and_target(shared_server):
|
||||
)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
|
||||
dst_a = _dest_file(source, dest, _to_utf8(a))
|
||||
dst_b = _dest_file(source, dest, _to_utf8(b))
|
||||
dst_a = _dest_file(source, dest, a)
|
||||
dst_b = _dest_file(source, dest, b)
|
||||
assert os.path.exists(dst_a) and os.path.exists(dst_b)
|
||||
assert os.stat(dst_a).st_ino == os.stat(dst_b).st_ino, \
|
||||
"hard-link relationship not preserved across the transfer"
|
||||
@@ -232,16 +221,16 @@ def test_iconv_delete_manifest_consistent(shared_server):
|
||||
flags = ["--iconv=iso-8859-1,utf-8"]
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
assert os.path.exists(_dest_file(source, dest, _to_utf8(keep)))
|
||||
assert os.path.exists(_dest_file(source, dest, _to_utf8(gone)))
|
||||
assert os.path.exists(_dest_file(source, dest, keep))
|
||||
assert os.path.exists(_dest_file(source, dest, gone))
|
||||
|
||||
os.remove(os.path.join(os.fsencode(source), gone))
|
||||
result, _ = run_client(
|
||||
source, dest, flags=flags + ["--delete"], port=server.port
|
||||
)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
assert os.path.exists(_dest_file(source, dest, _to_utf8(keep))), "kept file deleted"
|
||||
assert not os.path.exists(_dest_file(source, dest, _to_utf8(gone))), \
|
||||
assert os.path.exists(_dest_file(source, dest, keep)), "kept file deleted"
|
||||
assert not os.path.exists(_dest_file(source, dest, gone)), \
|
||||
"missing file was not deleted"
|
||||
|
||||
|
||||
@@ -258,4 +247,4 @@ def test_iconv_chunk_serialization_blob(shared_server):
|
||||
)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
|
||||
assert os.path.exists(_dest_file(source, dest, _to_utf8(name)))
|
||||
assert os.path.exists(_dest_file(source, dest, name))
|
||||
@@ -1,517 +0,0 @@
|
||||
"""Differential parity tests for the option wave (bwlimit, --info=*, -M,
|
||||
--ignore-errors, --filter protect).
|
||||
|
||||
Every differential here runs the SAME scenario with real ``rsync 3.4.1`` and
|
||||
with fastsync and compares the observable result, so the modules are skipped
|
||||
when rsync is unavailable. The privilege-dependent --ignore-errors differential
|
||||
drops the client to an unprivileged uid so a mode-000 source directory is
|
||||
genuinely unreadable; it is marked ``setpriv`` (run as root locally, excluded
|
||||
from the root PR gate exactly like the other privilege tests).
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import ( # noqa: E402
|
||||
CLIENT_CMD,
|
||||
TEST_DATA_DIR,
|
||||
ServerManager,
|
||||
clean_dir,
|
||||
get_dest_received_dir,
|
||||
run_client,
|
||||
)
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
||||
|
||||
|
||||
def _rsync(args, timeout=120, as_nobody=False):
|
||||
env = dict(os.environ, LC_ALL="C")
|
||||
cmd = [RSYNC] + args
|
||||
if as_nobody:
|
||||
cmd = ["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"] + cmd
|
||||
return subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
class TestBwlimitParity:
|
||||
"""--bwlimit must accept rsync 3.4.1's spellings and pace like it."""
|
||||
|
||||
ACCEPTED = ["100", "0", "1.5", "100K", "100KB", "100KiB", "1M", "1MB", "1m", "1G", "512"]
|
||||
REJECTED = ["-1", "abc", "1x", "1 000"]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_parse_acceptance_matches_rsync(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "bwp_src")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "f.txt"), b"payload\n")
|
||||
|
||||
for value in self.ACCEPTED + self.REJECTED:
|
||||
rdst = os.path.join(TEST_DATA_DIR, "bwp_rdst")
|
||||
clean_dir(rdst)
|
||||
rsync_result = _rsync(["-a", "--bwlimit=" + value, source + "/", rdst + "/"])
|
||||
dest = os.path.join(TEST_DATA_DIR, "bwp_dst")
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, flags=["-a", "--bwlimit=" + value],
|
||||
port=shared_server.port)
|
||||
assert (result.returncode == 0) == (rsync_result.returncode == 0), (
|
||||
f"--bwlimit={value}: fastsync rc={result.returncode} "
|
||||
f"({(result.stderr or result.stdout)[:120]!r}) "
|
||||
f"rsync rc={rsync_result.returncode} ({rsync_result.stderr[:120]!r})"
|
||||
)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_throttle_rate_matches_rsync(self, shared_server):
|
||||
"""A 4 MiB transfer at --bwlimit=2048 (2 MiB/s) must take about the same
|
||||
wall-clock time for both tools (~2 s with rsync's leaky bucket)."""
|
||||
source = os.path.join(TEST_DATA_DIR, "bwt_src")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "big.bin"), os.urandom(4 * 1024 * 1024))
|
||||
dest = os.path.join(TEST_DATA_DIR, "bwt_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "bwt_rdst")
|
||||
|
||||
clean_dir(rdst)
|
||||
start = time.monotonic()
|
||||
rsync_result = _rsync(["-a", "--bwlimit=2048", source + "/", rdst + "/"])
|
||||
rsync_secs = time.monotonic() - start
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
clean_dir(dest)
|
||||
result, fast_secs = run_client(source, dest, flags=["-a", "--bwlimit=2048"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
# 4 MiB at 2 MiB/s rendezvous near 2 s. Use a coarse band on each side
|
||||
# (an unthrottled transfer finishes well under 1.5 s) plus a generous
|
||||
# cross-tolerance so a loaded CI runner cannot flake the parity assert.
|
||||
lo, hi = 1.5, 4.5
|
||||
assert lo <= fast_secs <= hi, f"fastsync throttle out of band: {fast_secs:.2f}s"
|
||||
assert lo <= rsync_secs <= hi, f"rsync throttle out of band: {rsync_secs:.2f}s"
|
||||
assert abs(fast_secs - rsync_secs) < 2.0, (
|
||||
f"fastsync {fast_secs:.2f}s vs rsync {rsync_secs:.2f}s"
|
||||
)
|
||||
|
||||
|
||||
def _output_tree(root):
|
||||
clean_dir(root)
|
||||
os.makedirs(os.path.join(root, "sub"))
|
||||
_write(os.path.join(root, "a.txt"), b"top\n")
|
||||
_write(os.path.join(root, "sub", "b.txt"), b"nested\n")
|
||||
os.symlink("a.txt", os.path.join(root, "link"))
|
||||
|
||||
|
||||
class TestInfoParity:
|
||||
"""The --info categories that map to a FastSync event must print rsync's
|
||||
line format."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_flist_matches_rsync(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_fl_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_fl_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_fl_rdst")
|
||||
_output_tree(source)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
rsync_result = _rsync(["-a", "--info=flist", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--info=flist"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
assert "sending incremental file list" in result.stdout
|
||||
assert "sending incremental file list" in rsync_result.stdout
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_name_matches_rsync(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_nm_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_nm_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_nm_rdst")
|
||||
_output_tree(source)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
rsync_result = _rsync(["-a", "--info=name", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--info=name"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
|
||||
def entries(text):
|
||||
# Compare the transferred entries only: rsync also prints the
|
||||
# transfer-root `./` and every directory (FastSync records dirs),
|
||||
# which are a separate documented divergence.
|
||||
out = []
|
||||
for line in text.splitlines():
|
||||
if not line or line.startswith("sending ") or line.startswith("created "):
|
||||
continue
|
||||
if line == "./" or line.endswith("/"):
|
||||
continue
|
||||
out.append(line)
|
||||
return sorted(out)
|
||||
|
||||
assert entries(result.stdout) == entries(rsync_result.stdout), (
|
||||
f"rsync={entries(rsync_result.stdout)} fastsync={entries(result.stdout)}"
|
||||
)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_name_root_line_matches_rsync(self, shared_server):
|
||||
"""A fresh destination: rsync prints `created directory`, then the
|
||||
transfer-root `./` name line before the entries; FastSync must emit the
|
||||
same `./` line."""
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_root_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_root_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_root_rdst")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "f.bin"), b"payload\n")
|
||||
clean_dir(dest)
|
||||
shutil.rmtree(rdst, ignore_errors=True)
|
||||
rsync_result = _rsync(["-a", "--info=name", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--info=name"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
|
||||
def names(text):
|
||||
return [l for l in text.splitlines()
|
||||
if l and not l.startswith("created directory")
|
||||
and not (l.endswith("/") and l != "./")]
|
||||
|
||||
assert names(rsync_result.stdout) == ["./", "f.bin"], names(rsync_result.stdout)
|
||||
assert names(result.stdout) == ["./", "f.bin"], names(result.stdout)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_name2_uptodate_matches_rsync(self, shared_server):
|
||||
"""--info=name2 prints `NAME is uptodate` for entries the receiver
|
||||
already has, matching rsync byte-for-byte."""
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_up_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_up_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_up_rdst")
|
||||
clean_dir(source)
|
||||
os.makedirs(os.path.join(source, "sub"))
|
||||
_write(os.path.join(source, "a.txt"), b"a\n")
|
||||
_write(os.path.join(source, "sub", "b.txt"), b"b\n")
|
||||
clean_dir(rdst)
|
||||
assert _rsync(["-a", source + "/", rdst + "/"]).returncode == 0
|
||||
rsync_result = _rsync(["-a", "--info=name2", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
clean_dir(dest)
|
||||
seed, _ = run_client(source, dest, flags=["-a", "--incremental"],
|
||||
port=shared_server.port)
|
||||
assert seed.returncode == 0, (seed.stderr or seed.stdout)[:200]
|
||||
result, _ = run_client(source, dest, flags=["-a", "--incremental", "--info=name2"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
rsync_lines = sorted(l for l in rsync_result.stdout.splitlines()
|
||||
if l.endswith("is uptodate"))
|
||||
fast_lines = sorted(l for l in result.stdout.splitlines()
|
||||
if l.endswith("is uptodate"))
|
||||
assert fast_lines == rsync_lines, (rsync_lines, fast_lines)
|
||||
assert fast_lines == ["a.txt is uptodate", "sub/b.txt is uptodate"], fast_lines
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_nonreg_matches_rsync(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_nr_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_nr_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_nr_rdst")
|
||||
clean_dir(source)
|
||||
os.mkfifo(os.path.join(source, "fifo"))
|
||||
_write(os.path.join(source, "a.txt"), b"a\n")
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
rsync_result = _rsync(["-rlt", "--info=nonreg", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-rlt", "--info=nonreg"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
rsync_lines = sorted(l for l in rsync_result.stdout.splitlines()
|
||||
if l.startswith("skipping non-regular"))
|
||||
fast_lines = sorted(l for l in result.stdout.splitlines()
|
||||
if l.startswith("skipping non-regular"))
|
||||
assert fast_lines == rsync_lines, (rsync_lines, fast_lines)
|
||||
assert fast_lines, "no non-regular skip line emitted"
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_del_real_matches_rsync(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_dl_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_dl_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_dl_rdst")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "keep.txt"), b"keep\n")
|
||||
clean_dir(rdst)
|
||||
_write(os.path.join(rdst, "extra.txt"), b"x\n")
|
||||
_write(os.path.join(rdst, "extra2.txt"), b"y\n")
|
||||
rsync_result = _rsync(["-a", "--delete", "--info=del", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_lines = sorted(l for l in rsync_result.stdout.splitlines()
|
||||
if l.startswith("deleting "))
|
||||
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
_write(os.path.join(received, "extra.txt"), b"x\n")
|
||||
_write(os.path.join(received, "extra2.txt"), b"y\n")
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=["-a", "--delete", "--info=del"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
fast_lines = sorted(l for l in result.stdout.splitlines()
|
||||
if l.startswith("deleting "))
|
||||
assert fast_lines == rsync_lines, (rsync_lines, fast_lines)
|
||||
assert fast_lines, "no deletion lines emitted"
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_del_itemize_real_matches_rsync(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_di_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_di_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_di_rdst")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "keep.txt"), b"keep\n")
|
||||
clean_dir(rdst)
|
||||
_write(os.path.join(rdst, "extra.txt"), b"x\n")
|
||||
rsync_result = _rsync(["-a", "-i", "--delete", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_lines = sorted(l for l in rsync_result.stdout.splitlines()
|
||||
if l.startswith("*deleting"))
|
||||
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
_write(os.path.join(received, "extra.txt"), b"x\n")
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=["-a", "-i", "--delete"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
fast_lines = sorted(l for l in result.stdout.splitlines()
|
||||
if l.startswith("*deleting"))
|
||||
assert fast_lines == rsync_lines, (rsync_lines, fast_lines)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_del_dry_run_matches_rsync(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_dd_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_dd_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_dd_rdst")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "keep.txt"), b"keep\n")
|
||||
clean_dir(rdst)
|
||||
_write(os.path.join(rdst, "extra.txt"), b"x\n")
|
||||
rsync_result = _rsync(["-a", "-n", "--delete", "--info=del", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_lines = sorted(l for l in rsync_result.stdout.splitlines()
|
||||
if l.startswith("deleting "))
|
||||
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
_write(os.path.join(received, "extra.txt"), b"x\n")
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=["-a", "-n", "--delete", "--info=del"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
fast_lines = sorted(l for l in result.stdout.splitlines()
|
||||
if l.startswith("deleting "))
|
||||
assert fast_lines == rsync_lines, (rsync_lines, fast_lines)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_remove_matches_rsync(self, shared_server):
|
||||
tag = "inf_rm"
|
||||
rsync_src = os.path.join(TEST_DATA_DIR, f"{tag}_rsrc")
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, f"{tag}_rdst")
|
||||
fast_src = os.path.join(TEST_DATA_DIR, f"{tag}_fsrc")
|
||||
fast_dst = os.path.join(TEST_DATA_DIR, f"{tag}_fdst")
|
||||
for root in (rsync_src, rsync_dst, fast_src, fast_dst):
|
||||
clean_dir(root)
|
||||
_write(os.path.join(rsync_src, "a.txt"), b"a\n")
|
||||
_write(os.path.join(rsync_src, "sub", "b.txt"), b"b\n")
|
||||
_write(os.path.join(fast_src, "a.txt"), b"a\n")
|
||||
_write(os.path.join(fast_src, "sub", "b.txt"), b"b\n")
|
||||
|
||||
rsync_result = _rsync(["-a", "--remove-source-files", "--info=remove",
|
||||
rsync_src + "/", rsync_dst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_lines = sorted(l for l in rsync_result.stdout.splitlines()
|
||||
if l.startswith("sender removed "))
|
||||
|
||||
result, _ = run_client(fast_src, fast_dst,
|
||||
flags=["-a", "--remove-source-files", "--info=remove"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
fast_lines = sorted(l for l in result.stdout.splitlines()
|
||||
if l.startswith("sender removed "))
|
||||
assert fast_lines == rsync_lines, (rsync_lines, fast_lines)
|
||||
assert fast_lines, "no source-removal lines emitted"
|
||||
|
||||
|
||||
class TestIgnoreErrorsParity:
|
||||
"""--ignore-errors: a source I/O error skips deletion by default; the flag
|
||||
lets deletion proceed. Both exit 23. Run the client as an unprivileged user
|
||||
so the mode-000 directory is genuinely unreadable."""
|
||||
|
||||
@pytest.mark.setpriv
|
||||
def test_delete_after_io_error_matches_rsync(self):
|
||||
if os.geteuid() != 0 or shutil.which("setpriv") is None:
|
||||
pytest.skip("requires root + setpriv to drop privileges for the client")
|
||||
tag = f"ie_{os.getpid()}"
|
||||
source = os.path.join(TEST_DATA_DIR, f"{tag}_src")
|
||||
rsync_dst = os.path.join(TEST_DATA_DIR, f"{tag}_rdst")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"{tag}_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(rsync_dst)
|
||||
clean_dir(dest)
|
||||
_write(os.path.join(source, "top.txt"), b"top\n")
|
||||
_write(os.path.join(source, "locked", "blocked.txt"), b"blocked\n")
|
||||
os.chmod(os.path.join(source, "locked"), 0)
|
||||
os.chmod(TEST_DATA_DIR, 0o777)
|
||||
os.chmod(source, 0o755)
|
||||
os.chmod(rsync_dst, 0o777)
|
||||
os.chmod(dest, 0o777)
|
||||
try:
|
||||
for ignore in (False, True):
|
||||
flags = ["-a", "--delete-after"] + (["--ignore-errors"] if ignore else [])
|
||||
# rsync side
|
||||
_write(os.path.join(rsync_dst, "extra.txt"), b"x\n")
|
||||
os.chmod(os.path.join(rsync_dst, "extra.txt"), 0o666)
|
||||
rres = _rsync(flags + [source + "/", rsync_dst + "/"], as_nobody=True)
|
||||
rsync_extra = os.path.exists(os.path.join(rsync_dst, "extra.txt"))
|
||||
|
||||
# fastsync side
|
||||
received = get_dest_received_dir(dest, source)
|
||||
_write(os.path.join(received, "extra.txt"), b"x\n")
|
||||
os.chmod(os.path.join(received, "extra.txt"), 0o666)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
fflags = (["--delete", "--ignore-errors"] if ignore else ["--delete"])
|
||||
cmd = CLIENT_CMD + ["--source-dir", source, "--dest-dir", dest,
|
||||
"--save-to-disk", "--server-port", str(server.port)] + fflags
|
||||
fres = subprocess.run(
|
||||
["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"] + cmd,
|
||||
text=True, capture_output=True)
|
||||
fast_extra = os.path.exists(os.path.join(received, "extra.txt"))
|
||||
|
||||
assert rres.returncode == 23, (ignore, rres.returncode, rres.stderr[:200])
|
||||
assert fres.returncode == 23, (ignore, fres.returncode, fres.stderr[:200])
|
||||
assert rsync_extra == fast_extra, (
|
||||
f"ignore_errors={ignore}: rsync extra={rsync_extra} fastsync extra={fast_extra}"
|
||||
)
|
||||
assert fast_extra is (not ignore), (ignore, fast_extra)
|
||||
finally:
|
||||
os.chmod(os.path.join(source, "locked"), 0o755)
|
||||
|
||||
|
||||
class TestRemoteOptionDaemon:
|
||||
"""rsync forwards -M/--remote-option to its remote process over a daemon
|
||||
connection; FastSync's daemon has no per-connection argv channel and rejects
|
||||
it. This pins the documented divergence with evidence."""
|
||||
|
||||
@requires_rsync
|
||||
def test_rsync_forwards_M_over_daemon_and_fastsync_rejects(self, tmp_path):
|
||||
import socket
|
||||
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
|
||||
module_root = tmp_path / "mod"
|
||||
module_root.mkdir()
|
||||
os.chmod(module_root, 0o777)
|
||||
source = tmp_path / "src"
|
||||
source.mkdir()
|
||||
(source / "a.txt").write_bytes(b"hello\n")
|
||||
conf = tmp_path / "rsyncd.conf"
|
||||
conf.write_text(
|
||||
f"port = {port}\nuse chroot = no\n[m]\npath = {module_root}\nread only = no\n"
|
||||
)
|
||||
daemon = subprocess.Popen(
|
||||
[RSYNC, "--daemon", "--no-detach", "--port", str(port), "--config", str(conf)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
pytest.skip("rsync daemon did not start")
|
||||
|
||||
# A well-formed -M option is forwarded and accepted by the daemon...
|
||||
ok = _rsync(["-a", "-M--safe-links", source.as_posix() + "/",
|
||||
f"rsync://127.0.0.1:{port}/m/"])
|
||||
# ...and a bogus one is rejected ON THE REMOTE with "unknown option",
|
||||
# which proves the option reached the daemon's parser.
|
||||
bogus = _rsync(["-a", "-M--totally-bogus", source.as_posix() + "/",
|
||||
f"rsync://127.0.0.1:{port}/m/"])
|
||||
assert bogus.returncode != 0
|
||||
assert "unknown option" in (bogus.stderr + bogus.stdout), bogus.stderr
|
||||
del ok
|
||||
finally:
|
||||
daemon.terminate()
|
||||
try:
|
||||
daemon.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
daemon.kill()
|
||||
|
||||
# FastSync rejects -M for a non-SSH transport up front.
|
||||
dest = os.path.join(TEST_DATA_DIR, "ro_dst")
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source.as_posix(), dest, flags=["-a", "-M--safe-links"])
|
||||
assert result.returncode != 0
|
||||
assert "remote-option" in (result.stderr + result.stdout)
|
||||
|
||||
|
||||
class TestFilterProtectDivergence:
|
||||
"""Documented residual: a protect rule that matches only a destination-only
|
||||
entry is not re-derived on the receiver (FastSync derives delete protection
|
||||
from the source scan), so rsync protects the extra but FastSync removes it."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_protect_dest_only_divergence(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "fpd_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "fpd_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "fpd_rdst")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "keep.txt"), b"keep\n")
|
||||
clean_dir(rdst)
|
||||
_write(os.path.join(rdst, "extra.log"), b"extra\n")
|
||||
_write(os.path.join(rdst, "other.txt"), b"other\n")
|
||||
|
||||
rsync_result = _rsync(["-a", "--delete", "--filter=P *.log", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
assert os.path.exists(os.path.join(rdst, "extra.log")), "rsync did not protect extra.log"
|
||||
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
_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", "--delete", "--filter=P *.log"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
# Pin the known divergence: FastSync deletes the destination-only file.
|
||||
assert not os.path.exists(os.path.join(received, "extra.log")), (
|
||||
"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"))
|
||||
@@ -289,49 +289,6 @@ def _make_one_file(root, name="f.bin", size=100):
|
||||
fh.write(bytes((i * 7 + 3) & 0xFF for i in range(size)))
|
||||
|
||||
|
||||
def _make_multidir_tree(root):
|
||||
"""Multi-directory corpus for the --progress file-list tests: nested files,
|
||||
a directory-only branch, an empty directory and a symlink."""
|
||||
clean_dir(root)
|
||||
for rel, data in (("a.txt", b"alpha\n"), ("b.txt", b"bravo\n"),
|
||||
("sub1/c.txt", b"charlie\n"), ("sub1/deep/d.txt", b"delta\n"),
|
||||
("sub2/e.txt", b"echo\n")):
|
||||
path = os.path.join(root, rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
os.symlink("a.txt", os.path.join(root, "link1"))
|
||||
os.makedirs(os.path.join(root, "emptydir"), exist_ok=True)
|
||||
|
||||
|
||||
def _parse_progress(text):
|
||||
"""Name lines and the `to-chk` denominators from a --progress run."""
|
||||
names = []
|
||||
totals = set()
|
||||
for line in text.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line or line == "sending incremental file list":
|
||||
continue
|
||||
if "%" in line:
|
||||
match = re.search(r"to-chk=\d+/(\d+)", line)
|
||||
if match:
|
||||
totals.add(int(match.group(1)))
|
||||
continue
|
||||
if line == "./": # root-line trigger is a separate documented residual
|
||||
continue
|
||||
names.append(line)
|
||||
return sorted(names), totals
|
||||
|
||||
|
||||
def _pick_stats(text, keys):
|
||||
out = {}
|
||||
for line in text.splitlines():
|
||||
for key in keys:
|
||||
if line.startswith(key + ":"):
|
||||
out[key] = line
|
||||
return out
|
||||
|
||||
|
||||
class TestWireStatsParity:
|
||||
"""Wire-counter output parity: --out-format %b/%c/%C, --progress and
|
||||
--stats versus real rsync 3.4.1."""
|
||||
@@ -490,99 +447,6 @@ class TestWireStatsParity:
|
||||
assert fast_frames[0] == rsync_frames[0], (rsync_frames[0], fast_frames[0])
|
||||
assert "(xfr#1," in fast_frames[-1], fast_frames[-1]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_progress_leading_root_line_and_to_chk_match_rsync(self, shared_server):
|
||||
"""A single-file transfer: rsync emits the transfer-root `./` name line
|
||||
and a `to-chk=0/2` denominator that counts that root entry. Both must
|
||||
match FastSync byte-for-byte for the deterministic frames."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_pgroot_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_pgroot_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_pgroot_rdst")
|
||||
_make_one_file(source, "f.bin", 100)
|
||||
clean_dir(dest)
|
||||
# rsync prints the `./` root line only when the transfer root itself is
|
||||
# created, so make the rsync destination absent. The "created directory"
|
||||
# line it then emits has no FastSync counterpart (different mirror
|
||||
# layout), so only the name/frame lines are compared.
|
||||
shutil.rmtree(rdst, ignore_errors=True)
|
||||
rsync_result = _rsync(["-a", "--progress", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--progress"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
|
||||
# subprocess text mode normalizes \r to \n (universal newlines).
|
||||
def lines_of(text):
|
||||
return [ln for ln in text.splitlines() if ln and not ln.startswith("created directory")]
|
||||
|
||||
rsync_lines = lines_of(rsync_result.stdout)
|
||||
fast_lines = lines_of(result.stdout)
|
||||
rsync_names = [ln for ln in rsync_lines if "%" not in ln]
|
||||
fast_names = [ln for ln in fast_lines if "%" not in ln]
|
||||
|
||||
assert rsync_names == ["sending incremental file list", "./", "f.bin"], rsync_names
|
||||
assert fast_names == rsync_names, (rsync_names, fast_names)
|
||||
# The final frame's to-chk denominator must include the source-root entry.
|
||||
assert "to-chk=0/2" in fast_lines[-1], fast_lines[-1]
|
||||
assert fast_lines[-1] == rsync_lines[-1], (rsync_lines[-1], fast_lines[-1])
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_progress_multidir_file_list_matches_rsync(self, shared_server, mt):
|
||||
"""A multi-directory tree: the paths-only pre-count must reproduce
|
||||
rsync's file-list set and `to-chk` denominator. Per-directory name
|
||||
lines are emitted for directories, symlinks and the empty directory; the
|
||||
name set and the denominator (every entry plus the transfer root) match
|
||||
rsync, while the emitted *order* remains a documented residual (rsync
|
||||
sorts depth-first, FastSync streams in readdir/BFS order)."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_pgmd_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_pgmd_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_pgmd_rdst")
|
||||
_make_multidir_tree(source)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
|
||||
rsync_result = _rsync(["-a", "--progress", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
flags = ["-a", "--progress"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
|
||||
rsync_names, rsync_totals = _parse_progress(rsync_result.stdout)
|
||||
fast_names, fast_totals = _parse_progress(result.stdout)
|
||||
assert sorted(rsync_names) == [
|
||||
"a.txt", "b.txt", "emptydir/", "link1 -> a.txt", "sub1/",
|
||||
"sub1/c.txt", "sub1/deep/", "sub1/deep/d.txt", "sub2/", "sub2/e.txt",
|
||||
], rsync_names
|
||||
assert fast_names == rsync_names, (rsync_names, fast_names)
|
||||
# 10 entries + the transfer-root "." counted by rsync's file list.
|
||||
assert rsync_totals == {11}, rsync_totals
|
||||
assert fast_totals == rsync_totals, (rsync_totals, fast_totals)
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_progress_delete_during_reuses_pre_scan(self):
|
||||
"""--delete-during + --progress reuses the keep-set pre-scan instead of
|
||||
walking the tree a second time: the file-list total and directory name
|
||||
lines are identical to a plain --progress run."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_pgdel_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_pgdel_dst")
|
||||
_make_multidir_tree(source)
|
||||
clean_dir(dest)
|
||||
server = ServerManager()
|
||||
server.start(extra_args=["--allow-super", "--allow-delete"])
|
||||
try:
|
||||
result, _ = run_client(source, dest, flags=["-a", "--progress", "--delete-during"],
|
||||
port=server.port)
|
||||
finally:
|
||||
server.stop()
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
names, totals = _parse_progress(result.stdout)
|
||||
assert totals == {11}, totals
|
||||
assert "sub1/" in names and "sub1/deep/" in names and "emptydir/" in names, names
|
||||
assert "link1 -> a.txt" in names, names
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@@ -595,9 +459,6 @@ class TestWireStatsParity:
|
||||
_make_one_file(source, "f.bin", 6000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
# Start both tools from the same state: rsync's destination root exists,
|
||||
# so pre-create FastSync's mirrored logical root as well.
|
||||
os.makedirs(get_dest_received_dir(dest, source), exist_ok=True)
|
||||
rsync_result = _rsync(["-a", "--stats", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
flags = ["-a", "--stats"] + (["--threads"] if mt else [])
|
||||
@@ -627,19 +488,24 @@ class TestWireStatsParity:
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_stats_file_count_breakdown_matches_rsync(self, shared_server):
|
||||
"""`Number of files` and `Number of created files` both carry rsync's
|
||||
per-type breakdown (protocol 2.28.0 reports the receiver-created
|
||||
reg/dir/link/special split over STATUS_STATS)."""
|
||||
def test_stats_file_count_breakdown_residual(self, shared_server):
|
||||
"""Residual (row #3): rsync prints the `Number of files` and
|
||||
`Number of created files` lines with a per-type breakdown
|
||||
(`(reg: X, dir: Y, link: Z)`).
|
||||
|
||||
FastSync cannot reproduce it from what the sender currently knows: the
|
||||
scanner does not put directory entries in the transfer list (directories
|
||||
are created implicitly), and without a per-entry destination-probe the
|
||||
sender cannot tell which entries the receiver newly created. So FastSync
|
||||
prints the bare transferred-entry count. This test pins the divergence
|
||||
explicitly -- the row must not be marked ✅.
|
||||
"""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_stc_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_stc_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_stc_rdst")
|
||||
_make_one_file(source, "f.bin", 6000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
# Start both tools from the same state: rsync's destination root exists,
|
||||
# so pre-create FastSync's mirrored logical root as well.
|
||||
os.makedirs(get_dest_received_dir(dest, source), exist_ok=True)
|
||||
rsync_result = _rsync(["-a", "--stats", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--stats"],
|
||||
@@ -657,104 +523,14 @@ class TestWireStatsParity:
|
||||
f_files = stats_line(result.stdout, "Number of files")
|
||||
f_created = stats_line(result.stdout, "Number of created files")
|
||||
|
||||
# rsync always carries the type breakdown (the source root counts as a
|
||||
# directory; the single regular file as reg).
|
||||
assert re.match(r"Number of files: 2 \(reg: 1, dir: 1\)$", r_files), r_files
|
||||
assert r_files == f_files, (r_files, f_files)
|
||||
assert re.match(r"Number of created files: 1 \(reg: 1\)$", r_created), r_created
|
||||
assert f_created == r_created, (r_created, f_created)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_stats_created_and_literal_fresh_update_delta(self, shared_server, mt):
|
||||
"""The receiver-observed counters must match rsync for the three
|
||||
transfer shapes: a fresh create (created breakdown + whole-file literal),
|
||||
an update (created == 0, whole-file literal), and a delta update (only
|
||||
the literal delta fragments are counted, not the whole file)."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_stcd_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_stcd_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_stcd_rdst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
os.makedirs(source, exist_ok=True)
|
||||
os.makedirs(get_dest_received_dir(dest, source), exist_ok=True)
|
||||
with open(os.path.join(source, "big.bin"), "wb") as fh:
|
||||
fh.write(bytes(range(256)) * 4096) # 1 MiB
|
||||
mt_flag = ["--threads"] if mt else []
|
||||
|
||||
def compare(tag):
|
||||
# Pin the delta block size on both ends: rsync's adaptive block size
|
||||
# would otherwise make the literal/matched split non-comparable.
|
||||
rsync_result = _rsync(["-a", "--stats", "--no-whole-file", "-B8192",
|
||||
source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(
|
||||
source, dest,
|
||||
flags=["-a", "--stats", "--incremental", "--delta", "-B8192"] + mt_flag,
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
keys = ("Number of created files", "Literal data", "Matched data",
|
||||
"Total transferred file size")
|
||||
r = _pick_stats(rsync_result.stdout, keys)
|
||||
f = _pick_stats(result.stdout, keys)
|
||||
assert r == f, f"{tag}: rsync={r} fastsync={f}"
|
||||
return r
|
||||
|
||||
fresh = compare("fresh")
|
||||
assert re.match(r"Number of created files: 1 \(reg: 1\)$",
|
||||
fresh["Number of created files"]), fresh
|
||||
|
||||
# Update the source and re-run: the destination already exists.
|
||||
sleep_mtime = os.path.getmtime(os.path.join(source, "big.bin")) + 2
|
||||
with open(os.path.join(source, "big.bin"), "r+b") as fh:
|
||||
fh.seek(100)
|
||||
fh.write(b"XXXXXXXXXX")
|
||||
os.utime(os.path.join(source, "big.bin"), (sleep_mtime, sleep_mtime))
|
||||
update = compare("update")
|
||||
assert update["Number of created files"] == "Number of created files: 0", update
|
||||
|
||||
# Second delta update: change bytes far apart, so rsync ships only the
|
||||
# literal fragments and FastSync must report the same Literal data.
|
||||
sleep_mtime = os.path.getmtime(os.path.join(source, "big.bin")) + 2
|
||||
with open(os.path.join(source, "big.bin"), "r+b") as fh:
|
||||
fh.seek(500000)
|
||||
fh.write(b"YYYYYYYYYY")
|
||||
os.utime(os.path.join(source, "big.bin"), (sleep_mtime, sleep_mtime))
|
||||
delta = compare("delta")
|
||||
assert delta["Number of created files"] == "Number of created files: 0", delta
|
||||
lit = int(delta["Literal data"].split(":", 1)[1].strip().split()[0].replace(",", ""))
|
||||
assert 0 < lit < 1024 * 1024, delta
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("choice", ["xxh128", "xxh64", "xxh3", "md5", "md4", "sha1", "none"])
|
||||
def test_out_format_C_selected_algorithm_matches_rsync(self, shared_server, choice):
|
||||
"""`%C` must use the algorithm selected by --checksum-choice, not always
|
||||
xxh128, and render it exactly like rsync (big-endian for the 64-bit
|
||||
hashes, high-then-low for xxh128, standard hex for md5/md4/sha1)."""
|
||||
source = os.path.join(TEST_DATA_DIR, f"wire_cc_{choice}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"wire_cc_{choice}_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, f"wire_cc_{choice}_rdst")
|
||||
_make_one_file(source, "f.bin", 200000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
fmt = "%C %l %n"
|
||||
rsync_result = _rsync(["-a", "--checksum-choice=" + choice,
|
||||
"--out-format=" + fmt, source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["-a", "--checksum-choice=" + choice,
|
||||
"--out-format=" + fmt],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
|
||||
def file_lines(text):
|
||||
return [line for line in text.splitlines()
|
||||
if line and not line.rsplit(" ", 1)[-1].endswith("/")]
|
||||
|
||||
assert file_lines(result.stdout) == file_lines(rsync_result.stdout), (
|
||||
f"choice={choice}: rsync={rsync_result.stdout!r} fastsync={result.stdout!r}"
|
||||
)
|
||||
# FastSync prints only the bare count: no directory accounting and no
|
||||
# per-entry "created" knowledge.
|
||||
assert re.fullmatch(r"Number of files: 1", f_files), f_files
|
||||
assert re.fullmatch(r"Number of created files: 1", f_created), f_created
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
|
||||
@@ -769,47 +769,6 @@ class TestVerifyAndFlip:
|
||||
assert os.stat(dest_file).st_ino == os.stat(basis_file).st_ino, \
|
||||
"--link-dest must hard-link to the basis file"
|
||||
|
||||
@requires_rsync
|
||||
def test_basis_dir_size_only_content_residual(self, shared_server):
|
||||
"""Documented residual (RSYNC_COMPAT.md basis-dir rows): FastSync
|
||||
xxHash-verifies a basis hit, while rsync's `--size-only` quick check
|
||||
trusts the size alone. With a same-size, different-content basis,
|
||||
rsync links/copies the wrong basis content while FastSync transfers the
|
||||
source. This test pins both observed behaviors (FastSync is stricter,
|
||||
so the rows are reclassified Divergent)."""
|
||||
source = self._src("basissz")
|
||||
rdest = self._dst("basissz_r")
|
||||
fdest = self._dst("basissz_f")
|
||||
with open(os.path.join(source, "f.txt"), "wb") as fh:
|
||||
fh.write(b"AAAA\n")
|
||||
OLD = 1_400_000_000
|
||||
# rsync basis at the transfer-relative path (relative to the dest dir).
|
||||
os.makedirs(os.path.join(rdest, "basis"), exist_ok=True)
|
||||
with open(os.path.join(rdest, "basis", "f.txt"), "wb") as fh:
|
||||
fh.write(b"BBBB\n")
|
||||
os.utime(os.path.join(rdest, "basis", "f.txt"), (OLD, OLD))
|
||||
rs = _rsync(["-a", "--size-only", "--link-dest=basis", source + "/", rdest + "/"])
|
||||
assert rs.returncode == 0, rs.stderr
|
||||
with open(os.path.join(rdest, "f.txt"), "rb") as fh:
|
||||
assert fh.read() == b"BBBB\n", "rsync --size-only did not trust the basis size"
|
||||
|
||||
# FastSync basis is relative to the receive root; the file mirrors the
|
||||
# source path.
|
||||
rel = os.path.abspath(source).lstrip(os.sep)
|
||||
basis = os.path.join(fdest, "basis", rel)
|
||||
os.makedirs(basis, exist_ok=True)
|
||||
with open(os.path.join(basis, "f.txt"), "wb") as fh:
|
||||
fh.write(b"BBBB\n")
|
||||
os.utime(os.path.join(basis, "f.txt"), (OLD, OLD))
|
||||
received = get_dest_received_dir(fdest, source)
|
||||
result, _ = run_client(source, fdest,
|
||||
flags=["-a", "--size-only", "--link-dest=basis", "--incremental"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
with open(os.path.join(received, "f.txt"), "rb") as fh:
|
||||
assert fh.read() == b"AAAA\n", \
|
||||
"FastSync must verify the basis content and transfer the source"
|
||||
|
||||
|
||||
class TestIgnoreExistingShortCircuit:
|
||||
"""#9: --ignore-existing is decided by the receiver during the per-file
|
||||
|
||||
@@ -111,45 +111,6 @@ class TestRelativeGeneral:
|
||||
assert int(rs.st_mtime) == int(fs.st_mtime), \
|
||||
f"mtime mismatch for {rel} with {extra}"
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_no_implied_dirs_files_from_matches_rsync(self, shared_server):
|
||||
"""-R --no-implied-dirs --files-from: a listed file whose parent is not
|
||||
itself listed still transfers; the implied parent is created with
|
||||
default attributes (rsync 3.4.1 parity)."""
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_nidff_src"))
|
||||
# Make the implied parent unmistakably non-default on the source so a
|
||||
# wrongly-applied attribute would be observable.
|
||||
os.chmod(os.path.join(source, "foo"), 0o700)
|
||||
os.chmod(os.path.join(source, "foo", "bar"), 0o711)
|
||||
os.utime(os.path.join(source, "foo"), (978307200, 978307200))
|
||||
os.utime(os.path.join(source, "foo", "bar"), (978307200, 978307200))
|
||||
lst = os.path.join(TEST_DATA_DIR, "sel_nidff_list")
|
||||
with open(lst, "w") as fh:
|
||||
fh.write("foo/bar/baz/f.txt\n")
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_nidff_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_nidff_rdst")
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
r = _rsync(["-rlpt", "-R", "--no-implied-dirs", "--files-from=" + lst,
|
||||
source + "/", rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
result, _ = run_client(source, dest, flags=[
|
||||
"-rlpt", "-R", "--no-implied-dirs", "--files-from", lst],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
assert _tree(rdst) == _tree(dest), "implied-parent layout mismatch"
|
||||
# The implied parents exist on both sides and carry the run-time default
|
||||
# attributes, not the source's (non-default) ones.
|
||||
for rel in ("foo", "foo/bar", "foo/bar/baz"):
|
||||
rs = os.stat(os.path.join(rdst, rel))
|
||||
fs = os.stat(os.path.join(dest, rel))
|
||||
assert (rs.st_mode & 0o7777) == (fs.st_mode & 0o7777), \
|
||||
f"mode mismatch for implied {rel}"
|
||||
# The listed file is transferred with its content.
|
||||
with open(os.path.join(dest, "foo", "bar", "baz", "f.txt"), "rb") as fh:
|
||||
assert fh.read() == b"deep\n"
|
||||
|
||||
|
||||
class TestDirsOneLevel:
|
||||
"""#13: -d with a trailing slash (or '.') lists the source's immediate
|
||||
|
||||
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
|
||||
class TestProtocol:
|
||||
@pytest.mark.ci
|
||||
def test_protocol_current_version_accepted(self, shared_server):
|
||||
"""--protocol=2.28.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
"""--protocol=2.26.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
transfer completes normally."""
|
||||
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.makedirs(dest)
|
||||
_seed_protocol_source(source)
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.28.0"],
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.26.0"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include "test_daemon_conf.h"
|
||||
#include "test_daemon_limits.h"
|
||||
#include "test_delay_updates.h"
|
||||
#include "test_delete_plan.h"
|
||||
#include "test_delta.h"
|
||||
#include "test_file.h"
|
||||
#include "test_file_list.h"
|
||||
@@ -43,7 +42,6 @@
|
||||
#include "test_utils.h"
|
||||
#include "test_xattr.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <signal.h>
|
||||
|
||||
// Define global test state variables
|
||||
@@ -53,11 +51,6 @@ bool current_test_failed = false;
|
||||
|
||||
int main() {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
/* The codec/checksum resolvers consult rsync's preference-list environment
|
||||
* variables; clear them so a developer's shell cannot change test outcomes.
|
||||
* The env-specific tests set and restore their own values. */
|
||||
unsetenv("RSYNC_COMPRESS_LIST");
|
||||
unsetenv("RSYNC_CHECKSUM_LIST");
|
||||
printf("\033[1;36m=== RUNNING UNIT TESTS ===\033[0m\n\n");
|
||||
|
||||
RUN_TEST(test_queue);
|
||||
@@ -73,7 +66,6 @@ int main() {
|
||||
RUN_TEST(test_scanner);
|
||||
RUN_TEST(test_checksum);
|
||||
RUN_TEST(test_delta);
|
||||
RUN_TEST(test_delete_plan);
|
||||
RUN_TEST(test_data);
|
||||
RUN_TEST(test_protocol);
|
||||
RUN_TEST(test_protocol_error);
|
||||
|
||||
@@ -177,40 +177,6 @@ static void test_change_list_enabled() {
|
||||
config_delete(config); /* closes config->log_file */
|
||||
}
|
||||
|
||||
/* %C uses the negotiated TRANSFER checksum's column width (not the pre-transfer
|
||||
* whole-file digest), and `none` renders as a blank 2-char column, matching
|
||||
* rsync. A not-yet-filled checksum renders as spaces. */
|
||||
static void test_format_C_padding_uses_transfer_algo() {
|
||||
ChangeEvent event = sample_event();
|
||||
Config* config = config_create();
|
||||
EXPECT_NOT_NULL(config);
|
||||
/* Deliberately different pre-transfer algorithm: the transfer one must win. */
|
||||
config->checksum_algo = (int)CHECKSUM_ALGO_MD4;
|
||||
struct {
|
||||
int algo;
|
||||
int width;
|
||||
} cases[] = {
|
||||
{CHECKSUM_ALGO_XXH128, 32}, {CHECKSUM_ALGO_XXH64, 16}, {CHECKSUM_ALGO_XXH3, 16},
|
||||
{CHECKSUM_ALGO_MD5, 32}, {CHECKSUM_ALGO_MD4, 32}, {CHECKSUM_ALGO_SHA1, 40},
|
||||
{CHECKSUM_ALGO_NONE, 2},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
config->checksum_transfer_algo = cases[i].algo;
|
||||
char expected[64];
|
||||
size_t n = 0;
|
||||
expected[n++] = '[';
|
||||
for (int j = 0; j < cases[i].width; j++)
|
||||
expected[n++] = ' ';
|
||||
expected[n++] = ']';
|
||||
expected[n] = '\0';
|
||||
char* line = change_render_format("[%C]", config, &event);
|
||||
EXPECT_NOT_NULL(line);
|
||||
EXPECT_EQ_STR(line, expected);
|
||||
free(line);
|
||||
}
|
||||
config_delete(config);
|
||||
}
|
||||
|
||||
void test_change_list() {
|
||||
test_format_tokens();
|
||||
test_format_unknown_tokens_preserved();
|
||||
@@ -222,5 +188,4 @@ void test_change_list() {
|
||||
test_render_itemize_up_to_date_is_empty();
|
||||
test_render_list_line();
|
||||
test_change_list_enabled();
|
||||
test_format_C_padding_uses_transfer_algo();
|
||||
}
|
||||
|
||||
+1
-60
@@ -1,9 +1,7 @@
|
||||
#include "test_checksum.h"
|
||||
#include "checksum.h"
|
||||
#include "test_utils.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Known xxHash64 vector (seed 0) for the empty string and a literal.
|
||||
* The md5 vectors are the standard NIST/RFC1321 test strings. These pin the
|
||||
@@ -232,61 +230,6 @@ static void test_checksum_null_empty_digest() {
|
||||
EXPECT_TRUE(memcmp(a, b, alen) == 0);
|
||||
}
|
||||
|
||||
/* Every algorithm checksum_digest_file() claims to support must produce the
|
||||
* SAME digest as the in-memory one-shot, including the newly added md4/sha1/
|
||||
* none. A 200000-byte payload forces several 64 KiB streaming reads. */
|
||||
static void test_checksum_digest_file_matches_oneshot(void) {
|
||||
static const ChecksumAlgo algos[] = {
|
||||
CHECKSUM_ALGO_XXH64, CHECKSUM_ALGO_XXH3, CHECKSUM_ALGO_XXH128, CHECKSUM_ALGO_MD5,
|
||||
CHECKSUM_ALGO_MD4, CHECKSUM_ALGO_SHA1, CHECKSUM_ALGO_NONE,
|
||||
};
|
||||
enum { SIZE = 200000 };
|
||||
uint8_t* data = malloc(SIZE);
|
||||
EXPECT_NOT_NULL(data);
|
||||
for (int i = 0; i < SIZE; i++)
|
||||
data[i] = (uint8_t)((i * 7 + 3) & 0xff);
|
||||
char path[] = "/tmp/fastsync_ck_XXXXXX";
|
||||
int fd = mkstemp(path);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
ssize_t written = write(fd, data, SIZE);
|
||||
close(fd);
|
||||
EXPECT_EQ_INT((int)written, SIZE);
|
||||
for (size_t a = 0; a < sizeof(algos) / sizeof(algos[0]); a++) {
|
||||
ChecksumAlgo algo = algos[a];
|
||||
uint8_t one[CHECKSUM_MAX_DIGEST_LEN];
|
||||
uint8_t file[CHECKSUM_MAX_DIGEST_LEN];
|
||||
size_t one_len = 0, file_len = 0;
|
||||
EXPECT_TRUE(checksum_digest(algo, 0, data, SIZE, one, sizeof(one), &one_len));
|
||||
EXPECT_TRUE(checksum_digest_file(algo, 0, path, file, sizeof(file), &file_len));
|
||||
EXPECT_EQ_INT((int)file_len, (int)one_len);
|
||||
EXPECT_TRUE(memcmp(one, file, one_len) == 0);
|
||||
}
|
||||
unlink(path);
|
||||
free(data);
|
||||
}
|
||||
|
||||
/* RSYNC_CHECKSUM_LIST precedence, syntax and fallback. */
|
||||
static void test_checksum_choice_env_list() {
|
||||
unsetenv("RSYNC_CHECKSUM_LIST");
|
||||
EXPECT_EQ_INT(checksum_choice_resolve(), (int)CHECKSUM_ALGO_XXH128);
|
||||
EXPECT_EQ_INT((int)checksum_negotiate_default(), (int)CHECKSUM_ALGO_XXH128);
|
||||
|
||||
setenv("RSYNC_CHECKSUM_LIST", "bogus md5 xxh3", 1);
|
||||
EXPECT_EQ_INT(checksum_choice_resolve(), (int)CHECKSUM_ALGO_MD5);
|
||||
|
||||
setenv("RSYNC_CHECKSUM_LIST", "SHA1", 1);
|
||||
EXPECT_EQ_INT(checksum_choice_resolve(), (int)CHECKSUM_ALGO_SHA1);
|
||||
|
||||
/* Whitespace-separated only: comma is not a separator in rsync's syntax. */
|
||||
setenv("RSYNC_CHECKSUM_LIST", "md5,xxh3", 1);
|
||||
EXPECT_EQ_INT(checksum_choice_resolve(), -1);
|
||||
|
||||
setenv("RSYNC_CHECKSUM_LIST", " ", 1);
|
||||
EXPECT_EQ_INT(checksum_choice_resolve(), (int)CHECKSUM_ALGO_XXH128);
|
||||
|
||||
unsetenv("RSYNC_CHECKSUM_LIST");
|
||||
}
|
||||
|
||||
void test_checksum(void) {
|
||||
test_checksum_xxh64_seed0();
|
||||
test_checksum_xxh64_empty();
|
||||
@@ -302,6 +245,4 @@ void test_checksum(void) {
|
||||
test_checksum_xxh3_xxh128();
|
||||
test_checksum_truncated_buffer_rejected();
|
||||
test_checksum_null_empty_digest();
|
||||
test_checksum_digest_file_matches_oneshot();
|
||||
test_checksum_choice_env_list();
|
||||
}
|
||||
}
|
||||
+7
-280
@@ -7,7 +7,6 @@
|
||||
#include "delta.h"
|
||||
#include "file_list.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
#include <pwd.h>
|
||||
@@ -318,7 +317,7 @@ static void test_parse_args_protocol_accept_current() {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
|
||||
"--dest-dir", "/dst", "--protocol=2.28.0"};
|
||||
"--dest-dir", "/dst", "--protocol=2.26.0"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
||||
@@ -328,7 +327,7 @@ static void test_parse_args_protocol_accept_current() {
|
||||
cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
||||
"/dst", "--protocol", "2.28.0"};
|
||||
"/dst", "--protocol", "2.26.0"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
|
||||
@@ -1328,7 +1327,7 @@ static void test_parse_args_rejects_invalid_info_flag() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* rsync's info "name" category maps to fastsync's per-file name output, and
|
||||
/* rsync's info "name" category maps to fastsync's per-file name logging, and
|
||||
* --info=help prints the flag list and exits without error. */
|
||||
static void test_parse_args_info_name_and_help() {
|
||||
Config* cfg = config_create();
|
||||
@@ -1336,7 +1335,7 @@ static void test_parse_args_info_name_and_help() {
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_NAME);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_COPY);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
@@ -1346,10 +1345,8 @@ static void test_parse_args_info_name_and_help() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* rsync 3.4.1's full --info/--debug vocabulary parses. The info categories
|
||||
* with a FastSync event set their flag; the remaining rsync-only categories
|
||||
* (backup/mount/symsafe/syms) parse but stay silent. Every --debug category
|
||||
* listed here is FastSync-silent, so debug_level stays 0. */
|
||||
/* rsync 3.4.1's remaining --info/--debug categories parse successfully but
|
||||
* have no FastSync output wired to them, so they must not set any log flag. */
|
||||
static void test_parse_args_rsync_flag_vocabulary_accepted() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--info=backup,del,flist,mount,nonreg,progress,remove,symsafe,syms",
|
||||
@@ -1361,8 +1358,7 @@ static void test_parse_args_rsync_flag_vocabulary_accepted() {
|
||||
int positional_count = 0;
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_DEL | LOG_INFO_FLIST | LOG_INFO_NONREG |
|
||||
LOG_INFO_PROGRESS | LOG_INFO_REMOVE);
|
||||
EXPECT_EQ_INT(cfg->info_level, 0);
|
||||
EXPECT_EQ_INT(cfg->debug_level, 0);
|
||||
config_delete(cfg);
|
||||
}
|
||||
@@ -1393,29 +1389,6 @@ static void test_parse_args_debug_info_levels() {
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_STATS);
|
||||
config_delete(cfg);
|
||||
|
||||
/* --info=name level 2 enables the "is uptodate" marker; a later level-1 or
|
||||
level-0 token clears it again. */
|
||||
cfg = config_create();
|
||||
char* name2_argv[] = {"fastsync", "--info=name2", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, name2_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_NAME | LOG_INFO_NAME_UPTODATE);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* name1_argv[] = {"fastsync", "--info=name2,name1", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, name1_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_NAME);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* name0_argv[] = {"fastsync", "--info=name2,name0", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, name0_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, 0);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* bad_argv[] = {"fastsync", "--debug=123", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
@@ -2431,119 +2404,6 @@ static void test_parse_args_rejects_invalid_compression_choice() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* rsync gives each codec its own default --compress-level; an omitted level
|
||||
* resolves to that default and an explicit one is clamped to the codec range. */
|
||||
static void test_parse_args_per_codec_compression_level_defaults() {
|
||||
unsetenv("RSYNC_COMPRESS_LIST");
|
||||
struct {
|
||||
const char* choice;
|
||||
int level;
|
||||
} cases[] = {
|
||||
{"zstd", 3},
|
||||
{"zlib", 6},
|
||||
{"zlibx", 6},
|
||||
{"lz4", 1},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "-z", "--compress-choice", (char*)cases[i].choice, "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->use_compression);
|
||||
EXPECT_EQ_INT(cfg->compression_level, cases[i].level);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Bare -z resolves to the zstd default. */
|
||||
Config* cfg = config_create();
|
||||
char* bare[] = {"fastsync", "-z", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, bare, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->compression_level, 3);
|
||||
config_delete(cfg);
|
||||
|
||||
/* An explicit level wins unchanged for zstd... */
|
||||
cfg = config_create();
|
||||
char* zv[] = {"fastsync", "-z", "--compress-level", "10", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, zv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->compression_level, 10);
|
||||
config_delete(cfg);
|
||||
|
||||
/* ...but zlib clamps an over-range level to 9 like rsync. */
|
||||
cfg = config_create();
|
||||
char* zc[] = {"fastsync", "-z", "--compress-choice", "zlib", "--compress-level", "15",
|
||||
"/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 8, zc, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->compression_level, 9);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* RSYNC_COMPRESS_LIST drives the bare -z ("auto") resolution. */
|
||||
static void test_parse_args_compression_env_list() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "-z", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
|
||||
setenv("RSYNC_COMPRESS_LIST", "zlib lz4", 1);
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->compression_algo, (int)COMPRESSION_ALGO_ZLIB);
|
||||
EXPECT_EQ_INT(cfg->compression_level, 6);
|
||||
config_delete(cfg);
|
||||
|
||||
/* An explicit --compress-choice beats the env list. */
|
||||
cfg = config_create();
|
||||
char* explicit_argv[] = {"fastsync", "-z", "--compress-choice", "zstd", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, explicit_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->compression_algo, (int)COMPRESSION_ALGO_ZSTD);
|
||||
config_delete(cfg);
|
||||
|
||||
/* A list with no supported name is rsync's failed negotiation (exit 4). */
|
||||
setenv("RSYNC_COMPRESS_LIST", "bogus", 1);
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
unsetenv("RSYNC_COMPRESS_LIST");
|
||||
}
|
||||
|
||||
/* RSYNC_CHECKSUM_LIST drives the default checksum choice. */
|
||||
static void test_parse_args_checksum_env_list() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--checksum", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
|
||||
setenv("RSYNC_CHECKSUM_LIST", "md5", 1);
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_MD5);
|
||||
EXPECT_EQ_INT(cfg->checksum_transfer_algo, (int)CHECKSUM_ALGO_MD5);
|
||||
config_delete(cfg);
|
||||
|
||||
/* An explicit --cc wins. */
|
||||
cfg = config_create();
|
||||
char* cc_argv[] = {"fastsync", "--checksum", "--cc=sha1", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, cc_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_SHA1);
|
||||
config_delete(cfg);
|
||||
|
||||
/* A list with no supported name is rsync's failed negotiation (exit 4). */
|
||||
setenv("RSYNC_CHECKSUM_LIST", "bogus", 1);
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
unsetenv("RSYNC_CHECKSUM_LIST");
|
||||
}
|
||||
|
||||
/* Every value-taking table option accepts an inline "--opt=value" form. */
|
||||
static void test_parse_args_table_equals_size_options() {
|
||||
Config* cfg = config_create();
|
||||
@@ -4010,113 +3870,6 @@ static void test_parse_args_unsigned_options_reject_sign() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --bwlimit must parse with rsync 3.4.1's units and quantization: a bare value
|
||||
* is KiB/s, K/M/G/T/P are binary multipliers, KB/MB are decimal, KiB/MiB are
|
||||
* binary, decimals are rounded to whole KiB like rsync's (size + 512) / 1024,
|
||||
* and 0 (or an empty value) means "no limit". */
|
||||
static void test_parse_args_bwlimit_rsync_units() {
|
||||
struct {
|
||||
const char* value;
|
||||
unsigned long long expected; /* bytes/sec */
|
||||
int ok;
|
||||
} cases[] = {
|
||||
{"100", 100ULL * 1024, 1},
|
||||
{"0", 0, 1},
|
||||
{"", 0, 1},
|
||||
{"1.5", 2ULL * 1024, 1},
|
||||
{"100K", 100ULL * 1024, 1},
|
||||
{"100KiB", 100ULL * 1024, 1},
|
||||
{"100KB", (100000ULL + 512) / 1024 * 1024, 1},
|
||||
{"1M", 1024ULL * 1024, 1},
|
||||
{"1MB", (1000000ULL + 512) / 1024 * 1024, 1},
|
||||
{"1.5m", 1536ULL * 1024, 1},
|
||||
{"1G", 1024ULL * 1024 * 1024, 1},
|
||||
{"1000B", (1000ULL + 512) / 1024 * 1024, 1},
|
||||
{"100B", 0, 0}, /* below the 512-byte floor (not 0) */
|
||||
{"0.4", 0, 0}, /* 409 bytes, below the floor */
|
||||
{"511", 511ULL * 1024, 1},
|
||||
{"-1", 0, 0},
|
||||
{"abc", 0, 0},
|
||||
{"1x", 0, 0},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
char option[32];
|
||||
snprintf(option, sizeof(option), "--bwlimit=%s", cases[i].value);
|
||||
char* argv[] = {"fastsync", option, "/src", "/dst"};
|
||||
int rc = parse_args(cfg, 4, argv, positional_args, &positional_count);
|
||||
if (cases[i].ok) {
|
||||
EXPECT_EQ_INT(rc, 0);
|
||||
EXPECT_TRUE(io_get_bwlimit() == cases[i].expected);
|
||||
} else {
|
||||
EXPECT_EQ_INT(rc, -1);
|
||||
}
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
char* argv[] = {"fastsync", "--bwlimit", "512", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(io_get_bwlimit() == 512ULL * 1024);
|
||||
config_delete(cfg);
|
||||
io_set_bwlimit(0);
|
||||
}
|
||||
|
||||
/* Huge/malformed --bwlimit values must be rejected (not accepted or UB) and
|
||||
* oversized-but-representable ones must still be accepted: the scaling used to
|
||||
* be done with an unchecked signed double->long long cast, which is undefined
|
||||
* when the product leaves long long's range. */
|
||||
static void test_parse_args_bwlimit_huge_and_boundary() {
|
||||
struct {
|
||||
const char* value;
|
||||
unsigned long long expected; /* bytes/sec, ignored when !ok */
|
||||
int ok;
|
||||
} cases[] = {
|
||||
/* Malformed / non-numeric prefixes. */
|
||||
{"1e300", 0, 0},
|
||||
{"99999999999999999999999999e3", 0, 0},
|
||||
/* Products that exceed LLONG_MAX at every suffix. */
|
||||
{"99999999999999999999999999", 0, 0},
|
||||
{"99999999999999999999999999K", 0, 0},
|
||||
{"100000000000000000000P", 0, 0},
|
||||
{"99999999999999999999999999999999999999999999999999B", 0, 0},
|
||||
/* `strtod` overflow to +inf must be caught by the isfinite() guard. */
|
||||
{"9999999999999999999999999999999999999999999999999999999999999999999999"
|
||||
"9999999999999999999999999999999999999999999999999999999999999999999999"
|
||||
"99999999999999999999999999999999999999999999999999999999999999999999999",
|
||||
0, 0},
|
||||
/* 2^52 KiB/s: the largest power-of-two scaling that still fits. */
|
||||
{"4503599627370496", 4611686018427387904ULL, 1},
|
||||
/* The +/-1 suffix forms accepted by rsync. */
|
||||
{"1+1", 1024ULL, 1},
|
||||
{"1-1", 1024ULL, 1},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
char option[1024];
|
||||
snprintf(option, sizeof(option), "--bwlimit=%s", cases[i].value);
|
||||
char* argv[] = {"fastsync", option, "/src", "/dst"};
|
||||
int rc = parse_args(cfg, 4, argv, positional_args, &positional_count);
|
||||
if (cases[i].ok) {
|
||||
EXPECT_EQ_INT(rc, 0);
|
||||
EXPECT_TRUE(io_get_bwlimit() == cases[i].expected);
|
||||
} else {
|
||||
EXPECT_EQ_INT(rc, -1);
|
||||
}
|
||||
config_delete(cfg);
|
||||
io_set_bwlimit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* --dry-run must not emit a batch file, so it is rejected alongside
|
||||
* --read-batch/--only-write-batch. */
|
||||
static void test_validate_config_dry_run_rejects_write_batch() {
|
||||
@@ -4641,26 +4394,6 @@ static void test_parse_args_rejects_unsupported_short() {
|
||||
}
|
||||
}
|
||||
|
||||
/* The --ignore-errors deletion gate, unit-tested without a privileged source
|
||||
* directory: a clean scan always deletes; an I/O error suppresses deletion
|
||||
* unless --ignore-errors is set. (The end-to-end mode-000 differential lives in
|
||||
* the setpriv integration test; this pins the decision itself in the PR gate.) */
|
||||
static void test_ignore_errors_allows_delete(void) {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
EXPECT_TRUE(ignore_errors_allows_delete(cfg, false));
|
||||
EXPECT_FALSE(ignore_errors_allows_delete(cfg, true));
|
||||
|
||||
cfg->ignore_errors = true;
|
||||
EXPECT_TRUE(ignore_errors_allows_delete(cfg, false));
|
||||
EXPECT_TRUE(ignore_errors_allows_delete(cfg, true));
|
||||
|
||||
/* A NULL config cannot opt into --ignore-errors. */
|
||||
EXPECT_TRUE(ignore_errors_allows_delete(NULL, false));
|
||||
EXPECT_FALSE(ignore_errors_allows_delete(NULL, true));
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
void test_client_cli() {
|
||||
test_validate_config_required_paths();
|
||||
test_parse_args_numeric_ids();
|
||||
@@ -4795,9 +4528,6 @@ void test_client_cli() {
|
||||
test_parse_args_compression_alias_equals();
|
||||
test_parse_args_rejects_invalid_compression_level_equals();
|
||||
test_parse_args_rejects_invalid_compression_choice();
|
||||
test_parse_args_per_codec_compression_level_defaults();
|
||||
test_parse_args_compression_env_list();
|
||||
test_parse_args_checksum_env_list();
|
||||
test_parse_args_table_equals_size_options();
|
||||
test_parse_args_table_equals_string_and_int_options();
|
||||
test_parse_args_missing_argument_diagnostic();
|
||||
@@ -4849,8 +4579,6 @@ void test_client_cli() {
|
||||
test_parse_args_password_file();
|
||||
test_parse_args_pattern_file_oversized_rejected();
|
||||
test_parse_args_unsigned_options_reject_sign();
|
||||
test_parse_args_bwlimit_rsync_units();
|
||||
test_parse_args_bwlimit_huge_and_boundary();
|
||||
test_validate_config_dry_run_rejects_write_batch();
|
||||
test_parse_args_short_clustering();
|
||||
test_parse_args_attached_short_values();
|
||||
@@ -4859,5 +4587,4 @@ void test_client_cli() {
|
||||
test_parse_args_noop_does_not_consume_argv();
|
||||
test_parse_args_backup_copy_links_shorts();
|
||||
test_parse_args_rejects_unsupported_short();
|
||||
test_ignore_errors_allows_delete();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include "data.h"
|
||||
#include "file.h"
|
||||
#include "utils.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
@@ -391,56 +390,6 @@ static void test_codec_name_mapping() {
|
||||
EXPECT_TRUE(compression_algo_enabled(COMPRESSION_ALGO_ZSTD));
|
||||
}
|
||||
|
||||
/* rsync 3.4.1's per-codec default levels and its clamping ranges. */
|
||||
static void test_codec_level_defaults_and_clamp() {
|
||||
EXPECT_EQ_INT(compression_default_level(COMPRESSION_ALGO_ZSTD), ZSTD_CLEVEL_DEFAULT);
|
||||
EXPECT_EQ_INT(compression_default_level(COMPRESSION_ALGO_ZSTD), 3);
|
||||
EXPECT_EQ_INT(compression_default_level(COMPRESSION_ALGO_ZLIB), 6);
|
||||
EXPECT_EQ_INT(compression_default_level(COMPRESSION_ALGO_ZLIBX), 6);
|
||||
/* lz4 has no tunable level; a positive placeholder keeps the codec engaged. */
|
||||
EXPECT_TRUE(compression_default_level(COMPRESSION_ALGO_LZ4) > 0);
|
||||
EXPECT_EQ_INT(compression_default_level(COMPRESSION_ALGO_NONE), 0);
|
||||
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_ZSTD, 1), 1);
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_ZSTD, 22), 22);
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_ZSTD, 23), 22);
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_ZSTD, 0), 1);
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_ZLIB, 15), 9);
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_ZLIBX, 15), 9);
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_ZLIB, 1), 1);
|
||||
EXPECT_TRUE(compression_clamp_level(COMPRESSION_ALGO_LZ4, 20) > 0);
|
||||
EXPECT_EQ_INT(compression_clamp_level(COMPRESSION_ALGO_NONE, 20), 0);
|
||||
}
|
||||
|
||||
/* RSYNC_COMPRESS_LIST precedence, syntax and fallback. */
|
||||
static void test_codec_choice_env_list() {
|
||||
unsetenv("RSYNC_COMPRESS_LIST");
|
||||
EXPECT_EQ_INT(compression_choice_resolve(), (int)COMPRESSION_ALGO_ZSTD);
|
||||
EXPECT_EQ_INT((int)compression_negotiate_default(), (int)COMPRESSION_ALGO_ZSTD);
|
||||
|
||||
/* Unknown entries are skipped; the first supported wins. */
|
||||
setenv("RSYNC_COMPRESS_LIST", "bogus zlib lz4", 1);
|
||||
EXPECT_EQ_INT(compression_choice_resolve(), (int)COMPRESSION_ALGO_ZLIB);
|
||||
|
||||
/* Case-insensitive. */
|
||||
setenv("RSYNC_COMPRESS_LIST", "ZSTD", 1);
|
||||
EXPECT_EQ_INT(compression_choice_resolve(), (int)COMPRESSION_ALGO_ZSTD);
|
||||
|
||||
/* Whitespace-separated; the client half ends at '&'. */
|
||||
setenv("RSYNC_COMPRESS_LIST", "lz4 zlib & zstd", 1);
|
||||
EXPECT_EQ_INT(compression_choice_resolve(), (int)COMPRESSION_ALGO_LZ4);
|
||||
|
||||
/* Blank falls back to the compiled-in order. */
|
||||
setenv("RSYNC_COMPRESS_LIST", " ", 1);
|
||||
EXPECT_EQ_INT(compression_choice_resolve(), (int)COMPRESSION_ALGO_ZSTD);
|
||||
|
||||
/* rsync's syntax has no comma/colon separator: this is one unknown name. */
|
||||
setenv("RSYNC_COMPRESS_LIST", "bogus,lz4", 1);
|
||||
EXPECT_EQ_INT(compression_choice_resolve(), -1);
|
||||
|
||||
unsetenv("RSYNC_COMPRESS_LIST");
|
||||
}
|
||||
|
||||
/* The process-global codec selects what the legacy wrappers produce. */
|
||||
static void test_codec_global_selection() {
|
||||
Data* original = data_create_empty(64);
|
||||
@@ -474,7 +423,5 @@ void test_compression() {
|
||||
test_chunk_compress_decompress_roundtrip();
|
||||
test_codec_roundtrips();
|
||||
test_codec_name_mapping();
|
||||
test_codec_level_defaults_and_clamp();
|
||||
test_codec_choice_env_list();
|
||||
test_codec_global_selection();
|
||||
}
|
||||
|
||||
+6
-9
@@ -2831,17 +2831,14 @@ static void golden_config_populate(Config* c) {
|
||||
c->copy_as_gid = 222;
|
||||
}
|
||||
|
||||
/* The pinned golden frame (protocol 2.28.0). The values below are the only
|
||||
/* The pinned golden frame (protocol 2.26.0). The values below are the only
|
||||
* thing that ties the generated table to the historical wire format; update
|
||||
* 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
|
||||
* 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
|
||||
* STATUS_STATS body grew, but the config frame layout is unchanged, so the
|
||||
* frame length is identical). The byte-exact values are recomputed for the
|
||||
* merged layout. */
|
||||
#define GOLDEN_WIRE_LEN 709
|
||||
#define GOLDEN_WIRE_HASH 417335736347473203ULL
|
||||
* report_stats bool and 2.26.0 appended the compression_algo int. The
|
||||
* byte-exact values are recomputed for the merged layout. */
|
||||
#define GOLDEN_WIRE_LEN 705
|
||||
#define GOLDEN_WIRE_HASH 4673424031554175633ULL
|
||||
|
||||
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
||||
unsigned long long h = 1469598103934665603ULL;
|
||||
@@ -2923,7 +2920,7 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Byte-for-byte wire compatibility guard (protocol 2.28.0). The expected hash
|
||||
/* Byte-for-byte wire compatibility guard (protocol 2.26.0). The expected hash
|
||||
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
|
||||
static void test_config_wire_golden() {
|
||||
if (is_running_under_valgrind())
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
#include "test_delete_plan.h"
|
||||
#include "charset.h"
|
||||
#include "config.h"
|
||||
#include "delete_plan.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Send one STATUS_DELETE_PLAN body (the leading status is consumed by the
|
||||
* caller/receiver entry point) describing `dir` with no kept children. */
|
||||
static void send_plan_frame(int fd, const char* dir) {
|
||||
EXPECT_TRUE(send_int(fd, 0)); /* has_config */
|
||||
EXPECT_TRUE(send_wire_str(fd, dir));
|
||||
EXPECT_TRUE(send_int(fd, 0)); /* kept child dirs */
|
||||
EXPECT_TRUE(send_int(fd, 0)); /* kept child files */
|
||||
}
|
||||
|
||||
/* --delete-delay: a directory snapshotted into the plan that is refilled before
|
||||
* the commit is re-scanned and removed recursively (rsync parity). Regression
|
||||
* for the old single-unlink ENOTEMPTY path that left the directory behind. */
|
||||
static void test_delete_delay_refilled_dir_removed_recursively(void) {
|
||||
char root[] = "/tmp/fastsync_dp_refill_XXXXXX";
|
||||
EXPECT_TRUE(mkdtemp(root) != NULL);
|
||||
char extra[1024];
|
||||
snprintf(extra, sizeof(extra), "%s/extra", root);
|
||||
EXPECT_EQ_INT(mkdir(extra, 0700), 0);
|
||||
|
||||
Config* config = config_create();
|
||||
EXPECT_NOT_NULL(config);
|
||||
config->receive_root_directory = str_dup(root);
|
||||
config->use_delete = true;
|
||||
config->delete_delay = true;
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
|
||||
DeletePlanSession* session = delete_plan_session_create(config);
|
||||
EXPECT_NOT_NULL(session);
|
||||
send_plan_frame(p[1], ".");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
/* The empty extra directory was snapshotted, not removed yet. */
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
|
||||
/* Refill the directory while the deferred commit is pending. */
|
||||
char refill[1200];
|
||||
snprintf(refill, sizeof(refill), "%s/new.txt", extra);
|
||||
int fd = open(refill, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
close(fd);
|
||||
|
||||
EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_OK);
|
||||
/* The late content and the directory itself are both removed. */
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 2);
|
||||
struct stat st;
|
||||
EXPECT_TRUE(lstat(extra, &st) != 0);
|
||||
|
||||
delete_plan_session_destroy(session);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
rmdir(root);
|
||||
config_delete(config);
|
||||
}
|
||||
|
||||
/* The complement: a deferred regular extra that DOES get removed is counted. */
|
||||
static void test_delete_delay_removed_file_counted(void) {
|
||||
char root[] = "/tmp/fastsync_dp_file_XXXXXX";
|
||||
EXPECT_TRUE(mkdtemp(root) != NULL);
|
||||
char extra[1024];
|
||||
snprintf(extra, sizeof(extra), "%s/extra.txt", root);
|
||||
int fd = open(extra, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
close(fd);
|
||||
|
||||
Config* config = config_create();
|
||||
EXPECT_NOT_NULL(config);
|
||||
config->receive_root_directory = str_dup(root);
|
||||
config->use_delete = true;
|
||||
config->delete_delay = true;
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
|
||||
DeletePlanSession* session = delete_plan_session_create(config);
|
||||
EXPECT_NOT_NULL(session);
|
||||
send_plan_frame(p[1], ".");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_OK);
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1);
|
||||
EXPECT_TRUE(lstat(extra, &(struct stat){0}) != 0);
|
||||
|
||||
delete_plan_session_destroy(session);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
rmdir(root);
|
||||
config_delete(config);
|
||||
}
|
||||
|
||||
/* --max-delete is charged on actual removals, not at plan/snapshot time: after
|
||||
* receiving the plans the budget is untouched, and only the commit removes up to
|
||||
* the limit. */
|
||||
static void test_delete_delay_max_delete_bounds_actual(void) {
|
||||
char root[] = "/tmp/fastsync_dp_max_XXXXXX";
|
||||
EXPECT_TRUE(mkdtemp(root) != NULL);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/e%d.txt", root, i);
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
Config* config = config_create();
|
||||
EXPECT_NOT_NULL(config);
|
||||
config->receive_root_directory = str_dup(root);
|
||||
config->use_delete = true;
|
||||
config->delete_delay = true;
|
||||
config->max_delete = 1;
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
|
||||
DeletePlanSession* session = delete_plan_session_create(config);
|
||||
EXPECT_NOT_NULL(session);
|
||||
send_plan_frame(p[1], ".");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
/* Nothing removed yet, so the budget is not consumed at snapshot time. */
|
||||
EXPECT_FALSE(delete_plan_session_limit_reached(session));
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_LIMIT_REACHED);
|
||||
EXPECT_TRUE(delete_plan_session_limit_reached(session));
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1);
|
||||
|
||||
delete_plan_session_destroy(session);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/e%d.txt", root, i);
|
||||
unlink(path);
|
||||
}
|
||||
rmdir(root);
|
||||
config_delete(config);
|
||||
}
|
||||
|
||||
/* --max-delete is charged on ACTUAL removals: the refilled directory's late
|
||||
* content is removed first (consuming the single budget slot), so the directory
|
||||
* itself and the later extra are skipped, matching rsync. The two plans are
|
||||
* sent as separate frames for "a" then "b", so the ordering that decides which
|
||||
* entry gets the budget is deterministic (unlike readdir order). */
|
||||
static void test_delete_delay_actual_removal_charges_budget(void) {
|
||||
char root[] = "/tmp/fastsync_dp_planbudget_XXXXXX";
|
||||
EXPECT_TRUE(mkdtemp(root) != NULL);
|
||||
char adir[1024], bdir[1024], xdir[1024], ydir[1024];
|
||||
snprintf(adir, sizeof(adir), "%s/a", root);
|
||||
snprintf(bdir, sizeof(bdir), "%s/b", root);
|
||||
snprintf(xdir, sizeof(xdir), "%s/a/x", root);
|
||||
snprintf(ydir, sizeof(ydir), "%s/b/y", root);
|
||||
EXPECT_EQ_INT(mkdir(adir, 0700), 0);
|
||||
EXPECT_EQ_INT(mkdir(bdir, 0700), 0);
|
||||
EXPECT_EQ_INT(mkdir(xdir, 0700), 0);
|
||||
EXPECT_EQ_INT(mkdir(ydir, 0700), 0);
|
||||
|
||||
Config* config = config_create();
|
||||
EXPECT_NOT_NULL(config);
|
||||
config->receive_root_directory = str_dup(root);
|
||||
config->use_delete = true;
|
||||
config->delete_delay = true;
|
||||
config->max_delete = 1;
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
|
||||
DeletePlanSession* session = delete_plan_session_create(config);
|
||||
EXPECT_NOT_NULL(session);
|
||||
/* Both plan snapshots are taken; neither consumes budget yet. */
|
||||
send_plan_frame(p[1], "a");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
EXPECT_FALSE(delete_plan_session_limit_reached(session));
|
||||
send_plan_frame(p[1], "b");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
EXPECT_FALSE(delete_plan_session_limit_reached(session));
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
|
||||
/* Refill a/x after its plan: the recursive commit must remove this content. */
|
||||
char refill[1200];
|
||||
snprintf(refill, sizeof(refill), "%s/new.txt", xdir);
|
||||
int fd = open(refill, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
close(fd);
|
||||
|
||||
EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_LIMIT_REACHED);
|
||||
/* The one budget slot removed the late content; the two directories survive. */
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1);
|
||||
EXPECT_TRUE(delete_plan_session_limit_reached(session));
|
||||
struct stat st;
|
||||
EXPECT_TRUE(lstat(refill, &st) != 0);
|
||||
EXPECT_EQ_INT(lstat(xdir, &st), 0);
|
||||
EXPECT_EQ_INT(lstat(ydir, &st), 0);
|
||||
|
||||
delete_plan_session_destroy(session);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
rmdir(xdir);
|
||||
rmdir(ydir);
|
||||
rmdir(adir);
|
||||
rmdir(bdir);
|
||||
rmdir(root);
|
||||
config_delete(config);
|
||||
}
|
||||
|
||||
void test_delete_plan(void) {
|
||||
test_delete_delay_refilled_dir_removed_recursively();
|
||||
test_delete_delay_removed_file_counted();
|
||||
test_delete_delay_max_delete_bounds_actual();
|
||||
test_delete_delay_actual_removal_charges_budget();
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
#ifndef TEST_DELETE_PLAN_H
|
||||
#define TEST_DELETE_PLAN_H
|
||||
|
||||
void test_delete_plan(void);
|
||||
|
||||
#endif
|
||||
@@ -86,43 +86,9 @@ static void test_dest_state_roundtrip() {
|
||||
close(fds[1]);
|
||||
}
|
||||
|
||||
static void test_stats_roundtrip() {
|
||||
/* STATUS_STATS grew from three counters (2.25.0) to eight (2.28.0); the codec
|
||||
* must carry every field, including the receiver-observed literal/created
|
||||
* counters, across the wire in order. */
|
||||
int fds[2];
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0)
|
||||
return;
|
||||
ReceiverStats out;
|
||||
memset(&out, 0, sizeof(out));
|
||||
out.matched_data = 111111111ULL;
|
||||
out.deleted_files = 7;
|
||||
out.would_delete_count = 3;
|
||||
out.literal_bytes = 222222222ULL;
|
||||
out.created_reg = 5;
|
||||
out.created_dir = 4;
|
||||
out.created_link = 2;
|
||||
out.created_special = 1;
|
||||
ReceiverStats in;
|
||||
memset(&in, 0, sizeof(in));
|
||||
EXPECT_TRUE(format_stats_send(fds[0], &out));
|
||||
EXPECT_TRUE(format_stats_receive(fds[1], &in));
|
||||
EXPECT_TRUE(in.matched_data == out.matched_data);
|
||||
EXPECT_TRUE(in.deleted_files == out.deleted_files);
|
||||
EXPECT_TRUE(in.would_delete_count == out.would_delete_count);
|
||||
EXPECT_TRUE(in.literal_bytes == out.literal_bytes);
|
||||
EXPECT_TRUE(in.created_reg == out.created_reg);
|
||||
EXPECT_TRUE(in.created_dir == out.created_dir);
|
||||
EXPECT_TRUE(in.created_link == out.created_link);
|
||||
EXPECT_TRUE(in.created_special == out.created_special);
|
||||
close(fds[0]);
|
||||
close(fds[1]);
|
||||
}
|
||||
|
||||
void test_format(void) {
|
||||
test_human_size_decimal();
|
||||
test_big_num_grouping();
|
||||
test_datetime_format();
|
||||
test_dest_state_roundtrip();
|
||||
test_stats_roundtrip();
|
||||
}
|
||||
|
||||
+3
-17
@@ -148,23 +148,10 @@ static void test_iconv_wire_sender_converts_local_to_remote() {
|
||||
charset_wire_free();
|
||||
}
|
||||
|
||||
/* rsync push parity: with no server --iconv the destination charset is the
|
||||
* client spec's REMOTE half, so the receiver writes the wire bytes verbatim. */
|
||||
static void test_iconv_wire_receiver_default_writes_remote() {
|
||||
static void test_iconv_wire_receiver_converts_remote_to_local() {
|
||||
EXPECT_TRUE(charset_wire_init_receiver("utf-8,iso-8859-1", NULL));
|
||||
char* local = charset_wire_apply("caf\xe9");
|
||||
EXPECT_NOT_NULL(local);
|
||||
EXPECT_EQ_INT(strcmp(local, "caf\xe9"), 0);
|
||||
free(local);
|
||||
charset_wire_free();
|
||||
}
|
||||
|
||||
/* A server that declares its own --iconv LOCAL converts wire(REMOTE) into that
|
||||
* declared charset (the daemon "charset" analog). */
|
||||
static void test_iconv_wire_receiver_server_local_override() {
|
||||
EXPECT_TRUE(charset_wire_init_receiver("utf-8,iso-8859-1", "utf-8"));
|
||||
char* local = charset_wire_apply("caf\xe9");
|
||||
EXPECT_NOT_NULL(local);
|
||||
EXPECT_EQ_INT(strcmp(local, "caf\xc3\xa9"), 0);
|
||||
free(local);
|
||||
charset_wire_free();
|
||||
@@ -192,7 +179,7 @@ static void test_iconv_wire_str_roundtrip() {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
charset_wire_free();
|
||||
charset_wire_init_receiver("utf-8,iso-8859-1", "utf-8");
|
||||
charset_wire_init_receiver("utf-8,iso-8859-1", NULL);
|
||||
char* got = receive_wire_str(p[0]);
|
||||
bool ok = got != NULL && strcmp(got, "caf\xc3\xa9") == 0;
|
||||
free(got);
|
||||
@@ -224,8 +211,7 @@ void test_iconv() {
|
||||
test_iconv_exact_fill_no_overflow();
|
||||
test_iconv_growth_expanding_name();
|
||||
test_iconv_wire_sender_converts_local_to_remote();
|
||||
test_iconv_wire_receiver_default_writes_remote();
|
||||
test_iconv_wire_receiver_server_local_override();
|
||||
test_iconv_wire_receiver_converts_remote_to_local();
|
||||
test_iconv_wire_disabled_passthrough();
|
||||
// This subtest forks to exercise the wire string handshake; the instrumented
|
||||
// parent is too slow under valgrind for the child's blocking reads.
|
||||
|
||||
@@ -103,62 +103,6 @@ static void test_sender_zero_capacity() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Regression (blocker): PipelineContextSender.delete_suppressed must be
|
||||
initialized false. A garbage true silently suppresses the --delete keep-set
|
||||
manifest under -m/--threads, so destination extras would never be removed. */
|
||||
static void test_sender_delete_suppressed_initialized() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
free(cfg->version);
|
||||
cfg->version = str_dup(PROTOCOL_VERSION);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup("/dst");
|
||||
|
||||
Queue* q1 = queue_create(5, NULL);
|
||||
Queue* q2 = queue_create(5, NULL);
|
||||
EXPECT_NOT_NULL(q1);
|
||||
EXPECT_NOT_NULL(q2);
|
||||
PipelineContextSender* ctx = pipeline_context_sender_create(cfg, q1, q2);
|
||||
EXPECT_NOT_NULL(ctx);
|
||||
EXPECT_FALSE(ctx->delete_suppressed);
|
||||
pipeline_context_sender_destroy(ctx);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --info=del (report_deletes) is the only reason the receiver retains the
|
||||
actually-removed paths: a plain --delete receiver must not allocate the list,
|
||||
and with report_deletes armed the observer records into it. */
|
||||
static void test_receiver_deleted_paths_gated_by_report_deletes() {
|
||||
Config* plain = config_create();
|
||||
EXPECT_NOT_NULL(plain);
|
||||
free(plain->version);
|
||||
plain->version = str_dup(PROTOCOL_VERSION);
|
||||
plain->receive_root_directory = str_dup("/dst");
|
||||
plain->report_deletes = false;
|
||||
Queue* q_plain = queue_create(5, file_destroy);
|
||||
EXPECT_NOT_NULL(q_plain);
|
||||
PipelineContextReceiver* ctx_plain = pipeline_context_receiver_create(plain, q_plain, -1, NULL);
|
||||
EXPECT_NOT_NULL(ctx_plain);
|
||||
EXPECT_NULL(ctx_plain->deleted_paths);
|
||||
pipeline_context_receiver_destroy(ctx_plain);
|
||||
|
||||
Config* info = config_create();
|
||||
EXPECT_NOT_NULL(info);
|
||||
free(info->version);
|
||||
info->version = str_dup(PROTOCOL_VERSION);
|
||||
info->receive_root_directory = str_dup("/dst");
|
||||
info->report_deletes = true;
|
||||
Queue* q_info = queue_create(5, file_destroy);
|
||||
EXPECT_NOT_NULL(q_info);
|
||||
PipelineContextReceiver* ctx_info = pipeline_context_receiver_create(info, q_info, -1, NULL);
|
||||
EXPECT_NOT_NULL(ctx_info);
|
||||
EXPECT_NOT_NULL(ctx_info->deleted_paths);
|
||||
receiver_record_deleted_path(ctx_info->deleted_paths, "d/old_extra");
|
||||
EXPECT_EQ_INT(ctx_info->deleted_paths->size, 1);
|
||||
EXPECT_EQ_STR((const char*)ctx_info->deleted_paths->items[0], "d/old_extra");
|
||||
pipeline_context_receiver_destroy(ctx_info);
|
||||
}
|
||||
|
||||
/* Test receiver with zero file_descriptor */
|
||||
static void test_receiver_fd_zero() {
|
||||
Config* cfg = config_create();
|
||||
@@ -300,7 +244,6 @@ static void test_write_thread_done() {
|
||||
* and queue_destroy which would double-free since we created them
|
||||
* in this test. Let me just free the context directly. */
|
||||
array_list_delete(ctx->would_delete);
|
||||
array_list_delete(ctx->deleted_paths);
|
||||
mtx_destroy(&ctx->mutex);
|
||||
cnd_destroy(&ctx->condition_not_full);
|
||||
cnd_destroy(&ctx->condition_not_empty);
|
||||
@@ -386,7 +329,6 @@ static void test_receiver_enqueue_byte_budget() {
|
||||
|
||||
/* Tear down: the second file is still queued and is freed by queue_destroy. */
|
||||
array_list_delete(ctx->would_delete);
|
||||
array_list_delete(ctx->deleted_paths);
|
||||
mtx_destroy(&ctx->mutex);
|
||||
cnd_destroy(&ctx->condition_not_full);
|
||||
cnd_destroy(&ctx->condition_not_empty);
|
||||
@@ -518,8 +460,6 @@ void test_multiprocessing() {
|
||||
test_receiver_create_destroy();
|
||||
test_sender_queue_capacities();
|
||||
test_sender_zero_capacity();
|
||||
test_sender_delete_suppressed_initialized();
|
||||
test_receiver_deleted_paths_gated_by_report_deletes();
|
||||
test_receiver_fd_zero();
|
||||
if (!is_running_under_valgrind()) {
|
||||
test_receive_thread_finished();
|
||||
|
||||
@@ -1240,55 +1240,6 @@ static int collect_scan_info_parallel(ParallelScanner* scanner, const char* root
|
||||
return failed ? -1 : count;
|
||||
}
|
||||
|
||||
/* The --progress paths-only pre-count relies on `list_dirs` emitting every
|
||||
* directory (including empty ones) exactly once, alongside the files and
|
||||
* symlinks the streaming scanner already emits. */
|
||||
static void test_scanner_list_dirs_counts_every_entry() {
|
||||
const char* root = "test_scan_listdirs";
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir("test_scan_listdirs/sub1", 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir("test_scan_listdirs/sub1/deep", 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir("test_scan_listdirs/sub2", 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir("test_scan_listdirs/emptydir", 0755), 0);
|
||||
create_test_file("test_scan_listdirs/a.txt", "a");
|
||||
create_test_file("test_scan_listdirs/sub1/c.txt", "c");
|
||||
create_test_file("test_scan_listdirs/sub1/deep/d.txt", "d");
|
||||
create_test_file("test_scan_listdirs/sub2/e.txt", "e");
|
||||
EXPECT_EQ_INT(symlink("a.txt", "test_scan_listdirs/link1"), 0);
|
||||
|
||||
ScannerOptions options = {0};
|
||||
options.list_dirs = true;
|
||||
options.emit_empty_dirs = true;
|
||||
options.follow_symlinks = true; /* -l/--links: carry symlinks, don't skip */
|
||||
ScanInfo infos[16];
|
||||
int count = collect_scan_info(root, &options, infos, 16);
|
||||
EXPECT_EQ_INT(count, 9);
|
||||
int dirs = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (infos[i].is_dir)
|
||||
dirs++;
|
||||
}
|
||||
EXPECT_EQ_INT(dirs, 4);
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub1", true, NULL));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub1/deep", true, NULL));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub2", true, NULL));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "emptydir", true, NULL));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "a.txt", false, ""));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub1/c.txt", false, ""));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "link1", false, ""));
|
||||
|
||||
unlink("test_scan_listdirs/a.txt");
|
||||
unlink("test_scan_listdirs/sub1/c.txt");
|
||||
unlink("test_scan_listdirs/sub1/deep/d.txt");
|
||||
unlink("test_scan_listdirs/sub2/e.txt");
|
||||
unlink("test_scan_listdirs/link1");
|
||||
rmdir("test_scan_listdirs/sub1/deep");
|
||||
rmdir("test_scan_listdirs/sub1");
|
||||
rmdir("test_scan_listdirs/sub2");
|
||||
rmdir("test_scan_listdirs/emptydir");
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* -d without --files-from emits exactly the source-root directory (empty) and
|
||||
* never descends. */
|
||||
static void test_dirs_no_descent() {
|
||||
@@ -1716,7 +1667,6 @@ void test_scanner() {
|
||||
test_per_dir_filter_override(true);
|
||||
test_dirs_no_descent();
|
||||
test_dirs_files_from();
|
||||
test_scanner_list_dirs_counts_every_entry();
|
||||
test_files_from_relative_send_path();
|
||||
test_scanner_captures_directory_times();
|
||||
test_scanner_chunk_ownership();
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
#include "config.h"
|
||||
#include "delta.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
#include "format.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
@@ -136,124 +134,6 @@ static void test_receive_files_single_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Protocol 2.28.0: a fresh single-file transfer over the wire reports the
|
||||
* receiver-observed literal bytes and the created-regular counter through the
|
||||
* terminal STATUS_STATS frame, and an update reports created_reg == 0. */
|
||||
static void test_receive_stats_frame_created_and_literal() {
|
||||
const char* content = "stats frame content";
|
||||
size_t len = strlen(content);
|
||||
char root_template[] = "/tmp/fastsync_stats_XXXXXX";
|
||||
char* root = mkdtemp(root_template);
|
||||
EXPECT_NOT_NULL(root);
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
free(cfg->version);
|
||||
cfg->version = str_dup(PROTOCOL_VERSION);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup(root);
|
||||
cfg->save_to_disk = true;
|
||||
cfg->report_stats = true;
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
int ret = receiver_receive_files(cfg, p[0]);
|
||||
close(p[0]);
|
||||
config_delete(cfg);
|
||||
_exit(ret == 0 ? 0 : 1);
|
||||
}
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
send_status(p[1], STATUS_NEXT);
|
||||
File* file = file_create("created.bin");
|
||||
EXPECT_NOT_NULL(file);
|
||||
file->data->data = malloc(len);
|
||||
EXPECT_NOT_NULL(file->data->data);
|
||||
memcpy(file->data->data, content, len);
|
||||
file->data->size = len;
|
||||
send_str(p[1], file->path);
|
||||
send_data(p[1], file->data);
|
||||
file_destroy(file);
|
||||
send_status(p[1], STATUS_FINISHED);
|
||||
|
||||
Status status;
|
||||
EXPECT_TRUE(receive_status(p[1], &status));
|
||||
EXPECT_EQ_INT(status, STATUS_STATS);
|
||||
ReceiverStats stats;
|
||||
EXPECT_TRUE(format_stats_receive(p[1], &stats));
|
||||
int would = 0;
|
||||
EXPECT_TRUE(receive_int(p[1], &would));
|
||||
EXPECT_EQ_INT(would, 0);
|
||||
EXPECT_TRUE(stats.created_reg == 1);
|
||||
EXPECT_TRUE(stats.created_dir == 0);
|
||||
EXPECT_TRUE(stats.created_link == 0);
|
||||
EXPECT_TRUE(stats.created_special == 0);
|
||||
EXPECT_TRUE(stats.literal_bytes == (unsigned long long)len);
|
||||
EXPECT_TRUE(stats.matched_data == 0);
|
||||
|
||||
Status final;
|
||||
EXPECT_TRUE(receive_status(p[1], &final));
|
||||
EXPECT_EQ_INT(final, STATUS_OK);
|
||||
int wstatus;
|
||||
waitpid(pid, &wstatus, 0);
|
||||
close(p[1]);
|
||||
config_delete(cfg);
|
||||
EXPECT_TRUE(WIFEXITED(wstatus) && WEXITSTATUS(wstatus) == 0);
|
||||
|
||||
/* Second run against the now-existing destination: no created file. */
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
free(cfg->version);
|
||||
cfg->version = str_dup(PROTOCOL_VERSION);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup(root);
|
||||
cfg->save_to_disk = true;
|
||||
cfg->report_stats = true;
|
||||
pid = fork();
|
||||
if (pid == 0) {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
int ret = receiver_receive_files(cfg, p[0]);
|
||||
close(p[0]);
|
||||
config_delete(cfg);
|
||||
_exit(ret == 0 ? 0 : 1);
|
||||
}
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
send_status(p[1], STATUS_NEXT);
|
||||
file = file_create("created.bin");
|
||||
EXPECT_NOT_NULL(file);
|
||||
file->data->data = malloc(len);
|
||||
EXPECT_NOT_NULL(file->data->data);
|
||||
memcpy(file->data->data, content, len);
|
||||
file->data->size = len;
|
||||
send_str(p[1], file->path);
|
||||
send_data(p[1], file->data);
|
||||
file_destroy(file);
|
||||
send_status(p[1], STATUS_FINISHED);
|
||||
EXPECT_TRUE(receive_status(p[1], &status));
|
||||
EXPECT_EQ_INT(status, STATUS_STATS);
|
||||
EXPECT_TRUE(format_stats_receive(p[1], &stats));
|
||||
EXPECT_TRUE(receive_int(p[1], &would));
|
||||
EXPECT_TRUE(stats.created_reg == 0);
|
||||
EXPECT_TRUE(stats.literal_bytes == (unsigned long long)len);
|
||||
EXPECT_TRUE(receive_status(p[1], &final));
|
||||
waitpid(pid, &wstatus, 0);
|
||||
close(p[1]);
|
||||
config_delete(cfg);
|
||||
EXPECT_TRUE(WIFEXITED(wstatus) && WEXITSTATUS(wstatus) == 0);
|
||||
}
|
||||
|
||||
/* Test receive_files with STATUS_ABORT */
|
||||
static void test_receive_files_abort() {
|
||||
Config* cfg = config_create();
|
||||
@@ -1303,7 +1183,6 @@ void test_server() {
|
||||
if (!is_running_under_valgrind()) {
|
||||
test_receive_files_finished();
|
||||
test_receive_files_single_file();
|
||||
test_receive_stats_frame_created_and_literal();
|
||||
test_receive_files_abort();
|
||||
test_receive_manifest_rejects_traversal();
|
||||
test_receive_incremental_check_rejects_invalid_nanoseconds();
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <threads.h>
|
||||
@@ -579,43 +578,7 @@ static void test_getdelim_bounded() {
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
static int test_env_resolver(const char* name) {
|
||||
if (strcasecmp(name, "alpha") == 0)
|
||||
return 10;
|
||||
if (strcasecmp(name, "beta") == 0)
|
||||
return 20;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void test_env_choice_first_parsing() {
|
||||
const char* var = "FASTSYNC_TEST_CHOICE_LIST";
|
||||
bool specified = true;
|
||||
unsetenv(var);
|
||||
EXPECT_EQ_INT(env_choice_first(var, test_env_resolver, &specified), -1);
|
||||
EXPECT_FALSE(specified);
|
||||
|
||||
/* Unknown entries are skipped, case-insensitive, first supported wins. */
|
||||
setenv(var, "bogus BETA alpha", 1);
|
||||
EXPECT_EQ_INT(env_choice_first(var, test_env_resolver, &specified), 20);
|
||||
EXPECT_TRUE(specified);
|
||||
|
||||
/* The client half ends at '&'. */
|
||||
setenv(var, "alpha & beta", 1);
|
||||
EXPECT_EQ_INT(env_choice_first(var, test_env_resolver, &specified), 10);
|
||||
|
||||
/* Blank means "unspecified"; all-unknown means "specified but no match". */
|
||||
setenv(var, " ", 1);
|
||||
EXPECT_EQ_INT(env_choice_first(var, test_env_resolver, &specified), -1);
|
||||
EXPECT_FALSE(specified);
|
||||
setenv(var, "nope,alpha", 1);
|
||||
EXPECT_EQ_INT(env_choice_first(var, test_env_resolver, &specified), -1);
|
||||
EXPECT_TRUE(specified);
|
||||
|
||||
unsetenv(var);
|
||||
}
|
||||
|
||||
void test_shared_utils() {
|
||||
test_env_choice_first_parsing();
|
||||
test_path_index_bounded();
|
||||
test_path_index_semantics();
|
||||
test_getdelim_bounded();
|
||||
|
||||
Reference in New Issue
Block a user