Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef76c9034d | ||
|
|
cee281ff55 | ||
|
|
5c509831b8 | ||
|
|
ee57aeea4a | ||
|
|
803c1d3385 | ||
|
|
b8ec62beef | ||
|
|
59bfd32b9e | ||
|
|
3432a33d9a | ||
|
|
a1b081d328 | ||
|
|
bbecff9c04 | ||
|
|
c1553bd5d6 | ||
|
|
1a550bda24 | ||
|
|
902f86192d | ||
|
|
6a40ac86e5 | ||
|
|
410ba6e992 | ||
|
|
6c6f02e5dd | ||
|
|
d9006d1fda | ||
|
|
36375010d3 | ||
|
|
5efa0dba7c | ||
|
|
e32733fbf6 | ||
|
|
b02799327d | ||
|
|
946aa934cc | ||
|
|
125921c11b | ||
|
|
9dd5288381 | ||
|
|
3f2c74dd9e | ||
|
|
51e41dee2a | ||
|
|
dbf1b39d47 | ||
|
|
845f20a28d | ||
|
|
a5083776da | ||
|
|
76a81f1684 | ||
|
|
24b81c7e5a | ||
|
|
0e33f84f38 | ||
|
|
c7ac039523 | ||
|
|
e771cc9da6 | ||
|
|
7f9f82a068 | ||
|
|
695b5c8c25 | ||
|
|
5b2188d909 | ||
|
|
e5da916d54 | ||
|
|
de640bba1b | ||
|
|
f0f5719be0 | ||
|
|
6200b298ac | ||
|
|
394a9aae22 | ||
|
|
12d4af1b89 | ||
|
|
9d7c55d3c0 | ||
|
|
5a104bfd88 | ||
|
|
2ada8f9ad5 | ||
|
|
1493f1806d | ||
|
|
ea28e25535 | ||
|
|
d6295d62ce | ||
|
|
a9f416ce44 | ||
|
|
448edc0432 | ||
|
|
4a7703b06a | ||
|
|
6fc297544e | ||
|
|
583d3c8edb | ||
|
|
9883757190 | ||
|
|
a690109975 | ||
|
|
36d4d0e43e | ||
|
|
a0b9d9794b | ||
|
|
3e9f70d9ba | ||
|
|
2b5aaef409 | ||
|
|
478f80be9f | ||
|
|
e674b25213 |
No files matched your search
@@ -55,6 +55,16 @@ if(NOT ZSTD_LIBRARY)
|
||||
message(FATAL_ERROR "zstd library not found. Ensure it is in your nix-shell!")
|
||||
endif()
|
||||
|
||||
find_library(ZLIB_LIBRARY z)
|
||||
if(NOT ZLIB_LIBRARY)
|
||||
message(FATAL_ERROR "zlib library not found. Ensure zlib1g-dev / nix zlib is available!")
|
||||
endif()
|
||||
|
||||
find_library(LZ4_LIBRARY lz4)
|
||||
if(NOT LZ4_LIBRARY)
|
||||
message(FATAL_ERROR "lz4 library not found. Ensure liblz4-dev / nix lz4 is available!")
|
||||
endif()
|
||||
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
file(GLOB SHARED_SRCS "src/shared/*.c")
|
||||
@@ -64,15 +74,15 @@ file(GLOB TEST_SRCS "tests/*.c")
|
||||
|
||||
add_executable(server ${SERVER_SRCS} ${SHARED_SRCS})
|
||||
target_include_directories(server PRIVATE src/shared src/server src/client)
|
||||
target_link_libraries(server PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
target_link_libraries(server PRIVATE Threads::Threads ${ZSTD_LIBRARY} ${ZLIB_LIBRARY} ${LZ4_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
|
||||
add_executable(client ${CLIENT_SRCS} ${SHARED_SRCS})
|
||||
target_include_directories(client PRIVATE src/shared src/server src/client)
|
||||
target_link_libraries(client PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
target_link_libraries(client PRIVATE Threads::Threads ${ZSTD_LIBRARY} ${ZLIB_LIBRARY} ${LZ4_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
|
||||
add_executable(tests ${TEST_SRCS} ${SHARED_SRCS} src/client/scanner.c)
|
||||
target_include_directories(tests PRIVATE tests src/shared src/server src/client)
|
||||
target_link_libraries(tests PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
target_link_libraries(tests PRIVATE Threads::Threads ${ZSTD_LIBRARY} ${ZLIB_LIBRARY} ${LZ4_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
```
|
||||
|
||||
### Source Layout
|
||||
@@ -85,17 +95,23 @@ tests/integration/ — Python pytest integration tests
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
- **zstd** — found via `find_library(ZSTD_LIBRARY zstd)`
|
||||
- **zstd** — found via `find_library(ZSTD_LIBRARY zstd)` (default compression codec)
|
||||
- **zlib** — found via `find_library(ZLIB_LIBRARY z)` (the `zlib`/`zlibx` codecs)
|
||||
- **lz4** — found via `find_library(LZ4_LIBRARY lz4)` (the `lz4` codec)
|
||||
- **OpenSSL** — found via `find_package(OpenSSL REQUIRED)` (TLS 1.2+ transport)
|
||||
- **xxHash** — fetched via `FetchContent` from the upstream repository (delta transfer hashing, v0.8.3)
|
||||
- **pthreads** — found via `find_package(Threads REQUIRED)`
|
||||
- **C11 standard** — required
|
||||
- **CMake 3.22+** — minimum version
|
||||
|
||||
The codec matrix (protocol 2.26.0) uses zstd/zlib/lz4 for compression and
|
||||
xxHash/OpenSSL for the `xxh128`/`xxh3`/`xxh64`/`md5`/`md4`/`sha1` checksums
|
||||
(`none` needs no library); both codec families are negotiated per transfer.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Use `file(GLOB ...)` for source collection (existing pattern).
|
||||
- All targets link `Threads::Threads`, `${ZSTD_LIBRARY}`, `OpenSSL::SSL`, `OpenSSL::Crypto`, and `xxhash`.
|
||||
- All targets link `Threads::Threads`, `${ZSTD_LIBRARY}`, `${ZLIB_LIBRARY}`, `${LZ4_LIBRARY}`, `OpenSSL::SSL`, `OpenSSL::Crypto`, and `xxhash`.
|
||||
- Include directories: `src/shared`, `src/server`, `src/client`, `tests` (for test target).
|
||||
- Sanitizer support: pass `-DSANITIZER=address`, `-DSANITIZER=thread`, or `-DSANITIZER=undefined` to cmake (live option in CMakeLists.txt).
|
||||
- Build with `cmake -B build -S . && cmake --build build -j$(nproc)`.
|
||||
|
||||
@@ -16,7 +16,7 @@ Ask the user or determine from context:
|
||||
- **Minor** (x.Y.0) — new features, backward compatible
|
||||
- **Patch** (x.y.Z) — bug fixes, no protocol changes
|
||||
|
||||
Current version: `PROTOCOL_VERSION "2.23.0"` in `src/shared/config.h`
|
||||
Current version: `PROTOCOL_VERSION "2.26.0"` in `src/shared/config.h`
|
||||
|
||||
### Step 2: Check Protocol Version
|
||||
|
||||
|
||||
@@ -4,6 +4,73 @@ All notable changes to FastSync are documented here. Versions match
|
||||
`PROTOCOL_VERSION` (printed by `fastsync --version`); the client and server must
|
||||
run the same version because the handshake is strict.
|
||||
|
||||
## [2.26.0] - 2026-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Parity-completion wave.** Closed the remaining rsync-parity gaps against
|
||||
rsync 3.4.1 and reclassified the inherently non-rsync rows. It moved the wire
|
||||
protocol three times (`2.23.0 → 2.24.0 → 2.25.0 → 2.26.0`).
|
||||
- **Delete timing (2.24.0):** per-directory delete plans
|
||||
(`STATUS_DELETE_PLAN`) for `--delete-during`/`--delete-delay`. An interrupted
|
||||
during-transfer has already removed the reached directories' extras, while a
|
||||
delayed transfer commits per directory only after the whole transfer
|
||||
succeeds (a late-created extra survives `--delete-delay` but not
|
||||
`--delete-after`). `-R --delete` is scoped to the transferred prefix; empty
|
||||
in-scope source directories survive; dry-run never deletes.
|
||||
- **Wire stats (2.25.0):** `STATUS_STATS` carries the receiver counters
|
||||
(matched data, deleted files) and the dry-run would-delete list. `--stats`
|
||||
prints rsync's protocol-independent lines; `--progress`/`-P` print per-file
|
||||
blocks; `--out-format` gains `%b` (wire bytes), `%c` (block-sum bytes) and
|
||||
`%C` (whole-file digest); `-n --delete` prints escaped `*deleting` lines in
|
||||
the sequential and `--threads` paths.
|
||||
- **Codecs (2.26.0):** `lz4`/`zlib`/`zlibx` compression and `md4`/`sha1`/
|
||||
`none` checksums, with rsync-style `auto` negotiation (default `xxh128` +
|
||||
`zstd`) and exit-4 rejection of unknown names; the resolved `compression_algo`
|
||||
crosses the wire.
|
||||
- General `-R`/`--relative` (including the `/./` cut) and `--no-implied-dirs`;
|
||||
one-level `-d`/`--dirs` listing for `dir`, `dir/` and `.`; the full filter
|
||||
grammar (`merge`/`dir-merge`/`hide`/`show`/`protect`/`risk`/`clear` and
|
||||
modifiers) with `-f` bound to `--filter`; a single `-F` transfers
|
||||
`.rsync-filter` and `-FF` excludes it.
|
||||
- Receiver-side `--chown`/`--usermap`/`--groupmap` TO-name resolution; absolute
|
||||
basis directories and a `--link-dest` relink of an up-to-date destination;
|
||||
a receiver-side `--ignore-existing` short-circuit before any payload;
|
||||
`--preallocate` now wins over `--sparse` via `fallocate(2)`.
|
||||
- Client quick wins: `--iconv=.`/`-`/`--no-iconv`, a lone `-h` prints help, an
|
||||
empty `--files-from` succeeds (exit 0), a broken referent under
|
||||
`-L`/`--copy-unsafe-links` exits 23, the full `--info`/`--debug`
|
||||
vocabularies, and the aliases `--ignore-non-existing`, `--protect-args`,
|
||||
`--msgs2stderr`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `PROTOCOL_VERSION` bumped `2.23.0 → 2.24.0` (delete plans),
|
||||
`2.24.0 → 2.25.0` (`STATUS_STATS` + `report_stats`), and
|
||||
`2.25.0 → 2.26.0` (codec negotiation + `md4`/`sha1`/`none`).
|
||||
- `--checksum-choice`/`--cc` now accepts `md4`, `sha1`, `none` and the two-name
|
||||
form; the negotiated whole-file default is `xxh128`.
|
||||
- `--compress-choice`/`--zc` now accepts `lz4`, `zlib`, `zlibx`.
|
||||
- `RSYNC_COMPAT.md` reclassifies the matrix: 9 already-parity rows to ✅, 17
|
||||
inherently non-rsync rows to ❌ (native daemon config/auth, batch, privileged
|
||||
xattr namespaces, and the safe-subset device/privilege flags), and the genuine
|
||||
fixes to ✅; new rows cover `--bwlimit`, `--partial`, `--partial-dir`,
|
||||
`--no-whole-file`, `--inc-recursive`/`--no-inc-recursive`, `--protect-args`
|
||||
and `--msgs2stderr`.
|
||||
- The client `--help` `--max-delete` text now describes the implemented partial
|
||||
semantics (delete up to N, skip the rest, exit 25).
|
||||
|
||||
### Notes
|
||||
|
||||
- Remaining documented divergences include the `--stats` per-type file-count
|
||||
breakdown, `%b`/`%c` being FastSync wire counts, `-n --delete` line ordering,
|
||||
the default `--delete` timing (delete-after, not rsync's delete-during),
|
||||
destination-only exclude protection (still sender-derived), `--temp-dir`
|
||||
absolute paths, basis-dir attribute re-application and the 256 MiB whole-file
|
||||
cap, `--fuzzy` tie-breaking, `--bwlimit=0`/decimal rates, `zlibx`==`zlib`, and
|
||||
recursive empty-directory creation.
|
||||
- Build: adds zlib and lz4 as link dependencies.
|
||||
|
||||
## [2.23.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
|
||||
+16
-5
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
project(FastFileTransfer VERSION 2.23.0)
|
||||
project(FastFileTransfer VERSION 2.26.0)
|
||||
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
@@ -68,6 +68,16 @@ if(NOT ZSTD_LIBRARY)
|
||||
message(FATAL_ERROR "zstd library not found. Ensure it is in your nix-shell!")
|
||||
endif()
|
||||
|
||||
find_library(ZLIB_LIBRARY z)
|
||||
if(NOT ZLIB_LIBRARY)
|
||||
message(FATAL_ERROR "zlib library not found. Ensure zlib1g-dev / nix zlib is available!")
|
||||
endif()
|
||||
|
||||
find_library(LZ4_LIBRARY lz4)
|
||||
if(NOT LZ4_LIBRARY)
|
||||
message(FATAL_ERROR "lz4 library not found. Ensure liblz4-dev / nix lz4 is available!")
|
||||
endif()
|
||||
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
# --- Explicit source lists ---
|
||||
@@ -89,6 +99,7 @@ set(SHARED_SRCS
|
||||
src/shared/daemon_limits.c
|
||||
src/shared/data.c
|
||||
src/shared/delay_updates.c
|
||||
src/shared/delete_plan.c
|
||||
src/shared/delta.c
|
||||
src/shared/file.c
|
||||
src/shared/file_list.c
|
||||
@@ -135,8 +146,8 @@ set(CLIENT_MAIN_SRCS src/client/client_cli.c)
|
||||
# --- Library targets ---
|
||||
add_library(fastsync_shared STATIC ${SHARED_SRCS})
|
||||
target_include_directories(fastsync_shared PUBLIC src/shared)
|
||||
target_link_libraries(fastsync_shared PUBLIC Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL
|
||||
OpenSSL::Crypto xxhash)
|
||||
target_link_libraries(fastsync_shared PUBLIC Threads::Threads ${ZSTD_LIBRARY} ${ZLIB_LIBRARY}
|
||||
${LZ4_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
|
||||
add_library(fastsync_client_core STATIC ${CLIENT_CORE_SRCS})
|
||||
target_include_directories(fastsync_client_core PUBLIC src/client)
|
||||
@@ -275,7 +286,7 @@ if(ENABLE_FUZZ)
|
||||
target_include_directories(${FUZZ_NAME} PRIVATE tests src/shared src/server)
|
||||
target_compile_options(${FUZZ_NAME} PRIVATE -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer)
|
||||
target_link_options(${FUZZ_NAME} PRIVATE -fsanitize=fuzzer,address,undefined)
|
||||
target_link_libraries(${FUZZ_NAME} PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL
|
||||
OpenSSL::Crypto xxhash)
|
||||
target_link_libraries(${FUZZ_NAME} PRIVATE Threads::Threads ${ZSTD_LIBRARY} ${ZLIB_LIBRARY}
|
||||
${LZ4_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
endforeach()
|
||||
endif()
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
# FastSync — Session Handoff (2026-09-14)
|
||||
# FastSync — Session Handoff (2026-09-17)
|
||||
|
||||
## Current status
|
||||
- **Release `v2.21.0`** tagged (`919a729`, "Release v2.21.0"); full CI green
|
||||
@@ -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.23.0"`** (`src/shared/config.h`); CMake
|
||||
`project(FastFileTransfer VERSION 2.23.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,6 +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 ❌.
|
||||
|
||||
## Next steps
|
||||
1. **Merge PR #284** (`dev` -> `main`) once reviewed (protected branch).
|
||||
|
||||
@@ -28,7 +28,7 @@ FastSync uses a producer-consumer transfer pipeline and can combine several
|
||||
optimizations for large or high-latency transfers:
|
||||
|
||||
- Multithreaded scanning, loading, and sending.
|
||||
- Streaming zstd compression with levels 1 through 22.
|
||||
- Streaming compression (zstd by default, plus lz4/zlib/zlibx) with levels 1 through 22.
|
||||
- Configurable file chunking and compact chunk serialization.
|
||||
- `sendfile()` zero-copy transfers over TCP.
|
||||
- Batched incremental checks to reduce round trips.
|
||||
@@ -54,7 +54,8 @@ replacement for every rsync feature or protocol mode.
|
||||
- Dry runs (server-contacting since protocol 2.21.0 for server-routed targets),
|
||||
excludes, includes, size filters, backups, statistics, and bandwidth
|
||||
limiting.
|
||||
- Incremental size/mtime checks and optional xxHash64 content checks.
|
||||
- Incremental size/mtime checks and optional content checks (`xxh128` by
|
||||
default, selectable with `--checksum-choice`).
|
||||
- FastSync-native delta transfer for changed files.
|
||||
- Optional mode and timestamp preservation.
|
||||
- Delete manifests with server-side delete authorization.
|
||||
@@ -111,9 +112,14 @@ matrix is classified as parity, caveat, or divergent in
|
||||
- Short-option clustering (`-av`, `-aAX`, `-rlpt`) and attached values
|
||||
(`-B1000`, `-essh`, `-MOPT`, `--opt=value`) are accepted, matching rsync.
|
||||
- `-r`, `-b`, `-L`, and `-B` are parsed with the rsync short names.
|
||||
- `--stats` prints the counters FastSync can observe locally; receiver-only
|
||||
counters (matched data, file-list bytes, deleted count) are reported as 0, and
|
||||
`--progress` is an aggregate line rather than a per-file block.
|
||||
- `--stats` prints the counters FastSync can observe plus the receiver-only
|
||||
counters (`Matched data`, deleted files) reported over the wire; rsync's
|
||||
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, 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**,
|
||||
@@ -137,16 +143,16 @@ This produces `./build/client` and `./build/server`. `compile_commands.json` is
|
||||
|----------|-------------|
|
||||
| Positional | `<source> <dest>` — automatic SSH detection if dest contains `:` |
|
||||
| `-c, --checksum` | Verify content by checksum instead of size+mtime (implies the incremental checksum quick-check) |
|
||||
| `--checksum-choice <alg>` | Whole-file checksum algorithm: `xxh64`/`xxhash` (default), `xxh3`, `xxh128`, `md5`, or `auto`; `md4`/`sha1`/`none` are rejected by name |
|
||||
| `-z, --compress [level]` | Enable streaming zstd compression (level 1–22, default 5) |
|
||||
| `--compress-choice <alg>` | Compression algorithm: `zstd` (default), `none`, or `auto`; `lz4`/`zlib`/`zlibx` are rejected by name |
|
||||
| `--checksum-choice <alg>` | Whole-file checksum algorithm: `xxh128` (default), `xxh3`, `xxh64`/`xxhash`, `md5`, `md4`, `sha1`, `none`, or `auto` (plus rsync's two-name `transfer,pre-transfer` form) |
|
||||
| `-z, --compress [level]` | Enable streaming compression (default `zstd`; level 1–22, default 5) |
|
||||
| `--compress-choice <alg>` | Compression algorithm: `zstd` (default), `lz4`, `zlib`, `zlibx`, `none`, or `auto` |
|
||||
| `--skip-compress <list>` | Skip compression for suffixes (`/`- or `,`-separated); defaults to rsync 3.4.1's built-in suffix list |
|
||||
| `-a, --archive` | rsync archive mode (`-rlptgoD`): links, perms, times, owner, group, devices and specials; ownership application stays privilege-gated (not compression/multithreading) |
|
||||
| `-j, --threads[=N]` | Multithreading mode; `N` (1–256) sets the parallel scanner worker count, bare `-j`/`--threads` uses the default |
|
||||
| `-m` | rsync `--prune-empty-dirs` (short form now rsync-parity) |
|
||||
| `-r, --recursive` | Recurse into directories (FastSync is always recursive; accepted for rsync compatibility) |
|
||||
| `-d, --dirs` | Transfer the named directory entries without recursing into their contents; aliases `--old-dirs`/`--old-d` |
|
||||
| `-R, --relative` | With `--files-from`, preserve each listed entry's relative path below the destination root |
|
||||
| `-R, --relative` | Use rsync's relative path semantics (including the `/./` cut); with `--files-from`, preserve each listed entry's relative path below the destination root |
|
||||
| `--chunk-serialization` | Chunk serialization (batch all files per chunk; long form only) |
|
||||
| `-s` | rsync `--secluded-args` compatibility no-op (remote SSH argv is already injection-safe) |
|
||||
| `--sendfile` | Sendfile zero-copy. Incompatible with compression / chunk serialization. TCP only. Long form only. |
|
||||
@@ -208,9 +214,9 @@ This produces `./build/client` and `./build/server`. `compile_commands.json` is
|
||||
| `-n, --dry-run` | Report what would be transferred without mutating the destination. Since protocol 2.21.0 a server-routed target contacts the receiver and reports would-transfer based on receiver state; a plain local destination keeps the client-side scan. Never mutates or deletes. |
|
||||
| `-v, --verbose` | Enable debug logging |
|
||||
| `-q, --quiet` | Suppress non-error output |
|
||||
| `--progress` | Show a periodic aggregate transfer line (bytes sent, current rate); not rsync's per-file progress block |
|
||||
| `--progress` | Show rsync-style per-file progress blocks from the receiver's wire counters (FastSync does not print rsync's leading `./` line) |
|
||||
| `-P` | Enables partial-transfer mode + progress output; interrupted writes retain the already-written temp for resumption |
|
||||
| `--stats` | Print transfer statistics at end (bytes, files, timing). Receiver-only counters (matched data, file-list bytes, deleted count) are reported as 0 |
|
||||
| `--stats` | Print transfer statistics at end (bytes, files, timing), including the receiver-only counters reported over the wire; rsync's per-type `Number of files` breakdown is not reproduced |
|
||||
| `-i, --itemize-changes` | Print an rsync-style per-file change line |
|
||||
| `--out-format=FORMAT` | Output format for changed files (`%f %n %l %b %M %%`) |
|
||||
| `--list-only` | List source files instead of transferring |
|
||||
@@ -315,10 +321,10 @@ transfer is never aborted.
|
||||
4. **Network protocol** — status-code-driven exchange with metadata packing,
|
||||
keep-alive, and abort support.
|
||||
5. **Incremental check** — the client sends `STATUS_CHECK` + path + size +
|
||||
mtime and, with `--checksum`, a whole-file content checksum (xxHash64 by
|
||||
default, or md5 via `--checksum-choice=md5`/`--cc`, seeded by
|
||||
`--checksum-seed`); the server compares against the destination. Can be
|
||||
batched via `STATUS_CHECK_BATCH` for reduced round-trips.
|
||||
mtime and, with `--checksum`, a whole-file content checksum (`xxh128` by
|
||||
default; selectable via `--checksum-choice`/`--cc`, seeded by
|
||||
`--checksum-seed`); the server compares against the destination. Can be
|
||||
batched via `STATUS_CHECK_BATCH` for reduced round-trips.
|
||||
6. **Bandwidth limiting** — token-bucket algorithm with sleep throttling on
|
||||
64 KiB write chunks.
|
||||
7. **Metadata restoration** — mode via `chmod()`/`fchmod()`, times via
|
||||
@@ -498,9 +504,9 @@ features without changing the meaning of ordinary compatibility options.
|
||||
| Option | Purpose |
|
||||
|---|---|
|
||||
| `-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 zstd compression, levels 1-22. |
|
||||
| `--compress-level <n>` | Set the zstd compression level. |
|
||||
| `--zc <alg>` | Alias for `--compress-choice`. FastSync supports `zstd`, `none`, and `auto`; `lz4`/`zlib`/`zlibx` are rejected by name. |
|
||||
| `-z [level]`, `--compress [level]` | Enable streaming compression (default `zstd`), levels 1-22. |
|
||||
| `--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. |
|
||||
@@ -514,8 +520,8 @@ features without changing the meaning of ordinary compatibility options.
|
||||
| `--server-port <port>` | Select the TCP server port (`--port <port>` and `--port=<port>` are rsync-friendly aliases). |
|
||||
| `--tls` | Enable TLS for TCP transport. |
|
||||
| `--bwlimit <KB/s>` | Apply token-bucket bandwidth limiting. |
|
||||
| `--progress` | Show a periodic aggregate transfer line (throughput; not a per-file block). |
|
||||
| `--stats` | Print transfer statistics (receiver-only counters are 0). |
|
||||
| `--progress` | Show rsync-style per-file progress blocks from the receiver's wire counters (FastSync omits rsync's leading `./` line). |
|
||||
| `--stats` | Print transfer statistics, including the receiver-only counters reported over the wire; rsync's per-type `Number of files` breakdown is not reproduced. |
|
||||
| `--timeout <seconds>` | Set the socket **and** per-message protocol I/O timeout. Default `0` = disabled (matching rsync); `0` disables it. |
|
||||
| `--contimeout <seconds>` | Connection timeout (default 60, matching rsync); `0` disables it. |
|
||||
|
||||
@@ -552,7 +558,7 @@ remote SSH argv is already built injection-safe.
|
||||
| `-W, --whole-file` | Transfer changed files without delta processing (`--no-whole-file` clears it). |
|
||||
| `-B <n>, --block-size <n>` | Delta block size in bytes (alias `--delta-block`). |
|
||||
| `-d, --dirs` | Transfer the named directory entries without recursing into their contents (aliases `--old-dirs`/`--old-d`). |
|
||||
| `-R, --relative` | With `--files-from`, preserve each listed entry's relative path below the destination root. |
|
||||
| `-R, --relative` | Use rsync's relative path semantics (including the `/./` cut); with `--files-from`, preserve each listed entry's relative path below the destination root. |
|
||||
| `--files-from <file>` | Read the source file list from FILE (paths relative to the source root). |
|
||||
| `--delay-updates` | Put updated files into place only at the end of the transfer. |
|
||||
| `--compare-dest <dir>` | Extra comparison basis: unchanged files are not transferred (requires/implies `--incremental`). |
|
||||
@@ -573,6 +579,7 @@ remote SSH argv is already built injection-safe.
|
||||
| `--include <pattern>` | Include matching paths. Repeatable. |
|
||||
| `--exclude-from <file>` | Read exclude patterns from a file. |
|
||||
| `--include-from <file>` | Read include patterns from a file. |
|
||||
| `-f, --filter=RULE` | Add an rsync-style filter rule (`+`/`-`, `include`/`exclude`, `merge`/`.`, `dir-merge`/`:`, `hide`/`H`, `show`/`S`, `protect`/`P`, `risk`/`R`, `clear`/`!`, and modifiers; repeatable). |
|
||||
| `--max-size <bytes>` | Skip files larger than the limit. |
|
||||
| `--min-size <bytes>` | Skip files smaller than the limit. |
|
||||
| `--max-alloc <SIZE>` | Maximum single allocation (binary units; default 1G; `0` = no local limit). |
|
||||
@@ -616,7 +623,7 @@ remote SSH argv is already built injection-safe.
|
||||
| `--super` | Permit the receiver to attempt confined super-user activities (device nodes). |
|
||||
| `--no-super` | Forbid those super-user activities even when the receiver is root. |
|
||||
| `-l`, `--links` | Copy symlinks as symlinks; the target is stored verbatim (absolute and `..`-bearing targets included), matching rsync. |
|
||||
| `-L`, `--copy-links` | Copy symlink referents (a broken referent exits 0). |
|
||||
| `-L`, `--copy-links` | Copy symlink referents (a broken referent makes the run exit 23, matching rsync). |
|
||||
| `--safe-links` | Skip symlinks whose target points outside the transfer tree (applied on the sender). |
|
||||
| `--copy-unsafe-links` | Copy unsafe symlink referents. |
|
||||
| `--munge-links` | Rewrite stored symlink targets with rsync's `/rsyncd-munged/` marker. |
|
||||
@@ -634,8 +641,8 @@ remote SSH argv is already built injection-safe.
|
||||
|---|---|
|
||||
| `-v`, `--verbose` | Enable debug logging. |
|
||||
| `-q`, `--quiet` | Suppress non-error output. |
|
||||
| `--progress` | Show a periodic aggregate transfer line (not a per-file block). |
|
||||
| `--stats` | Print transfer statistics (receiver-only counters are reported as 0). |
|
||||
| `--progress` | Show rsync-style per-file progress blocks (not rsync's leading `./` line). |
|
||||
| `--stats` | Print transfer statistics, including the receiver-only counters reported over the wire. |
|
||||
| `-i`, `--itemize-changes` | Print an rsync-style per-file change line. |
|
||||
| `--out-format=FORMAT` | Output format for changed files (`%f %n %l %b %M %%`). |
|
||||
| `--list-only` | List source files instead of transferring. |
|
||||
@@ -782,7 +789,7 @@ before the module list, before authentication, and the connecting peer address
|
||||
|
||||
## Protocol and Security
|
||||
|
||||
FastSync protocol version `2.23.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
|
||||
@@ -851,14 +858,17 @@ The project will reach the drop-in replacement goal in stages:
|
||||
`-L`/`-B`, short-option clustering (`-av`, `-aAX`, `-rlpt`), and attached
|
||||
values (`-B1000`, `-essh`, `-MOPT`) all parse.
|
||||
2. Add differential tests that compare FastSync and rsync contents, metadata,
|
||||
links, deletes, filters, dry runs, and exit codes.
|
||||
links, deletes, filters, dry runs, and exit codes — **done** for the
|
||||
completion wave's scope; the tests live in `tests/integration/` and skip
|
||||
cleanly when rsync is unavailable.
|
||||
3. `-a` implements full rsync `-rlptgoD`; under `-p` the source mode is copied
|
||||
exactly (no masking). Ownership application stays privilege-gated, as in
|
||||
rsync.
|
||||
4. Symlink (verbatim storage), sparse-file, metadata, delete-policy (including
|
||||
`--max-delete` partial + exit 25), and resumable-write semantics are
|
||||
implemented; remaining work is the documented edge cases, which the
|
||||
**Rsync-Parity Wave** section of `RSYNC_COMPAT.md` enumerates honestly.
|
||||
`--max-delete` partial + exit 25, per-directory `--delete-during`/
|
||||
`--delete-delay`), codecs, and resumable-write semantics are implemented;
|
||||
remaining work is the documented edge cases, which the **Parity Completion
|
||||
Wave** section of `RSYNC_COMPAT.md` enumerates honestly.
|
||||
5. Add rsync remote-shell and daemon protocol interoperability.
|
||||
6. Keep FastSync performance options as negotiated, optional extensions.
|
||||
|
||||
|
||||
+233
-89
@@ -6,34 +6,39 @@ This document maps rsync's full feature set to FastSync's current implementation
|
||||
|
||||
| Status | Count | Description |
|
||||
|--------|-------|-------------|
|
||||
| ✅ Parity | 83 | Reproduces rsync's semantics for this option's scope |
|
||||
| ⚠️ Caveat | 63 | Fully wired and tested, but carries a documented behavioral difference from rsync (named in the row and/or the wave notes) |
|
||||
| ❌ Divergent | 4 | Rejected, an accepted no-op, or impossible on any portable filesystem call |
|
||||
| **Total** | **150** | 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
|
||||
tested but diverges in at least one documented way — FastSync's push-only model,
|
||||
its own wire protocol, the delete timings that approximate rsync's engine modes,
|
||||
the safe-subset privilege model (`--super`/`--copy-as`), the stricter
|
||||
xattr/ACL and temp-dir policies, and the output counters that rsync computes on
|
||||
the generator side. An ❌ row is either rejected (`--stderr=client`, `--protocol`
|
||||
with any value but the current one), an accepted no-op (`-s`/`--secluded-args`),
|
||||
or impossible (`-N`/`--crtimes`). The counts are derived from the rows below;
|
||||
update them together with the table.
|
||||
tested but diverges in at least one documented way. An ❌ row is either
|
||||
rejected (`--protocol` with any value but the current one, `--inc-recursive`),
|
||||
an accepted no-op (`-s`/`--secluded-args`, `--protect-args`, `--old-args`),
|
||||
deliberately non-rsync and non-interoperable (the FastSync daemon config/auth,
|
||||
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.
|
||||
|
||||
**Recently closed parity gaps (protocol 2.23.0).** The rsync-parity wave wired up
|
||||
**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
|
||||
name resolution, absolute basis dirs, and the remaining client quick wins) and
|
||||
reclassified the inherently non-rsync rows as **divergent** (native daemon
|
||||
config/auth, the non-interoperable batch container, `--fake-super`'s xattr
|
||||
format, `-X`'s privileged namespaces, and the safe-subset device/privilege
|
||||
flags). It moved `PROTOCOL_VERSION` three times (`2.23.0 → 2.24.0` delete
|
||||
timing, `2.24.0 → 2.25.0` wire stats, `2.25.0 → 2.26.0` codecs). See the
|
||||
**Parity Completion Wave (protocol 2.26.0)** section near the end for the full
|
||||
list and the remaining limitations.
|
||||
|
||||
**Previous wave — rsync-parity (protocol 2.23.0).** That wave wired up
|
||||
the short options `-r`, `-b`, `-L`, `-B`; rsync short-option clustering
|
||||
(`-av`, `-aAX`, `-rlpt`) and attached/inline values (`--opt=value`, `-B1000`,
|
||||
`-essh`, `-MOPT`); `-c` now implies the checksum quick-check; `--checksum-choice`
|
||||
accepts `xxh64`/`xxhash`/`xxh3`/`xxh128`/`md5`/`auto` and rejects `md4`/`sha1`/
|
||||
`none` by name; `--compress-choice` accepts `zstd`/`none`/`auto`; `--checksum-seed=0`
|
||||
is randomized per transfer; `--skip-compress` uses rsync's default suffix list;
|
||||
`--timeout`/`--contimeout` match rsync's defaults; deletion gained
|
||||
`--max-delete` partial semantics with exit 25; symlinks are stored verbatim; and
|
||||
`--specials` recreates sockets. Every one of those still has an entry below with
|
||||
its remaining caveats. See the **Rsync-Parity Wave (protocol 2.23.0)** section
|
||||
near the end for the full list and the known limitations.
|
||||
`-essh`, `-MOPT`); `-c` now implies the checksum quick-check; and the codec
|
||||
and choice limits it introduced were broadened by the completion wave.
|
||||
Every one of those has an entry below with its remaining caveats.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,11 +49,12 @@ near the end for the full list and the known limitations.
|
||||
| `-a`, `--archive` | Archive mode is -rlptgoD (rsync includes owner/group) | ✅ Parity | Phase 7 Wave A: real rsync archive. `-a`/`--archive` now implies `--links` + the four per-attribute preserve flags (perms/times/owner/group) + `--devices` + `--specials`, i.e. **`-rlptgoD`**. Owner/group **are** implied, but their application stays privilege-gated exactly like rsync: a receiver that cannot `chown` logs a warning and skips it (see the preserve-attribute split note below). FastSync is always recursive, so no `-r` is needed. It no longer implies compression or multithreading (those moved to `-z`/`-j`). The short-option namespace is now rsync-parity (see the Phase 7 note) |
|
||||
| `-v`, `--verbose` | Increase verbosity | ✅ Parity | Sets `log_level=DEBUG` |
|
||||
| `-q`, `--quiet` | Suppress non-error messages | ✅ Parity | Suppresses client output while preserving errors |
|
||||
| `--help` | Show help | ✅ Parity | Prints usage and exits; `-h` is not accepted |
|
||||
| `--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 | Supports `copy`, `misc`, `skip`, `stats`, `all`, and `none`; explicit flags override `--verbose`, and `none` suppresses info output; unsupported names are rejected |
|
||||
| `--debug=FLAGS` | Fine-grained debug verbosity | ⚠️ Caveat | `io`, `proto`, `pack`, and `util` are supported; `--debug=help` lists flags; other rsync categories are rejected |
|
||||
| `--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` |
|
||||
| `--no-motd` | Suppress daemon MOTD | ✅ Parity | Client-only display switch (Wave C): the daemon still sends the configured `motd file` on a `host::module/path` connection; the client reads and discards the frame without showing it. Without the flag the MOTD is printed to stdout after the config/auth handshake and escaped so control bytes cannot inject terminal sequences |
|
||||
| `--exclude=PATTERN` | Exclude files matching pattern | ✅ Parity | Glob matching in scanner |
|
||||
| `--include=PATTERN` | Include files matching pattern | ✅ Parity | Glob matching in scanner |
|
||||
@@ -58,14 +64,14 @@ near the end for the full list and the known limitations.
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--stats` | Give transfer stats | ⚠️ Caveat | Prints file/byte counts. **Divergence:** the receiver-only counters rsync derives during its generator pass (matched/unchanged data, file-list bytes, deleted-entry count) are reported as **0** by FastSync, and the byte total counts source bytes actually sent rather than the post-delta/post-compression wire volume. Counts that FastSync can observe locally (files, bytes, timing) are accurate |
|
||||
| `-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 |
|
||||
| `--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 | Prints a periodic **aggregate** transfer line (bytes sent and current rate), not rsync's per-file progress block. With `-P` the partial-file retention behavior is fully implemented; only the progress presentation differs |
|
||||
| `-P` | Same as --partial --progress | ⚠️ Caveat | Phase 7 Wave B: `-P` parses to `--partial` + `--progress`. On a failed/interrupted write the receiver now retains the already-written temp file at the destination path (best-effort rename instead of unlink when configured), so a later `--append`/`--append-verify` run can resume it; `--partial-dir` still stages completed files under the confined partial dir and installs them atomically. The retention never runs when `--partial` is off, 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 (never a corrupt blend; a failed rename falls back to the normal unlink). See the `-S`/`--sparse` interplay note (a retained sparse temp has full logical size) |
|
||||
| `--out-format=FORMAT` | Custom output format | ⚠️ Caveat | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%M` `%%` (`%b` is the source length, always `== %l`; post-compression/delta wire bytes are not counted); unknown escapes preserved |
|
||||
| `--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 | ⚠️ 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` `==` source length) |
|
||||
| `--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 |
|
||||
| `--list-only` | List files instead of copying | ✅ Parity | `ls -l`-style listing of files that would be transferred; scans the source only, contacts no server, writes nothing; also works with `-n` |
|
||||
|
||||
@@ -75,29 +81,30 @@ near the end for the full list and the known limitations.
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--exclude-from=FILE` | Read exclude patterns from file | ✅ Parity | Reads patterns from file |
|
||||
| `--include-from=FILE` | Read include patterns from file | ✅ Parity | Reads patterns from file |
|
||||
| `--filter=RULE` | Add file-filtering rule | ⚠️ Caveat | Long option only: rsync's short `-f` conflicts with FastSync sendfile (see FastSync-specific list), so `-f` is not reassigned. Supported subset: `+`/`-` include/exclude, implicit-exclude patterns, `include`/`exclude` word forms, a leading `/` anchor (to the transfer root, or to a `.rsync-filter` file's directory), and a trailing `/` for dir-only rules; first match wins with a default of include inside the filter layer. Filters are an independent layer from `--exclude`/`--include` (an entry must pass both). Rejected with a clear error (no silent no-ops): `merge`/`dir-merge`/`hide`/`show`/`protect`/`risk`/`clear` words, rules that begin with `:`/`.`/`!` (merge/dir-merge/list-clear shorthands), and include/exclude modifiers other than `/` (`! C s r p x`) |
|
||||
| `--files-from=FILE` | Read source file list from file | ⚠️ Caveat | Entries are paths relative to the source root (leading `./` stripped, `..`/absolute entries 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, unlike rsync's non-recursive default). Non-listed paths and their subtrees are pruned by the scanner. A listed entry that does not exist under the source (and an empty list) is a hard error reported before any transfer, unless `--ignore-missing-args` / `--delete-missing-args` is given (see the Safety & Security rows): those flags downgrade the listed-but-missing case to a skip and, for `--delete-missing-args`, a destination deletion; an empty list stays a hard error in every mode. Listing `.` (whole tree) and empty listed directories are fine. Scalability note: `file_list_affects` is O(list size) per scanned entry, so a very large `--files-from` list against a huge tree is quadratic; lists are typically small enough that this is acceptable, but it is the documented bound. Delete scoping (protocol 2.23.0): the manifest carries the set of synchronized directories, and the extras walk only visits those subtrees, so `--delete` with a `--files-from` subset no longer removes destination paths outside the listed directory subtrees (a data-loss fix matching rsync) |
|
||||
| `--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 |
|
||||
| `--min-size=SIZE` | Skip files smaller than SIZE | ✅ Parity | `min_size` in scanner |
|
||||
| `-I`, `--ignore-times` | Don't skip files matching size+time | ✅ Parity | `ignore_times` config field (crosses the wire). Disables the size+mtime quick-check in the `--incremental` per-file handshake and the basis-dir quick-match, forcing the file to be transferred rather than skipped as unchanged. Receiver-side policy: `match_by_metadata` (file_receive.c) is bypassed, so the receiver never replies `STATUS_OK` for a matching size+mtime. Requires `--incremental` to have the handshake to act on (rsync does its quick check by default; FastSync's `-I`/`--size-only`/`--modify-window` only take effect under `--incremental`, exactly like they take effect through the basis check) |
|
||||
| `--size-only` | Skip based on size only | ✅ Parity | With `--incremental`, ignores mtime |
|
||||
| `-@`, `--modify-window=NUM` | Mod-time comparison accuracy | ✅ Parity | Whole-second tolerance with nanosecond-aware comparisons |
|
||||
| `--existing` | Skip creating new files on receiver | ✅ Parity | Existing destination files continue through normal update handling |
|
||||
| `--ignore-existing` | Skip updating existing files | ⚠️ Caveat | `ignore_existing` config field (crosses the wire; receiver-side policy). For a destination entry that already exists, the receiver skips the write: in the regular-file path, existing/delay-updates-staged, hardlink-sibling, and special/device handlers all return `FILE_SAVE_SKIPPED` without overwriting (passed as `no_replace` to the write engine), and `--backup` is disabled for skipped files. Note: it is applied at write time, so an existing dest whose size+mtime differ still has its data (or delta) transmitted before the write is discarded — functionally correct, bandwidth-suboptimal vs rsync, which short-circuits earlier. Like rsync, it does not apply to directories/symlinks (those return before the block). Combines with `-j`/`--threads` and `--delay-updates`. See Phase-4/— notes below |
|
||||
| `--existing` | Skip creating new files on receiver | ✅ Parity | Existing destination files continue through normal update handling. The rsync man-page alias `--ignore-non-existing` sets the same flag |
|
||||
| `--ignore-existing` | Skip updating existing files | ✅ Parity | `ignore_existing` config field (crosses the wire; receiver-side policy). Protocol 2.26.0 short-circuits in the per-file check **before any payload**: when the destination entry already exists, the receiver answers the skip during the incremental handshake instead of letting the sender stream data that would be discarded, so an existing 4 MiB destination costs only the config/check frames (verified with a counting proxy, matching rsync). The write-time paths (regular, delay-updates-staged, hardlink-sibling, special/device) still return `FILE_SAVE_SKIPPED` without overwriting, and `--backup` is disabled for skipped files. Like rsync, it does not apply to directories/symlinks. Combines with `-j`/`--threads` and `--delay-updates` |
|
||||
| `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Parity | |
|
||||
| `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Parity | Sender scanner captures the root device and does not descend into mount-point crossings (`st_dev` differs). **Protocol 2.23.0 matches rsync's entry emission:** the mount-point directory itself is emitted as a payload-less directory entry (so the destination gets an empty directory) while its contents are skipped; previously the crossing subdirectory was dropped entirely |
|
||||
| `-F` | Add the default `.rsync-filter` rules | ⚠️ Caveat | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (matching rsync's first-match-wins precedence); `.rsync-filter` files are never transferred. The rsync `-FF` behavior (also `.cvsignore`) is out of scope; unsupported rule types inside the file abort with a clear error |
|
||||
| `-F` | Add the default `.rsync-filter` rules | ✅ Parity | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (first match wins). **A single `-F` transfers the `.rsync-filter` files themselves, matching rsync; a repeated `-FF` additionally excludes them** (rsync 3.4.1's `-F`/`-FF` are exactly these two rules, with no `.cvsignore` branch). Unsupported/unparseable rules inside a per-directory file fail the scan with a clear error |
|
||||
|
||||
## 4. Directory Options
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-r`, `--recursive` | Recurse into directories | ✅ Parity | Default behavior |
|
||||
| `-R`, `--relative` | Use relative path names | ⚠️ Caveat | Meaningful together with `--files-from` (FastSync's default full-tree scan always mirrors the full source argument path below the destination root, so -R does not change it). With `-R` + `--files-from` each listed entry is transmitted under its bare relative destination path: an entry `sub/x.txt` lands at `<dest>/sub/x.txt` (its leading components preserved) instead of under the `<dest>/<full source path>` mirror. Only the path sent on the wire changes; the client still reads the absolute source path, and the delete manifest derives from the sent (relative) paths so `--delete` and `--remove-source-files` stay consistent in both layouts. Works single-threaded and under `-j`/`--threads` (including chunk serialization) |
|
||||
| `--no-implied-dirs` | Don't send implied dirs with -R | ⚠️ Caveat | Client-side, meaningful only with `-R` + `--files-from`. rsync would normally create the ancestor directories implied by a listed file so it can be written; with `--no-implied-dirs` a listed file whose parent directory is not itself (or via an ancestor) explicitly listed cannot be placed, and FastSync fails the whole run up front with a clear error (`--no-implied-dirs: cannot place file '...': parent directory '...' is not explicitly listed`). Listing the directory (or an ancestor of it, or the whole tree `.`) permits the file. In every other mode the option has no effect. FastSync has no per-entry skip channel, so the rsync "omit the file" case is surfaced as a hard pre-transfer error |
|
||||
| `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ⚠️ Caveat | `-d <dir>` transmits an explicit directory entry for the source-root directory, so the destination mirror is created empty and nothing is descended into. With `--files-from` exactly the listed items are transferred: a listed directory is created empty (no descent) and a listed file is transferred with its content; the dest layout follows the same -R rules as plain files. A new wire frame (`STATUS_MKDIR`) carries each directory entry — the path and, when `--preserve`/`-a` (metadata mode) is negotiated, the directory's metadata; the receiver creates it with the same confined mkdir-parent semantics as regular writes, in single-threaded and `-j`/`--threads` receivers (chunk serialization carries a per-entry type marker). Directory entries appear in the delete manifest so `--delete` prunes correctly. Directory TIMES are transmitted (the `STATUS_DIR_TIMES` frame carries every traversed source directory's captured times, including `--dirs` entries) and applied by the receiver at the END of the transfer, after all children and the delete/publication phases, so a later child write cannot clobber a directory's mtime (`-O`/`--omit-dir-times` skips this application). FastSync divergences: directory modes/ownership are still not applied (only times are), and empty directories are still never created (a `STATUS_DIR_TIMES` entry is record-only), filter/`--exclude` rules are not re-applied to the listed dirs mode (there is no descent during which they would apply), and `-d` never creates the intermediate directories between the destination root and a listed file beyond the usual on-demand parent creation. Under `--delay-updates` only regular files are staged: directory entries are created immediately, so a delayed run that fails part way can leave the already-created empty directories behind (matching rsync, which also creates directories as it processes the file list and only delays regular-file data) |
|
||||
| `-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 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 |
|
||||
|
||||
## 5. Transfer Modifications
|
||||
|
||||
@@ -105,21 +112,24 @@ near the end for the full list and the known limitations.
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-u`, `--update` | Skip files newer on receiver | ✅ Parity | `update` config field (crosses the wire; receiver-side policy, implies metadata transmission). Before writing a regular file, the receiver checks `file_destination_is_newer_secure()` (via `stat_is_newer`, second-then-nanosecond strict `>` on the existing destination) and skips the write when the destination is newer than the source (`FILE_SAVE_SKIPPED`); equal-or-older destination (or a newer source) is transferred normally. Applied at write time on the regular-file, delay-updates-staged, hardlink-sibling, and special/device paths. Only regular destinations can be guarded (the newer-check requires `S_ISREG`), and like the other write-time policies it does not short-circuit the data transfer for a differing-size dest. `--remove-source-files` correctly respects the receiver's skip outcome so a skipped source is not removed |
|
||||
| `--inplace` | Update files in-place | ✅ Parity | Direct write mode |
|
||||
| `--append` | Append data to shorter files | ⚠️ Caveat | Tail-only resume. When an existing destination file is SHORTER than the source, the receiver negotiates a resume offset with the sender and only the tail is transferred; the receiver rebuilds the full file (retained prefix + tail) and installs it through the normal atomic store path, so the result is byte-identical to the source whenever the retained prefix matches. Plain `--append` does NOT content-verify that prefix (rsync parity): a destination whose prefix differs from the source is resumed anyway, so the result (wrong prefix + correct tail) is NOT byte-identical and the file is effectively left corrupt — the documented rsync-parity risk (use `--append-verify` when the prefix cannot be trusted). Non-content attributes (permissions/ownership/mtime, via `-M`) are still applied. Requires the per-file `STATUS_CHECK` handshake, so it implies `--incremental`; it takes precedence over block delta for a growing file and falls back to delta/full when the destination is not shorter. Incompatible with `-s` (chunk serialization) and `--whole-file` (both rejected up front so the mode never silently degrades to a full transfer). Combines with `--inplace`, `--partial`/`--partial-dir`, and `--delay-updates` (the reconstructed full file flows through those paths unchanged). Divergence: rsync appends in place; FastSync reconstructs and atomically installs, so an interrupted or failed resume never leaves a half-written file at the destination (no corruption window), and `--append` is thus safe to use with the normal atomic path — not only with in-place writes |
|
||||
| `--append-verify` | Append with old-data checksum | ⚠️ Caveat | Like `--append`, but the retained prefix IS verified before resuming: the sender transmits the source prefix checksum and the receiver compares it to the xxHash64 of the retained destination prefix; on a match only the tail is transferred, on a MISMATCH the run falls back to a clean full transfer so the result is always a byte-identical source copy (never a corrupt prefix+tail blend). Wire/protocol: the append handshake adds `STATUS_APPEND` / `STATUS_APPEND_SIG` / `STATUS_APPEND_OK` / `STATUS_APPEND_DATA` frames and `PROTOCOL_VERSION` was bumped **2.9.0 → 2.10.0** (peers must match, and both must be 2.10.0 or the run fails the version check). Same implications/incompatibilities as `--append`; when both spellings are given `--append-verify` wins (the safer semantics). See the Phase-3 append notes below |
|
||||
| `--append` | Append data to shorter files | ✅ Parity | Tail-only resume: when an existing destination file is shorter than the source, the receiver negotiates a resume offset and only the tail is transferred; the receiver rebuilds the full file (retained prefix + tail) and installs it atomically. Differential tests confirm the result is byte-identical to rsync both when the retained prefix matches and (for plain `--append`, which does not verify the prefix) when it differs. FastSync reconstructs and atomically installs rather than appending in place — a crash-safety superset (an interrupted resume never leaves a half-written file) with identical normal-run behavior |
|
||||
| `--append-verify` | Append with old-data checksum | ✅ Parity | Like `--append`, but the retained prefix is verified: the sender transmits the source prefix checksum, the receiver compares it to the xxHash64 of the retained destination prefix, and on a mismatch the run falls back to a clean full transfer (always byte-identical to the source). Differential tests match rsync for both a matching and a mismatching prefix. Same atomic-install crash-safety superset as `--append` |
|
||||
| `-W`, `--whole-file` | Copy whole file (no delta) | ✅ Parity | `whole_file` config field. Forces a full (whole-file) copy, disabling the block-level delta machinery: the sender only sends `STATUS_NEXT` + full data (client_send.c) and the receiver never requests a delta signature/reconstruction — the receiver's `try_delta = use_delta && !whole_file && ...` short-circuits. `whole_file` crosses the wire folded into `use_delta` (the wire carries `use_delta && !whole_file`), so no separate field/bump is needed. Delta is opt-in (`--delta` needs `--incremental`); `-W` additionally makes `--fuzzy` inert (no similar-file delta basis). `--append`/`--append-verify` are incompatible with `-W` and rejected up front (both sides). See the delta/append notes below |
|
||||
| `--no-whole-file` | Negate `-W`/`--whole-file` | ✅ Parity | rsync spelling that clears `--whole-file`, re-enabling the delta path where `--delta`/`--incremental` are active. Accepted as a boolean negation of `-W` |
|
||||
| `--block-size=SIZE` | Force checksum block-size | ✅ Parity | Phase 7 Wave B: `--block-size` is an alias for `--delta-block`; both set `config->delta_block_size` (default `DELTA_BLOCK_SIZE_DEFAULT`, bounds `DELTA_BLOCK_SIZE_MIN..MAX`, out-of-range values are rejected with the default kept). The value is genuinely honored by the delta engine end-to-end: `delta_signature_create_seeded(old, size, config->delta_block_size, seed)` on the sender and receiver, `delta_apply(old, ...)` with the same size, so a non-default block size changes the block count of every signature the harnesses exchange (verified by unit + integration tests) |
|
||||
|
||||
## 6. Destination Handling
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-n`, `--dry-run` | Trial run with no changes | ⚠️ Caveat | Server-contacting since protocol 2.21.0. The final routing predicate is `dry_run_targets_server()` in `src/client/client_send.c`: any target a real run would reach over the wire selects the server-contacting path — an SSH transport, a daemon `host::module` destination, an explicit `--server-host` or `--server-port`/`--port`, TLS, or a source-bind `--address` — and 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. A plain local destination (none of those) keeps the original client-side manifest that never dials the default `127.0.0.1:8080`. Would-delete reporting for `--delete*` is deferred (dry-run never deletes). |
|
||||
| `-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 | ⚠️ 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` |
|
||||
|
||||
## 7. Deletion
|
||||
|
||||
@@ -127,13 +137,13 @@ near the end for the full list and the known limitations.
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--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`. FastSync streams the source in a single directory scan and has no per-directory generator pass, so deletions cannot be interleaved per-directory the way rsync's delete-during does. `--delete-during` therefore selects the same early engine mode as `--delete-before` (manifest transmitted before any data, extras removed and acknowledged before data is applied); observable success/failure behaviour equals `--delete-before`. That is the documented divergence from rsync, where `--del` is the default meaning of `--delete` |
|
||||
| `--delete-delay` | Find deletions during, delete after | ⚠️ Caveat | Implies `--delete`. Commit-mode timing: extras are removed only after the whole transfer succeeded. rsync's delete-delay records the deletion list during its scan and applies it at the end; FastSync never snapshots the destination while data flows (the keep-set is the transmitted manifest and the destination is listed only at deletion time), so `--delete-delay` is implemented as the same end-of-transfer commit as `--delete-after` with identical safety. That is the documented divergence |
|
||||
| `--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. **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 | ⚠️ 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 and the deletion still runs (the mirror of the unreadable directory is treated as an extra). The run still exits non-zero (the error is reported, matching rsync's error status). Divergence: without the flag FastSync aborts the whole run on the scan error, whereas rsync transfers the rest of the tree and merely skips the deletion; both leave the deletion undone |
|
||||
| `--force` | Force deletion of non-empty dirs | ⚠️ Caveat | `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) |
|
||||
| `--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`). 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
|
||||
@@ -268,26 +278,26 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
||||
| `-t`, `--times` | Preserve modification times | ✅ Parity | Real per-attribute flag (`preserve_times`): apply the source mtime independently of the other attributes. `-O/--omit-dir-times` suppresses directories only and `-J/--omit-link-times` suppresses symlinks only; `-U`/`-N` do not imply it. `--preserve`/`-a` imply it, and `--incremental`/`--delta` auto-enable it unless `--no-times`/`--no-preserve` |
|
||||
| `-E`, `--executability` | Preserve executability | ✅ Parity | Preserves executable permission bits (implies metadata preservation) |
|
||||
| `--chmod=CHMOD` | Affect file permissions | ✅ Parity | Faithful port of rsync 3.4.1's `parse_chmod`/`tweak_mode`: numeric octal and symbolic `ugo`/`rwx` changes, `D`/`F` directory/file selectors, `X` (execute only on directories or already-executable files), `s`/`t` setuid/setgid/sticky, and append semantics — repeated clauses and repeated `--chmod` options accumulate in order (joined with commas). The changes are applied to the new mode **without sanitization** (matching rsync) and `--chmod` does **not** imply `-p` (rsync parity). Applied to files and directories on the receiver |
|
||||
| `-A`, `--acls` | Preserve ACLs | ⚠️ Caveat | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs into the same bounded whitelisted set as `-X`, transmits them per-file, and the receiver re-applies them fd-relative. Setting an ACL the receiver is not permitted to set (non-root on a file it does not own, unsupported filesystem) is logged and skipped, never fatal. libacl is **not** required. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied (see the Phase-4 xattr/ACL notes below). Implies metadata transmission |
|
||||
| `-X`, `--xattrs` | Preserve extended attributes | ⚠️ Caveat | Preserves unprivileged `user.*` extended attributes (Linux `listxattr`/`getxattr` on capture, `fsetxattr` on the written destination fd). Both capture (sender) and application (receiver) are restricted to the `user.*` namespace and the two POSIX ACL xattrs, so a client can **never** force a `security.*`/`trusted.*`/privileged attribute onto the destination; the receiver independently re-validates every incoming name against this whitelist and rejects anything else. Payloads are bounded (per-name ≤255B, per-value ≤1MiB, per-file count ≤256 total bytes ≤4MiB) on both ends, and an oversized/malformed frame is a clean protocol rejection (no OOM). Applied fd-relative to the exact written file. Implies metadata transmission. Incompatible with `-s` (chunk serialization), rejected up front (see the notes); a `--link-dest`/`-H` hard-link copy fallback re-applies the attributes so they are not dropped when a link is refused |
|
||||
| `-A`, `--acls` | Preserve ACLs | ✅ Parity | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs and the receiver re-applies them fd-relative. A differential test with `setfacl` confirms the complete access and default ACL sets (including `mask`) are identical to rsync's on a directory. libacl is not required; a `fsetxattr` an unprivileged receiver may not perform is logged and skipped, never fatal. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied. Implies metadata transmission |
|
||||
| `-X`, `--xattrs` | Preserve extended attributes | ❌ Divergent | Deliberately restricted to unprivileged `user.*` extended attributes plus the two POSIX ACL xattrs; `security.*` (SELinux, capabilities, ...) and `trusted.*` are **never** captured or applied — a client can never force a privileged attribute onto the destination, and the receiver independently re-validates every incoming name against the whitelist. This is a security-policy divergence from rsync, which can preserve the privileged namespaces with the needed privilege; implementing them would defeat FastSync's privilege-escalation guard. `user.*` capture/apply matches rsync in a differential test. Payloads are bounded on both ends. Incompatible with `-s` |
|
||||
| `-H`, `--hard-links` | Preserve hard links | ✅ Parity | Files on the source that share an inode (`st_dev`+`st_ino`, e.g. a `cp -al` tree) are re-created as hard links to one another on the destination, so duplicate links stay deduplicated and only the first member's data is sent (later members are transmitted as payload-less `STATUS_HARDLINK` frames). The receiver links each sibling to the first member's installed file with an atomic link + rename; on `link()` failure it falls back to a byte-identical local copy of the first member, never a partial/corrupt file. Requires the sequential scan for ordering (the first member is always emitted and installed before any sibling is linked). Works single-threaded and under `-j`/`--threads`, `--inplace`, `--delay-updates` (links staged and published by rename) and `--partial`. Crosses the wire (`preserve_hard_links` bool; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0**, peers must match). Incompatible with `-s` (chunk serialization) and `--append`/`--append-verify`, rejected up front with a distinct error. See the Phase-4 hard-links notes below |
|
||||
| `-D` | Same as --devices --specials | ✅ Parity | Implies `--devices --specials`. `-D` was unassigned in FastSync (verified: no collision), so it is free to imply both device-node and special-file preservation. As of protocol 2.23.0 `--specials` genuinely covers **both FIFOs and unix sockets**, so `-D` covers the full rsync set. See the `--devices`/`--specials` rows and the Phase-4 devices notes below |
|
||||
| `--devices` | Preserve device files | ⚠️ Caveat | Recreates char/block device nodes on the destination via `mknod` instead of transferring content. Type + rdev are validated strictly (S_IFMT from the transmitted mode; major/minor range-checked, non-negative), and creation is **privilege-gated**: `mknod` needs `CAP_MKNOD`, so a non-root receiver (CI runs via setpriv as non-root) logs a warning and **skips the device entry safely** — the whole transfer never aborts just because the node could not be made. The node is created fd-relative below the receive root (`mknodat` on the confined secure parent), so it can never be placed outside the authorized root, never follows a symlink, and never replaces an existing directory. Only a char/block mode is honored. Crosses the wire (a `STATUS_SPECIAL` frame carries the path + metadata mode + rdev). Divergence: per-entry skip (not a hard error) when the receiver lacks `CAP_MKNOD`, documented in the Phase-4 devices notes |
|
||||
| `--devices` | Preserve device files | ❌ Divergent | Recreates char/block device nodes with `mknodat` (type + rdev strictly validated, confined fd-relative below the receive root), but only when the receiver has `CAP_MKNOD`: a non-root receiver logs a warning and skips the entry instead of erroring, so a transfer with devices never aborts. Deliberate privilege-model divergence from rsync, which errors when it cannot create the node. `--specials` (FIFOs and unix sockets) is unprivileged and remains parity |
|
||||
| `--specials` | Preserve special files | ✅ Parity | **FIFO and unix-socket recreation work** (protocol 2.23.0): FIFOs are recreated with `mkfifoat`, and sockets with `mknodat(..., S_IFSOCK)` — the latter is unprivileged on Linux because it materializes the socket *node*, not a live bound socket, so it is a real, assertable behavior under CI (it matches rsync, which also recreates a socket by `mknod`). Node creation is confined below the receive root (fd-relative parent; no `..`, no symlink follow) and type/rdev are validated strictly; a matching existing node is left in place and an unrelated entry is never replaced. Crosses the wire like `--devices` (the `STATUS_SPECIAL` frame). See the Phase-4 devices notes |
|
||||
| `--copy-devices` | Copy device contents as file | ⚠️ Caveat | Copy a device's CONTENT into an ordinary regular file on the destination instead of recreating the node — non-privileged and safe. FastSync scans a device/FIFO as a regular file: its reported size (`st_size`, typically 0 for char devices and FIFOs) is copied, so a FIFO or a non-readable device becomes an empty (or size-bounded) regular file. The default data path is size-bounded and never blocks (it sends exactly `st_size` bytes, never an unbounded pseudo-device stream); with `--sendfile`, a non-regular source (FIFO/device) is detected from its `stat` mode and falls back to that same buffered read, so `--copy-devices --sendfile` cannot hang either. The run always succeeds and never crashes on such input. **Deliberate, safe divergence from rsync's dd-like unbounded device read.** See the Phase-4 devices notes |
|
||||
| `--write-devices` | Write to devices as files | ⚠️ Caveat | Write the received data directly into an **existing** device node on the destination instead of creating a regular file. Restricted and best-effort: the destination must already exist and be a char/block device (opened only under the confined receive root, with `O_NOFOLLOW` + `O_NONBLOCK`); a missing, symlinked, FIFO-with-no-reader (`ENXIO`), non-device destination, or any write failure is **skipped with a warning** rather than allowed, so a run can never clobber the system, never blocks on a special-file target, and never aborts on an unusable target. See the Phase-4 devices notes |
|
||||
| `--copy-devices` | Copy device contents as file | ❌ Divergent | Copies a device/FIFO's reported `st_size` into an ordinary regular file and never reads an unbounded pseudo-device, so `--sendfile` cannot hang and the run always succeeds. Deliberate safe divergence from rsync's dd-like unbounded device read, which can block; the dangerous behavior will not be implemented |
|
||||
| `--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, 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 | ⚠️ Caveat | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); a **privileged (root) standalone TCP listener now also defaults to `OFF`** unless the operator opts in with the new server-only `--allow-super` flag (the flag is **rejected with `--stdio`**, whose remote argv is composed by the client and must never defeat the secure default; operators exposing `fastsync-server --stdio` over SSH need a forced command if the default must hold. An unprivileged receiver is unchanged, since the kernel refuses the confined attempts anyway; the `--daemon` path keeps its per-module `client owner = yes` opt-in); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) or a preserve-source request (`-o`/`-g`, or `-a`/`--archive`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates |
|
||||
| `--fake-super` | Store/recover privileged attrs via xattrs | ⚠️ Caveat | Full record **and replay** (protocol 2.23.0 parity update). The receiver writes the resolved `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative), then immediately re-applies the mode and times via `fake_super_restore_fd` (`fchmod` + `futimens`; absent/malformed records are a silent no-op, never fatal). **`--fake-super` never performs a real `chown`**: when an explicit ownership mapping (`--chown`/`--usermap`/`--groupmap`/`--copy-as`) is active the receiver records the *resolved* id, otherwise the source's own id, but the owner leg is always suppressed so recording can never defeat the flag; the record is retained for a later privileged restore. The replayed mode goes through the shared `metadata_mode_for_policy` helper, so under `-p` it is copied exactly (including group/other-write and special bits — strict rsync parity, no masking) and under `-E` it follows the rsync executability rule. Directory ownership and directory xattrs/ACLs are preserved alongside file entries (mode/owner are applied to directories under the same per-attribute policy and `-A`/`-X` carry the directory ACL/xattr block). Implies metadata transmission so the source uid/gid/mode/mtime are available. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
|
||||
| `--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` |
|
||||
| `--open-noatime` | Avoid changing access time when opening files | ✅ Parity | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
|
||||
| `--numeric-ids` | Do not map uid/gid by name | ✅ Parity | **A mapping modifier only:** when ownership is being applied it uses the transmitted numeric uid/gid directly, skipping the name lookup. It does **not** request ownership application on its own — combine it with `-o`/`-g`, `-a`, or an explicit map (`--chown`/`--usermap`/`--groupmap`) — and it does not need any metadata flag merely to parse. Ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the Phase-4 identity notes) |
|
||||
| `--usermap=STRING` | Map usernames | ⚠️ Caveat | Opt-in ownership application. rsync subset implemented (protocol 2.23.0): comma-separated `FROM:TO` rules evaluated in order, first match wins. `FROM` accepts a user name (resolved on the SOURCE machine at parse time), an `@N`/bare `N` numeric id, an inclusive `LOW-HIGH` **id range**, `*` (matches any id), or an **empty** field (matches ids with no name on the source). `TO` accepts a name (resolved on the **receiver**), an `@N`/bare `N` id, or `*` (the receiving process's current euid). Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`, including directory entries. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
|
||||
| `--groupmap=STRING` | Map group names | ⚠️ Caveat | Same rsync subset and semantics as `--usermap` (names, `@N`/bare `N`, inclusive ranges, `*`, empty-FROM for unnamed ids, receiver-resolved `TO` names) but for the group (gid) side and the group databases. See the Phase-4 identity notes |
|
||||
| `--chown=USER:GROUP` | Map owner and group | ⚠️ Caveat | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). **Protocol 2.23.0 makes `--chown` and `--usermap`/`--groupmap` mutually exclusive on the same side: combining them (in either order) is a clear configuration error** (`--usermap conflicts with prior --chown`), matching rsync and never an order-dependent silent winner. Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
|
||||
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ⚠️ Caveat | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it; a privileged (root) standalone TCP listener refuses it by default too and only honors it after the operator passes `--allow-super` (the flag is rejected with `--stdio`, where the client-composed remote argv could otherwise defeat the default; a forced command is required if the default must hold), and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (a root standalone listener honors these for its single operator-authorized root only when started with `--allow-super`). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
|
||||
| `--usermap=STRING` | Map usernames | ✅ Parity | Opt-in ownership application. Comma-separated `FROM:TO` rules evaluated in order, first match wins. `FROM` accepts a source-resolved user name, an `@N`/bare `N` numeric id, an inclusive `LOW-HIGH` id range, `*`, or an empty field (ids with no source name). `TO` accepts a receiver-resolved **name** (protocol 2.26.0 resolves it on the receiving side against the receiver's account database, matching rsync), an `@N`/bare `N` id, or `*` (the receiving process's euid). Rules travel as resolved numeric pairs plus an optional TO name; the receiver applies a matching rule, else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup, via fd-relative `fchown`. Malformed specs are clear errors. Implies metadata; only effective where the receiver can chown (otherwise a warning) |
|
||||
| `--groupmap=STRING` | Map group names | ✅ Parity | Same rules and receiver-side `TO`-name resolution as `--usermap`, applied to the group (gid) side |
|
||||
| `--chown=USER:GROUP` | Map owner and group | ✅ Parity | Opt-in ownership override. Forms `USER:GROUP`, `USER`, `:GROUP`; `*` means the current user/group as appropriate; `@N`/bare `N` ids; a name may escape `:` as `\:`. A name that resolves on the sender is sent as an id; an unresolvable name is carried as a receiver-resolved `TO` name (protocol 2.26.0), matching rsync's receiver-side resolution. Equivalent to a trailing `*:*` usermap+groupmap rule (an explicit map match wins). Conflicts with `--usermap`/`--groupmap` on the same side are a clear configuration error. Implies metadata; a non-root receiver warns and continues (rsync parity) |
|
||||
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ❌ Divergent | Close-refusal safe subset. FastSync never switches process credentials (its receiver is multithreaded, so a real `setuid`/`setgid` would be unsafe); instead the receiver forces the ownership of every entry it writes to the client-resolved ids through the confined fd-relative identity path. A privileged (root) receiver is required: an unprivileged receiver refuses the whole transfer at the config handshake, before any data, rather than produce wrong ownership. Deliberate divergence from rsync's real identity switching; a daemon refuses it unless the module sets `client owner = yes` |
|
||||
|
||||
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
|
||||
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
|
||||
@@ -527,9 +537,9 @@ warning + skip, never a system-clobbering write or an abort.
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-l`, `--links` | Copy symlinks as symlinks | ⚠️ Caveat | A symlink is transmitted as a real symlink: its target string crosses the wire (`STATUS_SYMLINK` / chunk entry type) and the receiver creates it with `symlinkat` beneath the receive root, never following the target. **Targets are stored verbatim (protocol 2.23.0), matching rsync `-l`: an absolute target or one containing `..` is copied exactly, and the receiver no longer enforces a containment predicate by default.** `--safe-links` is the sender-side opt-in that drops unsafe targets before transmission; `--trust-sender` does **not** affect symlink targets (it only relaxes the receiver's path-list re-validation). The *placement* path is still hard-confined (`has_path_traversal`, O_NOFOLLOW fd walk), and the link's own mode/times are applied with no-follow primitives. See the Phase-4 symlink-trust notes and the residual-risk note below |
|
||||
| `-L`, `--copy-links` | Transform symlink to referent | ⚠️ Caveat | Sender-side: every symlink is replaced by its referent's content (`copy_links` config field). A referent that cannot be read, including a broken symlink, is treated as a non-error and the run exits 0 — where rsync exits 23 (`RERR_PARTIAL`). This is the documented status-code divergence |
|
||||
| `--copy-unsafe-links` | Transform unsafe symlinks | ⚠️ Caveat | Sender-side: only symlinks whose target is unsafe (absolute or escaping via `..`, matching rsync's `unsafe_symlink()` semantics) are dereferenced into their referent; safe links stay symlinks. Same broken-referent exit-0 caveat as `-L` (`copy_unsafe_links` config field) |
|
||||
| `-l`, `--links` | Copy symlinks as symlinks | ✅ Parity | A symlink is transmitted as a real symlink: its target string crosses the wire (`STATUS_SYMLINK` / chunk entry type) and the receiver creates it with `symlinkat` beneath the receive root, never following the target. **Targets are stored verbatim (protocol 2.23.0), matching rsync `-l`: an absolute target or one containing `..` is copied exactly, and the receiver no longer enforces a containment predicate by default.** `--safe-links` is the sender-side opt-in that drops unsafe targets before transmission; `--trust-sender` does **not** affect symlink targets (it only relaxes the receiver's path-list re-validation). The *placement* path is still hard-confined (`has_path_traversal`, O_NOFOLLOW fd walk), and the link's own mode/times are applied with no-follow primitives. See the Phase-4 symlink-trust notes and the residual-risk note below |
|
||||
| `-L`, `--copy-links` | Transform symlink to referent | ✅ Parity | Sender-side: every symlink is replaced by its referent's content. A referent that cannot be read, including a broken symlink, makes the run exit 23 (`RERR_PARTIAL`) like rsync while the rest of the tree still transfers, in both the sequential and `--threads` paths (differential test). The transferred tree matches rsync |
|
||||
| `--copy-unsafe-links` | Transform unsafe symlinks | ✅ Parity | Sender-side: only symlinks whose target is unsafe (absolute or escaping via `..`, matching rsync's `unsafe_symlink()` semantics) are dereferenced into their referent; safe links stay symlinks. A broken unsafe referent makes the run exit 23 like rsync (differential test), while a safe broken symlink is not dereferenced and exits 0 |
|
||||
| `--safe-links` | Ignore symlinks outside tree | ✅ Parity | Sender-side: a symlink whose target is unsafe is not transmitted at all (skipped), matching rsync's `--safe-links`. Because FastSync applies this while scanning the source, the receiver does not need to repeat it (`safe_links` config field) |
|
||||
| `--munge-links` | Munge symlinks for safety | ✅ Parity | Sender rewrites each transmitted symlink target with rsync's `/rsyncd-munged/` prefix; the receiver strips the marker (only when the negotiated `munge_links` policy is on, so a source link that genuinely begins with the marker round-trips verbatim) and restores the exact real target. Unlike rsync, FastSync prefixes on the *sender* and un-munges on the receiver, but the wire result and the stored marker match rsync. See the Phase-4 symlink-trust notes |
|
||||
| `-k`, `--copy-dirlinks` | Transform symlink to dir | ✅ Parity | A symlink whose referent is a directory is dereferenced and recursed as a real directory; a symlink to a regular file stays a symlink. Sender-side only. See the Phase-4 symlink-trust notes |
|
||||
@@ -608,8 +618,8 @@ targets verbatim, matching rsync.
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-S`, `--sparse` | Sparse block handling | ✅ Parity | Phase 7 Wave B: real hole preservation with no wire change. The receiver's sparse-aware writer (`write_all_sparse`, next to `write_all` in `src/shared/file.c` and `src/shared/file_store.c`) walks the in-memory file image and emits any all-zero run ≥ 4096 bytes as a hole via `lseek(SEEK_CUR)` (the pre-size `ftruncate` guarantees the offset bookkeeping and logical size), `ftruncate(size)` after the last run pins the final size even with a hole tail. Wired into both the atomic temp+rename store and `--inplace` when `sparse` is set; the non-sparse path is byte-identical to before. **Sparse wins over `--preallocate`** (posix_fallocate is skipped when sparse is set, so the holes are not re-allocated). Interplay note: under `--partial` a retained sparse temp already has the full logical size (trailing content is holes), so `--append`'s "shorter destination" resume does not re-run; the retained file is still valid and a normal re-transfer (or `-W`/delta) repairs it — documented so the combination is never surprising |
|
||||
| `--preallocate` | Allocate dest files before writing | ⚠️ Caveat | The receiver preallocates the destination file's full expected space before any data is written, so a transfer that would overflow disk fails fast at allocation time (a clean error, not a half-written file) and the file is laid out contiguously, avoiding fragmentation. Crosses the wire (the config frame carries a `preallocate` boolean; `PROTOCOL_VERSION` bumped **2.10.0 → 2.11.0**, peers must match) so the sender knows the receiver will preallocate and the receiver performs it. **Allocation approach:** `posix_fallocate()` is preferred because it reserves *real* disk blocks (true fail-fast on ENOSPC), falling back to plain `ftruncate()` only when the filesystem reports the allocation is unsupported (`EOPNOTSUPP`/`ENOSYS`); `ftruncate` still extends the logical size so the intent degrades gracefully. **Fallback/error semantics:** `EOPNOTSUPP`/`ENOSYS` → clean fallback to `ftruncate` (best-effort, preallocates the logical size and never fails a transfer on filesystems that lack `posix_fallocate`); a genuine allocation failure (`ENOSPC`/`EDQUOT`/`EFBIG`/…) aborts the file/receive with a distinct `preallocate failed ... transfer aborted` error — it does **not** fall back to a normal non-preallocated write, preserving the fail-fast purpose. **Size-known requirement:** preallocation only runs when the final size is already known up front (the normal regular-file case); unknown-length data is skipped (never failed). **Orthogonality:** applies uniformly across the atomic temp+rename store path, `--inplace`, `--partial`/`--partial-dir`, `--delay-updates` (the staged temp file is preallocated before data flows) and the `--link-dest` copy fallback; it neither implies nor conflicts with `-s`, `--append`, or delta. rsync-divergence: rsync signals that `--preallocate` is ignored with `--sparse`; FastSync gives **sparse precedence** — when both are set, `posix_fallocate` is skipped so the holes the sparse writer creates are not re-allocated (the `ftruncate` presize sizing stays), matching the intent of "sparse wins". See the Phase-4 preallocate notes below |
|
||||
| `-S`, `--sparse` | Sparse block handling | ✅ Parity | Phase 7 Wave B: real hole preservation with no wire change. The receiver's sparse-aware writer (`write_all_sparse`, next to `write_all` in `src/shared/file.c` and `src/shared/file_store.c`) walks the in-memory file image and emits any all-zero run ≥ 4096 bytes as a hole via `lseek(SEEK_CUR)` (the pre-size `ftruncate` guarantees the offset bookkeeping and logical size), `ftruncate(size)` after the last run pins the final size even with a hole tail. Wired into both the atomic temp+rename store and `--inplace` when `sparse` is set; the non-sparse path is byte-identical to before. **`--preallocate` wins over `--sparse`** (protocol 2.26.0: the allocation still runs when both are set, so the sparse writer's seeks do not re-hole the reserved blocks, matching rsync's observed `st_blocks`). Interplay note: under `--partial` a retained sparse temp already has the full logical size (trailing content is holes), so `--append`'s "shorter destination" resume does not re-run; the retained file is still valid and a normal re-transfer (or `-W`/delta) repairs it — documented so the combination is never surprising |
|
||||
| `--preallocate` | Allocate dest files before writing | ✅ Parity | The receiver preallocates the destination file's full expected space before any data is written, so a transfer that would overflow disk fails fast at allocation time (a clean error, not a half-written file) and the file is laid out contiguously, avoiding fragmentation. Crosses the wire (the config frame carries a `preallocate` boolean; `PROTOCOL_VERSION` bumped **2.10.0 → 2.11.0**, peers must match) so the sender knows the receiver will preallocate and the receiver performs it. **Allocation approach (protocol 2.26.0):** `fallocate(2)` is tried first (what rsync uses); `posix_fallocate()` is the fallback and also reserves *real* disk blocks (true fail-fast on ENOSPC); plain `ftruncate()` is the final fallback when the filesystem reports the allocation is unsupported, still extending the logical size so the intent degrades gracefully. **Fallback/error semantics:** `EOPNOTSUPP`/`ENOSYS` → clean fallback to `ftruncate` (best-effort, preallocates the logical size and never fails a transfer on filesystems that lack `posix_fallocate`); a genuine allocation failure (`ENOSPC`/`EDQUOT`/`EFBIG`/…) aborts the file/receive with a distinct `preallocate failed ... transfer aborted` error — it does **not** fall back to a normal non-preallocated write, preserving the fail-fast purpose. **Size-known requirement:** preallocation only runs when the final size is already known up front (the normal regular-file case); unknown-length data is skipped (never failed). **Orthogonality:** applies uniformly across the atomic temp+rename store path, `--inplace`, `--partial`/`--partial-dir`, `--delay-updates` (the staged temp file is preallocated before data flows) and the `--link-dest` copy fallback; it neither implies nor conflicts with `-s`, `--append`, or delta. Protocol 2.26.0 matches rsync: `--preallocate` wins over `--sparse` — when both are set the allocation still runs (its reserved blocks survive the sparse writer's seeks), and a differential test's `st_blocks` agrees for every flag combination. See the Phase-4 preallocate notes below |
|
||||
|
||||
**Preallocate notes (Phase 4, preallocate wave):** `--preallocate` is implemented as a real receiver-side allocation of the destination file's space before data is written. It is a plain boolean config flag that crosses the wire (serialized in the config frame's selection-options block, mirroring `--inplace`/`--append`/`--force`), so the run requires matching ends: `PROTOCOL_VERSION` was bumped **2.10.0 → 2.11.0** (peers must match or the version check fails). The allocation is performed on the exact destination fd, immediately after it is opened, before any bytes are streamed; `posix_fallocate` (and the `ftruncate` fallback) leave the fd's file offset untouched, so the subsequent data write at offset 0 is unaffected and complete. Because FastSync writes each file's byte payload in one in-memory batch, the "full expected size" is exactly the known `data_size`, which is what gets preallocated. Unknown-length/streamed payloads are skipped rather than failed. A failed allocation logs a distinct `preallocate failed` error and aborts the file (the atomic temp is unlinked, the inplace target is left untrimmed) so the run fails cleanly and never silently degrades to a non-preallocated write — preserving rsync's fail-fast intent on a full disk.
|
||||
|
||||
@@ -618,22 +628,22 @@ 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 `xxh64` by default and is selectable via `--checksum-choice`/`--cc` (`xxh64`/`xxhash`/`xxh3`/`xxh128`/`md5`/`auto`) 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 by the basis-dir content verification. **Protocol 2.23.0 accepts `xxh64` (the default), `xxhash` (rsync's spelling of xxHash64), `xxh3`, `xxh128`, `md5`, and `auto` (which selects FastSync's default).** rsync choices FastSync does not implement — `md4`, `sha1`, `none`, and the two-name `transfer,pre-transfer` form — are **rejected by name** with a clear error at parse time, never a silent no-op. `--cc` is the alias (`--cc=ALG` and space forms both parse). The algorithm id and seed cross the wire with the config frame, so the receiver hashes its on-disk old file with the SAME algorithm+seed the sender used and both agree on a match; the sender's digest and the receiver's comparison live in the per-file `STATUS_CHECK` handshake, which carries a length-prefixed, bounded (1..16 byte) digest, and the receiver pins the received length to the negotiated algorithm's digest length (defense-in-depth: a mismatched/malicious length only forces a safe re-transfer). Digest lengths: `xxh64`/`xxh3` = 8 bytes, `xxh128`/`md5` = 16. Note: `md5` is a FIPS-non-approved algorithm, so under an OpenSSL build with FIPS mode enabled `--checksum-choice=md5` fails loudly rather than silently falling back. `PROTOCOL_VERSION` has moved well past the original 2.10.0 digest-frame bump. Like rsync, the choice only takes effect where a whole-file digest is actually computed (`--checksum` on, or a basis-dir flag). Closely-related divergence: the delta BLOCK strong checksum stays xxHash32 — `--checksum-choice` selects only the whole-file digest, matching rsync where the per-block checksum is independent of the whole-file choice |
|
||||
| `--compare-dest=DIR` | Compare dest files relative to DIR | ⚠️ Caveat | DIR is a receiver-side basis relative to the destination root (confined below it; absolute/`..`/`.` 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) |
|
||||
| `--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. **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: an already up-to-date destination file is not re-linked to a basis file (only files that would otherwise be written are linked); 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; the name gate is a Levenshtein edit distance between the basenames accepted only when ≤ half the length of the longer basename; the single best candidate (smallest distance, tie-break size closest to the incoming file then lexicographically smaller basename) is read; 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) |
|
||||
| `--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 | ⚠️ Caveat | Streaming zstd (rsync supports multiple algorithms — a documented divergence, selectable via `--compress-choice`). `-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 |
|
||||
| `--compress-choice=STR`, `--zc=STR` | Choose compression algorithm | ⚠️ Caveat | FastSync supports `zstd` (default), `none`, and `auto`. rsync's other compiled-in choices (`lz4`, `zlib`, `zlibx`) are **rejected by name** at parse time with a clear error, never silently ignored. `--zc` is the alias |
|
||||
| `-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 | ⚠️ Caveat | 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`) |
|
||||
| `--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`) |
|
||||
|
||||
## 13. Connectivity
|
||||
|
||||
@@ -650,18 +660,19 @@ targets verbatim, matching rsync.
|
||||
| `-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 | ⚠️ 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
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--daemon` | Run as rsync daemon | ⚠️ Caveat | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; every client-chosen-ownership/super-user request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/`--copy-as`/explicit `--super`) is refused unless the module opts in with `client owner = yes`, and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding |
|
||||
| `--config=FILE` | Alternate rsyncd.conf file | ⚠️ Caveat | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
||||
| `--dparam=OVERRIDE` | Override global daemon config | ⚠️ Caveat | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global keys the grammar defines (`port`, `motd file`, `address`, `max connections`, `max connections per host`, `auth failure delay`, `auth lockout threshold`, `auth lockout duration`, `hosts allow`, `hosts deny`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
||||
| `--daemon` | Run as rsync daemon | ❌ Divergent | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; every client-chosen-ownership/super-user request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/`--copy-as`/explicit `--super`) is refused unless the module opts in with `client owner = yes`, and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding |
|
||||
| `--config=FILE` | Alternate rsyncd.conf file | ❌ Divergent | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
||||
| `--dparam=OVERRIDE` | Override global daemon config | ❌ Divergent | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global keys the grammar defines (`port`, `motd file`, `address`, `max connections`, `max connections per host`, `auth failure delay`, `auth lockout threshold`, `auth lockout duration`, `hosts allow`, `hosts deny`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
||||
| `--no-detach` | Don't detach from parent | ✅ Parity | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
|
||||
| `--password-file=FILE` | Read daemon password from file | ⚠️ Caveat | A7 daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected); the literal password is held client-side only for the SCRAM handshake and wiped at teardown. Server (`fastsync-server --daemon --password-file FILE`): the salted-PBKDF2 verifier store that modules with `auth users` are verified against. **Neither the password nor any replayable bearer value crosses the wire or is stored server-side** — the store holds a per-user salt plus derived keys, and the daemon proves the secret with a per-connection nonce challenge. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
|
||||
| `--early-input=FILE` | Use FILE for daemon early exec | ⚠️ Caveat | Server-only (requires `--daemon`): a second credential-store file, same new-format grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: byte-identical verifiers dedupe, a conflicting verifier for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
|
||||
| `--hash-credentials=FILE`, `--iterations N` | Hash a plaintext credential file | ⚠️ Caveat | Server-only offline tool (A7): reads the `user:password` lines of FILE (same owner-only 0600 check) and prints one new-format store line per entry to stdout, then exits. `--iterations` sets the PBKDF2 work factor (default 600000, range 100000–10000000). Dependency-free and does not run a listener. Use its output as `--password-file` for `--daemon`. There is no auto-upgrade: a legacy store line is hard-rejected by the loader and must be regenerated |
|
||||
| `--password-file=FILE` | Read daemon password from file | ❌ Divergent | A7 daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected); the literal password is held client-side only for the SCRAM handshake and wiped at teardown. Server (`fastsync-server --daemon --password-file FILE`): the salted-PBKDF2 verifier store that modules with `auth users` are verified against. **Neither the password nor any replayable bearer value crosses the wire or is stored server-side** — the store holds a per-user salt plus derived keys, and the daemon proves the secret with a per-connection nonce challenge. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
|
||||
| `--early-input=FILE` | Use FILE for daemon early exec | ❌ Divergent | Server-only (requires `--daemon`): a second credential-store file, same new-format grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: byte-identical verifiers dedupe, a conflicting verifier for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
|
||||
| `--hash-credentials=FILE`, `--iterations N` | Hash a plaintext credential file | ❌ Divergent | Server-only offline tool (A7): reads the `user:password` lines of FILE (same owner-only 0600 check) and prints one new-format store line per entry to stdout, then exits. `--iterations` sets the PBKDF2 work factor (default 600000, range 100000–10000000). Dependency-free and does not run a listener. Use its output as `--password-file` for `--daemon`. There is no auto-upgrade: a legacy store line is hard-rejected by the loader and must be regenerated |
|
||||
|
||||
**Daemon Mode notes (Wave A protocol 2.15.0; A7 auth protocol 2.19.0; MOTD no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
||||
|
||||
@@ -690,30 +701,43 @@ targets verbatim, matching rsync.
|
||||
| Max data/string/chunk sizes | Prevent OOM attacks | ✅ Parity | Per-message limits |
|
||||
| Per-connection memory limit | Cap memory per connection | ✅ Parity | `MAX_CONNECTION_MEMORY` is **256 MiB per connection** (256 * 1024 * 1024 bytes), charged across protocol reservations and decompression/chunk allocations. This is a FastSync-internal bound with no direct rsync analogue |
|
||||
| `--max-alloc=SIZE` | Limit a single memory allocation | ✅ Parity | Caps the largest single allocation; binary units, default 1G |
|
||||
| `--trust-sender` | Trust remote sender's file list | ⚠️ Caveat | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. **It no longer affects symlink targets** (protocol 2.23.0): targets are stored verbatim under `-l` regardless of `--trust-sender`; the flag only relaxes the receiver's path-list checks. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) |
|
||||
| `--old-args` | Disable modern arg protection | ⚠️ Caveat | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way |
|
||||
| `--ignore-missing-args` | Ignore missing source args | ⚠️ Caveat | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation |
|
||||
| `--trust-sender` | Trust remote sender's file list | ✅ Parity | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. **It no longer affects symlink targets** (protocol 2.23.0): targets are stored verbatim under `-l` regardless of `--trust-sender`; the flag only relaxes the receiver's path-list checks. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) |
|
||||
| `--old-args` | Disable modern arg protection | ❌ Divergent | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way |
|
||||
| `--ignore-missing-args` | Ignore missing source args | ✅ Parity | The flags apply to the `--files-from` entries (the single source root always exists; inert without `--files-from`). Without the flag a listed-but-missing entry is a hard pre-transfer error. With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest (an all-missing list transfers nothing). Every skipped entry is logged and a per-run warning names the count. **An empty `--files-from` list is now a zero-transfer success with or without this flag (exit 0), matching rsync 3.4.1.** `--no-ignore-missing-args` is rejected exactly as rsync 3.4.1 rejects it, rather than being accepted as a negation |
|
||||
| `--delete-missing-args` | Delete missing source args | ✅ Parity | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. `--force` is deletion authority and is therefore gated by the server `--allow-delete` policy exactly like `--delete`/`--delete-missing-args`: without it the receiver clears the flag, so a client cannot use `--force` to recursively replace or remove a destination directory tree. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Protocol 2.23.0 parity: the missing-args exact-path removals and the ordinary extras walk **draw from one shared `--max-delete` budget**, so a capped run stops part-way and exits 25 exactly like rsync. See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump |
|
||||
|
||||
**Setuid/setgid/sticky bits under `-p` (security note).** As with upstream
|
||||
rsync, `-p`/`--perms` reproduces the source's special bits as well as the rwx
|
||||
bits: setuid, setgid, and sticky are applied whenever the receiver can apply
|
||||
them (the receiving user owns the file and the mount permits it), and a refused
|
||||
chmod is logged rather than silently masked (`tests/test_metadata.c:575`). This
|
||||
is a deliberate change from earlier FastSync releases, which always masked
|
||||
special bits. Deployments that do not trust the sender should rely on the
|
||||
existing mitigations rather than on that masking: keep the daemon module default
|
||||
`client owner = no`, run the daemon unprivileged, and use
|
||||
`--munge-links`/`--safe-links` so a hostile source cannot weaponize preserved
|
||||
modes or links.
|
||||
|
||||
## 16. Batch Operations
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--write-batch=FILE` | Write batched update to file | ⚠️ Caveat | Phase-6 residual-batch (client-only): runs the normal live transfer AND additionally emits a self-contained single-file batch of the whole source tree. The batch is a magic/format-version header followed by length-prefixed `chunk_serialize` blobs (full file images), replayable byte-identically by `--read-batch` on another machine with no source/server. `--write-batch` drives the single-threaded transfer path (the multithreaded path consumes the config before the separate batch scan pass). See the Phase-6 batch note below |
|
||||
| `--only-write-batch=FILE` | Write batch without updating dest | ⚠️ Caveat | Phase-6 residual-batch: emits the self-contained batch FILE only — NO destination update, NO server connection. Requires a source (scans it and serializes the full tree to FILE). Same single-file format as `--write-batch`, so the file is re-appliable via `--read-batch=FILE DEST`. See the Phase-6 batch note below |
|
||||
| `--read-batch=FILE` | Read batched update from file | ⚠️ Caveat | Phase-6 residual-batch: applies a previously written batch FILE locally to the destination. NO source and NO server — positional args are the destination only. Reads the magic/version header, then length-prefixed records, `chunk_deserialize`, and applies each via the confined `file_save_to_disk_full` path (same O_NOFOLLOW / `..`-rejection / root-confinement as the network receiver, so an attacker-controlled batch cannot escape the destination root). Malformed/truncated/oversized/traversal records are rejected cleanly. See the Phase-6 batch note below |
|
||||
| `--write-batch=FILE` | Write batched update to file | ❌ Divergent | Phase-6 residual-batch (client-only): runs the normal live transfer AND additionally emits a self-contained single-file batch of the whole source tree. The batch is a magic/format-version header followed by length-prefixed `chunk_serialize` blobs (full file images), replayable byte-identically by `--read-batch` on another machine with no source/server. `--write-batch` drives the single-threaded transfer path (the multithreaded path consumes the config before the separate batch scan pass). The FastSync container is deliberately not interoperable with rsync batch files. See the Phase-6 batch note below |
|
||||
| `--only-write-batch=FILE` | Write batch without updating dest | ❌ Divergent | Phase-6 residual-batch: emits the self-contained batch FILE only — NO destination update, NO server connection. Requires a source (scans it and serializes the full tree to FILE). Same single-file format as `--write-batch`, so the file is re-appliable via `--read-batch=FILE DEST`. The FastSync container is deliberately not interoperable with rsync batch files. See the Phase-6 batch note below |
|
||||
| `--read-batch=FILE` | Read batched update from file | ❌ Divergent | Phase-6 residual-batch: applies a previously written batch FILE locally to the destination. NO source and NO server — positional args are the destination only. Reads the magic/version header, then length-prefixed records, `chunk_deserialize`, and applies each via the confined `file_save_to_disk_full` path (same O_NOFOLLOW / `..`-rejection / root-confinement as the network receiver, so an attacker-controlled batch cannot escape the destination root). Malformed/truncated/oversized/traversal records are rejected cleanly. The FastSync container is deliberately not interoperable with rsync batch files. See the Phase-6 batch note below |
|
||||
|
||||
## 17. Advanced
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--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 | ⚠️ Caveat | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
|
||||
| `--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.23.0) with no downgrade/backward-compat code paths, so `--protocol=2.23.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.21.0`/`2.20.0`/`2.19.0`/`2.18.0`/`2.18`/`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. 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) |
|
||||
| `--no-OPTION` | Turn off implied option | ✅ Parity | Supported boolean FastSync options and archive-implied options; unsafe or value-taking options are rejected. |
|
||||
|
||||
---
|
||||
@@ -826,7 +850,7 @@ These are the hardest compatibility items because they require durable formats o
|
||||
|
||||
**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: `--protocol=2.23.0` (the current `PROTOCOL_VERSION`, as of the rsync-parity wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `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.
|
||||
**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.
|
||||
|
||||
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
|
||||
|
||||
@@ -849,9 +873,9 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
||||
| `-T` / `--timeout` | `-T` = `--temp-dir` | → `--timeout` (long-only) |
|
||||
| `-a` / `--archive` (= `-c -m -M`) | `-a` = `-rlptD` | → becomes **real rsync `-a`** after the renames |
|
||||
|
||||
**Wave B — Output & filesystem completion (✅ implemented).** `-S`/`--sparse` (`⚠️→✅`): real hole preservation — a sparse-aware writer (`write_all_sparse`) skips all-zero runs ≥ 4096 bytes with `lseek(SEEK_CUR)` and `ftruncate`s the final size, wired into both the atomic temp+rename store and `--inplace` receiver-side with **no wire change** (the full file image is already in memory; the ftruncate presize is kept). `-P` (`⚠️→✅`): interrupted-write retention — on a save failure after data reached the temp fd, `--partial` now renames the already-written temp to the destination path (best-effort; falls through to the normal unlink on failure, never retains when `--partial` is off) so a later `--append`/`--append-verify` run can resume. `--block-size=SIZE` (`⚠️→✅`): promoted after verification — `--block-size` is now an alias for `--delta-block`, both set `config->delta_block_size`, which the delta engine already honored end-to-end (`delta_signature_create_seeded` + `delta_apply`); out-of-range values keep the default. `--fake-super` (`⚠️→✅`): added `fake_super_restore_fd` to parse and re-apply the recorded `user.fastsync.stat` record fd-relative (mode/time only — protocol 2.23.0: **never a real chown**; the resolved owner is recorded for a later privileged restore); a save under `--fake-super` now re-applies the recorded attrs instead of only recording them, with the recording format unchanged. `--stderr=client` (`⚠️→❌ Divergent`): FastSync has no rsync client-message channel, and `client` is rejected at CLI parse — the rejection is the documented behavior (unit-tested). `-N`/`--crtimes` (`⚠️→❌ Divergent`): birth-times cannot be set by any portable fs call (`utimensat` sets only atime/mtime); capture/transmit stays, setting is impossible, the flag is accepted and safely inert. Review-hardening (post-eval): fake-super replay applies the mode through the shared `metadata_mode_for_policy` helper (protocol 2.23.0: exactly the source mode under `-p`, with no masking); `--sparse` takes precedence over `--preallocate` (posix_fallocate skipped so holes survive); `--partial` retention is disabled for `--no_replace` (ignore/existing) and only marks a write-attempt after the actual write begins; `--block-size=SIZE`/`--delta-block=SIZE` inline forms are accepted.
|
||||
**Wave B — Output & filesystem completion (✅ implemented).** `-S`/`--sparse` (`⚠️→✅`): real hole preservation — a sparse-aware writer (`write_all_sparse`) skips all-zero runs ≥ 4096 bytes with `lseek(SEEK_CUR)` and `ftruncate`s the final size, wired into both the atomic temp+rename store and `--inplace` receiver-side with **no wire change** (the full file image is already in memory; the ftruncate presize is kept). `-P` (`⚠️→✅`): interrupted-write retention — on a save failure after data reached the temp fd, `--partial` now renames the already-written temp to the destination path (best-effort; falls through to the normal unlink on failure, never retains when `--partial` is off) so a later `--append`/`--append-verify` run can resume. `--block-size=SIZE` (`⚠️→✅`): promoted after verification — `--block-size` is now an alias for `--delta-block`, both set `config->delta_block_size`, which the delta engine already honored end-to-end (`delta_signature_create_seeded` + `delta_apply`); out-of-range values keep the default. `--fake-super` (`⚠️→✅`): added `fake_super_restore_fd` to parse and re-apply the recorded `user.fastsync.stat` record fd-relative (mode/time only — protocol 2.23.0: **never a real chown**; the resolved owner is recorded for a later privileged restore); a save under `--fake-super` now re-applies the recorded attrs instead of only recording them, with the recording format unchanged. `--stderr=client` (`⚠️→❌ Divergent`): FastSync has no rsync client-message channel, and `client` is rejected at CLI parse — the rejection is the documented behavior (unit-tested). `-N`/`--crtimes` (`⚠️→❌ Divergent`): birth-times cannot be set by any portable fs call (`utimensat` sets only atime/mtime); capture/transmit stays, setting is impossible, the flag is accepted and safely inert. Review-hardening (post-eval): fake-super replay applies the mode through the shared `metadata_mode_for_policy` helper (protocol 2.23.0: exactly the source mode under `-p`, with no masking); `--sparse` takes precedence over `--preallocate` (posix_fallocate skipped so holes survive) — **reversed by the parity-completion wave: `--preallocate` now wins, matching rsync**; `--partial` retention is disabled for `--no_replace` (ignore/existing) and only marks a write-attempt after the actual write begins; `--block-size=SIZE`/`--delta-block=SIZE` inline forms are accepted.
|
||||
|
||||
**Wave C — Devices & special files (finalize statuses + tests) (✅ implemented).** The four special-file rows are finalized with coverage tests. `--devices`, `--copy-devices`, and `--write-devices` are **✅ Implemented**, each with a documented, safety-driven divergence: device-node creation is privilege-gated, so a receiver without `CAP_MKNOD` skips that entry with a warning (a per-entry skip, never a transfer failure); `--copy-devices` copies a device/FIFO's reported size into an ordinary regular file (a size-bounded safe divergence from rsync's unbounded dd-like read); `--write-devices` writes only into an existing char/block node under the confined receive root and skips every unusable target rather than clobbering or aborting. `--specials` reclassified from **⛔ Impossible/Divergence** to **✅ Parity** in protocol 2.23.0: **FIFO recreation works** (unprivileged `mkfifo`) **and unix sockets are recreated** with `mknod(S_IFSOCK)`, which Linux permits unprivileged (the flag previously assumed sockets were impossible — see the `--specials` row). Tests assert FIFO recreation, socket recreation, the regular-file result of `--copy-devices`, the skipped/missing and non-device `--write-devices` targets, and (root-gated) real device-node creation; a root runner additionally drops the receiver to an unprivileged user to assert the `CAP_MKNOD` skip is graceful.
|
||||
**Wave C — Devices & special files (finalize statuses + tests) (✅ implemented).** The four special-file rows are finalized with coverage tests. `--devices`, `--copy-devices`, and `--write-devices` are **✅ Implemented**, each with a documented, safety-driven divergence: device-node creation is privilege-gated, so a receiver without `CAP_MKNOD` skips that entry with a warning (a per-entry skip, never a transfer failure); `--copy-devices` copies a device/FIFO's reported size into an ordinary regular file (a size-bounded safe divergence from rsync's unbounded dd-like read); `--write-devices` writes only into an existing char/block node under the confined receive root and skips every unusable target rather than clobbering or aborting. `--specials` reclassified from **⛔ Impossible/Divergence** to **✅ Parity** in protocol 2.23.0: **FIFO recreation works** (unprivileged `mkfifo`) **and unix sockets are recreated** with `mknod(S_IFSOCK)`, which Linux permits unprivileged (the flag previously assumed sockets were impossible — see the `--specials` row). Tests assert FIFO recreation, socket recreation, the regular-file result of `--copy-devices`, the skipped/missing and non-device `--write-devices` targets, and (root-gated) real device-node creation; a root runner additionally drops the receiver to an unprivileged user to assert the `CAP_MKNOD` skip is graceful. (The parity-completion wave later reclassified `--devices`, `--copy-devices`, and `--write-devices` as explicit **❌ Divergent** rows, because their safe subsets are deliberately not rsync's behavior; the implementation itself is unchanged.)
|
||||
|
||||
**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:
|
||||
|
||||
@@ -869,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.
|
||||
|
||||
**Current honest status (protocol 2.23.0).** ✅ Parity 83 / ⚠️ Caveat 63 / ❌ Divergent 4 = 150 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. The reclassification makes the differences explicit and the rsync-parity wave closed the genuine gaps (short options, clustering, checksum/compression choices, seed randomization, timeout defaults, delete scoping and partial limits, verbatim symlink storage, socket recreation, `--chmod`, and more — see the next section). The four ❌ rows are `--stderr=client` (no rsync client-message channel), `-N/--crtimes` (no portable setter), `--protocol=NUM` (only the current wire version is accepted), and `-s/--secluded-args` (accepted no-op). `--specials` is now ✅ because sockets are recreated with `mknod(S_IFSOCK)`. **No `❌ Not Implemented` rows remain.**
|
||||
**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.
|
||||
|
||||
@@ -1011,6 +1035,126 @@ These remain after the wave; they are the reasons a row above is ⚠️.
|
||||
`--protocol` accepts only the current version and `-s`/`--secluded-args` is an
|
||||
accepted no-op.
|
||||
|
||||
## Parity Completion Wave (protocol 2.26.0)
|
||||
|
||||
This wave closed the remaining rsync-parity gaps left by the rsync-parity wave
|
||||
and reclassified the inherently non-rsync rows as **divergent**. It moved the
|
||||
wire protocol three times (full rationale in `src/shared/config.h`):
|
||||
|
||||
- **2.23.0 → 2.24.0 (delete timing):** the sender streams one delete plan per
|
||||
source directory (`STATUS_DELETE_PLAN`) so `--delete-during`/`--delete-delay`
|
||||
reproduce rsync's per-directory deletion timing.
|
||||
- **2.24.0 → 2.25.0 (wire stats):** the config frame gains `report_stats` and
|
||||
the receiver emits a `STATUS_STATS` frame carrying the receiver-only counters
|
||||
(matched data, deleted-file count) and, for `-n --delete`, the would-delete
|
||||
paths.
|
||||
- **2.25.0 → 2.26.0 (codecs):** the config frame gains the negotiated
|
||||
`compression_algo` int, and the checksum codec accepts `md4`/`sha1`/`none`
|
||||
(default `xxh128`, negotiated with `auto`).
|
||||
|
||||
### Deletion timing (2.24.0)
|
||||
|
||||
- **`--delete-during`/`--del`** streams a per-directory plan as each directory
|
||||
is scanned, so its extras are removed before the next directory's data; a
|
||||
mid-transfer abort has already deleted the reached directories' extras.
|
||||
- **`--delete-delay`** records each directory's plan while scanning and commits
|
||||
the removals only after the whole transfer succeeds, so an extra created
|
||||
mid-transfer after its directory's plan survives, while `--delete-after`'s
|
||||
fresh end scan removes it.
|
||||
- Per-directory plans are scoped to `-R`'s transferred prefix and bounded by the
|
||||
shared manifest caps; `-d`/`--dirs` (no descent) falls back to the end commit.
|
||||
- Empty in-scope source directories survive the per-directory delete. Dry-run
|
||||
never deletes; `-n --delete` prints the would-delete lines (see below).
|
||||
|
||||
### Receiver stats and output (2.25.0)
|
||||
|
||||
- **`STATUS_STATS`** is sent immediately before the terminal success status and
|
||||
carries `Matched data`, the deleted-file count, and the dry-run would-delete
|
||||
path list. The client reads it before the `--remove-source-files` acks so the
|
||||
counters are always populated, in both the sequential and `--threads` paths.
|
||||
- **`--stats`** prints octets/rates/file counts from the sender plus the
|
||||
receiver counters above; the protocol-independent lines match rsync exactly.
|
||||
- **`--progress`/`-P`** print rsync-style per-file blocks (the first frame is
|
||||
byte-identical) from the wire counters.
|
||||
- **`--out-format`** gains `%b` (FastSync wire bytes), `%c` (block-sum bytes)
|
||||
and `%C` (whole-file digest). `%C` is protocol-independent and matches rsync
|
||||
for a whole-file transfer.
|
||||
- **`-n --delete`** prints escaped `*deleting` lines from the receiver's
|
||||
would-delete list.
|
||||
|
||||
### Codec breadth and negotiation (2.26.0)
|
||||
|
||||
- **Compression:** `zstd` (default), `lz4`, `zlib`, `zlibx`, `none`, `auto`.
|
||||
The resolved codec id crosses the wire and the receiver validates it against
|
||||
its own set (rsync's "no common choice is an error").
|
||||
- **Checksums:** `xxh128` (default), `xxh3`, `xxh64`/`xxhash`, `md5`, `md4`,
|
||||
`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.
|
||||
- **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
|
||||
|
||||
- **General `-R`/`--relative`** implements the `/./` cut and prefix-scoped
|
||||
deletion; **`--no-implied-dirs`** stops implied-parent attribute application.
|
||||
- **`-d`/`--dirs`** implements rsync's one-level listing for `dir`, `dir/` and
|
||||
`.`, with `STATUS_MKDIR` directory entries in the delete manifest.
|
||||
- **Filter grammar:** `merge`/`.`, `dir-merge`/`:`, `hide`/`H`, `show`/`S`,
|
||||
`protect`/`P`, `risk`/`R`, `clear`/`!`, include/exclude and the `:`/`.`
|
||||
modifiers; `-f` is bound to `--filter`; a single `-F` transfers
|
||||
`.rsync-filter` and `-FF` excludes it.
|
||||
- **Absolute basis directories** are used verbatim (rsync semantics) and
|
||||
**`--link-dest`** relinks an already up-to-date destination.
|
||||
|
||||
### Client quick wins and aliases
|
||||
|
||||
- `--iconv=.`/`-`/`--no-iconv`; a lone `-h` prints help; an empty
|
||||
`--files-from` succeeds (exit 0); a broken referent under `-L`/
|
||||
`--copy-unsafe-links` exits 23; the full `--info`/`--debug` vocabularies; and
|
||||
the aliases `--ignore-non-existing`, `--protect-args`, `--msgs2stderr`.
|
||||
- Receiver-side `--chown`/`--usermap`/`--groupmap` TO-name resolution; a
|
||||
receiver-side `--ignore-existing` short-circuit before any payload; and
|
||||
`--preallocate` now wins over `--sparse` via `fallocate(2)`.
|
||||
|
||||
### Residuals and intentional divergences
|
||||
|
||||
These remain after the wave; the individual rows carry the precise wording.
|
||||
|
||||
- **`--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
|
||||
rsync's delete-during; per-directory plans have generator-order/abort-boundary
|
||||
differences; `--delete-before` keeps its pre-scan snapshot race; and
|
||||
destination-only files matching an exclude are still removed (protection is
|
||||
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;
|
||||
**`--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; 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)
|
||||
|
||||
Native daemon config/auth (`--daemon`, `--config`, `--dparam`,
|
||||
`--password-file`, `--early-input`, `--hash-credentials`/`--iterations`), the
|
||||
non-interoperable batch container (`--write-batch`/`--only-write-batch`/
|
||||
`--read-batch`), `--fake-super`'s native xattr format, `-X`'s privileged
|
||||
namespaces, `--devices`/`--copy-devices`/`--write-devices`'s safe subsets,
|
||||
`--super`/`--copy-as`'s refusal to elevate or switch credentials, and the
|
||||
`-s`/`--secluded-args`/`--protect-args`/`--old-args` accepted no-ops.
|
||||
|
||||
## Packed Metadata Frame (protocol 2.20.0)
|
||||
|
||||
A file's metadata used to cross the wire as up to 12 separate per-field framed
|
||||
|
||||
@@ -38,6 +38,8 @@ pkgs.mkShell {
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
zstd
|
||||
zlib
|
||||
lz4
|
||||
openssl
|
||||
];
|
||||
|
||||
|
||||
+121
-13
@@ -1,5 +1,7 @@
|
||||
#include "change_list.h"
|
||||
#include "checksum.h"
|
||||
#include "utils.h"
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
@@ -156,14 +158,6 @@ static void itemize_code(const Config* config, const ChangeEvent* event, char co
|
||||
code[11] = '\0';
|
||||
}
|
||||
|
||||
char* change_render_itemize_code(const Config* config, const ChangeEvent* event) {
|
||||
if (event == NULL || event->decision != CHANGE_SENT)
|
||||
return str_dup("");
|
||||
char code[12];
|
||||
itemize_code(config, event, code);
|
||||
return str_dup(code);
|
||||
}
|
||||
|
||||
/* rsync %n: the transfer-relative name, with a trailing slash for directories. */
|
||||
static bool append_name(StrBuf* buf, const ChangeEvent* event) {
|
||||
if (!strbuf_append(buf, event->name != NULL ? event->name : ""))
|
||||
@@ -200,6 +194,82 @@ char* change_render_itemize(const Config* config, const ChangeEvent* event) {
|
||||
|
||||
/* ---- --out-format / --log-file-format ---- */
|
||||
|
||||
/* 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) {
|
||||
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: 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;
|
||||
uint64_t high = 0;
|
||||
memcpy(&low, digest, sizeof(low));
|
||||
memcpy(&high, digest + 8, sizeof(high));
|
||||
snprintf(out, len * 2 + 1, "%016llx%016llx", (unsigned long long)high, (unsigned long long)low);
|
||||
return;
|
||||
}
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
out[i * 2] = hex[(digest[i] >> 4) & 0xf];
|
||||
out[i * 2 + 1] = hex[digest[i] & 0xf];
|
||||
}
|
||||
out[len * 2] = '\0';
|
||||
}
|
||||
|
||||
static bool format_uses_checksum(const char* format) {
|
||||
if (format == NULL)
|
||||
return false;
|
||||
for (const char* p = format; *p != '\0';) {
|
||||
if (*p != '%') {
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
char token = p[1];
|
||||
if (token == '\0')
|
||||
break;
|
||||
if (token == 'C')
|
||||
return true;
|
||||
p += 2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Fill event->checksum/checksum_known for a transferred regular file. A
|
||||
* non-regular entry (or a hard-link sibling) leaves checksum_known false, which
|
||||
* renders as spaces like rsync. */
|
||||
static void fill_event_checksum(const Config* config, const File* file, ChangeEvent* event) {
|
||||
if (file == NULL || file->is_dir || file->is_symlink || file->is_special ||
|
||||
(file->link_group != 0 && !file->link_first))
|
||||
return;
|
||||
if (!format_uses_checksum(config->out_format) && !format_uses_checksum(config->log_file_format))
|
||||
return;
|
||||
if (file->path == NULL)
|
||||
return;
|
||||
ChecksumAlgo algo = out_format_checksum_algo(config);
|
||||
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
|
||||
* independent of --checksum-seed, as rsync 3.4.1 demonstrates). */
|
||||
if (!checksum_digest_file(algo, 0, file->path, digest, sizeof(digest), &len))
|
||||
return;
|
||||
digest_to_hex(algo, digest, len, event->checksum);
|
||||
event->checksum_known = true;
|
||||
}
|
||||
|
||||
char* change_render_format(const char* format, const Config* config, const ChangeEvent* event) {
|
||||
if (format == NULL || event == NULL)
|
||||
return NULL;
|
||||
@@ -221,6 +291,11 @@ char* change_render_format(const char* format, const Config* config, const Chang
|
||||
ok = strbuf_append_char(&line, '%');
|
||||
break;
|
||||
case 'i': {
|
||||
if (event->deleted) {
|
||||
/* rsync's ITEM_DELETED itemize code: `*deleting ` (11 chars). */
|
||||
ok = strbuf_append(&line, "*deleting ");
|
||||
break;
|
||||
}
|
||||
char code[12];
|
||||
itemize_code(config, event, code);
|
||||
ok = strbuf_append(&line, code);
|
||||
@@ -245,6 +320,22 @@ char* change_render_format(const char* format, const Config* config, const Chang
|
||||
int written = snprintf(digits, sizeof(digits), "%llu", event->bytes_sent);
|
||||
ok = written >= 0 && (size_t)written < sizeof(digits) && strbuf_append(&line, digits);
|
||||
} break;
|
||||
case 'c': {
|
||||
char digits[32];
|
||||
int written = snprintf(digits, sizeof(digits), "%llu", event->bytes_read);
|
||||
ok = written >= 0 && (size_t)written < sizeof(digits) && strbuf_append(&line, digits);
|
||||
} break;
|
||||
case 'C': {
|
||||
if (event->checksum_known) {
|
||||
ok = strbuf_append(&line, event->checksum);
|
||||
} else {
|
||||
/* rsync pads a non-regular / untransferred entry with spaces. */
|
||||
ChecksumAlgo algo = out_format_checksum_algo(config);
|
||||
int width = checksum_digest_len(algo) * 2;
|
||||
for (int i = 0; i < width && ok; i++)
|
||||
ok = strbuf_append_char(&line, ' ');
|
||||
}
|
||||
} break;
|
||||
case 'M': {
|
||||
char when[32];
|
||||
if (format_rsync_datetime(event->mtime_sec, true, when, sizeof(when)))
|
||||
@@ -464,7 +555,8 @@ static void fill_event_from_file(const Config* config, const File* file, ChangeE
|
||||
}
|
||||
}
|
||||
|
||||
void change_emit_file_sent(const Config* config, const File* file) {
|
||||
void change_emit_file_sent_bytes(const Config* config, const File* file,
|
||||
unsigned long long bytes_sent, unsigned long long bytes_read) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
ChangeEvent event;
|
||||
@@ -489,19 +581,35 @@ void change_emit_file_sent(const Config* config, const File* file) {
|
||||
event.hardlink_target = file->hardlink_target;
|
||||
event.bytes_sent = 0;
|
||||
} else {
|
||||
/* Literal payload bytes delivered; compressed/delta wire bytes are not
|
||||
* separately counted. */
|
||||
event.bytes_sent = event.size;
|
||||
event.bytes_sent = bytes_sent;
|
||||
/* rsync's %c is the block-checksum bytes received for the file. Even a
|
||||
* whole-file transfer (no basis; --append/--inplace included) receives
|
||||
* rsync's 16-byte sum header, so rsync reports 16; a dry run transfers
|
||||
* nothing and reports 0. FastSync's whole-file path has no sum header, so
|
||||
* report rsync's value for parity. With delta enabled the real received
|
||||
* bytes are kept, but FastSync's signature framing differs from rsync's so
|
||||
* those stay numerically divergent. */
|
||||
bool delta_active = config->use_delta && !config->whole_file;
|
||||
event.bytes_read = (!config->dry_run && !delta_active) ? 16 : bytes_read;
|
||||
}
|
||||
char* name = NULL;
|
||||
char* path = NULL;
|
||||
fill_event_from_file(config, file, &event, &name, &path);
|
||||
if (name != NULL && path != NULL)
|
||||
if (name != NULL && path != NULL) {
|
||||
fill_event_checksum(config, file, &event);
|
||||
change_emit(config, &event);
|
||||
}
|
||||
free(name);
|
||||
free(path);
|
||||
}
|
||||
|
||||
void change_emit_file_sent(const Config* config, const File* file) {
|
||||
if (file == NULL)
|
||||
return;
|
||||
unsigned long long payload = file->data != NULL ? file->data->size : 0;
|
||||
change_emit_file_sent_bytes(config, file, payload, 0);
|
||||
}
|
||||
|
||||
void change_emit_dir_sent(const Config* config, const File* file) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define CHANGE_LIST_H
|
||||
|
||||
#include "config.h"
|
||||
#include "checksum.h"
|
||||
#include "file_types.h"
|
||||
#include "format.h"
|
||||
#include <stdbool.h>
|
||||
@@ -34,10 +35,17 @@ typedef struct {
|
||||
bool is_symlink;
|
||||
bool is_special;
|
||||
bool is_hardlink; /* a hard-link sibling (linked, no data sent) */
|
||||
bool deleted; /* a would-delete report (-n --delete); no source file */
|
||||
const char* symlink_target;
|
||||
const char* hardlink_target;
|
||||
unsigned long long size; /* source file length in bytes */
|
||||
unsigned long long bytes_sent; /* literal data bytes actually transferred */
|
||||
unsigned long long bytes_sent; /* wire bytes actually transferred (rsync %b) */
|
||||
unsigned long long bytes_read; /* wire bytes read back for this file (rsync %c) */
|
||||
/* rsync %C: whole-file checksum hex for a transferred regular file. Only
|
||||
* filled when the active format uses %C (checksum_known == false otherwise,
|
||||
* which renders as spaces like rsync for non-regular entries). */
|
||||
bool checksum_known;
|
||||
char checksum[CHECKSUM_MAX_DIGEST_LEN * 2 + 1];
|
||||
time_t mtime_sec;
|
||||
long mtime_nsec;
|
||||
mode_t mode;
|
||||
@@ -55,13 +63,13 @@ bool change_list_enabled(const Config* config);
|
||||
* (`%i %n%L`): `>f+++++++++ sub/b.txt`. Caller frees the result. */
|
||||
char* change_render_itemize(const Config* config, const ChangeEvent* event);
|
||||
|
||||
/* Render only the 11-character itemize code (rsync %i). Caller frees. */
|
||||
char* change_render_itemize_code(const Config* config, const ChangeEvent* event);
|
||||
|
||||
/* Expand an --out-format/--log-file-format template. Supported tokens:
|
||||
* %i itemize code %n transfer-relative name (dir: trailing /)
|
||||
* %f long display path %l file length in bytes
|
||||
* %b bytes actually sent %M mtime (YYYY/MM/DD-HH:MM:SS)
|
||||
* %b wire bytes transferred %c block-checksum bytes received (rsync: 16
|
||||
* for a whole-file transfer, 0 for a dry run)
|
||||
* %C whole-file checksum hex (xxh128 by default; spaces for non-regular)
|
||||
* %M mtime (YYYY/MM/DD-HH:MM:SS)
|
||||
* %t current time %o operation ("send"/"del.")
|
||||
* %p pid %B permission bits without the type char
|
||||
* %U uid %G gid
|
||||
@@ -80,7 +88,15 @@ char* change_render_list_line(const Config* config, const ChangeEvent* event);
|
||||
* CHANGE_UP_TO_DATE events produce no output. */
|
||||
void change_emit(const Config* config, const ChangeEvent* event);
|
||||
|
||||
/* Build and emit a CHANGE_SENT event for a file the client just sent. */
|
||||
/* Build and emit a CHANGE_SENT event for a file the client just sent. `bytes_sent`
|
||||
* is the process-wide wire-byte delta for this file (rsync's %b) and `bytes_read`
|
||||
* the received bytes used for the delta handshake; pass 0 when unknown. For a
|
||||
* whole-file transfer %c is pinned to rsync's 16-byte sum header regardless. */
|
||||
void change_emit_file_sent_bytes(const Config* config, const File* file,
|
||||
unsigned long long bytes_sent, unsigned long long bytes_read);
|
||||
|
||||
/* Build and emit a CHANGE_SENT event for a file the client just sent, deriving
|
||||
* the wire byte counts from the source payload length. */
|
||||
void change_emit_file_sent(const Config* config, const File* file);
|
||||
|
||||
/* Build and emit a CHANGE_SENT event for an explicit directory entry (-d). */
|
||||
|
||||
+337
-68
@@ -20,7 +20,9 @@
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <langinfo.h>
|
||||
#include <limits.h>
|
||||
#include <locale.h>
|
||||
#include <time.h>
|
||||
#include <signal.h>
|
||||
#include <stdbool.h>
|
||||
@@ -148,47 +150,96 @@ static int set_positive_int_option(int* dest, const char* value, const char* opt
|
||||
}
|
||||
|
||||
/* Set and validate the compression algorithm selected by the client. rsync
|
||||
* 3.4.1 can be built with zstd, none, lz4, zlibx, zlib and auto; FastSync only
|
||||
* implements zstd (and no compression). "auto" is accepted as the default
|
||||
* zstd choice; any other rsync choice is rejected by name instead of being
|
||||
* silently accepted and ignored. */
|
||||
* 3.4.1 can be built with zstd, none, lz4, zlibx, zlib and auto; all of those
|
||||
* names are accepted and mapped to a real codec here. "auto" resolves through
|
||||
* FastSync's compiled-in preference order (rsync 3.4.1's list). An unknown
|
||||
* name is a hard error with rsync's exit code 4, never a silent no-op. */
|
||||
static int set_compression_choice(Config* config, const char* value) {
|
||||
/* rsync's "auto" is normalized to the canonical "zstd" at parse time (like
|
||||
--checksum-choice=auto), so the value that crosses the wire is always one
|
||||
the receiver accepts. */
|
||||
const char* canonical = strcmp(value, "auto") == 0 ? "zstd" : value;
|
||||
if (strcmp(canonical, "zstd") != 0 && strcmp(canonical, "none") != 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--compress-choice '%s' is not implemented; FastSync supports zstd, none or auto "
|
||||
"(rsync's lz4/zlib/zlibx are rejected, never silently ignored)",
|
||||
value);
|
||||
if (!value) {
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
int algo;
|
||||
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, "
|
||||
"lz4, zlib, zlibx, none or auto",
|
||||
value);
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
const char* canonical = compression_algo_name((CompressionAlgo)algo);
|
||||
if (set_string_option(&config->compress_choice, canonical, "--compress-choice") != 0)
|
||||
return -1;
|
||||
config->use_compression = strcmp(canonical, "none") != 0;
|
||||
config->compression_algo = algo;
|
||||
config->use_compression = (algo != (int)COMPRESSION_ALGO_NONE);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Validate and store the --checksum-choice/--cc algorithm. Only the algorithms
|
||||
* the engine genuinely supports are accepted (xxh64/xxhash, xxh3, xxh128, md5);
|
||||
* rsync's compiled-in choices that FastSync does not implement (md4, sha1,
|
||||
* none) and the two-name transfer/pre-transfer syntax are a clear error, never
|
||||
* a silent no-op. "auto" (rsync's default automatic choice) selects FastSync's
|
||||
* default algorithm. */
|
||||
/* Store one algorithm name into *out. Returns 0 for a valid name, 1 for
|
||||
* "auto" (caller resolves it), -1 for an unknown/too-long name. */
|
||||
static int resolve_checksum_name(const char* name, size_t len, int* out) {
|
||||
char buf[64];
|
||||
if (len == 0 || len >= sizeof(buf))
|
||||
return -1;
|
||||
memcpy(buf, name, len);
|
||||
buf[len] = '\0';
|
||||
if (strcasecmp(buf, "auto") == 0)
|
||||
return 1;
|
||||
int algo = checksum_algo_from_name(buf);
|
||||
if (algo < 0)
|
||||
return -1;
|
||||
*out = algo;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Validate and store the --checksum-choice/--cc algorithm. rsync 3.4.1 accepts
|
||||
* a single name (used for both the transfer and pre-transfer checksums) or the
|
||||
* two-name "TRANSFER,PRE-TRANSFER" form (only one comma is significant). The
|
||||
* pre-transfer half is FastSync's whole-file digest; the transfer half is
|
||||
* validated for parity and, when "none", forces --whole-file like rsync. An
|
||||
* unknown name (including an empty half or a second comma) is exit 4. "auto"
|
||||
* resolves to FastSync's negotiated default (xxh128). */
|
||||
static int set_checksum_choice(Config* config, const char* value) {
|
||||
if (strcasecmp(value, "auto") == 0)
|
||||
return 0;
|
||||
int algo = checksum_algo_from_name(value);
|
||||
if (algo < 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--checksum-choice '%s' is not implemented; FastSync supports xxh64 (or xxhash), "
|
||||
"xxh3, xxh128, md5 or auto (rsync's md4/sha1/none and the two-name "
|
||||
"transfer,pre-transfer form are rejected, never silently ignored)",
|
||||
value);
|
||||
if (!value) {
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
config->checksum_algo = algo;
|
||||
const char* comma = strchr(value, ',');
|
||||
const char* name1 = value;
|
||||
size_t len1 = comma ? (size_t)(comma - value) : strlen(value);
|
||||
const char* name2 = comma ? comma + 1 : NULL;
|
||||
size_t len2 = name2 ? strlen(name2) : 0;
|
||||
|
||||
int transfer = -1;
|
||||
int pre = -1;
|
||||
int rc1 = resolve_checksum_name(name1, len1, &transfer);
|
||||
int rc2 = name2 ? resolve_checksum_name(name2, len2, &pre) : 1;
|
||||
if (rc1 < 0 || rc2 < 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--checksum-choice '%s' is invalid; FastSync supports xxh64 (or xxhash), xxh128, "
|
||||
"xxh3, md5, md4, sha1, none or auto, optionally as 'transfer,pre-transfer'",
|
||||
value);
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
ChecksumAlgo negotiated = checksum_negotiate_default();
|
||||
if (rc1 == 1)
|
||||
transfer = (int)negotiated;
|
||||
if (!name2)
|
||||
pre = transfer;
|
||||
else if (rc2 == 1)
|
||||
pre = (int)negotiated;
|
||||
|
||||
config->checksum_algo = pre;
|
||||
config->checksum_transfer_algo = transfer;
|
||||
/* rsync: "none" for the transfer checksum forces --whole-file. */
|
||||
if (transfer == (int)CHECKSUM_ALGO_NONE)
|
||||
config->whole_file = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -327,10 +378,10 @@ static int config_add_remote_option(Config* config, const char* value, const cha
|
||||
}
|
||||
|
||||
/* Validate and append one --compare-dest/--copy-dest/--link-dest directory.
|
||||
* The path is interpreted on the receiver relative to the destination root,
|
||||
* so it must be a non-empty relative path with no "." / ".." components (an
|
||||
* absolute or escaping path is rejected up front instead of failing on the
|
||||
* server). Returns 0 on success, -1 on error. */
|
||||
* A relative path is interpreted on the receiver below the destination root; an
|
||||
* absolute path is used verbatim on the receiver (matching rsync), still subject
|
||||
* to the receiver's authorized-root confinement. Either way the path must be
|
||||
* non-empty and traversal-free (no ".."). Returns 0 on success, -1 on error. */
|
||||
static int set_basis_dest_option(Config* config, BasisDestType type, const char* value,
|
||||
const char* option_name) {
|
||||
if (!value || !value[0]) {
|
||||
@@ -339,8 +390,9 @@ static int set_basis_dest_option(Config* config, BasisDestType type, const char*
|
||||
}
|
||||
if (config_basis_append(config, type, value) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"%s requires a non-empty relative directory name with no '.', '..', or absolute "
|
||||
"path (resolved below the destination root)",
|
||||
"%s requires a non-empty directory name with no '..' component "
|
||||
"(relative paths resolve below the destination root; absolute paths are used "
|
||||
"verbatim)",
|
||||
option_name);
|
||||
return -1;
|
||||
}
|
||||
@@ -395,6 +447,69 @@ static void apply_output_buffering(const Config* config) {
|
||||
static int read_patterns_from_file(const char* filepath, char*** patterns, int* count,
|
||||
Config* config, char sign, const char* optname);
|
||||
|
||||
/* Split one --debug/--info item into its category name and an optional rsync
|
||||
* verbosity level suffix (e.g. "io2", "all4", "none0"). The output `name` is
|
||||
* NUL-terminated and `level` is >= 0 (0 silences the item). Returns false for
|
||||
* an empty token or a token that is all digits. */
|
||||
static bool split_flag_level(const char* token, char* name, size_t name_size, int* level) {
|
||||
size_t len = strlen(token);
|
||||
if (len == 0 || name_size == 0)
|
||||
return false;
|
||||
size_t end = len;
|
||||
while (end > 0 && token[end - 1] >= '0' && token[end - 1] <= '9')
|
||||
end--;
|
||||
if (end == 0)
|
||||
return false; /* all digits: not a category name */
|
||||
size_t name_len = end < name_size - 1 ? end : name_size - 1;
|
||||
for (size_t i = 0; i < name_len; i++) {
|
||||
char c = token[i];
|
||||
name[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
|
||||
}
|
||||
name[name_len] = '\0';
|
||||
int lvl = 1;
|
||||
if (end < len) {
|
||||
lvl = 0;
|
||||
for (size_t i = end; i < len; i++) {
|
||||
int digit = token[i] - '0';
|
||||
if (lvl > (1000 - digit) / 10)
|
||||
return false;
|
||||
lvl = lvl * 10 + digit;
|
||||
}
|
||||
}
|
||||
*level = lvl;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* rsync --debug/--info categories that FastSync accepts for CLI parity but has
|
||||
* no output wired to (yet). They must parse successfully so a valid rsync
|
||||
* invocation is not rejected up front; only categories with a FastSync
|
||||
* counterpart set a log flag. `pack`/`util` are FastSync-specific (packed
|
||||
* metadata / general utility logging). `syms`, `hl`, and `owner` are aliases
|
||||
* of rsync's `symsafe`, `hlink`, and `own`. */
|
||||
static bool is_accepted_debug_category(const char* name) {
|
||||
static const char* const categories[] = {
|
||||
"acl", "backup", "bind", "chdir", "cmd", "connect", "del", "deltasum",
|
||||
"dup", "exit", "filter", "flist", "fuzzy", "genr", "hash", "hl",
|
||||
"hlink", "iconv", "nstr", "own", "owner", "recv", "send", "time",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(categories) / sizeof(categories[0]); i++) {
|
||||
if (strcmp(name, categories[i]) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool is_accepted_info_category(const char* name) {
|
||||
static const char* const categories[] = {
|
||||
"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)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int parse_debug_flags(const char* value, Config* config) {
|
||||
if (!value || value[0] == '\0' || value[0] == ',' || value[strlen(value) - 1] == ',' ||
|
||||
strstr(value, ",,")) {
|
||||
@@ -412,30 +527,42 @@ static int parse_debug_flags(const char* value, Config* config) {
|
||||
for (char* token = strtok_r(flags, ",", &saveptr); token != NULL;
|
||||
token = strtok_r(NULL, ",", &saveptr)) {
|
||||
uint32_t flag = 0;
|
||||
if (strcmp(token, "help") == 0) {
|
||||
char name[32];
|
||||
int level = 1;
|
||||
if (!split_flag_level(token, name, sizeof(name), &level)) {
|
||||
log_message(LOG_LEVEL_ERROR, "unsupported --debug flag: %s", token);
|
||||
free(flags);
|
||||
return -1;
|
||||
}
|
||||
if (strcmp(name, "help") == 0) {
|
||||
print_debug_usage();
|
||||
free(flags);
|
||||
return 1;
|
||||
} else if (strcmp(token, "all") == 0) {
|
||||
parsed = LOG_DEBUG_ALL;
|
||||
} else if (strcmp(name, "all") == 0) {
|
||||
parsed = level == 0 ? 0 : LOG_DEBUG_ALL;
|
||||
continue;
|
||||
} else if (strcmp(token, "none") == 0) {
|
||||
} else if (strcmp(name, "none") == 0) {
|
||||
parsed = 0;
|
||||
continue;
|
||||
} else if (strcmp(token, "io") == 0) {
|
||||
} else if (strcmp(name, "io") == 0) {
|
||||
flag = LOG_DEBUG_IO;
|
||||
} else if (strcmp(token, "proto") == 0) {
|
||||
} else if (strcmp(name, "proto") == 0) {
|
||||
flag = LOG_DEBUG_PROTO;
|
||||
} else if (strcmp(token, "pack") == 0) {
|
||||
} else if (strcmp(name, "pack") == 0) {
|
||||
flag = LOG_DEBUG_PACK;
|
||||
} else if (strcmp(token, "util") == 0) {
|
||||
} else if (strcmp(name, "util") == 0) {
|
||||
flag = LOG_DEBUG_UTIL;
|
||||
} else if (is_accepted_debug_category(name)) {
|
||||
continue;
|
||||
} else {
|
||||
log_message(LOG_LEVEL_ERROR, "unsupported --debug flag: %s", token);
|
||||
free(flags);
|
||||
return -1;
|
||||
}
|
||||
parsed |= flag;
|
||||
if (level == 0)
|
||||
parsed &= ~flag;
|
||||
else
|
||||
parsed |= flag;
|
||||
}
|
||||
free(flags);
|
||||
config->debug_level = (int)parsed;
|
||||
@@ -461,28 +588,45 @@ static int parse_info_flags(const char* value, Config* config) {
|
||||
for (char* token = strtok_r(flags, ",", &saveptr); token != NULL;
|
||||
token = strtok_r(NULL, ",", &saveptr)) {
|
||||
uint32_t flag = 0;
|
||||
if (strcmp(token, "all") == 0) {
|
||||
parsed = LOG_INFO_ALL;
|
||||
char name[32];
|
||||
int level = 1;
|
||||
if (!split_flag_level(token, name, sizeof(name), &level)) {
|
||||
log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token);
|
||||
free(flags);
|
||||
return -1;
|
||||
}
|
||||
if (strcmp(name, "all") == 0) {
|
||||
parsed = level == 0 ? 0 : LOG_INFO_ALL;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(token, "none") == 0) {
|
||||
if (strcmp(name, "none") == 0) {
|
||||
parsed = 0;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(token, "copy") == 0)
|
||||
if (strcmp(name, "help") == 0) {
|
||||
print_info_usage();
|
||||
free(flags);
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(name, "copy") == 0 || strcmp(name, "name") == 0)
|
||||
flag = LOG_INFO_COPY;
|
||||
else if (strcmp(token, "misc") == 0)
|
||||
else if (strcmp(name, "misc") == 0)
|
||||
flag = LOG_INFO_MISC;
|
||||
else if (strcmp(token, "skip") == 0)
|
||||
else if (strcmp(name, "skip") == 0)
|
||||
flag = LOG_INFO_SKIP;
|
||||
else if (strcmp(token, "stats") == 0)
|
||||
else if (strcmp(name, "stats") == 0)
|
||||
flag = LOG_INFO_STATS;
|
||||
else if (is_accepted_info_category(name))
|
||||
continue;
|
||||
else {
|
||||
log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token);
|
||||
free(flags);
|
||||
return -1;
|
||||
}
|
||||
parsed |= flag;
|
||||
if (level == 0)
|
||||
parsed &= ~flag;
|
||||
else
|
||||
parsed |= flag;
|
||||
}
|
||||
free(flags);
|
||||
config->info_level = (int)parsed;
|
||||
@@ -597,16 +741,25 @@ static int config_add_pattern(char*** patterns, int* count, const char* value,
|
||||
|
||||
/* Validate and append one --filter=RULE string. Returns 0 on success, -1 on error. */
|
||||
static int config_add_filter(Config* config, const char* rule) {
|
||||
char err[160];
|
||||
FilterRule* parsed = filter_rule_parse(rule, err, sizeof(err));
|
||||
if (!parsed) {
|
||||
char err[256];
|
||||
/* Validate through the full list parser so clear/merge/dir-merge and the rule
|
||||
modifiers are accepted (and a merge file is readable) at parse time. */
|
||||
FilterParseOptions opts = {.delete_excluded = config->delete_excluded,
|
||||
.cvs_exclude = config->cvs_exclude};
|
||||
FilterRuleList* probe = filter_rule_list_create();
|
||||
if (!probe) {
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --filter");
|
||||
return -1;
|
||||
}
|
||||
bool ok = filter_rule_list_parse_append(probe, rule, &opts, NULL, err, sizeof(err));
|
||||
filter_rule_list_free(probe);
|
||||
if (!ok) {
|
||||
char* escaped = output_escape(rule, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "invalid --filter rule '%s': %s",
|
||||
escaped ? escaped : "<allocation failed>", err);
|
||||
free(escaped);
|
||||
return -1;
|
||||
}
|
||||
filter_rule_free(parsed);
|
||||
if (!config->filters) {
|
||||
config->filters = array_list_create(free);
|
||||
if (!config->filters) {
|
||||
@@ -734,6 +887,9 @@ static const OptionEntry OPTION_TABLE[] = {
|
||||
{"--human-readable", "-h", OPT_FLAG, offsetof(Config, human_readable)},
|
||||
{"--partial", NULL, OPT_FLAG, offsetof(Config, partial)},
|
||||
{"--secluded-args", "-s", OPT_NOOP, 0},
|
||||
/* rsync's pre-3.2.6 name for --secluded-args (--protect-args) is accepted
|
||||
* as the same secure-argv no-op. */
|
||||
{"--protect-args", NULL, OPT_NOOP, 0},
|
||||
/* rsync -r/--recursive: FastSync is always recursive, so this is a
|
||||
* faithful no-op (accepted silently, never consumes an argument). */
|
||||
{"--recursive", "-r", OPT_NOOP, 0},
|
||||
@@ -762,6 +918,8 @@ static const OptionEntry OPTION_TABLE[] = {
|
||||
{"--out-format", NULL, OPT_STRING, offsetof(Config, out_format)},
|
||||
{"--log-file-format", NULL, OPT_STRING, offsetof(Config, log_file_format)},
|
||||
{"--existing", NULL, OPT_FLAG, offsetof(Config, existing)},
|
||||
/* rsync's man-page alias for --existing (--ignore-non-existing). */
|
||||
{"--ignore-non-existing", NULL, OPT_FLAG, offsetof(Config, existing)},
|
||||
{"--ignore-existing", NULL, OPT_FLAG, offsetof(Config, ignore_existing)},
|
||||
{"--delay-updates", NULL, OPT_FLAG, offsetof(Config, delay_updates)},
|
||||
{"--chmod", NULL, OPT_STRING, offsetof(Config, chmod_spec)},
|
||||
@@ -976,6 +1134,26 @@ static int apply_negation(Config* config, const char* arg) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* rsync's --iconv accepted extra spellings beyond explicit charset pairs:
|
||||
* "." selects the locale's default charset for both directions, and "-" (or
|
||||
* --no-iconv) disables conversion entirely. Normalize both here so the rest
|
||||
* of the pipeline only ever sees a real charset spec or NULL. */
|
||||
static int set_iconv_option(char** field, const char* value) {
|
||||
if (value && strcmp(value, "-") == 0) {
|
||||
free(*field);
|
||||
*field = NULL;
|
||||
return 0;
|
||||
}
|
||||
if (value && strcmp(value, ".") == 0) {
|
||||
setlocale(LC_ALL, "");
|
||||
const char* codeset = nl_langinfo(CODESET);
|
||||
if (!codeset || codeset[0] == '\0')
|
||||
codeset = "UTF-8";
|
||||
return set_string_option(field, codeset, "--iconv");
|
||||
}
|
||||
return set_string_option(field, value, "--iconv");
|
||||
}
|
||||
|
||||
static int apply_table_option(Config* config, const OptionEntry* entry, const char* value) {
|
||||
if (entry->kind == OPT_NOOP)
|
||||
return 0;
|
||||
@@ -989,6 +1167,8 @@ static int apply_table_option(Config* config, const OptionEntry* entry, const ch
|
||||
case OPT_STRING:
|
||||
if (entry->offset == offsetof(Config, chmod_spec))
|
||||
return append_chmod_spec((char**)field, value);
|
||||
if (entry->offset == offsetof(Config, iconv_spec))
|
||||
return set_iconv_option((char**)field, value);
|
||||
return set_string_option((char**)field, value, entry->name);
|
||||
case OPT_POS_INT:
|
||||
return set_positive_int_option((int*)field, value, entry->name);
|
||||
@@ -1027,17 +1207,22 @@ typedef struct {
|
||||
} CliParseCtx;
|
||||
|
||||
/* Apply output controls before processing other options so their order is
|
||||
* irrelevant. Returns 0 on success, -1 on error. */
|
||||
* irrelevant. Returns 0 on success, a positive code for a help request
|
||||
* (parse_args returns it verbatim), or -1 on error. */
|
||||
static int cli_apply_output_controls(Config* config, int argc, char* argv[]) {
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) {
|
||||
set_log_level(LOG_LEVEL_DEBUG);
|
||||
} else if (strncmp(argv[i], "--info=", 7) == 0) {
|
||||
if (parse_info_flags(argv[i] + 7, config) != 0)
|
||||
return -1;
|
||||
int ret = parse_info_flags(argv[i] + 7, config);
|
||||
if (ret != 0)
|
||||
return ret;
|
||||
} else if (strcmp(argv[i], "--info") == 0) {
|
||||
if (i + 1 >= argc || parse_info_flags(argv[++i], config) != 0)
|
||||
if (i + 1 >= argc)
|
||||
return -1;
|
||||
int ret = parse_info_flags(argv[++i], config);
|
||||
if (ret != 0)
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
@@ -1062,6 +1247,19 @@ static bool cli_handle_pre_negation(CliParseCtx* ctx) {
|
||||
config->no_implied_dirs = true;
|
||||
return true;
|
||||
}
|
||||
/* "--no-iconv" is a real rsync option name that turns charset conversion off
|
||||
* (the negation of the argument-taking --iconv), so it is handled before the
|
||||
* generic --no-* negation branch. */
|
||||
if (strcmp(arg, "--no-iconv") == 0) {
|
||||
free(config->iconv_spec);
|
||||
config->iconv_spec = NULL;
|
||||
return true;
|
||||
}
|
||||
/* "--no-msgs2stderr" is the deprecated spelling of --stderr=client (rsync
|
||||
* 3.4.1). FastSync has no separate client message channel, so the closest
|
||||
* supported mode is the errors-only default. */
|
||||
if (strcmp(arg, "--no-msgs2stderr") == 0)
|
||||
return set_stderr_mode("errors") == 0;
|
||||
/* "--no-motd" is a real rsync option name (client-side daemon MOTD display
|
||||
* suppression), not a negation of a "--motd" flag, so it is handled before
|
||||
* the generic --no-* negation branch. */
|
||||
@@ -1260,6 +1458,11 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
|
||||
}
|
||||
if (entry->offset == offsetof(Config, eight_bit_output))
|
||||
protocol_set_8_bit_output(true);
|
||||
/* -F is repeatable: rsync's single -F transfers .rsync-filter files, a
|
||||
repeated -FF excludes them. Count the occurrences so the scanner can
|
||||
distinguish the two. */
|
||||
if (entry->offset == offsetof(Config, per_dir_filter) && config->per_dir_filter_count < INT_MAX)
|
||||
config->per_dir_filter_count++;
|
||||
/* A delete-timing flag selects when --delete removes extras, so it
|
||||
implies --delete exactly like the rsync options do. */
|
||||
if (entry->offset == offsetof(Config, delete_before) ||
|
||||
@@ -1732,6 +1935,12 @@ static bool cli_handle_io_options(CliParseCtx* ctx) {
|
||||
ctx->exit_code = -1;
|
||||
return true;
|
||||
}
|
||||
/* rsync's deprecated spelling of --stderr=all. */
|
||||
if (opt_is(arg, "--msgs2stderr", NULL)) {
|
||||
if (set_stderr_mode("all") != 0)
|
||||
ctx->exit_code = -1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1842,13 +2051,19 @@ static bool cli_handle_logging_options(CliParseCtx* ctx) {
|
||||
return true;
|
||||
}
|
||||
if (strncmp(arg, "--info=", 7) == 0) {
|
||||
if (parse_info_flags(arg + 7, config) != 0)
|
||||
ctx->exit_code = -1;
|
||||
int info_ret = parse_info_flags(arg + 7, config);
|
||||
if (info_ret != 0)
|
||||
ctx->exit_code = info_ret;
|
||||
return true;
|
||||
}
|
||||
if (opt_is(arg, "--info", NULL)) {
|
||||
if (ctx->i + 1 >= ctx->argc || parse_info_flags(ctx->argv[++ctx->i], config) != 0)
|
||||
if (ctx->i + 1 >= ctx->argc) {
|
||||
ctx->exit_code = -1;
|
||||
return true;
|
||||
}
|
||||
int info_ret = parse_info_flags(ctx->argv[++ctx->i], config);
|
||||
if (info_ret != 0)
|
||||
ctx->exit_code = info_ret;
|
||||
return true;
|
||||
}
|
||||
if (strncmp(arg, "--skip-compress=", 16) == 0) {
|
||||
@@ -2194,8 +2409,23 @@ static bool cli_handle_outbuf_option(CliParseCtx* ctx) {
|
||||
* -1 on error. */
|
||||
static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool no_incremental) {
|
||||
set_log_level(config->quiet ? LOG_LEVEL_ERROR : (verbose ? LOG_LEVEL_DEBUG : LOG_LEVEL_WARNING));
|
||||
if (config->compress_choice)
|
||||
config->use_compression = strcmp(config->compress_choice, "none") != 0;
|
||||
if (config->compress_choice) {
|
||||
int algo = compression_algo_from_name(config->compress_choice);
|
||||
if (algo >= 0) {
|
||||
config->compression_algo = algo;
|
||||
config->use_compression = (algo != (int)COMPRESSION_ALGO_NONE);
|
||||
}
|
||||
}
|
||||
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. */
|
||||
if (config->checksum && config->checksum_algo == (int)CHECKSUM_ALGO_NONE) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid checksum-choice for --checksum: none");
|
||||
config->cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* rsync randomizes the checksum seed for every transfer when the user did not
|
||||
* supply one (a seed of 0, including an explicit --checksum-seed=0), using
|
||||
@@ -2280,6 +2510,15 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
config->preserve_times = true;
|
||||
}
|
||||
|
||||
/* --ignore-existing is a receiver-side existence policy: the receiver must
|
||||
* answer "skip" BEFORE the sender transmits any payload, which only the
|
||||
* per-file STATUS_CHECK handshake provides. Imply --incremental here (after
|
||||
* the auto-preserve capture above, so a bare --ignore-existing does not gain
|
||||
* -p/-t, which rsync likewise does not imply) so an existing destination is
|
||||
* skipped on the wire instead of being streamed and discarded. */
|
||||
if (config->ignore_existing)
|
||||
config->use_incremental = true;
|
||||
|
||||
/* Derive the transport bit from the FINAL parsed flags. Every
|
||||
* preservation/ownership option that needs the metadata frame (per-attribute
|
||||
* perms/times/owner/group, atimes/crtimes, executability, xattrs/acls,
|
||||
@@ -2297,6 +2536,21 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
* check. This is a wire field. */
|
||||
config->report_dest_info = config->itemize_changes || config->out_format != NULL ||
|
||||
(config->log_file != NULL && config->log_file_format != NULL);
|
||||
/* Wire-stats parity: --stats, --progress/-P, an --out-format token that needs
|
||||
* a wire counter (%b/%c), or a dry-run --delete need the receiver's
|
||||
* end-of-transfer STATUS_STATS report. This is a wire field (protocol
|
||||
* 2.25.0). */
|
||||
bool format_needs_wire = false;
|
||||
if (config->out_format != NULL) {
|
||||
for (const char* p = config->out_format; *p != '\0'; p++) {
|
||||
if (p[0] == '%' && (p[1] == 'b' || p[1] == 'c')) {
|
||||
format_needs_wire = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
config->report_stats = config->stats || config->show_progress || format_needs_wire ||
|
||||
(config->dry_run && config->use_delete);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -2436,8 +2690,18 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
|
||||
}
|
||||
|
||||
int result = -1;
|
||||
if (cli_apply_output_controls(config, exp_argc, exp_argv) != 0)
|
||||
/* rsync treats a lone -h as a help request (it only means human-readable
|
||||
* when combined with a source/destination or other options). */
|
||||
if (exp_argc == 2 && strcmp(exp_argv[1], "-h") == 0) {
|
||||
print_usage();
|
||||
result = 1;
|
||||
goto done;
|
||||
}
|
||||
int output_ret = cli_apply_output_controls(config, exp_argc, exp_argv);
|
||||
if (output_ret != 0) {
|
||||
result = output_ret;
|
||||
goto done;
|
||||
}
|
||||
|
||||
CliParseCtx ctx = {
|
||||
.config = config,
|
||||
@@ -2620,7 +2884,7 @@ int main(int argc, char* argv[]) {
|
||||
int parse_ret = parse_args(config, argc, argv, positional_args, &positional_count);
|
||||
if (parse_ret != 0) {
|
||||
if (parse_ret < 0)
|
||||
exit_code = 1;
|
||||
exit_code = config->cli_exit_code ? config->cli_exit_code : 1;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
@@ -2710,6 +2974,11 @@ int main(int argc, char* argv[]) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
/* Install the negotiated codec for this process before any transfer thread
|
||||
* is spawned; the compressed frames are self-describing, so the receiver's
|
||||
* decompressor does not need this, but the sender compressor does. */
|
||||
compression_set_algo((CompressionAlgo)config->compression_algo);
|
||||
|
||||
/* --iconv: install the sender-side local->wire conversion before any path is
|
||||
scanned or serialized (the scanner and the chunk/data path read windows are
|
||||
all driven from this process, so one global initialization covers every
|
||||
|
||||
+668
-172
File diff suppressed because it is too large.
Load diff
+447
-85
@@ -51,29 +51,63 @@ static FilterNode* filter_node_alloc(FilterNode* parent, FilterRuleList* own) {
|
||||
return node;
|
||||
}
|
||||
|
||||
/* Evaluate a rule chain for an entry inside the directory whose content
|
||||
* context is `node`. rsync precedence, highest first: the innermost (current)
|
||||
* directory's .rsync-filter rules, then each ancestor's, then the root's, and
|
||||
* finally the command-line base rules (--filter/-C). A deeper per-directory
|
||||
* file therefore overrides a shallower one, and per-directory files override
|
||||
* the base rules by default. Returns FILTER_ACTION_NONE when nothing matched. */
|
||||
static FilterAction chain_rules_apply(const FilterRuleList* base, const FilterNode* node,
|
||||
const char* rel, const char* leaf, bool is_dir) {
|
||||
if (node) {
|
||||
FilterAction own_action = filter_rules_apply(node->own, rel, leaf, is_dir);
|
||||
if (own_action != FILTER_ACTION_NONE)
|
||||
return own_action;
|
||||
return chain_rules_apply(base, node->parent, rel, leaf, is_dir);
|
||||
/* Evaluate a rule chain for one entry. rsync precedence, highest first: the
|
||||
* innermost (current) directory's .rsync-filter rules, then each ancestor's,
|
||||
* then the root's, and finally the command-line base rules (--filter/-C). The
|
||||
* sender-side verdict decides whether the entry is hidden from the transfer;
|
||||
* the receiver-side verdict decides whether its destination mirror is protected
|
||||
* from --delete. Each side takes the FIRST matching rule independently. */
|
||||
typedef struct {
|
||||
bool hide; /* sender-side exclude matched */
|
||||
bool protect; /* receiver-side exclude matched */
|
||||
} FilterOutcome;
|
||||
|
||||
static void chain_rules_outcome(const FilterRuleList* base, const FilterNode* node, const char* rel,
|
||||
const char* leaf, bool is_dir, FilterOutcome* out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
bool sender_decided = false;
|
||||
bool receiver_decided = false;
|
||||
const FilterNode* n = node;
|
||||
while (!sender_decided || !receiver_decided) {
|
||||
const FilterRuleList* list = n ? n->own : base;
|
||||
if (list) {
|
||||
if (!sender_decided) {
|
||||
FilterAction action = filter_rules_apply_side(list, rel, leaf, is_dir, FILTER_SIDE_SENDER);
|
||||
if (action != FILTER_ACTION_NONE) {
|
||||
out->hide = action == FILTER_ACTION_EXCLUDE;
|
||||
sender_decided = true;
|
||||
}
|
||||
}
|
||||
if (!receiver_decided) {
|
||||
FilterAction action =
|
||||
filter_rules_apply_side(list, rel, leaf, is_dir, FILTER_SIDE_RECEIVER);
|
||||
if (action != FILTER_ACTION_NONE) {
|
||||
out->protect = action == FILTER_ACTION_PROTECT;
|
||||
receiver_decided = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!n)
|
||||
break;
|
||||
n = n->parent;
|
||||
}
|
||||
return base ? filter_rules_apply(base, rel, leaf, is_dir) : FILTER_ACTION_NONE;
|
||||
}
|
||||
|
||||
static bool entry_allowed(const FilterRuleList* base, const FilterNode* node, const char* rel,
|
||||
const char* leaf, bool is_dir, bool per_dir_filters) {
|
||||
/* -F: per-directory .rsync-filter files are never transferred. */
|
||||
if (per_dir_filters && !is_dir && strcmp(leaf, ".rsync-filter") == 0)
|
||||
const char* leaf, bool is_dir, bool exclude_filter_files,
|
||||
bool* protect_out) {
|
||||
/* -FF: per-directory .rsync-filter files are never transferred (single -F
|
||||
transfers them, matching rsync). */
|
||||
if (exclude_filter_files && !is_dir && strcmp(leaf, ".rsync-filter") == 0) {
|
||||
if (protect_out)
|
||||
*protect_out = false;
|
||||
return false;
|
||||
return chain_rules_apply(base, node, rel, leaf, is_dir) != FILTER_ACTION_EXCLUDE;
|
||||
}
|
||||
FilterOutcome outcome;
|
||||
chain_rules_outcome(base, node, rel, leaf, is_dir, &outcome);
|
||||
if (protect_out)
|
||||
*protect_out = outcome.protect;
|
||||
return !outcome.hide;
|
||||
}
|
||||
|
||||
static void dir_entry_destroy(void* item) {
|
||||
@@ -163,6 +197,12 @@ typedef struct {
|
||||
Size pruning protects the destination mirror even under --delete-excluded,
|
||||
so it is recorded into a separate sink from `excluded`. */
|
||||
bool size_excluded;
|
||||
/* True when a symlink selected for dereferencing (-L/--copy-links or an
|
||||
unsafe target under --copy-unsafe-links) had no usable referent (a broken
|
||||
link or a stat() failure). rsync still reports this as a partial transfer
|
||||
(exit 23) even though the entry is skipped, so the scanner records it as a
|
||||
non-fatal I/O error. */
|
||||
bool referent_error;
|
||||
} ScannerEntry;
|
||||
|
||||
/* --one-file-system (-x) decision. Only directories can carry a different
|
||||
@@ -214,6 +254,44 @@ char* scanner_path_relative(const char* root, const char* fs_path) {
|
||||
return str_dup(fs_path + root_len + 1);
|
||||
}
|
||||
|
||||
/* -R/--relative destination-relative prefix reconstructed from a source spec:
|
||||
* everything after the first '.' path component (rsync's '/./' cut point),
|
||||
* with leading/trailing slashes removed; or the whole spec (normalized) when
|
||||
* there is no cut. Returns "" for the receive root. Exposed for tests. */
|
||||
char* scanner_relative_prefix(const char* spec) {
|
||||
if (!spec || spec[0] == '\0')
|
||||
return NULL;
|
||||
const char* after = spec;
|
||||
if (spec[0] == '.' && spec[1] == '/') {
|
||||
after = spec + 2;
|
||||
} else {
|
||||
const char* cut = strstr(spec, "/./");
|
||||
if (cut)
|
||||
after = cut + 3;
|
||||
}
|
||||
size_t cap = strlen(spec) + 1;
|
||||
char* out = malloc(cap);
|
||||
if (!out)
|
||||
return NULL;
|
||||
size_t len = 0;
|
||||
for (const char* s = after; *s;) {
|
||||
while (*s == '/')
|
||||
s++;
|
||||
const char* comp = s;
|
||||
while (*s && *s != '/')
|
||||
s++;
|
||||
size_t clen = (size_t)(s - comp);
|
||||
if (clen == 0 || (clen == 1 && comp[0] == '.'))
|
||||
continue;
|
||||
if (len)
|
||||
out[len++] = '/';
|
||||
memcpy(out + len, comp, clen);
|
||||
len += clen;
|
||||
}
|
||||
out[len] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Relative path of a child entry below the current directory. */
|
||||
static char* child_rel_path(const char* parent_rel, const char* name) {
|
||||
if (!parent_rel || parent_rel[0] == '\0')
|
||||
@@ -221,14 +299,28 @@ static char* child_rel_path(const char* parent_rel, const char* name) {
|
||||
return path_cat(parent_rel, name);
|
||||
}
|
||||
|
||||
/* Apply the --files-from allow-set and the filter layer to one entry. */
|
||||
/* Destination-relative wire path for an entry under an -R prefix. */
|
||||
static char* scanner_prefix_send_path(const char* prefix, const char* rel) {
|
||||
if (prefix[0] == '\0')
|
||||
return str_dup(rel);
|
||||
if (rel[0] == '\0')
|
||||
return str_dup(prefix);
|
||||
return path_cat(prefix, rel);
|
||||
}
|
||||
|
||||
/* Apply the --files-from allow-set and the filter layer to one entry. On
|
||||
* return `*protect_out` is true when a receiver-side rule protects the entry's
|
||||
* destination mirror from deletion. */
|
||||
static bool entry_passes_selection(const FileListSet* file_list, const FilterRuleList* base,
|
||||
const FilterNode* node, const char* rel, const char* leaf,
|
||||
bool is_dir, bool per_dir_filters) {
|
||||
bool is_dir, bool per_dir_filters, bool exclude_filter_files,
|
||||
bool* protect_out) {
|
||||
if (protect_out)
|
||||
*protect_out = false;
|
||||
if (file_list && !file_list_affects(file_list, rel))
|
||||
return false;
|
||||
if (base || per_dir_filters)
|
||||
return entry_allowed(base, node, rel, leaf, is_dir, per_dir_filters);
|
||||
return entry_allowed(base, node, rel, leaf, is_dir, exclude_filter_files, protect_out);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -357,40 +449,109 @@ static void scanner_record_size_skipped(DirectoryScanner* scanner, const char* f
|
||||
Returns false on allocation failure. */
|
||||
static bool scanner_record_synced_dir(const ScannerOptions* options, const char* fs_path,
|
||||
const char* rel, bool relative_mode) {
|
||||
if (!options->synced_dirs)
|
||||
if (!options->synced_dirs && !options->plan_dirs)
|
||||
return true;
|
||||
if (!file_list_dir_in_scope(options->file_list, rel))
|
||||
return true;
|
||||
const char* dest = relative_mode ? rel : fs_path;
|
||||
char* prefixed = NULL;
|
||||
const char* dest;
|
||||
if (relative_mode) {
|
||||
dest = rel;
|
||||
} else if (options->relative_prefix) {
|
||||
prefixed = scanner_prefix_send_path(options->relative_prefix, rel);
|
||||
if (!prefixed)
|
||||
return false;
|
||||
dest = prefixed;
|
||||
} else {
|
||||
dest = fs_path;
|
||||
}
|
||||
if (dest[0] == '/')
|
||||
dest++;
|
||||
if (dest[0] == '\0')
|
||||
dest = ".";
|
||||
return excluded_sink_append(options->synced_dirs, options->excluded_mutex, dest);
|
||||
bool ok = true;
|
||||
if (options->synced_dirs)
|
||||
ok = excluded_sink_append(options->synced_dirs, options->excluded_mutex, dest);
|
||||
/* The delete-plan keep set needs an entry for every traversed source
|
||||
directory, including empty ones, so its destination mirror is kept rather
|
||||
than deleted as an extra; the receive root (".") is implicit. */
|
||||
if (ok && options->plan_dirs && strcmp(dest, ".") != 0)
|
||||
ok = excluded_sink_append(options->plan_dirs, options->excluded_mutex, dest);
|
||||
free(prefixed);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Merge the open directory's own .rsync-filter rules into the inherited
|
||||
* context, returning the context used for this directory's entries. On a parse
|
||||
* error the scanner is marked failed. Returns 0 on success, -1 on failure. */
|
||||
static int open_directory_filter_context(DirectoryScanner* scanner, const FilterNode* inherited) {
|
||||
if (!scanner->options.per_dir_filters) {
|
||||
scanner->current_node = (FilterNode*)inherited;
|
||||
return 0;
|
||||
}
|
||||
char err[256];
|
||||
bool exists = false;
|
||||
FilterRuleList* own =
|
||||
filter_file_read(scanner->current_path, scanner->current_rel ? scanner->current_rel : "",
|
||||
&exists, err, sizeof(err));
|
||||
/* Read every per-directory filter file that applies to `dir_path` (its
|
||||
* .rsync-filter when -F is active, plus each registered "dir-merge NAME") into a
|
||||
* fresh list. Returns NULL on allocation/parse failure (message in `err`);
|
||||
* returns an empty list (and *any_exists=false) when no file exists. */
|
||||
static FilterRuleList* read_dir_filters(const ScannerOptions* options, const char* dir_path,
|
||||
const char* rel, bool* any_exists, char* err,
|
||||
size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
const FilterRuleList* base = options->base_filters;
|
||||
bool have_names = options->per_dir_filters || (base && base->dir_merge_count > 0);
|
||||
if (any_exists)
|
||||
*any_exists = false;
|
||||
if (!have_names)
|
||||
return NULL;
|
||||
FilterRuleList* own = filter_rule_list_create();
|
||||
if (!own) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
FilterParseOptions opts = {.delete_excluded = options->delete_excluded, .cvs_exclude = false};
|
||||
bool exists = false;
|
||||
if (options->per_dir_filters) {
|
||||
if (!filter_file_append(own, dir_path, ".rsync-filter", rel, &opts, &exists, err, err_size))
|
||||
goto fail;
|
||||
if (exists && any_exists)
|
||||
*any_exists = true;
|
||||
}
|
||||
if (base) {
|
||||
for (int i = 0; i < base->dir_merge_count; i++) {
|
||||
if (!filter_file_append(own, dir_path, base->dir_merge_names[i], rel, &opts, &exists, err,
|
||||
err_size))
|
||||
goto fail;
|
||||
if (exists && any_exists)
|
||||
*any_exists = true;
|
||||
}
|
||||
}
|
||||
return own;
|
||||
fail:
|
||||
filter_rule_list_free(own);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Merge the open directory's own per-directory filter files (the default
|
||||
* .rsync-filter when -F is active, plus every "dir-merge NAME" registered on the
|
||||
* base rule list) into the inherited context, returning the context used for
|
||||
* this directory's entries. On a parse error the scanner is marked failed.
|
||||
* Returns 0 on success, -1 on failure. */
|
||||
static int open_directory_filter_context(DirectoryScanner* scanner, const FilterNode* inherited) {
|
||||
char err[256];
|
||||
bool any_exists = false;
|
||||
FilterRuleList* own = read_dir_filters(&scanner->options, scanner->current_path,
|
||||
scanner->current_rel ? scanner->current_rel : "",
|
||||
&any_exists, err, sizeof(err));
|
||||
if (!own) {
|
||||
/* read_dir_filters() leaves `err` set on a parse/allocation failure even
|
||||
when an earlier merge file in the same directory existed (any_exists true);
|
||||
key off the error text rather than any_exists so an invalid per-directory
|
||||
filter file can never be silently ignored. */
|
||||
if (err[0] == '\0') {
|
||||
scanner->current_node = (FilterNode*)inherited;
|
||||
return 0;
|
||||
}
|
||||
char* escaped_path = output_escape(scanner->current_path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "invalid .rsync-filter in %s: %s",
|
||||
log_message(LOG_LEVEL_ERROR, "invalid per-directory filter in %s: %s",
|
||||
escaped_path ? escaped_path : "<allocation failed>", err);
|
||||
free(escaped_path);
|
||||
scanner->failed = true;
|
||||
return -1;
|
||||
}
|
||||
if (exists && own->count > 0) {
|
||||
if (any_exists && (own->count > 0 || own->dir_merge_count > 0)) {
|
||||
FilterNode* node = filter_node_alloc((FilterNode*)inherited, own);
|
||||
if (!node || !array_list_add(scanner->filter_nodes, node)) {
|
||||
filter_node_destroy(node);
|
||||
@@ -412,6 +573,7 @@ static int scanner_inspect_entry(const ScannerOptions* options, const char* cont
|
||||
const char* link_rel, const char* name, ScannerEntry* entry) {
|
||||
entry->excluded = false;
|
||||
entry->size_excluded = false;
|
||||
entry->referent_error = false;
|
||||
entry->is_symlink = false;
|
||||
entry->link_target = NULL;
|
||||
entry->path = path_cat(containing_dir, name);
|
||||
@@ -438,12 +600,13 @@ static int scanner_inspect_entry(const ScannerOptions* options, const char* cont
|
||||
goto skip;
|
||||
case LINK_ACTION_DEREF:
|
||||
if (stat(entry->path, &entry->stats) != 0) {
|
||||
/* rsync reports "symlink has no referent" and continues (exit 23); we
|
||||
surface the same condition rather than silently dropping the entry. */
|
||||
/* rsync reports "symlink has no referent" and continues with a partial
|
||||
transfer (exit 23); record the error so the run exits 23 too. */
|
||||
char* escaped = output_escape(entry->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "symlink has no referent: %s",
|
||||
escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
entry->referent_error = true;
|
||||
goto skip;
|
||||
}
|
||||
entry->is_directory = S_ISDIR(entry->stats.st_mode);
|
||||
@@ -664,7 +827,8 @@ static Chunk* chunk_data_to_chunk(ArrayList* chunk_data) {
|
||||
* non-directory path is silently skipped (the transfer is unaffected); an
|
||||
* allocation failure is fatal and reported to the caller. */
|
||||
static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const char* root_path,
|
||||
const char* fs_path, bool relative_mode, bool preserve_atimes,
|
||||
const char* fs_path, bool relative_mode,
|
||||
const char* relative_prefix, bool preserve_atimes,
|
||||
bool preserve_crtimes, bool preserve_xattrs,
|
||||
bool preserve_acls) {
|
||||
if (!dir_entries || !root_path || !fs_path)
|
||||
@@ -681,14 +845,30 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
free(rel);
|
||||
return true;
|
||||
}
|
||||
char* prefixed = NULL;
|
||||
if (relative_prefix) {
|
||||
prefixed = scanner_prefix_send_path(relative_prefix, rel);
|
||||
if (!prefixed) {
|
||||
free(rel);
|
||||
return false;
|
||||
}
|
||||
if (prefixed[0] == '\0') {
|
||||
/* -R with a cut at the receive root: the root itself has no wire path. */
|
||||
free(prefixed);
|
||||
free(rel);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
File* file = file_create(fs_path);
|
||||
if (!file) {
|
||||
free(prefixed);
|
||||
free(rel);
|
||||
return false;
|
||||
}
|
||||
file->is_dir = true;
|
||||
file->metadata = file_metadata_create(fs_path, &st, preserve_atimes, preserve_crtimes);
|
||||
if (!file->metadata) {
|
||||
free(prefixed);
|
||||
free(rel);
|
||||
file_destroy(file);
|
||||
return false;
|
||||
@@ -701,7 +881,11 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
if (relative_mode) {
|
||||
file->send_path = rel;
|
||||
rel = NULL;
|
||||
} else if (prefixed) {
|
||||
file->send_path = prefixed;
|
||||
prefixed = NULL;
|
||||
}
|
||||
free(prefixed);
|
||||
free(rel);
|
||||
bool added;
|
||||
if (mutex) {
|
||||
@@ -799,9 +983,9 @@ static int open_next_directory(DirectoryScanner* scanner) {
|
||||
if (scanner->options.capture_dir_times &&
|
||||
!scanner_capture_dir_time(
|
||||
scanner->options.dir_entries, scanner->options.dir_entries_mutex, scanner->root_path,
|
||||
scanner->current_path, scanner->relative_mode, scanner->options.preserve_atimes,
|
||||
scanner->options.preserve_crtimes, scanner->options.preserve_xattrs,
|
||||
scanner->options.preserve_acls)) {
|
||||
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)) {
|
||||
closedir(scanner->current_dir);
|
||||
scanner->current_dir = NULL;
|
||||
free(scanner->current_path);
|
||||
@@ -817,18 +1001,20 @@ static int open_next_directory(DirectoryScanner* scanner) {
|
||||
/* ---- --dirs mode ----
|
||||
With -d the scanner transfers directory entries and never recurses into
|
||||
contents. A plain `-d <dir>` sends only the source-root directory mirror
|
||||
(created empty at the destination). With -d + --files-from exactly the
|
||||
listed items are sent: listed directories become empty directory entries and
|
||||
listed regular files are transferred as files; nothing else is scanned, so
|
||||
no descent into a listed directory can happen. */
|
||||
(created empty at the destination); `-d dir/`, `-d dir/.` and `-d .` list
|
||||
the directory's immediate contents instead (files plus empty directory
|
||||
entries), matching rsync. With -d + --files-from exactly the listed items
|
||||
are sent: listed directories become empty directory entries and listed
|
||||
regular files are transferred as files; nothing else is scanned, so no
|
||||
descent into a listed directory can happen. */
|
||||
|
||||
/* Directory entries carry no payload, so the dirs generator also bounds every
|
||||
chunk by element count; chunk_deserialize refuses more than this many files
|
||||
per chunk (see MAX_FILES_PER_CHUNK in chunk.c). */
|
||||
#define DIRS_CHUNK_MAX_FILES 65536U
|
||||
|
||||
/* Build the File for the transfer root directory itself (the `-d <dir>` and
|
||||
* "." cases). */
|
||||
/* Build the File for the transfer root directory itself (the `-d <dir>`
|
||||
* no-trailing-slash case). */
|
||||
static File* dirs_root_dir_file(DirectoryScanner* scanner) {
|
||||
struct stat st;
|
||||
if (stat(scanner->root_path, &st) != 0 || !S_ISDIR(st.st_mode)) {
|
||||
@@ -851,6 +1037,14 @@ static File* dirs_root_dir_file(DirectoryScanner* scanner) {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
if (scanner->options.relative_prefix && scanner->options.relative_prefix[0] != '\0') {
|
||||
file->send_path = str_dup(scanner->options.relative_prefix);
|
||||
if (!file->send_path) {
|
||||
file_destroy(file);
|
||||
scanner->failed = true;
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
scanner_capture_xattrs(scanner, file);
|
||||
return file;
|
||||
}
|
||||
@@ -955,6 +1149,13 @@ static File* dirs_file_for_entry(DirectoryScanner* scanner, const char* entry) {
|
||||
scanner->failed = true;
|
||||
return NULL;
|
||||
}
|
||||
} else if (scanner->options.relative_prefix) {
|
||||
file->send_path = scanner_prefix_send_path(scanner->options.relative_prefix, entry);
|
||||
if (!file->send_path) {
|
||||
file_destroy(file);
|
||||
scanner->failed = true;
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
if (scanner->options.use_metadata) {
|
||||
file->metadata = file_metadata_create(file->path, &effective, scanner->options.preserve_atimes,
|
||||
@@ -988,9 +1189,64 @@ static bool dirs_source_dir_is_empty(const char* path) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
/* The next immediate child of the source root for a one-level --dirs listing
|
||||
* (rsync: -d DIR/ lists DIR's immediate contents without recursing). */
|
||||
static File* dirs_next_child(DirectoryScanner* scanner) {
|
||||
if (!scanner->current_dir)
|
||||
return NULL;
|
||||
const struct dirent* entry;
|
||||
while ((entry = readdir(scanner->current_dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
File* file = dirs_file_for_entry(scanner, entry->d_name);
|
||||
if (scanner->failed)
|
||||
return NULL;
|
||||
if (file && !entry_passes_selection(scanner->options.file_list, scanner->options.base_filters,
|
||||
NULL, entry->d_name, entry->d_name, file->is_dir,
|
||||
scanner->options.per_dir_filters,
|
||||
scanner->options.exclude_per_dir_filter_files, NULL)) {
|
||||
file_destroy(file);
|
||||
continue;
|
||||
}
|
||||
if (file && file->is_dir && scanner->options.prune_empty_dirs &&
|
||||
dirs_source_dir_is_empty(file->path)) {
|
||||
file_destroy(file);
|
||||
continue;
|
||||
}
|
||||
if (file)
|
||||
return file;
|
||||
}
|
||||
closedir(scanner->current_dir);
|
||||
scanner->current_dir = NULL;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The next File from the --dirs generator, or NULL when exhausted. */
|
||||
static File* dirs_next_file(DirectoryScanner* scanner) {
|
||||
if (!scanner->options.file_list) {
|
||||
const char* spec = scanner->root_path ? scanner->root_path : "";
|
||||
size_t n = strlen(spec);
|
||||
/* rsync: a trailing slash or "/." on the source argument lists the
|
||||
directory's immediate contents (files and empty directory entries)
|
||||
without recursing. A bare directory sends only its own entry. */
|
||||
bool list_children =
|
||||
(n == 1 && spec[0] == '.') ||
|
||||
(n > 0 && (spec[n - 1] == '/' || (n >= 2 && spec[n - 1] == '.' && spec[n - 2] == '/')));
|
||||
if (list_children) {
|
||||
if (!scanner->dirs_root_emitted) {
|
||||
scanner->dirs_root_emitted = true;
|
||||
if (scanner->options.prune_empty_dirs && dirs_source_dir_is_empty(scanner->root_path))
|
||||
return NULL;
|
||||
scanner->current_dir = opendir(scanner->root_path);
|
||||
if (!scanner->current_dir) {
|
||||
scanner->io_error = true;
|
||||
log_perror("Could not open directory");
|
||||
scanner->failed = true;
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
return dirs_next_child(scanner);
|
||||
}
|
||||
if (scanner->dirs_root_emitted)
|
||||
return NULL;
|
||||
scanner->dirs_root_emitted = true;
|
||||
@@ -1118,6 +1374,10 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
break;
|
||||
}
|
||||
if (inspection == 0) {
|
||||
/* A dereferenced symlink with no referent is a partial-transfer error
|
||||
(rsync exit 23): record it as a non-fatal scan I/O error. */
|
||||
if (inspected.referent_error)
|
||||
scanner->io_error = true;
|
||||
/* A user-selection exclude protects its destination mirror from --delete
|
||||
unless --delete-excluded; a size prune is always protected. Other
|
||||
skips (unreadable, symlink policy) protect nothing. Under -R +
|
||||
@@ -1125,9 +1385,17 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
wire path, not its source path (which would not match the destination
|
||||
layout and would leave the mirror deletable). */
|
||||
if (inspected.excluded) {
|
||||
char* protected_path = scanner->relative_mode
|
||||
? child_rel_path(scanner->current_rel, entry->d_name)
|
||||
: path_cat(scanner->current_path, entry->d_name);
|
||||
char* protected_path;
|
||||
if (scanner->relative_mode) {
|
||||
protected_path = child_rel_path(scanner->current_rel, entry->d_name);
|
||||
} else if (scanner->options.relative_prefix) {
|
||||
char* relc = child_rel_path(scanner->current_rel, entry->d_name);
|
||||
protected_path =
|
||||
relc ? scanner_prefix_send_path(scanner->options.relative_prefix, relc) : NULL;
|
||||
free(relc);
|
||||
} else {
|
||||
protected_path = path_cat(scanner->current_path, entry->d_name);
|
||||
}
|
||||
if (!protected_path) {
|
||||
scanner->failed = true;
|
||||
break;
|
||||
@@ -1152,10 +1420,15 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
scanner->failed = true;
|
||||
break;
|
||||
}
|
||||
bool protect = false;
|
||||
bool passes_selection = entry_passes_selection(
|
||||
scanner->options.file_list, scanner->options.base_filters, scanner->current_node, rel,
|
||||
entry->d_name, is_dir, scanner->options.per_dir_filters);
|
||||
if (!passes_selection) {
|
||||
entry->d_name, is_dir, scanner->options.per_dir_filters,
|
||||
scanner->options.exclude_per_dir_filter_files, &protect);
|
||||
/* A sender-side hide leaves the entry out of the transfer; an independent
|
||||
receiver-side protect rule keeps a transferred entry's destination mirror
|
||||
from being deleted. Both are recorded in the same protection set. */
|
||||
if (!passes_selection || protect) {
|
||||
/* --files-from subset pruning is not a filter exclusion: its delete
|
||||
semantics stay keep-set-only (an unlisted source path is treated as
|
||||
absent, so its destination mirror is a deletable extra). A rule-based
|
||||
@@ -1163,14 +1436,33 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
wire paths are never recorded (see ScannerOptions.excluded_paths). */
|
||||
bool files_from_prune =
|
||||
scanner->options.file_list && !file_list_affects(scanner->options.file_list, rel);
|
||||
if (!files_from_prune && !scanner->relative_mode)
|
||||
scanner_record_excluded(scanner, cur_path);
|
||||
if (protect && scanner->relative_mode) {
|
||||
/* -R + --files-from: the destination/wire path is the bare relative
|
||||
name, so the protected mirror prefix must be `rel` (not the source
|
||||
path) for the delete walker to match it. */
|
||||
scanner_record_excluded(scanner, rel);
|
||||
} else if (!files_from_prune && !scanner->relative_mode) {
|
||||
if (scanner->options.relative_prefix) {
|
||||
char* wrel = scanner_prefix_send_path(scanner->options.relative_prefix, rel);
|
||||
if (!wrel) {
|
||||
free(rel);
|
||||
free(cur_path);
|
||||
scanner->failed = true;
|
||||
break;
|
||||
}
|
||||
scanner_record_excluded(scanner, wrel);
|
||||
free(wrel);
|
||||
} else {
|
||||
scanner_record_excluded(scanner, cur_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* With -R + --files-from the wire/destination path is the entry's bare
|
||||
relative path; keep `rel` alive to attach it to a transferred file. */
|
||||
char* rel_copy = scanner->relative_mode ? str_dup(rel) : NULL;
|
||||
/* With -R the wire/destination path is a reconstructed relative path, not
|
||||
the source path; keep `rel` alive to build it for a transferred file. */
|
||||
bool needs_rel = scanner->relative_mode || scanner->options.relative_prefix != NULL;
|
||||
char* rel_copy = needs_rel ? str_dup(rel) : NULL;
|
||||
free(rel);
|
||||
if (rel_copy == NULL && scanner->relative_mode) {
|
||||
if (rel_copy == NULL && needs_rel) {
|
||||
free(cur_path);
|
||||
scanner->failed = true;
|
||||
break;
|
||||
@@ -1245,6 +1537,15 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
if (scanner->relative_mode) {
|
||||
file->send_path = rel_copy;
|
||||
rel_copy = NULL;
|
||||
} else if (scanner->options.relative_prefix) {
|
||||
file->send_path = scanner_prefix_send_path(scanner->options.relative_prefix, rel_copy);
|
||||
free(rel_copy);
|
||||
rel_copy = NULL;
|
||||
if (!file->send_path) {
|
||||
file_destroy(file);
|
||||
scanner->failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* --devices/--specials: a device/FIFO/socket entry marked for preservation
|
||||
becomes a node to recreate (is_special, no data, rdev captured); an
|
||||
@@ -1511,6 +1812,8 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
return;
|
||||
}
|
||||
if (inspection == 0) {
|
||||
if (inspected.referent_error)
|
||||
ps->io_error = true;
|
||||
ArrayList* sink = NULL;
|
||||
if (inspected.excluded)
|
||||
sink = inspected.size_excluded ? options->size_skipped_paths : options->excluded_paths;
|
||||
@@ -1522,6 +1825,15 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
if (options->relative && options->file_list != NULL) {
|
||||
if (!excluded_sink_append(sink, options->excluded_mutex, entry->d_name))
|
||||
ps->failed = true;
|
||||
} else if (options->relative_prefix) {
|
||||
char* wrel = scanner_prefix_send_path(options->relative_prefix, entry->d_name);
|
||||
if (!wrel) {
|
||||
ps->failed = true;
|
||||
} else {
|
||||
if (!excluded_sink_append(sink, options->excluded_mutex, wrel))
|
||||
ps->failed = true;
|
||||
free(wrel);
|
||||
}
|
||||
} else {
|
||||
char* abs_path = path_cat(root_directory, entry->d_name);
|
||||
if (!abs_path) {
|
||||
@@ -1545,31 +1857,54 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
ps->failed = true;
|
||||
return;
|
||||
}
|
||||
bool protect = false;
|
||||
bool passes = entry_passes_selection(options->file_list, options->base_filters, root_node, rel,
|
||||
entry->d_name, is_dir, options->per_dir_filters);
|
||||
entry->d_name, is_dir, options->per_dir_filters,
|
||||
options->exclude_per_dir_filter_files, &protect);
|
||||
/* -R + --files-from: root-level files keep their bare relative send path. */
|
||||
bool use_rel = options->relative && options->file_list != NULL;
|
||||
if (!passes) {
|
||||
if (!passes || protect) {
|
||||
/* --files-from subset pruning is not a filter exclusion; -R bare-wire-path
|
||||
exclusions are never recorded (see ScannerOptions.excluded_paths). */
|
||||
bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel);
|
||||
if (!files_from_prune && !use_rel && options->excluded_paths) {
|
||||
const char* rel_path = *cur_path == '/' ? cur_path + 1 : cur_path;
|
||||
if (!excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path))
|
||||
if ((!files_from_prune && !use_rel) || protect) {
|
||||
const char* rel_path;
|
||||
char* prefixed = NULL;
|
||||
if (use_rel) {
|
||||
/* -R + --files-from: the destination/wire path is the bare relative
|
||||
name, not the source path. */
|
||||
rel_path = rel;
|
||||
} else if (options->relative_prefix) {
|
||||
prefixed = scanner_prefix_send_path(options->relative_prefix, entry->d_name);
|
||||
if (!prefixed) {
|
||||
free(rel);
|
||||
free(cur_path);
|
||||
ps->failed = true;
|
||||
return;
|
||||
}
|
||||
rel_path = prefixed;
|
||||
} else {
|
||||
rel_path = *cur_path == '/' ? cur_path + 1 : cur_path;
|
||||
}
|
||||
if (options->excluded_paths &&
|
||||
!excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path))
|
||||
ps->failed = true;
|
||||
free(prefixed);
|
||||
}
|
||||
if (!passes) {
|
||||
free(rel);
|
||||
free(cur_path);
|
||||
return;
|
||||
}
|
||||
free(rel);
|
||||
free(cur_path);
|
||||
return;
|
||||
}
|
||||
if (is_dir) {
|
||||
free(rel);
|
||||
if (!scanner_same_filesystem(options->one_file_system, root_dev, st.st_dev)) {
|
||||
/* -x/--one-file-system: emit the mount-point directory entry (empty) but
|
||||
do not descend into it (see the sequential scanner for the same rule). */
|
||||
File* mount = file_create(cur_path);
|
||||
free(cur_path);
|
||||
if (mount == NULL) {
|
||||
free(rel);
|
||||
ps->failed = true;
|
||||
return;
|
||||
}
|
||||
@@ -1578,17 +1913,29 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
mount->metadata = file_metadata_create(mount->path, &st, options->preserve_atimes,
|
||||
options->preserve_crtimes);
|
||||
if (!mount->metadata) {
|
||||
free(rel);
|
||||
file_destroy(mount);
|
||||
ps->failed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (options->relative_prefix) {
|
||||
mount->send_path = scanner_prefix_send_path(options->relative_prefix, rel);
|
||||
if (!mount->send_path) {
|
||||
free(rel);
|
||||
file_destroy(mount);
|
||||
ps->failed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
free(rel);
|
||||
if (!array_list_add(root_files, mount)) {
|
||||
file_destroy(mount);
|
||||
ps->failed = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
free(rel);
|
||||
if (!array_list_add(subdirs, cur_path)) {
|
||||
free(cur_path);
|
||||
ps->failed = true;
|
||||
@@ -1614,6 +1961,15 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
if (use_rel) {
|
||||
file->send_path = rel;
|
||||
rel = NULL;
|
||||
} else if (options->relative_prefix) {
|
||||
file->send_path = scanner_prefix_send_path(options->relative_prefix, rel);
|
||||
free(rel);
|
||||
rel = NULL;
|
||||
if (!file->send_path) {
|
||||
file_destroy(file);
|
||||
ps->failed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
ScannerSpecial special = scanner_prepare_special(
|
||||
options->preserve_devices, options->preserve_specials, options->copy_devices, file, &st);
|
||||
@@ -1801,22 +2157,27 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
|
||||
root_dev = root_stats.st_dev;
|
||||
}
|
||||
|
||||
/* Build the root directory's .rsync-filter context once; workers seed their
|
||||
* scanners with it so per-dir rules behave identically to the sequential
|
||||
/* Build the root directory's per-directory filter context once; workers seed
|
||||
* their scanners with it so per-dir rules behave identically to the sequential
|
||||
* scanner. */
|
||||
FilterNode* root_node = NULL;
|
||||
if (options->per_dir_filters) {
|
||||
{
|
||||
char err[256];
|
||||
bool exists = false;
|
||||
FilterRuleList* own = filter_file_read(root_directory, "", &exists, err, sizeof(err));
|
||||
bool any_exists = false;
|
||||
FilterRuleList* own =
|
||||
read_dir_filters(options, root_directory, "", &any_exists, err, sizeof(err));
|
||||
if (!own) {
|
||||
log_message(LOG_LEVEL_ERROR, "invalid .rsync-filter in %s: %s", root_directory, err);
|
||||
array_list_delete(root_files);
|
||||
array_list_delete(subdirs);
|
||||
parallel_scanner_destroy(ps);
|
||||
return NULL;
|
||||
}
|
||||
if (exists && own->count > 0) {
|
||||
/* A parse/allocation failure must fail the scan even when an earlier
|
||||
merge file in the same directory existed (see the sequential scanner). */
|
||||
if (err[0] != '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "invalid per-directory filter in %s: %s", root_directory, err);
|
||||
array_list_delete(root_files);
|
||||
array_list_delete(subdirs);
|
||||
parallel_scanner_destroy(ps);
|
||||
return NULL;
|
||||
}
|
||||
/* no files exist: leave root_node NULL */
|
||||
} else if (any_exists && (own->count > 0 || own->dir_merge_count > 0)) {
|
||||
root_node = filter_node_alloc(NULL, own);
|
||||
if (!root_node) {
|
||||
filter_rule_list_free(own);
|
||||
@@ -1843,8 +2204,9 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
|
||||
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->preserve_atimes, options->preserve_crtimes,
|
||||
options->preserve_xattrs, options->preserve_acls)) {
|
||||
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);
|
||||
|
||||
+27
-2
@@ -63,8 +63,19 @@ typedef struct {
|
||||
const FileListSet* file_list; /* --files-from allow-set, or NULL */
|
||||
const FilterRuleList* base_filters; /* command-line + -C rules, or NULL */
|
||||
bool per_dir_filters; /* -F: read .rsync-filter per directory */
|
||||
bool dirs; /* -d/--dirs: transfer dir entries, no recursion */
|
||||
bool relative; /* -R/--relative (dest rel paths, with --files-from) */
|
||||
/* --delete-excluded: per-directory plain rules become sender-only, so they no
|
||||
longer protect the receiver from deletion. */
|
||||
bool delete_excluded;
|
||||
/* -FF: also exclude the per-directory filter files themselves from the
|
||||
transfer (single -F transfers them). */
|
||||
bool exclude_per_dir_filter_files;
|
||||
bool dirs; /* -d/--dirs: transfer dir entries, no recursion */
|
||||
bool relative; /* -R/--relative (dest rel paths, with --files-from) */
|
||||
/* -R/--relative outside --files-from: the destination-relative path prefix
|
||||
* reconstructed from the source spec (rsync's '/./' cut point), or NULL when
|
||||
* -R is off or --files-from is in use (the bare-relative path then comes from
|
||||
* the listed entry). Borrowed read-only; owned by client_send. */
|
||||
const char* relative_prefix;
|
||||
/* --list-only: emit an is_dir File for every traversed directory (the listing
|
||||
* includes directory entries, matching rsync). Client-only; never set on a
|
||||
* real transfer, which relies on implicit parent creation. */
|
||||
@@ -103,6 +114,13 @@ typedef struct {
|
||||
* directories, exactly like rsync; the receive root is the "." sentinel.
|
||||
* Guarded by `excluded_mutex`. */
|
||||
ArrayList* synced_dirs;
|
||||
/* Delete-plan directory sink (optional): when non-NULL the scanner appends
|
||||
* the destination-relative path of every directory it traverses (except the
|
||||
* receive root). The per-directory --delete-during/--delete-delay plan
|
||||
* builder uses this to keep an empty in-scope source directory (rsync keeps
|
||||
* 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 directory during the scan is recorded as an
|
||||
* I/O error and skipped instead of aborting the scan. Client-only. */
|
||||
bool ignore_io_errors;
|
||||
@@ -213,6 +231,13 @@ bool scanner_same_filesystem(bool one_file_system, dev_t root_device, dev_t entr
|
||||
* "/". Exposed so tests can exercise the mapping directly. */
|
||||
char* scanner_path_relative(const char* root, const char* fs_path);
|
||||
|
||||
/* -R/--relative destination-relative prefix reconstructed from a source spec:
|
||||
* the path after rsync's first '.' path component (the '/./' cut point), with
|
||||
* leading/trailing slashes removed, or the whole spec (normalized) when there
|
||||
* is no cut. Returns "" for the receive root, or NULL when `spec` is NULL or
|
||||
* allocation fails. Exposed so tests can exercise the mapping directly. */
|
||||
char* scanner_relative_prefix(const char* spec);
|
||||
|
||||
ParallelScanner* parallel_scanner_create_with_options(const char* root_directory,
|
||||
const ScannerOptions* options,
|
||||
ProtocolSession* allocation_session);
|
||||
|
||||
+39
-23
@@ -66,19 +66,19 @@ void print_usage(void) {
|
||||
printf(" transfer has succeeded)\n");
|
||||
printf(" --delete-before Delete extras before the transfer starts\n");
|
||||
printf(" (implies --delete)\n");
|
||||
printf(" --delete-during Delete extras once the keep-set manifest is known,\n");
|
||||
printf(" before the data is applied (implies --delete)\n");
|
||||
printf(" --delete-during Delete a directory's extras as that directory is\n");
|
||||
printf(" processed (implies --delete)\n");
|
||||
printf(" --del Alias for --delete-during\n");
|
||||
printf(" --delete-delay Delete extras only after a successful transfer\n");
|
||||
printf(" (implies --delete)\n");
|
||||
printf(" --delete-delay Record the extras during the scan but remove them\n");
|
||||
printf(" only after a successful transfer (implies --delete)\n");
|
||||
printf(" --delete-after Delete only after the whole transfer succeeded\n");
|
||||
printf(" (the default --delete timing; implies --delete)\n");
|
||||
printf(" --delete-excluded Also delete destination files that were excluded on\n");
|
||||
printf(" the source (default protects them, matching rsync)\n");
|
||||
printf(" --max-delete=NUM Never delete more than NUM destination entries per run;\n");
|
||||
printf(" if the extras would exceed NUM, nothing is deleted and\n");
|
||||
printf(" the run fails with a clear error (implies --delete only\n");
|
||||
printf(" when used with it)\n");
|
||||
printf(" --max-delete=NUM Delete at most NUM destination entries per run; if the\n");
|
||||
printf(" extras exceed NUM, the rest are skipped and the run is\n");
|
||||
printf(" reported as partial (exit 25, matching rsync). Only\n");
|
||||
printf(" applies together with --delete\n");
|
||||
printf(" --ignore-errors Continue (and still delete) when a source directory is\n");
|
||||
printf(" unreadable during the scan, instead of aborting with no\n");
|
||||
printf(" deletion\n");
|
||||
@@ -114,10 +114,12 @@ void print_usage(void) {
|
||||
printf(" --files-from <file> Read the source file list from FILE (paths relative to the "
|
||||
"source root)\n");
|
||||
printf(" -0, --from0 Entries in --files-from are NUL-delimited\n");
|
||||
printf(" -f, --filter=RULE rsync-style filter rule (+/- include/exclude; repeatable;\n");
|
||||
printf(" both --filter=RULE and the -f RULE / -f=RULE short forms work)\n");
|
||||
printf(" -f, --filter=RULE rsync-style filter rule: exclude/- include/+ hide/H show/S\n");
|
||||
printf(" protect/P risk/R merge/. dir-merge/: clear/! with modifiers\n");
|
||||
printf(" (repeatable; --filter=RULE and -f RULE / -f=RULE both work)\n");
|
||||
printf(" -C, --cvs-exclude Auto-ignore common CVS/SCM files (.git/, .svn/, *.o, *~, ...)\n");
|
||||
printf(" -F Apply per-directory .rsync-filter files during the scan\n");
|
||||
printf(" -F Apply per-directory .rsync-filter files; repeated -FF also\n");
|
||||
printf(" excludes the .rsync-filter files themselves\n");
|
||||
printf(" --max-size <n> Skip files larger than n bytes\n");
|
||||
printf(" --min-size <n> Skip files smaller than n bytes\n");
|
||||
printf(" --max-alloc <SIZE> Maximum single allocation (default: 1G; 0 = no limit,\n");
|
||||
@@ -136,10 +138,10 @@ void print_usage(void) {
|
||||
printf(" --link-dest <dir> Like --copy-dest, but hard-links the unchanged file from DIR\n");
|
||||
printf(" into the destination (repeatable; earlier DIRs win)\n");
|
||||
printf(" --checksum-choice, --cc <alg> Whole-file checksum algorithm for --incremental/\n");
|
||||
printf(" --checksum compares. Accepted: xxh64 (aka xxhash), xxh3,\n");
|
||||
printf(" xxh128, md5, or auto (default xxh64). rsync choices FastSync\n");
|
||||
printf(" does not implement (md4, sha1, none) and the two-name\n");
|
||||
printf(" transfer,pre-transfer form are rejected by name\n");
|
||||
printf(" --checksum compares. Accepted: xxh128 (default), xxh3, xxh64\n");
|
||||
printf(" (aka xxhash), md5, md4, sha1, or none. A two-name\n");
|
||||
printf(" 'transfer,pre-transfer' form is accepted like rsync; 'none' as\n");
|
||||
printf(" the pre-transfer algorithm is rejected with --checksum\n");
|
||||
printf(" --checksum-seed <num> Seed for the whole-file xxHash digest (and the delta\n");
|
||||
printf(" block strong hash, low 32 bits); md5 ignores the seed. A seed\n");
|
||||
printf(" of 0 (the default) is randomized per transfer, exactly like\n");
|
||||
@@ -166,13 +168,14 @@ void print_usage(void) {
|
||||
printf(" SSH argv is already built injection-safe)\n");
|
||||
printf(" --sendfile Enable sendfile zero-copy (TCP only; long form only;\n");
|
||||
printf(" -f is bound to --filter, not --sendfile)\n");
|
||||
printf(" --compress-choice <alg> Compression algorithm (default: zstd)\n");
|
||||
printf(" --compress-choice <alg> Compression algorithm: zstd (default), lz4, zlib,\n");
|
||||
printf(" zlibx, none, or auto\n");
|
||||
printf(" --zc <alg> Alias for --compress-choice\n");
|
||||
printf(" -v, --verbose Enable debug logging\n");
|
||||
printf(" -q, --quiet Suppress non-error output\n");
|
||||
printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\n");
|
||||
printf(" --info=FLAGS Fine-grained info: copy,misc,skip,stats,all,none\n");
|
||||
printf(" none suppresses info even with --verbose\n");
|
||||
printf(" --info=FLAGS Fine-grained info: copy,name,misc,skip,stats,all,none\n");
|
||||
printf(" (use --info=help for flags; none suppresses --verbose)\n");
|
||||
printf(" --preserve Preserve permissions and times (= -pt; long form only)\n");
|
||||
printf(" --no-perms Negate -p/--perms\n");
|
||||
printf(" --no-times Negate -t/--times\n");
|
||||
@@ -268,7 +271,7 @@ void print_usage(void) {
|
||||
printf(" --suffix <str> Backup suffix (default: ~)\n");
|
||||
printf(" --stats Print transfer statistics at end\n");
|
||||
printf(" -i, --itemize-changes Print an rsync-style per-file change line\n");
|
||||
printf(" --out-format=FORMAT Output format for changed files (%%f %%n %%l %%b %%M %%%%)\n");
|
||||
printf(" --out-format=FORMAT Output format (%%f %%n %%l %%b %%c %%C %%i %%M %%%%)\n");
|
||||
printf(" --list-only List source files instead of transferring\n");
|
||||
printf(" --log-file-format=FORMAT Per-file log line format (needs --log-file)\n");
|
||||
printf(" -h, --human-readable Print byte sizes in human-readable form\n");
|
||||
@@ -279,8 +282,8 @@ void print_usage(void) {
|
||||
printf(" --partial Keep partial files on interrupted transfer\n");
|
||||
printf(" --partial-dir <dir> Directory for partial files\n");
|
||||
printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install.\n");
|
||||
printf(" Relative dirs resolve below the destination root; absolute\n");
|
||||
printf(" dirs are used as-is (rsync semantics). The dir must\n");
|
||||
printf(" Confined to the receive root: a relative dir resolves below\n");
|
||||
printf(" it and an absolute/traversal dir is rejected. The dir must\n");
|
||||
printf(" already exist; a different filesystem falls back to a\n");
|
||||
printf(" non-atomic copy instead of aborting\n");
|
||||
printf(" --fastsync-server-path <path>\n");
|
||||
@@ -338,7 +341,20 @@ void print_usage(void) {
|
||||
}
|
||||
|
||||
void print_debug_usage(void) {
|
||||
printf("Supported debug flags: IO,PROTO,PACK,UTIL,ALL,NONE\n");
|
||||
printf("Emitting debug flags: IO,PROTO,PACK,UTIL,ALL,NONE\n");
|
||||
printf("Also accepted for rsync CLI parity (silent): ACL,BACKUP,BIND,CHDIR,\n");
|
||||
printf("CONNECT,CMD,DEL,DELTASUM,DUP,EXIT,FILTER,FLIST,FUZZY,GENR,HASH,HLINK,\n");
|
||||
printf("ICONV,NSTR,OWN,RECV,SEND,TIME.\n");
|
||||
printf("Flags may be comma-separated, for example: --debug=io,proto\n");
|
||||
printf("Other rsync debug flags are unsupported and rejected.\n");
|
||||
printf("An optional level suffix is accepted (e.g. --debug=io2); level 0\n");
|
||||
printf("silences that item. Unknown names are rejected.\n");
|
||||
}
|
||||
|
||||
void print_info_usage(void) {
|
||||
printf("Emitting info flags: COPY,NAME,MISC,SKIP,STATS,ALL,NONE\n");
|
||||
printf("Also accepted for rsync CLI parity (silent): BACKUP,DEL,FLIST,MOUNT,\n");
|
||||
printf("NONREG,PROGRESS,REMOVE,SYMSAFE.\n");
|
||||
printf("Flags may be comma-separated, for example: --info=name,stats\n");
|
||||
printf("An optional level suffix is accepted (e.g. --info=stats2); level 0\n");
|
||||
printf("silences that item. Unknown names are rejected.\n");
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
|
||||
void print_usage(void);
|
||||
void print_debug_usage(void);
|
||||
void print_info_usage(void);
|
||||
|
||||
#endif
|
||||
+137
-17
@@ -3,6 +3,7 @@
|
||||
#include "charset.h"
|
||||
#include "chunk.h"
|
||||
#include "config.h"
|
||||
#include "delete_plan.h"
|
||||
#include "delay_updates.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
@@ -11,6 +12,7 @@
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
|
||||
@@ -58,6 +60,36 @@ bool receiver_send_final_success(int fd, const Config* config, const ReceiverOut
|
||||
return send_status(fd, final_status);
|
||||
}
|
||||
|
||||
bool receiver_send_stats_frame(int fd, const Config* config, const ReceiverStats* stats,
|
||||
const struct ArrayList* would_delete) {
|
||||
if (!config->report_stats)
|
||||
return true;
|
||||
ReceiverStats local;
|
||||
memset(&local, 0, sizeof(local));
|
||||
const ReceiverStats* out = stats ? stats : &local;
|
||||
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;
|
||||
record.would_delete_count = count;
|
||||
if (!send_status(fd, STATUS_STATS) || !format_stats_send(fd, &record) ||
|
||||
!send_int(fd, (int)count))
|
||||
return false;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const char* path = (const char*)would_delete->items[i];
|
||||
if (!send_wire_str(fd, path ? path : ""))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Add a delete commit's tally to the sink's end-of-transfer wire counters (when
|
||||
the sink reports them). Runs on the receiving thread, so no locking. */
|
||||
static void receiver_tally_deleted(const ReceiverSink* sink, size_t deleted) {
|
||||
if (sink && sink->stats && deleted > 0)
|
||||
sink->stats->deleted_files += deleted;
|
||||
}
|
||||
|
||||
static bool receiver_process_chunk(Chunk* chunk, const ReceiverSink* sink) {
|
||||
if (!chunk || !sink || !sink->store_file)
|
||||
return false;
|
||||
@@ -244,7 +276,7 @@ static bool receiver_note_status(const struct timespec* session_start,
|
||||
}
|
||||
|
||||
int receiver_process(Config* config, int file_descriptor, const ReceiverSink* sink) {
|
||||
return receiver_process_pending(config, file_descriptor, sink, NULL);
|
||||
return receiver_process_pending(config, file_descriptor, sink, NULL, NULL);
|
||||
}
|
||||
|
||||
/* Runs the whole receive loop. The delete manifest may legitimately arrive
|
||||
@@ -258,7 +290,7 @@ int receiver_process(Config* config, int file_descriptor, const ReceiverSink* si
|
||||
the whole transfer succeeded. See receiver_process_pending() for how the -m
|
||||
receiver defers that commit until its disk writer has drained. */
|
||||
int receiver_process_pending(Config* config, int file_descriptor, const ReceiverSink* sink,
|
||||
DeleteManifest** pending_manifest) {
|
||||
DeleteManifest** pending_manifest, DeletePlanSession** pending_plans) {
|
||||
Status status;
|
||||
if (!receive_status(file_descriptor, &status))
|
||||
return -1;
|
||||
@@ -272,14 +304,22 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
if (!receiver_note_status(&session_start, &last_progress, status, file_descriptor, sink))
|
||||
return -1;
|
||||
bool early_delete = config_delete_timing_early(config);
|
||||
bool per_dir_delete = config_delete_timing_per_dir(config);
|
||||
/* Parked keep-set for the late/commit timing. Every exit path below frees it
|
||||
exactly once; the only exception is the successful FINISHED handoff, which
|
||||
transfers ownership to *pending_manifest (used by the -m receiver). */
|
||||
DeleteManifest* deferred_manifest = NULL;
|
||||
/* Per-directory delete session for --delete-during/--delete-delay. During the
|
||||
loop it applies plans inline (during) or snapshots their extras (delay); on
|
||||
a successful FINISHED it is either committed here or handed to
|
||||
*pending_plans so the -m caller commits after its disk writer drained. */
|
||||
DeletePlanSession* plan_session = NULL;
|
||||
bool delete_limit_noted = false;
|
||||
while (status == STATUS_NEXT || status == STATUS_CHUNK || status == STATUS_CHECK ||
|
||||
status == STATUS_KEEPALIVE || status == STATUS_ABORT || status == STATUS_CHECK_BATCH ||
|
||||
status == STATUS_MKDIR || status == STATUS_MANIFEST || status == STATUS_HARDLINK ||
|
||||
status == STATUS_SYMLINK || status == STATUS_SPECIAL || status == STATUS_DIR_TIMES) {
|
||||
status == STATUS_SYMLINK || status == STATUS_SPECIAL || status == STATUS_DIR_TIMES ||
|
||||
status == STATUS_DELETE_PLAN) {
|
||||
if (status == STATUS_KEEPALIVE) {
|
||||
if (!send_status(file_descriptor, STATUS_KEEPALIVE))
|
||||
goto fail;
|
||||
@@ -337,23 +377,31 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
if (config->dry_run) {
|
||||
/* Server-contacting --dry-run mutates nothing, so a keep-set manifest
|
||||
is consumed and discarded. The early-delete mode still needs its ACK
|
||||
so a sender blocked on the delete handshake is not left hanging. */
|
||||
so a sender blocked on the delete handshake is not left hanging.
|
||||
When would-delete reporting is armed, enumerate (read-only) the
|
||||
destination extras so the terminal STATUS_STATS frame can list them. */
|
||||
if (config->use_delete && sink->would_delete) {
|
||||
size_t count = 0;
|
||||
if (!manifest_would_delete_list(config, manifest, sink->would_delete, &count))
|
||||
log_message(LOG_LEVEL_WARNING, "dry-run: could not enumerate would-delete paths");
|
||||
}
|
||||
delete_manifest_free(manifest);
|
||||
if (early_delete && !send_status(file_descriptor, STATUS_OK))
|
||||
goto fail;
|
||||
goto next_status;
|
||||
}
|
||||
if (early_delete) {
|
||||
/* --delete-before / --delete-during: the manifest is authoritative the
|
||||
moment it arrives, before any file data. Delete now and acknowledge
|
||||
so the sender only starts streaming once the deletion committed (or
|
||||
failed). This is the rsync delete-before/delete-during window: a
|
||||
later transfer failure does not restore these deletions. A
|
||||
/* --delete-before: the whole-tree manifest is authoritative the moment
|
||||
it arrives, before any file data. Delete now and acknowledge so the
|
||||
sender only starts streaming once the deletion committed (or failed).
|
||||
A later transfer failure does not restore these deletions. A
|
||||
--max-delete-capped commit still succeeds and the transfer proceeds;
|
||||
the terminal success frame reports the cap. */
|
||||
size_t deleted = 0;
|
||||
DeleteCommitResult deletion = (config->use_delete || config->delete_missing_args)
|
||||
? manifest_delete_all(config, manifest)
|
||||
? manifest_delete_all_counted(config, manifest, &deleted)
|
||||
: DELETE_COMMIT_OK;
|
||||
receiver_tally_deleted(sink, deleted);
|
||||
delete_manifest_free(manifest);
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
@@ -364,9 +412,9 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
if (!send_status(file_descriptor, STATUS_OK))
|
||||
goto fail;
|
||||
} else if (config->use_delete || config->delete_missing_args) {
|
||||
/* Plain --delete / --delete-after / --delete-delay and the
|
||||
--delete-missing-args exact-path deletions: hold the manifest and
|
||||
commit it only after STATUS_FINISHED. */
|
||||
/* Plain --delete / --delete-after and the --delete-missing-args
|
||||
exact-path deletions: hold the manifest and commit it only after
|
||||
STATUS_FINISHED. The per-directory modes never send this frame. */
|
||||
if (deferred_manifest) {
|
||||
log_message(LOG_LEVEL_ERROR, "Received a second delete manifest");
|
||||
delete_manifest_free(deferred_manifest);
|
||||
@@ -380,6 +428,23 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
delete_manifest_free(manifest);
|
||||
}
|
||||
goto next_status;
|
||||
} else if (status == STATUS_DELETE_PLAN) {
|
||||
if (!per_dir_delete) {
|
||||
log_message(LOG_LEVEL_ERROR, "Received a per-directory delete plan without a per-dir "
|
||||
"delete timing");
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
if (!plan_session)
|
||||
plan_session = delete_plan_session_create(config);
|
||||
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 &&
|
||||
sink->note_delete_limit) {
|
||||
sink->note_delete_limit(sink->context);
|
||||
delete_limit_noted = true;
|
||||
}
|
||||
goto next_status;
|
||||
} else {
|
||||
File* file = file_receive(config, file_descriptor);
|
||||
if (!file) {
|
||||
@@ -413,7 +478,10 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
*pending_manifest = deferred_manifest;
|
||||
deferred_manifest = NULL;
|
||||
} else {
|
||||
DeleteCommitResult deletion = manifest_delete_all(config, deferred_manifest);
|
||||
size_t deleted = 0;
|
||||
DeleteCommitResult deletion =
|
||||
manifest_delete_all_counted(config, deferred_manifest, &deleted);
|
||||
receiver_tally_deleted(sink, deleted);
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
@@ -424,6 +492,33 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
sink->note_delete_limit(sink->context);
|
||||
}
|
||||
}
|
||||
/* Per-directory deletion: --delete-during already applied each plan inline, so
|
||||
this only finishes the missing-args deletions; --delete-delay committed
|
||||
nothing yet and applies its decompressed snapshot here. The -m receiver
|
||||
hands the session to its caller instead, which commits after the disk
|
||||
writer drained. */
|
||||
if (plan_session) {
|
||||
if (pending_plans) {
|
||||
*pending_plans = plan_session;
|
||||
plan_session = NULL;
|
||||
} else if (config->dry_run) {
|
||||
/* Central dry-run no-op: never commit a deletion for a -n run. */
|
||||
delete_plan_session_destroy(plan_session);
|
||||
plan_session = NULL;
|
||||
} else {
|
||||
DeleteCommitResult deletion = delete_plan_session_commit(plan_session, config);
|
||||
bool limit = delete_plan_session_limit_reached(plan_session);
|
||||
receiver_tally_deleted(sink, delete_plan_session_deleted(plan_session));
|
||||
delete_plan_session_destroy(plan_session);
|
||||
plan_session = NULL;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
if (limit && !delete_limit_noted && sink->note_delete_limit)
|
||||
sink->note_delete_limit(sink->context);
|
||||
}
|
||||
}
|
||||
if (sink->send_success) {
|
||||
if (sink->send_success_frame) {
|
||||
if (!sink->send_success_frame(file_descriptor, sink->context))
|
||||
@@ -436,11 +531,14 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
|
||||
fail:
|
||||
/* Failure exits that must not (or already did) report a STATUS_ERROR. The
|
||||
parked keep-set is dropped: never commit a deletion for a failed stream. */
|
||||
parked keep-set/session is dropped: never commit a deletion for a failed
|
||||
stream. */
|
||||
if (deferred_manifest) {
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
}
|
||||
if (plan_session)
|
||||
delete_plan_session_destroy(plan_session);
|
||||
return -1;
|
||||
|
||||
receive_error:
|
||||
@@ -448,6 +546,8 @@ receive_error:
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
}
|
||||
if (plan_session)
|
||||
delete_plan_session_destroy(plan_session);
|
||||
if (sink->send_error)
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return -1;
|
||||
@@ -465,6 +565,10 @@ typedef struct {
|
||||
/* Set when a --max-delete commit was capped; the terminal frame then carries
|
||||
STATUS_DELETE_LIMIT so the sender exits 25 like rsync. */
|
||||
bool delete_limit_reached;
|
||||
/* End-of-transfer wire counters (protocol 2.25.0) and the -n/--dry-run
|
||||
--delete would-delete path list collected while processing the manifest. */
|
||||
ReceiverStats stats;
|
||||
ArrayList* would_delete;
|
||||
} ReceiverSaveContext;
|
||||
|
||||
static bool receiver_save_file(File* file, void* context_pointer) {
|
||||
@@ -481,6 +585,10 @@ static bool receiver_save_file(File* file, void* context_pointer) {
|
||||
} else {
|
||||
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;
|
||||
/* 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
|
||||
@@ -513,6 +621,8 @@ 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))
|
||||
return false;
|
||||
/* Server-contacting --dry-run: nothing was staged or written, so there is
|
||||
nothing to publish and no directory times to stamp. */
|
||||
if (context->config->dry_run)
|
||||
@@ -540,12 +650,22 @@ static bool receiver_send_success_frame(int fd, void* context_pointer) {
|
||||
int receiver_receive_files(Config* config, int file_descriptor) {
|
||||
ReceiverSaveContext context = {.config = config, .outcomes = {0}};
|
||||
dir_time_list_init(&context.dir_times);
|
||||
ReceiverSink sink = {receiver_save_file, &context, true, true, receiver_send_success_frame,
|
||||
receiver_note_delete_limit};
|
||||
context.would_delete = array_list_create(free);
|
||||
if (!context.would_delete)
|
||||
return -1;
|
||||
ReceiverSink sink = {receiver_save_file,
|
||||
&context,
|
||||
true,
|
||||
true,
|
||||
receiver_send_success_frame,
|
||||
receiver_note_delete_limit,
|
||||
&context.stats,
|
||||
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);
|
||||
return ret;
|
||||
}
|
||||
+19
-3
@@ -2,6 +2,7 @@
|
||||
#define RECEIVER_H
|
||||
|
||||
#include "config.h"
|
||||
#include "delete_plan.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
#include "protocol.h"
|
||||
@@ -39,24 +40,39 @@ typedef struct {
|
||||
ReceiverSuccessFrame send_success_frame;
|
||||
/* Optional; may be NULL when the sink has no --max-delete handling. */
|
||||
ReceiverNoteDeleteLimit note_delete_limit;
|
||||
/* Optional end-of-transfer wire counters (protocol 2.25.0). When non-NULL
|
||||
and the wire config carries report_stats, the success frame is preceded by
|
||||
a STATUS_STATS record; `would_delete` (optional, receiver-owned strings)
|
||||
carries the -n/--dry-run --delete path list. */
|
||||
ReceiverStats* stats;
|
||||
struct ArrayList* would_delete;
|
||||
} ReceiverSink;
|
||||
|
||||
bool receiver_outcomes_append(ReceiverOutcomes* outcomes, unsigned char code);
|
||||
void receiver_outcomes_destroy(ReceiverOutcomes* outcomes);
|
||||
|
||||
/* 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,
|
||||
Status final_status);
|
||||
|
||||
/* Emit STATUS_STATS (a fixed ReceiverStats record plus, when `would_delete` is
|
||||
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);
|
||||
|
||||
int receiver_process(Config* config, int file_descriptor, const ReceiverSink* sink);
|
||||
/* receiver_process with an escape hatch for the commit-style (late) deletion:
|
||||
when `pending_manifest` is non-NULL the receiver does NOT delete at
|
||||
STATUS_FINISHED itself; instead it stores the owned keep-set manifest there
|
||||
(leaving *pending_manifest untouched on early modes/errors) so the caller can
|
||||
commit the deletion only after its disk writer has fully drained. Pass NULL
|
||||
to keep the default behaviour (delete before the success frame). */
|
||||
commit the deletion only after its disk writer has fully drained. Likewise,
|
||||
when `pending_plans` is non-NULL the --delete-delay per-directory session is
|
||||
handed to the caller instead of being committed at STATUS_FINISHED. Pass NULL
|
||||
for either to keep the default behaviour (delete before the success frame). */
|
||||
int receiver_process_pending(Config* config, int file_descriptor, const ReceiverSink* sink,
|
||||
DeleteManifest** pending_manifest);
|
||||
DeleteManifest** pending_manifest, DeletePlanSession** pending_plans);
|
||||
int receiver_receive_files(Config* config, int file_descriptor);
|
||||
|
||||
/* ---- Connection time bounds (anti-slowloris) ----
|
||||
|
||||
@@ -27,7 +27,10 @@ PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue*
|
||||
context->queued_bytes = 0;
|
||||
context->max_queue_bytes = 0;
|
||||
context->deferred_manifest = NULL;
|
||||
context->deferred_plans = NULL;
|
||||
context->delete_limit_reached = false;
|
||||
memset(&context->stats, 0, sizeof(context->stats));
|
||||
context->would_delete = NULL;
|
||||
atomic_init(&context->cancelled, false);
|
||||
int init = 0;
|
||||
if (mtx_init(&context->mutex, mtx_plain) != thrd_success)
|
||||
@@ -40,6 +43,9 @@ PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue*
|
||||
goto fail;
|
||||
// cppcheck-suppress unreadVariable
|
||||
init++;
|
||||
context->would_delete = array_list_create(free);
|
||||
if (!context->would_delete)
|
||||
goto fail;
|
||||
return context;
|
||||
|
||||
fail:
|
||||
@@ -58,9 +64,13 @@ void pipeline_context_receiver_destroy(PipelineContextReceiver* context) {
|
||||
config_delete(context->config);
|
||||
if (context->deferred_manifest)
|
||||
delete_manifest_free(context->deferred_manifest);
|
||||
if (context->deferred_plans)
|
||||
delete_plan_session_destroy(context->deferred_plans);
|
||||
queue_destroy(context->queue);
|
||||
receiver_outcomes_destroy(&context->outcomes);
|
||||
dir_time_list_free(&context->dir_times);
|
||||
if (context->would_delete)
|
||||
array_list_delete(context->would_delete);
|
||||
mtx_destroy(&context->mutex);
|
||||
cnd_destroy(&context->condition_not_full);
|
||||
cnd_destroy(&context->condition_not_empty);
|
||||
@@ -133,6 +143,11 @@ bool pipeline_context_receiver_enqueue_file(PipelineContextReceiver* context, Fi
|
||||
|
||||
static bool receiver_enqueue_file(File* file, void* context_pointer) {
|
||||
PipelineContextReceiver* context = (PipelineContextReceiver*)context_pointer;
|
||||
if (file && file->matched_bytes > 0) {
|
||||
mtx_lock(&context->mutex);
|
||||
context->stats.matched_data += file->matched_bytes;
|
||||
mtx_unlock(&context->mutex);
|
||||
}
|
||||
return pipeline_context_receiver_enqueue_file(context, file);
|
||||
}
|
||||
|
||||
@@ -162,10 +177,16 @@ int receive_thread(void* pipeline_context) {
|
||||
const Config* config = context->config;
|
||||
mtx_unlock(&context->mutex);
|
||||
|
||||
ReceiverSink sink = {
|
||||
receiver_enqueue_file, context, false, false, NULL, receiver_pipeline_note_delete_limit};
|
||||
if (receiver_process_pending((Config*)config, file_descriptor, &sink,
|
||||
&context->deferred_manifest) != 0) {
|
||||
ReceiverSink sink = {receiver_enqueue_file,
|
||||
context,
|
||||
false,
|
||||
false,
|
||||
NULL,
|
||||
receiver_pipeline_note_delete_limit,
|
||||
&context->stats,
|
||||
context->would_delete};
|
||||
if (receiver_process_pending((Config*)config, file_descriptor, &sink, &context->deferred_manifest,
|
||||
&context->deferred_plans) != 0) {
|
||||
receiver_thread_fail(context);
|
||||
protocol_session_unbind();
|
||||
return thrd_error;
|
||||
|
||||
@@ -41,6 +41,11 @@ typedef struct PipelineContextReceiver {
|
||||
transfer truly succeeded. NULL in the early delete modes (which delete at
|
||||
the manifest). */
|
||||
DeleteManifest* deferred_manifest;
|
||||
/* Per-directory delete session for --delete-delay: receive_thread snapshots
|
||||
each plan's extras as it arrives and hands the session here instead of
|
||||
committing while the disk writer may still be draining; server.c commits it
|
||||
after both threads joined. NULL for every other timing. */
|
||||
DeletePlanSession* deferred_plans;
|
||||
/* Set by server.c when the deferred delete commit hit the --max-delete
|
||||
budget; the terminal success frame then carries STATUS_DELETE_LIMIT
|
||||
(rsync exit 25) while the transfer itself still succeeds. */
|
||||
@@ -49,6 +54,13 @@ typedef struct PipelineContextReceiver {
|
||||
directory entries. Only write_thread mutates it (before it joins); the
|
||||
caller (server.c) applies it after the delete/delay-updates phase. */
|
||||
DirTimeList dir_times;
|
||||
/* End-of-transfer wire counters (protocol 2.25.0). receive_thread accumulates
|
||||
matched_data under `mutex`; server.c adds the delete-commit tallies after
|
||||
both threads join and emits the STATUS_STATS frame. */
|
||||
ReceiverStats stats;
|
||||
/* -n/--dry-run --delete would-delete path list, collected by receive_thread
|
||||
and reported in the STATUS_STATS frame. */
|
||||
struct ArrayList* would_delete;
|
||||
} PipelineContextReceiver;
|
||||
|
||||
PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue* queue_receiver,
|
||||
|
||||
+31
-2
@@ -741,6 +741,10 @@ void handler(int file_descriptor) {
|
||||
* received config. */
|
||||
if (gate_ctx.super_mode_override != -1)
|
||||
config->super_mode = (SuperMode)gate_ctx.super_mode_override;
|
||||
/* Install the codec this connection negotiated before the receiver/writer
|
||||
* threads start (the server forks per connection, so the process-global
|
||||
* codec is private to this session). */
|
||||
compression_set_algo((CompressionAlgo)config->compression_algo);
|
||||
/* If the client requested ownership but the effective super mode forbids it
|
||||
* (operator --no-super, a privileged standalone receiver's secure default, or
|
||||
* a daemon module without `client owner = yes`), say so ONCE per connection so
|
||||
@@ -951,7 +955,10 @@ void handler(int file_descriptor) {
|
||||
--delay-updates run; the walker skips the staging directory. A
|
||||
server-contacting --dry-run deletes nothing (no manifest is sent). */
|
||||
if (context->deferred_manifest) {
|
||||
DeleteCommitResult deletion = manifest_delete_all(config, context->deferred_manifest);
|
||||
size_t deleted = 0;
|
||||
DeleteCommitResult deletion =
|
||||
manifest_delete_all_counted(config, context->deferred_manifest, &deleted);
|
||||
context->stats.deleted_files += deleted;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
transfer_ok = false;
|
||||
} else if (deletion == DELETE_COMMIT_LIMIT_REACHED) {
|
||||
@@ -962,6 +969,24 @@ void handler(int file_descriptor) {
|
||||
delete_manifest_free(context->deferred_manifest);
|
||||
context->deferred_manifest = NULL;
|
||||
}
|
||||
/* --delete-delay: receive_thread snapshotted each plan's extras as it
|
||||
arrived; with the disk writer drained, commit the deferred removals.
|
||||
--delete-during already applied its plans on the receive thread. */
|
||||
if (context->deferred_plans) {
|
||||
/* Defence in depth (the enclosing block already excludes dry-run): a
|
||||
-n run never commits a deletion. */
|
||||
DeleteCommitResult deletion =
|
||||
config->dry_run ? DELETE_COMMIT_OK
|
||||
: delete_plan_session_commit(context->deferred_plans, config);
|
||||
context->stats.deleted_files += delete_plan_session_deleted(context->deferred_plans);
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
transfer_ok = false;
|
||||
} else if (deletion == DELETE_COMMIT_LIMIT_REACHED) {
|
||||
context->delete_limit_reached = true;
|
||||
}
|
||||
delete_plan_session_destroy(context->deferred_plans);
|
||||
context->deferred_plans = NULL;
|
||||
}
|
||||
}
|
||||
if (transfer_ok && !config->dry_run) {
|
||||
/* --delay-updates: receive_thread has finished the whole protocol stream
|
||||
@@ -982,7 +1007,11 @@ void handler(int file_descriptor) {
|
||||
}
|
||||
if (transfer_ok) {
|
||||
Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK;
|
||||
if (!receiver_send_final_success(file_descriptor, config, &context->outcomes, final_status))
|
||||
/* 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) ||
|
||||
!receiver_send_final_success(file_descriptor, config, &context->outcomes, final_status))
|
||||
transfer_ok = false;
|
||||
} else {
|
||||
send_error_detail(file_descriptor, "transfer failed on receiver");
|
||||
|
||||
+308
-22
@@ -1,12 +1,170 @@
|
||||
#include "checksum.h"
|
||||
#include <fcntl.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* delta.c owns the single XXH_IMPLEMENTATION that provides the xxHash symbols
|
||||
* for the whole binary; this TU only needs the declarations. */
|
||||
* for the whole binary; this TU only needs the declarations. The streaming
|
||||
* state structs and XXH3_update are exposed only with XXH_STATIC_LINKING_ONLY. */
|
||||
#define XXH_STATIC_LINKING_ONLY
|
||||
#include <xxhash.h>
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Self-contained MD4 (RFC 1320). OpenSSL's MD4 lives in the legacy provider
|
||||
* and is not guaranteed present, so FastSync carries its own implementation to
|
||||
* keep --checksum-choice=md4 working on every build.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
typedef struct {
|
||||
uint32_t state[4];
|
||||
uint64_t bit_count;
|
||||
uint8_t buffer[64];
|
||||
size_t buffer_len;
|
||||
} Md4Ctx;
|
||||
|
||||
static uint32_t md4_rotl(uint32_t x, int n) {
|
||||
return (x << n) | (x >> (32 - n));
|
||||
}
|
||||
|
||||
static void md4_transform(uint32_t state[4], const uint8_t block[64]) {
|
||||
uint32_t x[16];
|
||||
for (int i = 0; i < 16; i++)
|
||||
x[i] = (uint32_t)block[i * 4] | ((uint32_t)block[i * 4 + 1] << 8) |
|
||||
((uint32_t)block[i * 4 + 2] << 16) | ((uint32_t)block[i * 4 + 3] << 24);
|
||||
|
||||
uint32_t a = state[0], b = state[1], c = state[2], d = state[3];
|
||||
|
||||
#define F(x, y, z) (((x) & (y)) | (~(x) & (z)))
|
||||
#define G(x, y, z) (((x) & (y)) | ((x) & (z)) | ((y) & (z)))
|
||||
#define H(x, y, z) ((x) ^ (y) ^ (z))
|
||||
#define ROUND1(a, b, c, d, k, s) a = md4_rotl(a + F(b, c, d) + x[k], s)
|
||||
#define ROUND2(a, b, c, d, k, s) a = md4_rotl(a + G(b, c, d) + x[k] + 0x5a827999u, s)
|
||||
#define ROUND3(a, b, c, d, k, s) a = md4_rotl(a + H(b, c, d) + x[k] + 0x6ed9eba1u, s)
|
||||
|
||||
ROUND1(a, b, c, d, 0, 3);
|
||||
ROUND1(d, a, b, c, 1, 7);
|
||||
ROUND1(c, d, a, b, 2, 11);
|
||||
ROUND1(b, c, d, a, 3, 19);
|
||||
ROUND1(a, b, c, d, 4, 3);
|
||||
ROUND1(d, a, b, c, 5, 7);
|
||||
ROUND1(c, d, a, b, 6, 11);
|
||||
ROUND1(b, c, d, a, 7, 19);
|
||||
ROUND1(a, b, c, d, 8, 3);
|
||||
ROUND1(d, a, b, c, 9, 7);
|
||||
ROUND1(c, d, a, b, 10, 11);
|
||||
ROUND1(b, c, d, a, 11, 19);
|
||||
ROUND1(a, b, c, d, 12, 3);
|
||||
ROUND1(d, a, b, c, 13, 7);
|
||||
ROUND1(c, d, a, b, 14, 11);
|
||||
ROUND1(b, c, d, a, 15, 19);
|
||||
|
||||
ROUND2(a, b, c, d, 0, 3);
|
||||
ROUND2(d, a, b, c, 4, 5);
|
||||
ROUND2(c, d, a, b, 8, 9);
|
||||
ROUND2(b, c, d, a, 12, 13);
|
||||
ROUND2(a, b, c, d, 1, 3);
|
||||
ROUND2(d, a, b, c, 5, 5);
|
||||
ROUND2(c, d, a, b, 9, 9);
|
||||
ROUND2(b, c, d, a, 13, 13);
|
||||
ROUND2(a, b, c, d, 2, 3);
|
||||
ROUND2(d, a, b, c, 6, 5);
|
||||
ROUND2(c, d, a, b, 10, 9);
|
||||
ROUND2(b, c, d, a, 14, 13);
|
||||
ROUND2(a, b, c, d, 3, 3);
|
||||
ROUND2(d, a, b, c, 7, 5);
|
||||
ROUND2(c, d, a, b, 11, 9);
|
||||
ROUND2(b, c, d, a, 15, 13);
|
||||
|
||||
ROUND3(a, b, c, d, 0, 3);
|
||||
ROUND3(d, a, b, c, 8, 9);
|
||||
ROUND3(c, d, a, b, 4, 11);
|
||||
ROUND3(b, c, d, a, 12, 15);
|
||||
ROUND3(a, b, c, d, 2, 3);
|
||||
ROUND3(d, a, b, c, 10, 9);
|
||||
ROUND3(c, d, a, b, 6, 11);
|
||||
ROUND3(b, c, d, a, 14, 15);
|
||||
ROUND3(a, b, c, d, 1, 3);
|
||||
ROUND3(d, a, b, c, 9, 9);
|
||||
ROUND3(c, d, a, b, 5, 11);
|
||||
ROUND3(b, c, d, a, 13, 15);
|
||||
ROUND3(a, b, c, d, 3, 3);
|
||||
ROUND3(d, a, b, c, 11, 9);
|
||||
ROUND3(c, d, a, b, 7, 11);
|
||||
ROUND3(b, c, d, a, 15, 15);
|
||||
|
||||
#undef F
|
||||
#undef G
|
||||
#undef H
|
||||
#undef ROUND1
|
||||
#undef ROUND2
|
||||
#undef ROUND3
|
||||
|
||||
state[0] += a;
|
||||
state[1] += b;
|
||||
state[2] += c;
|
||||
state[3] += d;
|
||||
}
|
||||
|
||||
static void md4_init(Md4Ctx* ctx) {
|
||||
ctx->state[0] = 0x67452301u;
|
||||
ctx->state[1] = 0xefcdab89u;
|
||||
ctx->state[2] = 0x98badcfeu;
|
||||
ctx->state[3] = 0x10325476u;
|
||||
ctx->bit_count = 0;
|
||||
ctx->buffer_len = 0;
|
||||
}
|
||||
|
||||
static void md4_update(Md4Ctx* ctx, const uint8_t* data, size_t len) {
|
||||
ctx->bit_count += (uint64_t)len * 8;
|
||||
while (len > 0) {
|
||||
size_t space = sizeof(ctx->buffer) - ctx->buffer_len;
|
||||
size_t take = len < space ? len : space;
|
||||
memcpy(ctx->buffer + ctx->buffer_len, data, take);
|
||||
ctx->buffer_len += take;
|
||||
data += take;
|
||||
len -= take;
|
||||
if (ctx->buffer_len == sizeof(ctx->buffer)) {
|
||||
md4_transform(ctx->state, ctx->buffer);
|
||||
ctx->buffer_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void md4_final(Md4Ctx* ctx, uint8_t out[16]) {
|
||||
uint64_t bit_count = ctx->bit_count;
|
||||
uint8_t pad = 0x80;
|
||||
md4_update(ctx, &pad, 1);
|
||||
uint8_t zero = 0;
|
||||
while (ctx->buffer_len != 56)
|
||||
md4_update(ctx, &zero, 1);
|
||||
uint8_t length_le[8];
|
||||
for (int i = 0; i < 8; i++)
|
||||
length_le[i] = (uint8_t)((bit_count >> (8 * i)) & 0xff);
|
||||
md4_update(ctx, length_le, sizeof(length_le));
|
||||
for (int i = 0; i < 4; i++) {
|
||||
out[i * 4] = (uint8_t)(ctx->state[i] & 0xff);
|
||||
out[i * 4 + 1] = (uint8_t)((ctx->state[i] >> 8) & 0xff);
|
||||
out[i * 4 + 2] = (uint8_t)((ctx->state[i] >> 16) & 0xff);
|
||||
out[i * 4 + 3] = (uint8_t)((ctx->state[i] >> 24) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
/* One-shot EVP digest (md5/sha1). Returns false when OpenSSL refuses. */
|
||||
static bool evp_digest(const EVP_MD* md, const void* data, size_t size, uint8_t* out,
|
||||
size_t out_capacity, size_t* out_len) {
|
||||
static const uint8_t empty = 0;
|
||||
const void* input = data ? data : ∅
|
||||
unsigned int digest_len = 0;
|
||||
if (EVP_Digest(input, size, out, &digest_len, md, NULL) != 1)
|
||||
return false;
|
||||
if (digest_len > out_capacity)
|
||||
return false;
|
||||
*out_len = digest_len;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checksum_digest(ChecksumAlgo algo, uint64_t seed, const void* data, size_t size, uint8_t* out,
|
||||
size_t out_capacity, size_t* out_len) {
|
||||
if (!out || !out_len || out_capacity < CHECKSUM_MAX_DIGEST_LEN)
|
||||
@@ -14,46 +172,141 @@ bool checksum_digest(ChecksumAlgo algo, uint64_t seed, const void* data, size_t
|
||||
if (data == NULL && size != 0)
|
||||
return false;
|
||||
|
||||
if (algo == CHECKSUM_ALGO_XXH64) {
|
||||
switch (algo) {
|
||||
case CHECKSUM_ALGO_XXH64: {
|
||||
uint64_t digest = XXH64(data, size, seed);
|
||||
memcpy(out, &digest, sizeof(digest));
|
||||
*out_len = sizeof(digest);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (algo == CHECKSUM_ALGO_XXH3) {
|
||||
case CHECKSUM_ALGO_XXH3: {
|
||||
uint64_t digest = XXH3_64bits_withSeed(data, size, seed);
|
||||
memcpy(out, &digest, sizeof(digest));
|
||||
*out_len = sizeof(digest);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (algo == CHECKSUM_ALGO_XXH128) {
|
||||
case CHECKSUM_ALGO_XXH128: {
|
||||
XXH128_hash_t digest = XXH3_128bits_withSeed(data, size, seed);
|
||||
memcpy(out, &digest, sizeof(digest));
|
||||
*out_len = sizeof(digest);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (algo == CHECKSUM_ALGO_MD5) {
|
||||
case CHECKSUM_ALGO_MD5:
|
||||
/* md5 takes no seed; the caller's seed is deliberately ignored (documented
|
||||
* in RSYNC_COMPAT.md). OpenSSL's one-shot EVP_Digest needs a non-NULL
|
||||
* buffer even for an empty input, so map a NULL data + size==0 to an empty
|
||||
* buffer. */
|
||||
static const uint8_t empty = 0;
|
||||
const void* input = data ? data : ∅
|
||||
unsigned int digest_len = 0;
|
||||
if (EVP_Digest(input, size, out, &digest_len, EVP_md5(), NULL) != 1)
|
||||
return false;
|
||||
if (digest_len > out_capacity)
|
||||
return false;
|
||||
*out_len = digest_len;
|
||||
* in RSYNC_COMPAT.md). */
|
||||
return evp_digest(EVP_md5(), data, size, out, out_capacity, out_len);
|
||||
case CHECKSUM_ALGO_MD4: {
|
||||
Md4Ctx ctx;
|
||||
md4_init(&ctx);
|
||||
md4_update(&ctx, (const uint8_t*)data, size);
|
||||
md4_final(&ctx, out);
|
||||
*out_len = 16;
|
||||
return true;
|
||||
}
|
||||
case CHECKSUM_ALGO_SHA1:
|
||||
/* sha1 takes no seed; the caller's seed is deliberately ignored. */
|
||||
return evp_digest(EVP_sha1(), data, size, out, out_capacity, out_len);
|
||||
case CHECKSUM_ALGO_NONE:
|
||||
/* No checksum requested: an empty digest is the successful result. */
|
||||
*out_len = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool checksum_digest_file(ChecksumAlgo algo, uint64_t seed, const char* path, uint8_t* out,
|
||||
size_t out_capacity, size_t* out_len) {
|
||||
if (!path || !out || !out_len || out_capacity < CHECKSUM_MAX_DIGEST_LEN)
|
||||
return false;
|
||||
|
||||
int fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
return false;
|
||||
|
||||
uint8_t buffer[64 * 1024];
|
||||
bool ok = false;
|
||||
|
||||
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, EVP_md5(), NULL) == 1) {
|
||||
ok = true;
|
||||
ssize_t got;
|
||||
while ((got = read(fd, buffer, sizeof(buffer))) > 0) {
|
||||
if (EVP_DigestUpdate(ctx, buffer, (size_t)got) != 1) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (got < 0)
|
||||
ok = false;
|
||||
if (ok && EVP_DigestFinal_ex(ctx, out, &digest_len) == 1 && digest_len <= out_capacity)
|
||||
*out_len = digest_len;
|
||||
else
|
||||
ok = false;
|
||||
}
|
||||
EVP_MD_CTX_free(ctx);
|
||||
close(fd);
|
||||
return ok;
|
||||
}
|
||||
|
||||
XXH64_state_t xxh64;
|
||||
XXH3_state_t* xxh3 = NULL;
|
||||
if (algo == CHECKSUM_ALGO_XXH64) {
|
||||
XXH64_reset(&xxh64, seed);
|
||||
} else if (algo == CHECKSUM_ALGO_XXH3 || algo == CHECKSUM_ALGO_XXH128) {
|
||||
xxh3 = XXH3_createState();
|
||||
if (!xxh3) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
if (algo == CHECKSUM_ALGO_XXH3)
|
||||
XXH3_64bits_reset_withSeed(xxh3, seed);
|
||||
else
|
||||
XXH3_128bits_reset_withSeed(xxh3, seed);
|
||||
} else {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
ok = true;
|
||||
ssize_t got;
|
||||
while ((got = read(fd, buffer, sizeof(buffer))) > 0) {
|
||||
if (algo == CHECKSUM_ALGO_XXH64)
|
||||
XXH64_update(&xxh64, buffer, (size_t)got);
|
||||
else if (XXH3_64bits_update(xxh3, buffer, (size_t)got) == XXH_ERROR) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (got < 0)
|
||||
ok = false;
|
||||
|
||||
if (ok) {
|
||||
if (algo == CHECKSUM_ALGO_XXH64) {
|
||||
uint64_t digest = XXH64_digest(&xxh64);
|
||||
memcpy(out, &digest, sizeof(digest));
|
||||
*out_len = sizeof(digest);
|
||||
} else if (algo == CHECKSUM_ALGO_XXH3) {
|
||||
uint64_t digest = XXH3_64bits_digest(xxh3);
|
||||
memcpy(out, &digest, sizeof(digest));
|
||||
*out_len = sizeof(digest);
|
||||
} else {
|
||||
XXH128_hash_t digest = XXH3_128bits_digest(xxh3);
|
||||
memcpy(out, &digest, sizeof(digest));
|
||||
*out_len = sizeof(digest);
|
||||
}
|
||||
}
|
||||
if (xxh3)
|
||||
XXH3_freeState(xxh3);
|
||||
close(fd);
|
||||
return ok;
|
||||
}
|
||||
|
||||
int checksum_algo_from_name(const char* name) {
|
||||
if (!name)
|
||||
return -1;
|
||||
@@ -65,6 +318,12 @@ int checksum_algo_from_name(const char* name) {
|
||||
return (int)CHECKSUM_ALGO_XXH128;
|
||||
if (strcasecmp(name, "md5") == 0)
|
||||
return (int)CHECKSUM_ALGO_MD5;
|
||||
if (strcasecmp(name, "md4") == 0)
|
||||
return (int)CHECKSUM_ALGO_MD4;
|
||||
if (strcasecmp(name, "sha1") == 0)
|
||||
return (int)CHECKSUM_ALGO_SHA1;
|
||||
if (strcasecmp(name, "none") == 0)
|
||||
return (int)CHECKSUM_ALGO_NONE;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -78,13 +337,21 @@ const char* checksum_algo_name(ChecksumAlgo algo) {
|
||||
return "xxh128";
|
||||
case CHECKSUM_ALGO_MD5:
|
||||
return "md5";
|
||||
case CHECKSUM_ALGO_MD4:
|
||||
return "md4";
|
||||
case CHECKSUM_ALGO_SHA1:
|
||||
return "sha1";
|
||||
case CHECKSUM_ALGO_NONE:
|
||||
return "none";
|
||||
}
|
||||
return "<unknown>";
|
||||
}
|
||||
|
||||
bool checksum_algo_valid(int algo) {
|
||||
return algo == (int)CHECKSUM_ALGO_XXH64 || algo == (int)CHECKSUM_ALGO_MD5 ||
|
||||
algo == (int)CHECKSUM_ALGO_XXH3 || algo == (int)CHECKSUM_ALGO_XXH128;
|
||||
algo == (int)CHECKSUM_ALGO_XXH3 || algo == (int)CHECKSUM_ALGO_XXH128 ||
|
||||
algo == (int)CHECKSUM_ALGO_MD4 || algo == (int)CHECKSUM_ALGO_SHA1 ||
|
||||
algo == (int)CHECKSUM_ALGO_NONE;
|
||||
}
|
||||
|
||||
uint8_t checksum_digest_len(ChecksumAlgo algo) {
|
||||
@@ -94,7 +361,26 @@ uint8_t checksum_digest_len(ChecksumAlgo algo) {
|
||||
return 8;
|
||||
case CHECKSUM_ALGO_XXH128:
|
||||
case CHECKSUM_ALGO_MD5:
|
||||
case CHECKSUM_ALGO_MD4:
|
||||
return 16;
|
||||
case CHECKSUM_ALGO_SHA1:
|
||||
return 20;
|
||||
case CHECKSUM_ALGO_NONE:
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
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[] = {
|
||||
CHECKSUM_ALGO_XXH128, CHECKSUM_ALGO_XXH3, CHECKSUM_ALGO_XXH64, CHECKSUM_ALGO_MD5,
|
||||
CHECKSUM_ALGO_MD4, CHECKSUM_ALGO_SHA1, CHECKSUM_ALGO_NONE,
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(preference) / sizeof(preference[0]); i++) {
|
||||
if (checksum_algo_valid((int)preference[i]))
|
||||
return preference[i];
|
||||
}
|
||||
return CHECKSUM_ALGO_XXH64;
|
||||
}
|
||||
+36
-15
@@ -8,25 +8,35 @@
|
||||
/* Whole-file content-digest algorithms selectable with --checksum-choice and
|
||||
* seeded with --checksum-seed. The ids are the values actually placed on the
|
||||
* wire (config frame), so they must be kept stable and validated on receive.
|
||||
* CHECKSUM_ALGO_XXH64 == 0 is the default and is byte-for-byte what FastSync
|
||||
* computed before these options existed (xxHash64 with seed 0). The set mirrors
|
||||
* the algorithms rsync 3.4.1 can be built with; the ones FastSync does not
|
||||
* implement (md4, sha1, none) are rejected by name at parse time. */
|
||||
* CHECKSUM_ALGO_XXH64 == 0 is the historical FastSync default and its numeric
|
||||
* value is preserved. The full set mirrors the algorithms rsync 3.4.1 can be
|
||||
* built with; every one of them is implemented here. */
|
||||
typedef enum {
|
||||
CHECKSUM_ALGO_XXH64 = 0,
|
||||
CHECKSUM_ALGO_MD5 = 1,
|
||||
CHECKSUM_ALGO_XXH3 = 2,
|
||||
CHECKSUM_ALGO_XXH128 = 3
|
||||
CHECKSUM_ALGO_XXH128 = 3,
|
||||
CHECKSUM_ALGO_MD4 = 4,
|
||||
CHECKSUM_ALGO_SHA1 = 5,
|
||||
CHECKSUM_ALGO_NONE = 6
|
||||
} ChecksumAlgo;
|
||||
|
||||
/* xxh128 digest is 16 bytes, the longest supported. */
|
||||
#define CHECKSUM_MAX_DIGEST_LEN 16
|
||||
/* FastSync's negotiated default (rsync 3.4.1 auto-negotiates xxh128 first).
|
||||
* The wire default for Config->checksum_algo is this value. */
|
||||
#define CHECKSUM_ALGO_DEFAULT CHECKSUM_ALGO_XXH128
|
||||
|
||||
/* sha1 digest is 20 bytes, the longest supported. */
|
||||
#define CHECKSUM_MAX_DIGEST_LEN 20
|
||||
|
||||
/* Compute the whole-file digest of the first `size` bytes of `data`.
|
||||
*
|
||||
* - CHECKSUM_ALGO_XXH64: xxHash64(data, size, seed) (full 64-bit seed).
|
||||
* - CHECKSUM_ALGO_MD5: md5(data, size) via OpenSSL EVP.
|
||||
* md5 has no seed, so `seed` is ignored (documented).
|
||||
* - CHECKSUM_ALGO_XXH3: XXH3_64bits_withSeed(data, size, seed).
|
||||
* - CHECKSUM_ALGO_XXH128: XXH3_128bits_withSeed(data, size, seed).
|
||||
* - CHECKSUM_ALGO_MD5: md5(data, size) via OpenSSL EVP (seed ignored).
|
||||
* - CHECKSUM_ALGO_MD4: md4(data, size), self-contained RFC 1320 (seed ignored).
|
||||
* - CHECKSUM_ALGO_SHA1: sha1(data, size) via OpenSSL EVP (seed ignored).
|
||||
* - CHECKSUM_ALGO_NONE: no digest; *out_len is 0 and nothing is written.
|
||||
* - `size == 0` hashes the empty input (plus its seed), not a NULL input.
|
||||
*
|
||||
* Writes up to `out_capacity` bytes into `out`, storing the digest length in
|
||||
@@ -35,11 +45,16 @@ typedef enum {
|
||||
bool checksum_digest(ChecksumAlgo algo, uint64_t seed, const void* data, size_t size, uint8_t* out,
|
||||
size_t out_capacity, size_t* out_len);
|
||||
|
||||
/* Streaming whole-file digest: hash the contents of `path` without holding the
|
||||
* whole file in memory. Same digest/capacity contract as checksum_digest.
|
||||
* Returns false on open/read failure or an undersized buffer. */
|
||||
bool checksum_digest_file(ChecksumAlgo algo, uint64_t seed, const char* path, uint8_t* out,
|
||||
size_t out_capacity, size_t* out_len);
|
||||
|
||||
/* Resolve a --checksum-choice string (case-insensitive) to an algorithm id.
|
||||
* Accepts "xxh64"/"xxhash", "xxh3", "xxh128" and "md5". "auto", rsync's
|
||||
* default automatic choice, is resolved to the default by the caller (it is not
|
||||
* a distinct algorithm here). Returns -1 for any name FastSync does not
|
||||
* implement (md4/sha1/none included). */
|
||||
* Accepts "xxh64"/"xxhash", "xxh3", "xxh128", "md5", "md4", "sha1", "none".
|
||||
* "auto" is not an algorithm here; the caller resolves it to the negotiated
|
||||
* default. Returns -1 for any unrecognized name. */
|
||||
int checksum_algo_from_name(const char* name);
|
||||
|
||||
/* Canonical name of an algorithm (used in CLI error messages). */
|
||||
@@ -48,7 +63,13 @@ const char* checksum_algo_name(ChecksumAlgo algo);
|
||||
/* True when `algo` is a supported id (used by config receive validation). */
|
||||
bool checksum_algo_valid(int algo);
|
||||
|
||||
/* Digest length in bytes for an algorithm (xxh64/xxh3 = 8, md5/xxh128 = 16). */
|
||||
/* Digest length in bytes for an algorithm (xxh64/xxh3 = 8,
|
||||
* md5/md4/xxh128 = 16, sha1 = 20, none = 0). */
|
||||
uint8_t checksum_digest_len(ChecksumAlgo algo);
|
||||
|
||||
#endif /* CHECKSUM_H */
|
||||
/* Pick the first algorithm from FastSync's compiled-in preference list that is
|
||||
* supported on this build (rsync 3.4.1's `--version` order:
|
||||
* xxh128 xxh3 xxh64 md5 md4 sha1 none). Used to resolve "auto". */
|
||||
ChecksumAlgo checksum_negotiate_default(void);
|
||||
|
||||
#endif /* CHECKSUM_H */
|
||||
+294
-24
@@ -3,12 +3,15 @@
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include <limits.h>
|
||||
#include <lz4.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <threads.h>
|
||||
#include <unistd.h>
|
||||
#include <zlib.h>
|
||||
#include <zstd.h>
|
||||
|
||||
#define INITIAL_DECOMPRESS_BUF_SIZE (1024 * 1024)
|
||||
@@ -29,6 +32,13 @@
|
||||
"z " \
|
||||
"zip zst"
|
||||
|
||||
/* Self-describing compressed frames: the first byte is the CompressionAlgo id.
|
||||
* zlib/lz4 store the uncompressed size as a little-endian uint32 after the
|
||||
* codec byte so decompression can be exactly pre-sized and bounded. */
|
||||
#define LZ4_SIZE_PREFIX_LEN 4
|
||||
|
||||
static _Atomic int g_compression_algo = COMPRESSION_ALGO_ZSTD;
|
||||
|
||||
/* Case-insensitive match of a bare suffix (no leading dot) against a
|
||||
* space-separated suffix list. */
|
||||
static bool suffix_in_list(const char* name, const char* list) {
|
||||
@@ -67,6 +77,71 @@ bool compression_should_skip_with_suffixes(const char* path, char* const* suffix
|
||||
return false;
|
||||
}
|
||||
|
||||
int compression_algo_from_name(const char* name) {
|
||||
if (!name)
|
||||
return -1;
|
||||
if (strcasecmp(name, "zstd") == 0)
|
||||
return (int)COMPRESSION_ALGO_ZSTD;
|
||||
if (strcasecmp(name, "lz4") == 0)
|
||||
return (int)COMPRESSION_ALGO_LZ4;
|
||||
if (strcasecmp(name, "zlib") == 0)
|
||||
return (int)COMPRESSION_ALGO_ZLIB;
|
||||
if (strcasecmp(name, "zlibx") == 0)
|
||||
return (int)COMPRESSION_ALGO_ZLIBX;
|
||||
if (strcasecmp(name, "none") == 0)
|
||||
return (int)COMPRESSION_ALGO_NONE;
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* compression_algo_name(CompressionAlgo algo) {
|
||||
switch (algo) {
|
||||
case COMPRESSION_ALGO_NONE:
|
||||
return "none";
|
||||
case COMPRESSION_ALGO_ZSTD:
|
||||
return "zstd";
|
||||
case COMPRESSION_ALGO_LZ4:
|
||||
return "lz4";
|
||||
case COMPRESSION_ALGO_ZLIB:
|
||||
return "zlib";
|
||||
case COMPRESSION_ALGO_ZLIBX:
|
||||
return "zlibx";
|
||||
}
|
||||
return "<unknown>";
|
||||
}
|
||||
|
||||
bool compression_algo_valid(int algo) {
|
||||
return algo == (int)COMPRESSION_ALGO_NONE || algo == (int)COMPRESSION_ALGO_ZSTD ||
|
||||
algo == (int)COMPRESSION_ALGO_LZ4 || algo == (int)COMPRESSION_ALGO_ZLIB ||
|
||||
algo == (int)COMPRESSION_ALGO_ZLIBX;
|
||||
}
|
||||
|
||||
bool compression_algo_enabled(CompressionAlgo algo) {
|
||||
return algo != COMPRESSION_ALGO_NONE;
|
||||
}
|
||||
|
||||
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[] = {
|
||||
COMPRESSION_ALGO_ZSTD, COMPRESSION_ALGO_LZ4, COMPRESSION_ALGO_ZLIBX,
|
||||
COMPRESSION_ALGO_ZLIB, COMPRESSION_ALGO_NONE,
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(preference) / sizeof(preference[0]); i++) {
|
||||
if (compression_algo_valid((int)preference[i]))
|
||||
return preference[i];
|
||||
}
|
||||
return COMPRESSION_ALGO_ZSTD;
|
||||
}
|
||||
|
||||
void compression_set_algo(CompressionAlgo algo) {
|
||||
if (compression_algo_valid((int)algo))
|
||||
atomic_store(&g_compression_algo, (int)algo);
|
||||
}
|
||||
|
||||
CompressionAlgo compression_get_algo(void) {
|
||||
return (CompressionAlgo)atomic_load(&g_compression_algo);
|
||||
}
|
||||
|
||||
/* Per-thread cache of zstd contexts plus the grow-only compression scratch
|
||||
* buffer. zstd contexts are stateful and not safe to share between threads,
|
||||
* so each thread keeps its own (see compression_get_thread_ctx). The cache is
|
||||
@@ -157,17 +232,25 @@ static void compression_ctx_put(CompressionThreadCtx* ctx) {
|
||||
compression_ctx_free(ctx);
|
||||
}
|
||||
|
||||
Data* data_compress(Data* data_to_compress, int compression_level) {
|
||||
return data_compress_with_threads(data_to_compress, compression_level, 0);
|
||||
/* Build a frame consisting of a copy of `src` prefixed by `codec`. */
|
||||
static Data* frame_with_codec(const void* src, size_t size, CompressionAlgo codec) {
|
||||
if (size > SIZE_MAX - 1)
|
||||
return NULL;
|
||||
Data* out = data_create_empty(size + 1);
|
||||
if (!out)
|
||||
return NULL;
|
||||
((uint8_t*)out->data)[0] = (uint8_t)codec;
|
||||
if (size > 0)
|
||||
memcpy((uint8_t*)out->data + 1, src, size);
|
||||
out->size = size + 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
int compression_threads) {
|
||||
if (!data_to_compress || (!data_to_compress->data && data_to_compress->size != 0) ||
|
||||
compression_threads < 0 || compression_threads > COMPRESSION_MAX_THREADS)
|
||||
static Data* zstd_compress(Data* in, int compression_level, int compression_threads) {
|
||||
size_t dst_size = ZSTD_compressBound(in->size);
|
||||
if (dst_size > SIZE_MAX - 1)
|
||||
return NULL;
|
||||
log_message(LOG_LEVEL_DEBUG, "Starting to compress data");
|
||||
size_t dst_size = ZSTD_compressBound(data_to_compress->size);
|
||||
dst_size += 1; /* codec prefix */
|
||||
|
||||
CompressionThreadCtx* ctx = compression_get_thread_ctx();
|
||||
if (ctx == NULL) {
|
||||
@@ -218,7 +301,7 @@ Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
|
||||
if (available_threads > 0) {
|
||||
/* Streaming compression needs the source size before threaded mode can end a frame. */
|
||||
size_t zret = ZSTD_CCtx_setPledgedSrcSize(ctx->cctx, data_to_compress->size);
|
||||
size_t zret = ZSTD_CCtx_setPledgedSrcSize(ctx->cctx, in->size);
|
||||
if (ZSTD_isError(zret)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to set compression source size: %s",
|
||||
ZSTD_getErrorName(zret));
|
||||
@@ -236,8 +319,8 @@ Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
ctx->out_cap = dst_size;
|
||||
}
|
||||
|
||||
ZSTD_inBuffer input = {data_to_compress->data, data_to_compress->size, 0};
|
||||
ZSTD_outBuffer output = {ctx->out_buf, dst_size, 0};
|
||||
ZSTD_inBuffer input = {in->data, in->size, 0};
|
||||
ZSTD_outBuffer output = {(uint8_t*)ctx->out_buf + 1, dst_size - 1, 0};
|
||||
|
||||
size_t ret;
|
||||
do {
|
||||
@@ -250,30 +333,192 @@ Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
|
||||
/* Hand off an exactly-sized copy; the scratch buffer stays cached so the next
|
||||
* call does not reallocate a ZSTD_compressBound-sized block. */
|
||||
compressed_data = data_create_empty(output.pos);
|
||||
compressed_data = data_create_empty(output.pos + 1);
|
||||
if (compressed_data == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to allocate compressed data");
|
||||
goto cleanup;
|
||||
}
|
||||
((uint8_t*)compressed_data->data)[0] = (uint8_t)COMPRESSION_ALGO_ZSTD;
|
||||
if (output.pos > 0)
|
||||
memcpy(compressed_data->data, ctx->out_buf, output.pos);
|
||||
compressed_data->size = output.pos;
|
||||
memcpy((uint8_t*)compressed_data->data + 1, (uint8_t*)ctx->out_buf + 1, output.pos);
|
||||
compressed_data->size = output.pos + 1;
|
||||
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Data succesfully compressed from %zu to %zu",
|
||||
data_to_compress->size, compressed_data->size);
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Data succesfully compressed from %zu to %zu", in->size,
|
||||
compressed_data->size);
|
||||
|
||||
cleanup:
|
||||
compression_ctx_put(ctx);
|
||||
return compressed_data;
|
||||
}
|
||||
|
||||
Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
|
||||
if (!compressed_data || (!compressed_data->data && compressed_data->size != 0) ||
|
||||
maximum_size == 0)
|
||||
static Data* lz4_compress(Data* in) {
|
||||
int bound = LZ4_compressBound((int)in->size);
|
||||
if (bound < 0 || in->size > (size_t)INT_MAX)
|
||||
return NULL;
|
||||
Data* out = data_create_empty((size_t)bound + 1 + LZ4_SIZE_PREFIX_LEN);
|
||||
if (!out)
|
||||
return NULL;
|
||||
uint32_t raw_size = (uint32_t)in->size;
|
||||
uint8_t* p = (uint8_t*)out->data;
|
||||
p[0] = (uint8_t)COMPRESSION_ALGO_LZ4;
|
||||
for (int i = 0; i < LZ4_SIZE_PREFIX_LEN; i++)
|
||||
p[1 + i] = (uint8_t)((raw_size >> (8 * i)) & 0xff);
|
||||
int written = 0;
|
||||
if (in->size > 0) {
|
||||
written = LZ4_compress_default((const char*)in->data, (char*)p + 1 + LZ4_SIZE_PREFIX_LEN,
|
||||
(int)in->size, bound);
|
||||
if (written <= 0) {
|
||||
data_destroy(out);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
out->size = (size_t)written + 1 + LZ4_SIZE_PREFIX_LEN;
|
||||
return out;
|
||||
}
|
||||
|
||||
static Data* zlib_compress(Data* in, CompressionAlgo algo, int compression_level) {
|
||||
int level = compression_level;
|
||||
if (level < 1)
|
||||
level = Z_DEFAULT_COMPRESSION;
|
||||
if (level > 9)
|
||||
level = 9;
|
||||
uLong bound = compressBound((uLong)in->size);
|
||||
if (in->size > (size_t)ULONG_MAX)
|
||||
return NULL;
|
||||
Data* out = data_create_empty((size_t)bound + 1 + LZ4_SIZE_PREFIX_LEN);
|
||||
if (!out)
|
||||
return NULL;
|
||||
uint32_t raw_size = (uint32_t)in->size;
|
||||
uint8_t* p = (uint8_t*)out->data;
|
||||
p[0] = (uint8_t)algo;
|
||||
for (int i = 0; i < LZ4_SIZE_PREFIX_LEN; i++)
|
||||
p[1 + i] = (uint8_t)((raw_size >> (8 * i)) & 0xff);
|
||||
uLongf dest_len = bound;
|
||||
int rc = compress2(p + 1 + LZ4_SIZE_PREFIX_LEN, &dest_len, (const Bytef*)in->data,
|
||||
(uLong)in->size, level);
|
||||
if (rc != Z_OK) {
|
||||
data_destroy(out);
|
||||
return NULL;
|
||||
}
|
||||
out->size = (size_t)dest_len + 1 + LZ4_SIZE_PREFIX_LEN;
|
||||
return out;
|
||||
}
|
||||
|
||||
Data* data_compress_codec(Data* data_to_compress, CompressionAlgo algo, int compression_level,
|
||||
int compression_threads) {
|
||||
if (!data_to_compress || (!data_to_compress->data && data_to_compress->size != 0) ||
|
||||
compression_threads < 0 || compression_threads > COMPRESSION_MAX_THREADS)
|
||||
return NULL;
|
||||
if (!compression_algo_valid((int)algo))
|
||||
return NULL;
|
||||
log_message(LOG_LEVEL_DEBUG, "Starting to compress data");
|
||||
switch (algo) {
|
||||
case COMPRESSION_ALGO_NONE:
|
||||
return frame_with_codec(data_to_compress->data, data_to_compress->size, COMPRESSION_ALGO_NONE);
|
||||
case COMPRESSION_ALGO_ZSTD:
|
||||
return zstd_compress(data_to_compress, compression_level, compression_threads);
|
||||
case COMPRESSION_ALGO_LZ4:
|
||||
return lz4_compress(data_to_compress);
|
||||
case COMPRESSION_ALGO_ZLIB:
|
||||
case COMPRESSION_ALGO_ZLIBX:
|
||||
return zlib_compress(data_to_compress, algo, compression_level);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
int compression_threads) {
|
||||
return data_compress_codec(data_to_compress, compression_get_algo(), compression_level,
|
||||
compression_threads);
|
||||
}
|
||||
|
||||
Data* data_compress(Data* data_to_compress, int compression_level) {
|
||||
return data_compress_codec(data_to_compress, compression_get_algo(), compression_level, 0);
|
||||
}
|
||||
|
||||
static Data* decompress_none(const Data* compressed_data, size_t maximum_size) {
|
||||
size_t size = compressed_data->size - 1;
|
||||
if (size > maximum_size)
|
||||
return NULL;
|
||||
Data* out = data_create_empty(size);
|
||||
if (!out)
|
||||
return NULL;
|
||||
if (size > 0)
|
||||
memcpy(out->data, (const uint8_t*)compressed_data->data + 1, size);
|
||||
out->size = size;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Read the 4-byte little-endian raw size stored after the codec byte. */
|
||||
static bool read_raw_size(const Data* in, uint32_t* raw_size) {
|
||||
if (in->size < 1 + LZ4_SIZE_PREFIX_LEN)
|
||||
return false;
|
||||
const uint8_t* p = (const uint8_t*)in->data;
|
||||
uint32_t v = 0;
|
||||
for (int i = 0; i < LZ4_SIZE_PREFIX_LEN; i++)
|
||||
v |= (uint32_t)p[1 + i] << (8 * i);
|
||||
*raw_size = v;
|
||||
return true;
|
||||
}
|
||||
|
||||
static Data* lz4_decompress(Data* compressed_data, size_t maximum_size, size_t hard_limit) {
|
||||
uint32_t raw_size = 0;
|
||||
if (!read_raw_size(compressed_data, &raw_size))
|
||||
return NULL;
|
||||
if (raw_size > hard_limit || raw_size > maximum_size)
|
||||
return NULL;
|
||||
size_t comp_size = compressed_data->size - 1 - LZ4_SIZE_PREFIX_LEN;
|
||||
Data* out = data_create_empty(raw_size);
|
||||
if (!out)
|
||||
return NULL;
|
||||
if (raw_size == 0) {
|
||||
out->size = 0;
|
||||
return out;
|
||||
}
|
||||
int rc = LZ4_decompress_safe((const char*)compressed_data->data + 1 + LZ4_SIZE_PREFIX_LEN,
|
||||
(char*)out->data, (int)comp_size, (int)raw_size);
|
||||
if (rc < 0 || (uint32_t)rc != raw_size) {
|
||||
log_message(LOG_LEVEL_ERROR, "LZ4 decompression failed");
|
||||
data_destroy(out);
|
||||
return NULL;
|
||||
}
|
||||
out->size = raw_size;
|
||||
return out;
|
||||
}
|
||||
|
||||
static Data* zlib_decompress(Data* compressed_data, size_t maximum_size, size_t hard_limit) {
|
||||
uint32_t raw_size = 0;
|
||||
if (!read_raw_size(compressed_data, &raw_size))
|
||||
return NULL;
|
||||
if (raw_size > hard_limit || raw_size > maximum_size)
|
||||
return NULL;
|
||||
size_t comp_size = compressed_data->size - 1 - LZ4_SIZE_PREFIX_LEN;
|
||||
Data* out = data_create_empty(raw_size);
|
||||
if (!out)
|
||||
return NULL;
|
||||
if (raw_size == 0) {
|
||||
out->size = 0;
|
||||
return out;
|
||||
}
|
||||
uLongf dest_len = raw_size;
|
||||
int rc =
|
||||
uncompress((Bytef*)out->data, &dest_len,
|
||||
(const Bytef*)compressed_data->data + 1 + LZ4_SIZE_PREFIX_LEN, (uLong)comp_size);
|
||||
if (rc != Z_OK || dest_len != raw_size) {
|
||||
log_message(LOG_LEVEL_ERROR, "zlib decompression failed");
|
||||
data_destroy(out);
|
||||
return NULL;
|
||||
}
|
||||
out->size = raw_size;
|
||||
return out;
|
||||
}
|
||||
|
||||
static Data* zstd_decompress(Data* compressed_data, size_t maximum_size) {
|
||||
/* The zstd frame starts after the codec byte. */
|
||||
const void* frame = (const uint8_t*)compressed_data->data + 1;
|
||||
size_t frame_size = compressed_data->size - 1;
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Start to decompress data");
|
||||
unsigned long long dst_size =
|
||||
ZSTD_getFrameContentSize(compressed_data->data, compressed_data->size);
|
||||
unsigned long long dst_size = ZSTD_getFrameContentSize(frame, frame_size);
|
||||
/* ZSTD_isError() is also true for ZSTD_CONTENTSIZE_ERROR and
|
||||
* ZSTD_CONTENTSIZE_UNKNOWN (both are encoded near (size_t)-1), so test the
|
||||
* sentinels explicitly instead of blanket-rejecting every error-ish value:
|
||||
@@ -287,9 +532,9 @@ Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
|
||||
// ZSTD_CONTENTSIZE_UNKNOWN (~2^64) can cause massive allocation;
|
||||
// fall back to a conservative estimate (3x compressed size) when unknown.
|
||||
if (dst_size == ZSTD_CONTENTSIZE_UNKNOWN) {
|
||||
if (compressed_data->size > ULLONG_MAX / 3)
|
||||
if (frame_size > ULLONG_MAX / 3)
|
||||
return NULL;
|
||||
dst_size = compressed_data->size * 3;
|
||||
dst_size = frame_size * 3;
|
||||
if (dst_size < INITIAL_DECOMPRESS_BUF_SIZE)
|
||||
dst_size = INITIAL_DECOMPRESS_BUF_SIZE;
|
||||
}
|
||||
@@ -326,7 +571,7 @@ Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
ZSTD_inBuffer input = {compressed_data->data, compressed_data->size, 0};
|
||||
ZSTD_inBuffer input = {frame, frame_size, 0};
|
||||
ZSTD_outBuffer output = {uncompressed_data->data, buf_size, 0};
|
||||
|
||||
size_t ret;
|
||||
@@ -385,6 +630,31 @@ cleanup:
|
||||
return uncompressed_data;
|
||||
}
|
||||
|
||||
Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
|
||||
if (!compressed_data || (!compressed_data->data && compressed_data->size != 0) ||
|
||||
maximum_size == 0)
|
||||
return NULL;
|
||||
if (compressed_data->size < 1)
|
||||
return NULL;
|
||||
unsigned long long hard_limit =
|
||||
maximum_size < MAX_DECOMPRESSED_SIZE ? maximum_size : MAX_DECOMPRESSED_SIZE;
|
||||
uint8_t codec = ((const uint8_t*)compressed_data->data)[0];
|
||||
if (!compression_algo_valid(codec))
|
||||
return NULL;
|
||||
switch ((CompressionAlgo)codec) {
|
||||
case COMPRESSION_ALGO_NONE:
|
||||
return decompress_none(compressed_data, (size_t)hard_limit);
|
||||
case COMPRESSION_ALGO_ZSTD:
|
||||
return zstd_decompress(compressed_data, (size_t)hard_limit);
|
||||
case COMPRESSION_ALGO_LZ4:
|
||||
return lz4_decompress(compressed_data, maximum_size, (size_t)hard_limit);
|
||||
case COMPRESSION_ALGO_ZLIB:
|
||||
case COMPRESSION_ALGO_ZLIBX:
|
||||
return zlib_decompress(compressed_data, maximum_size, (size_t)hard_limit);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Data* data_decompress(Data* compressed_data) {
|
||||
return data_decompress_limited(compressed_data, MAX_DECOMPRESSED_SIZE);
|
||||
}
|
||||
@@ -6,11 +6,59 @@
|
||||
|
||||
#define COMPRESSION_MAX_THREADS 64
|
||||
|
||||
/* Compression algorithms selectable with --compress-choice / -z. The ids are
|
||||
* the values placed on the wire (Config->compression_algo), so they must be
|
||||
* kept stable. NONE is "no compression"; ZSTD is the historical FastSync
|
||||
* default and the negotiated "auto" choice. ZLIBX is rsync's zlib-without-
|
||||
* matched-data variant: FastSync compresses only the delta/token bytes (it does
|
||||
* not put matched file data in the compression stream), so its zlib codec is
|
||||
* already the "x" form and zlib/zlibx share the same implementation, recorded
|
||||
* under distinct ids. */
|
||||
typedef enum {
|
||||
COMPRESSION_ALGO_NONE = 0,
|
||||
COMPRESSION_ALGO_ZSTD = 1,
|
||||
COMPRESSION_ALGO_LZ4 = 2,
|
||||
COMPRESSION_ALGO_ZLIB = 3,
|
||||
COMPRESSION_ALGO_ZLIBX = 4
|
||||
} CompressionAlgo;
|
||||
|
||||
/* Resolve a --compress-choice string (case-insensitive) to an algorithm id.
|
||||
* Accepts "zstd", "lz4", "zlib", "zlibx", "none". "auto" is not an algorithm
|
||||
* here; the caller resolves it to the negotiated default. Returns -1 for any
|
||||
* unrecognized name. */
|
||||
int compression_algo_from_name(const char* name);
|
||||
const char* compression_algo_name(CompressionAlgo algo);
|
||||
bool compression_algo_valid(int algo);
|
||||
|
||||
/* Pick the first algorithm from FastSync's compiled-in preference list
|
||||
* (rsync 3.4.1's `--version` order: zstd lz4 zlibx zlib none). Resolves
|
||||
* "auto". */
|
||||
CompressionAlgo compression_negotiate_default(void);
|
||||
|
||||
/* True when the algorithm actually compresses (i.e. is not NONE). */
|
||||
bool compression_algo_enabled(CompressionAlgo algo);
|
||||
|
||||
/* Select the process-wide codec used by the legacy wrappers below. Each
|
||||
* process serves exactly one transfer config (the server forks per connection,
|
||||
* the client configures itself before spawning transfer threads), so a
|
||||
* process-global default is sufficient and constant for the lifetime of a
|
||||
* transfer. Defaults to ZSTD when never set. Thread-safe. */
|
||||
void compression_set_algo(CompressionAlgo algo);
|
||||
CompressionAlgo compression_get_algo(void);
|
||||
|
||||
/* Codec-aware primitives. The compressed buffer is self-describing: its first
|
||||
* byte is the CompressionAlgo id, so decompression never needs the codec passed
|
||||
* separately (this keeps every existing Decompress call site source-compatible).
|
||||
* `data_compress_codec` returns NULL on invalid input or an unsupported codec. */
|
||||
Data* data_compress_codec(Data* data_to_compress, CompressionAlgo algo, int compression_level,
|
||||
int compression_threads);
|
||||
Data* data_decompress_limited(Data* compressed_data, size_t maximum_size);
|
||||
|
||||
/* Legacy zstd-default wrappers retained for existing callers/tests. */
|
||||
Data* data_compress(Data* data_to_compress, int compression_level);
|
||||
Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
int compression_threads);
|
||||
Data* data_decompress(Data* compressed_data);
|
||||
Data* data_decompress_limited(Data* compressed_data, size_t maximum_size);
|
||||
bool compression_should_skip_with_suffixes(const char* path, char* const* suffixes, int count);
|
||||
|
||||
/* Release the calling thread's cached zstd contexts (compressor, decompressor
|
||||
|
||||
+103
-40
@@ -14,6 +14,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <limits.h>
|
||||
#include <errno.h>
|
||||
|
||||
@@ -67,12 +68,15 @@ static void config_set_defaults(Config* config) {
|
||||
config->human_readable = false;
|
||||
config->ignore_errors = false;
|
||||
config->ignore_missing_args = false;
|
||||
config->checksum_transfer_algo = CHECKSUM_ALGO_DEFAULT;
|
||||
config->cli_exit_code = 0;
|
||||
config->filters = NULL;
|
||||
config->files_from = NULL;
|
||||
config->files_from_set = NULL;
|
||||
config->from0 = false;
|
||||
config->cvs_exclude = false;
|
||||
config->per_dir_filter = false;
|
||||
config->per_dir_filter_count = 0;
|
||||
config->one_file_system = false;
|
||||
config->no_implied_dirs = false;
|
||||
config->dirs = false;
|
||||
@@ -196,12 +200,13 @@ static bool validate_received_config(const Config* config) {
|
||||
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
|
||||
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) &&
|
||||
valid_wire_bool(config->dry_run) && checksum_algo_valid(config->checksum_algo) &&
|
||||
identity_wire_valid(config) && valid_wire_bool(config->preserve_atimes) &&
|
||||
valid_wire_bool(config->preserve_crtimes) && valid_wire_bool(config->omit_dir_times) &&
|
||||
valid_wire_bool(config->omit_link_times) && 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) &&
|
||||
compression_algo_valid(config->compression_algo) && identity_wire_valid(config) &&
|
||||
valid_wire_bool(config->preserve_atimes) && valid_wire_bool(config->preserve_crtimes) &&
|
||||
valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) &&
|
||||
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) &&
|
||||
(!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)) &&
|
||||
@@ -228,7 +233,13 @@ Config* config_create(void) {
|
||||
bool config_delete_timing_early(const Config* config) {
|
||||
if (!config)
|
||||
return false;
|
||||
return config->delete_before || config->delete_during;
|
||||
return config->delete_before;
|
||||
}
|
||||
|
||||
bool config_delete_timing_per_dir(const Config* config) {
|
||||
if (!config)
|
||||
return false;
|
||||
return config->delete_during || config->delete_delay;
|
||||
}
|
||||
|
||||
/* A delete-timing flag is only meaningful together with --delete. At most one
|
||||
@@ -329,30 +340,38 @@ bool config_has_basis(const Config* config) {
|
||||
}
|
||||
|
||||
/* A basis-dir path travels from the client to the receiver and is resolved
|
||||
* below the destination root, so it must be a non-empty relative path with no
|
||||
* "." or ".." component and no traversal: an absolute or escaping path would
|
||||
* make the receiver read or link files outside its authorized root.
|
||||
* below the destination root when relative, or used verbatim when absolute
|
||||
* (matching rsync). Either form must be non-empty, traversal-free (no "..")
|
||||
* and free of "." components: an escaping path would make the receiver read or
|
||||
* link files outside its authorized root. An absolute path is still subject to
|
||||
* the receiver's root confinement at open time (file_open_secure_parent), so a
|
||||
* basis outside the authorized root is simply not found rather than an escape.
|
||||
*
|
||||
* Returns a malloc'd CANONICAL copy of an accepted path, or NULL when the path
|
||||
* is rejected. Canonicalization collapses interior empty components ("a//b" ->
|
||||
* "a/b"), drops "." components and trailing "/"s, so validation, the delete
|
||||
* walker prefix match and the receiver's basis lookup all agree on one form.
|
||||
* The normalizer is the single source of truth for both config_basis_path_valid
|
||||
* and config_basis_append. */
|
||||
* "a/b"), drops "." components and trailing "/"s, and preserves a leading '/'
|
||||
* for absolute paths, so validation, the delete walker prefix match and the
|
||||
* receiver's basis lookup all agree on one form. The normalizer is the single
|
||||
* source of truth for both config_basis_path_valid and config_basis_append. */
|
||||
static char* basis_path_normalize(const char* path) {
|
||||
if (!path || path[0] == '\0' || path[0] == '/' || has_path_traversal(path))
|
||||
if (!path || path[0] == '\0' || has_path_traversal(path))
|
||||
return NULL;
|
||||
if (strcmp(path, ".") == 0)
|
||||
bool absolute = path[0] == '/';
|
||||
if (!absolute && strcmp(path, ".") == 0)
|
||||
return NULL;
|
||||
if (absolute && strcmp(path, "/") == 0)
|
||||
return NULL;
|
||||
char* dup = str_dup(path);
|
||||
if (!dup)
|
||||
return NULL;
|
||||
size_t out_len = 0;
|
||||
char* out = malloc(strlen(path) + 1);
|
||||
char* out = malloc(strlen(path) + 2);
|
||||
if (!out) {
|
||||
free(dup);
|
||||
return NULL;
|
||||
}
|
||||
if (absolute)
|
||||
out[out_len++] = '/';
|
||||
char* saveptr = NULL;
|
||||
bool ok = true;
|
||||
for (char* part = strtok_r(dup, "/", &saveptr); part; part = strtok_r(NULL, "/", &saveptr)) {
|
||||
@@ -362,14 +381,14 @@ static char* basis_path_normalize(const char* path) {
|
||||
}
|
||||
if (strcmp(part, ".") == 0)
|
||||
continue;
|
||||
if (out_len > 0)
|
||||
if (out_len > 0 && out[out_len - 1] != '/')
|
||||
out[out_len++] = '/';
|
||||
size_t len = strlen(part);
|
||||
memcpy(out + out_len, part, len);
|
||||
out_len += len;
|
||||
}
|
||||
free(dup);
|
||||
if (!ok || out_len == 0) {
|
||||
if (!ok || out_len == 0 || (absolute && out_len == 1)) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
@@ -880,6 +899,14 @@ static bool config_receive_checksum_algo(int fd, int* value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool config_receive_compression_algo(int fd, int* value) {
|
||||
int algo;
|
||||
if (!receive_int(fd, &algo) || !compression_algo_valid(algo))
|
||||
return false;
|
||||
*value = algo;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool config_receive_super_mode(int fd, SuperMode* value) {
|
||||
int mode;
|
||||
if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF)
|
||||
@@ -1081,6 +1108,9 @@ fail:
|
||||
#define CONFIG_SEND_INT_CHECKSUM_ALGO(name) send_int(fd, c->name)
|
||||
#define CONFIG_RECV_INT_CHECKSUM_ALGO(name) config_receive_checksum_algo(fd, &c->name)
|
||||
|
||||
#define CONFIG_SEND_INT_COMPRESSION_ALGO(name) send_int(fd, c->name)
|
||||
#define CONFIG_RECV_INT_COMPRESSION_ALGO(name) config_receive_compression_algo(fd, &c->name)
|
||||
|
||||
#define CONFIG_SEND_SUPERMODE(name) send_int(fd, (int)c->name)
|
||||
#define CONFIG_RECV_SUPERMODE(name) config_receive_super_mode(fd, &c->name)
|
||||
|
||||
@@ -1156,6 +1186,7 @@ CONFIG_DEFINE_SEND(send_iconv_spec, CONFIG_WIRE_ICONV_FIELDS)
|
||||
CONFIG_DEFINE_SEND(send_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS)
|
||||
CONFIG_DEFINE_SEND(send_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS)
|
||||
CONFIG_DEFINE_SEND(send_output_options, CONFIG_WIRE_OUTPUT_FIELDS)
|
||||
CONFIG_DEFINE_SEND(send_codec_options, CONFIG_WIRE_CODEC_FIELDS)
|
||||
|
||||
CONFIG_DEFINE_RECV(receive_core_fields, CONFIG_WIRE_CORE_FIELDS)
|
||||
CONFIG_DEFINE_RECV(receive_delta_fields, CONFIG_WIRE_DELTA_FIELDS)
|
||||
@@ -1175,6 +1206,7 @@ CONFIG_DEFINE_RECV(receive_iconv_spec, CONFIG_WIRE_ICONV_FIELDS)
|
||||
CONFIG_DEFINE_RECV(receive_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS)
|
||||
CONFIG_DEFINE_RECV(receive_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS)
|
||||
CONFIG_DEFINE_RECV(receive_output_options, CONFIG_WIRE_OUTPUT_FIELDS)
|
||||
CONFIG_DEFINE_RECV(receive_codec_options, CONFIG_WIRE_CODEC_FIELDS)
|
||||
|
||||
#undef XSEND
|
||||
#undef XRECV
|
||||
@@ -1292,7 +1324,8 @@ bool config_send_wire_block(int file_descriptor, const Config* config) {
|
||||
send_iconv_spec(file_descriptor, config) &&
|
||||
send_privilege_options(file_descriptor, config) &&
|
||||
send_copy_as_options(file_descriptor, config) &&
|
||||
send_output_options(file_descriptor, config);
|
||||
send_output_options(file_descriptor, config) &&
|
||||
send_codec_options(file_descriptor, config);
|
||||
}
|
||||
|
||||
bool config_send(int file_descriptor, const Config* config) {
|
||||
@@ -1363,29 +1396,59 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
||||
!receive_iconv_spec(file_descriptor, config, &budget) ||
|
||||
!receive_privilege_options(file_descriptor, config, &budget) ||
|
||||
!receive_copy_as_options(file_descriptor, config, &budget) ||
|
||||
!receive_output_options(file_descriptor, config, &budget))
|
||||
!receive_output_options(file_descriptor, config, &budget) ||
|
||||
!receive_codec_options(file_descriptor, config, &budget))
|
||||
goto error;
|
||||
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
|
||||
strcmp(config->compress_choice, "none") != 0 &&
|
||||
strcmp(config->compress_choice, "auto") != 0) {
|
||||
char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output);
|
||||
log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s",
|
||||
escaped_choice ? escaped_choice : "<allocation failed>");
|
||||
char detail[128];
|
||||
snprintf(detail, sizeof(detail), "unsupported compression choice: %s",
|
||||
escaped_choice ? escaped_choice : "<allocation failed>");
|
||||
send_error_detail(file_descriptor, detail);
|
||||
free(escaped_choice);
|
||||
/* Validate/normalize the negotiated codec. compress_choice is the human
|
||||
* spelling (NULL or "" when -z was not given); compression_algo is the
|
||||
* concrete codec id the sender used. They must agree, and "auto" is
|
||||
* canonicalized to FastSync's negotiated default so the stored spelling is
|
||||
* always concrete (a hostile/older client may still send "auto"). */
|
||||
if (config->compress_choice && config->compress_choice[0] != '\0') {
|
||||
int choice_algo = compression_algo_from_name(config->compress_choice);
|
||||
if (choice_algo < 0 && strcasecmp(config->compress_choice, "auto") != 0) {
|
||||
char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output);
|
||||
log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s",
|
||||
escaped_choice ? escaped_choice : "<allocation failed>");
|
||||
char detail[160];
|
||||
snprintf(detail, sizeof(detail), "unsupported compression choice: %s",
|
||||
escaped_choice ? escaped_choice : "<allocation failed>");
|
||||
send_error_detail(file_descriptor, detail);
|
||||
free(escaped_choice);
|
||||
goto error;
|
||||
}
|
||||
if (choice_algo < 0)
|
||||
choice_algo = (int)compression_negotiate_default();
|
||||
if (strcasecmp(config->compress_choice, "auto") == 0 ||
|
||||
choice_algo == (int)COMPRESSION_ALGO_NONE) {
|
||||
const char* canonical = compression_algo_name((CompressionAlgo)choice_algo);
|
||||
char* dup = str_dup(canonical);
|
||||
if (!dup)
|
||||
goto error;
|
||||
free(config->compress_choice);
|
||||
config->compress_choice = dup;
|
||||
}
|
||||
if (config->compression_algo != choice_algo) {
|
||||
log_message(LOG_LEVEL_ERROR, "Compression choice '%s' does not match codec id %d",
|
||||
config->compress_choice, config->compression_algo);
|
||||
send_error_detail(file_descriptor, "compression choice/codec mismatch");
|
||||
goto error;
|
||||
}
|
||||
}
|
||||
/* The concrete codec must exist only when compression is on. A client that
|
||||
* left -z off has no codec in effect, but the field keeps whatever id it
|
||||
* carried (the receiver never dispatches on it without use_compression), so
|
||||
* the wire value round-trips untouched. */
|
||||
if (config->use_compression && config->compression_algo == (int)COMPRESSION_ALGO_NONE) {
|
||||
log_message(LOG_LEVEL_ERROR, "Compression requested with the 'none' codec");
|
||||
send_error_detail(file_descriptor, "compression requested with the none codec");
|
||||
goto error;
|
||||
}
|
||||
/* Defensive: an older/hostile client may still send "auto"; canonicalize it
|
||||
to zstd (its effective choice) so the stored value is always concrete. */
|
||||
if (strcmp(config->compress_choice, "auto") == 0) {
|
||||
char* canonical = str_dup("zstd");
|
||||
if (!canonical)
|
||||
goto error;
|
||||
free(config->compress_choice);
|
||||
config->compress_choice = canonical;
|
||||
/* rsync: "none" as the pre-transfer checksum is invalid with --checksum. */
|
||||
if (config->checksum && config->checksum_algo == (int)CHECKSUM_ALGO_NONE) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid checksum-choice for --checksum: none");
|
||||
send_error_detail(file_descriptor, "checksum-choice 'none' cannot be used with --checksum");
|
||||
goto error;
|
||||
}
|
||||
if (!validate_received_config(config)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client");
|
||||
|
||||
+98
-18
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "array_list.h"
|
||||
#include "checksum.h"
|
||||
#include "compression.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
@@ -81,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.23.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
|
||||
@@ -203,7 +204,7 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
||||
#define CONFIG_WIRE_FUZZY_FIELDS(X) X(fuzzy, bool, false, BOOL)
|
||||
|
||||
#define CONFIG_WIRE_CHECKSUM_FIELDS(X) \
|
||||
X(checksum_algo, int, CHECKSUM_ALGO_XXH64, INT_CHECKSUM_ALGO) \
|
||||
X(checksum_algo, int, CHECKSUM_ALGO_DEFAULT, INT_CHECKSUM_ALGO) \
|
||||
X(checksum_seed, uint64_t, 0, RAW)
|
||||
|
||||
#define CONFIG_WIRE_IDENTITY_FIELDS(X) \
|
||||
@@ -250,8 +251,38 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
||||
* answer every per-file STATUS_CHECK with a STATUS_DEST_INFO snapshot of the
|
||||
* pre-transfer destination entry (see protocol.h). It is set by the client
|
||||
* only when -i/--itemize-changes or --out-format asks for per-file change
|
||||
* output; the transfer decision itself is unchanged. */
|
||||
#define CONFIG_WIRE_OUTPUT_FIELDS(X) X(report_dest_info, bool, false, BOOL)
|
||||
* output; the transfer decision itself is unchanged.
|
||||
*
|
||||
* Wire-stats wave (protocol 2.25.0). report_stats tells the receiver to send a
|
||||
* STATUS_STATS frame immediately before its terminal success status carrying
|
||||
* the receiver-only counters (matched data, deleted-file count) and,
|
||||
* 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. */
|
||||
#define CONFIG_WIRE_OUTPUT_FIELDS(X) \
|
||||
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
|
||||
* value the receiver validates and installs. It is the resolved result of
|
||||
* --compress-choice / the "auto" negotiation so both peers agree exactly.
|
||||
*
|
||||
* Negotiation model: FastSync enforces a strict same-version handshake, so both
|
||||
* peers carry the identical compiled-in codec set. The client resolves the
|
||||
* effective algorithm deterministically and serializes it here; "auto" picks
|
||||
* the first entry of the rsync 3.4.1 preference order
|
||||
* (compression: zstd lz4 zlibx zlib none; checksum: xxh128 xxh3 xxh64 md5 md4
|
||||
* sha1 none), and an explicit request wins. The receiver rejects (before
|
||||
* STATUS_OK) any algorithm outside its own supported set, which is rsync's
|
||||
* "no common choice is an error" behavior. The same resolver runs on both
|
||||
* sides (compression_negotiate_default / checksum_negotiate_default), so the
|
||||
* fallback is consistent.
|
||||
*
|
||||
* The field is appended after the output block so every pre-2.26 field keeps
|
||||
* its wire position. */
|
||||
#define CONFIG_WIRE_CODEC_FIELDS(X) \
|
||||
X(compression_algo, int, COMPRESSION_ALGO_ZSTD, INT_COMPRESSION_ALGO)
|
||||
|
||||
/* All serialized fields, in exact wire order. Concatenating the per-segment
|
||||
* lists here is what keeps the declaration order = the wire order. */
|
||||
@@ -274,7 +305,8 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
||||
CONFIG_WIRE_ICONV_FIELDS(X) \
|
||||
CONFIG_WIRE_PRIVILEGE_FIELDS(X) \
|
||||
CONFIG_WIRE_COPY_AS_FIELDS(X) \
|
||||
CONFIG_WIRE_OUTPUT_FIELDS(X)
|
||||
CONFIG_WIRE_OUTPUT_FIELDS(X) \
|
||||
CONFIG_WIRE_CODEC_FIELDS(X)
|
||||
|
||||
typedef struct Config {
|
||||
/* -j/--threads=N: number of parallel scanner worker threads for the -m
|
||||
@@ -369,6 +401,16 @@ typedef struct Config {
|
||||
* enters the keep-set. Implied by --delete-missing-args. */
|
||||
bool ignore_missing_args;
|
||||
|
||||
/* Codec-negotiation CLI state (all client-only, never serialized). The
|
||||
* effective pre-transfer checksum is Config->checksum_algo (serialized);
|
||||
* checksum_transfer_algo is the rsync "transfer" half of a two-name
|
||||
* --checksum-choice form (validated and used only to mirror rsync's
|
||||
* whole-file forcing, since FastSync's per-block strong hash is fixed).
|
||||
* cli_exit_code carries a parser-requested process exit status (rsync uses 4
|
||||
* for an unsupported checksum/compress algorithm) so main() can mirror it. */
|
||||
int checksum_transfer_algo;
|
||||
int cli_exit_code;
|
||||
|
||||
// Issue #129: Advanced file selection. These fields are CLIENT-ONLY: they are
|
||||
// never serialized to the wire (the receiver must not learn them).
|
||||
ArrayList* filters; /* --filter=RULE rule strings, in order */
|
||||
@@ -377,6 +419,10 @@ typedef struct Config {
|
||||
bool from0; /* -0/--from0: NUL-delimited *-from files */
|
||||
bool cvs_exclude; /* -C/--cvs-exclude: standard CVS ignore set */
|
||||
bool per_dir_filter; /* -F: apply per-directory .rsync-filter files */
|
||||
/* -F click count. rsync's single -F means --filter='dir-merge
|
||||
* /.rsync-filter' (the .rsync-filter files themselves are transferred); a
|
||||
* 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 + --files-from, refuse to place a
|
||||
* listed file whose ancestor directory is not itself explicitly listed. */
|
||||
@@ -547,13 +593,17 @@ typedef struct Config {
|
||||
/* rsync deletion-timing family (real from Phase 3). At most one of
|
||||
delete_before / delete_during / delete_delay / delete_after may be set, and
|
||||
only together with use_delete (the CLI implies --delete for each of them).
|
||||
delete_before and delete_during select the EARLY engine mode: the keep-set
|
||||
delete_before selects the EARLY engine mode: the whole-tree keep-set
|
||||
manifest is transmitted before any file data and extras are removed then,
|
||||
acknowledged, before the first data byte. delete_delay and delete_after
|
||||
select the LATE commit mode: extras are removed only after the whole
|
||||
transfer has succeeded (plain --delete keeps this mode). The exact
|
||||
semantics and the divergences from rsync are documented in RSYNC_COMPAT.md
|
||||
and in config_delete_timing_early() below. */
|
||||
acknowledged, before the first data byte. delete_during and delete_delay
|
||||
select the per-directory delete-plan mode (protocol 2.24.0): one plan per
|
||||
source directory is streamed in directory order, and the receiver removes
|
||||
each directory's extras when its plan arrives (during) or snapshots them
|
||||
and removes them only after a successful transfer (delay). delete_after
|
||||
(and plain --delete) keep the whole-tree commit mode: extras are removed
|
||||
from a fresh end-of-transfer destination scan only after the whole transfer
|
||||
succeeded. See config_delete_timing_early()/config_delete_timing_per_dir()
|
||||
below. */
|
||||
/* partial_dir */
|
||||
// PR #174: Partial transfer resumption
|
||||
/* suffix */
|
||||
@@ -911,8 +961,34 @@ typedef struct Config {
|
||||
* snapshot of the old entry) before its ordinary verdict when the config frame
|
||||
* carries the new report_dest_info bool appended after the --copy-as block.
|
||||
* This is both a config-frame layout change (one trailing bool) and a frame
|
||||
* sequence change (the new status). */
|
||||
#define PROTOCOL_VERSION "2.23.0"
|
||||
* sequence change (the new status).
|
||||
*
|
||||
* (4) Delete timing (protocol 2.24.0): the sender streams one delete plan per
|
||||
* source directory so --delete-during/--delete-delay reproduce rsync's deletion
|
||||
* timing (the plan fields and STATUS_DELETE_PLAN are documented at the keep-set
|
||||
* / delete-plan definitions below).
|
||||
*
|
||||
* (5) Wire-stats parity (protocol 2.25.0): --stats, --progress/-P and the
|
||||
* --out-format %b/%c tokens need receiver-only and wire counters that the push
|
||||
* sender cannot observe, and -n/--dry-run --delete must report the extras it
|
||||
* would have removed without deleting anything. The config frame gains one
|
||||
* trailing report_stats bool and the receiver emits a new STATUS_STATS frame
|
||||
* (carrying matched data, the deleted-file count and the would-delete path
|
||||
* list) immediately before its terminal success status.
|
||||
*
|
||||
* (6) Codec breadth + negotiation (protocol 2.26.0): the config frame gains one
|
||||
* trailing int, compression_algo (a CompressionAlgo id), appended after the
|
||||
* output block. It is the negotiated/effective compression codec and is what
|
||||
* the receiver's self-describing decompressor validates against its own
|
||||
* supported set. The checksum_algo wire value now also accepts md4/sha1/none,
|
||||
* and its default changes to the rsync 3.4.1 auto-negotiated xxh128.
|
||||
*
|
||||
* Any config-frame layout change must bump the protocol version: a peer that
|
||||
* does not parse the new trailing bytes would desynchronize on the frame
|
||||
* 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. */
|
||||
#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
|
||||
@@ -1006,13 +1082,17 @@ int config_parse_daemon_dest(Config* config);
|
||||
* 0. */
|
||||
int config_parse_transport_dest(Config* config);
|
||||
|
||||
/* True when the negotiated delete timing performs the extra-file deletion
|
||||
* BEFORE the transfer data (--delete-before / --delete-during). The flag is
|
||||
* a pure function of the config and is used identically on the sender (to pick
|
||||
/* True for the whole-tree delete-before timing: a complete keep-set manifest is
|
||||
* transmitted before any data and committed (with an ack) before the first data
|
||||
* byte. Pure function of the config, used identically on the sender (to pick
|
||||
* the manifest-first frame order) and the receiver (to delete when the early
|
||||
* manifest arrives). When false the deletion is committed only after the whole
|
||||
* transfer succeeded (--delete / --delete-after / --delete-delay). */
|
||||
* manifest arrives). */
|
||||
bool config_delete_timing_early(const Config* config);
|
||||
/* True for the per-directory timings (--delete-during / --delete-delay). The
|
||||
* sender streams a delete plan per source directory in directory order; the
|
||||
* receiver applies each plan on arrival (during) or snapshots its extras and
|
||||
* commits them only after a fully-successful transfer (delay). */
|
||||
bool config_delete_timing_per_dir(const Config* config);
|
||||
/* Delete-timing sanity: with deletion enabled at most one timing flag may be
|
||||
* set (none = the default delete-after commit timing); without deletion no
|
||||
* timing flag may be set (each timing flag implies --delete). */
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
#include "delete_plan.h"
|
||||
|
||||
#include "charset.h"
|
||||
#include "delay_updates.h"
|
||||
#include "file.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Mirrors MAX_SERVER_DELETE_COUNT in file_receive.c: the server's hard bound on
|
||||
* the number of entries one deletion commit may remove. A client
|
||||
* --max-delete=NUM smaller than this replaces it for the run. */
|
||||
#define DELETE_PLAN_SERVER_LIMIT 100000U
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sender: plan builder */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct PlanNode {
|
||||
char* dir;
|
||||
ArrayList* files; /* basenames kept directly in dir */
|
||||
ArrayList* dirs; /* basenames of kept child directories */
|
||||
bool sent;
|
||||
struct PlanNode* hash_next;
|
||||
} PlanNode;
|
||||
|
||||
struct DeletePlanSender {
|
||||
PlanNode** buckets;
|
||||
size_t capacity;
|
||||
size_t count;
|
||||
bool config_sent;
|
||||
bool all_synced;
|
||||
const ArrayList* synced_dirs;
|
||||
/* Owned by the caller's synced_dirs list; non-NULL only for a general -R
|
||||
transfer, where it is the destination prefix the delete walk is confined
|
||||
to. NULL means the whole receive root (or a --files-from scope). */
|
||||
const char* walk_root;
|
||||
const ArrayList* protected_prefixes;
|
||||
const ArrayList* size_skipped;
|
||||
const ArrayList* missing_args;
|
||||
size_t entries;
|
||||
/* Transmitted FILE entries only. The caller's "empty scan" safety guard keys
|
||||
off this (an I/O error that hid every file must refuse to delete even when
|
||||
some directories were traversed), so directory keep entries do not count. */
|
||||
size_t file_entries;
|
||||
};
|
||||
|
||||
static size_t plan_hash(const char* key) {
|
||||
size_t h = 5381;
|
||||
for (const unsigned char* p = (const unsigned char*)key; *p; p++)
|
||||
h = ((h << 5) + h) + *p;
|
||||
return h;
|
||||
}
|
||||
|
||||
static bool list_contains_str(const ArrayList* list, const char* value) {
|
||||
if (!list)
|
||||
return false;
|
||||
for (int i = 0; i < list->size; i++) {
|
||||
if (strcmp((const char*)list->items[i], value) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool list_add_str_unique(ArrayList* list, const char* value) {
|
||||
if (!list || !value)
|
||||
return false;
|
||||
if (list_contains_str(list, value))
|
||||
return true;
|
||||
char* copy = str_dup(value);
|
||||
if (!copy)
|
||||
return false;
|
||||
if (!array_list_add(list, copy)) {
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
DeletePlanSender* delete_plan_sender_create(void) {
|
||||
DeletePlanSender* sender = calloc(1, sizeof(DeletePlanSender));
|
||||
if (!sender)
|
||||
return NULL;
|
||||
sender->capacity = 64;
|
||||
sender->buckets = calloc(sender->capacity, sizeof(PlanNode*));
|
||||
if (!sender->buckets) {
|
||||
free(sender);
|
||||
return NULL;
|
||||
}
|
||||
sender->all_synced = true;
|
||||
return sender;
|
||||
}
|
||||
|
||||
static void plan_node_destroy(PlanNode* node) {
|
||||
if (!node)
|
||||
return;
|
||||
free(node->dir);
|
||||
array_list_delete(node->files);
|
||||
array_list_delete(node->dirs);
|
||||
free(node);
|
||||
}
|
||||
|
||||
void delete_plan_sender_destroy(DeletePlanSender* sender) {
|
||||
if (!sender)
|
||||
return;
|
||||
for (size_t i = 0; i < sender->capacity; i++) {
|
||||
PlanNode* node = sender->buckets[i];
|
||||
while (node) {
|
||||
PlanNode* next = node->hash_next;
|
||||
plan_node_destroy(node);
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
free(sender->buckets);
|
||||
free(sender);
|
||||
}
|
||||
|
||||
static PlanNode* plan_find(const DeletePlanSender* sender, const char* dir) {
|
||||
size_t index = plan_hash(dir) & (sender->capacity - 1);
|
||||
for (PlanNode* node = sender->buckets[index]; node; node = node->hash_next) {
|
||||
if (strcmp(node->dir, dir) == 0)
|
||||
return node;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool plan_grow(DeletePlanSender* sender) {
|
||||
size_t new_capacity = sender->capacity * 2;
|
||||
PlanNode** buckets = calloc(new_capacity, sizeof(PlanNode*));
|
||||
if (!buckets)
|
||||
return false;
|
||||
for (size_t i = 0; i < sender->capacity; i++) {
|
||||
PlanNode* node = sender->buckets[i];
|
||||
while (node) {
|
||||
PlanNode* next = node->hash_next;
|
||||
size_t index = plan_hash(node->dir) & (new_capacity - 1);
|
||||
node->hash_next = buckets[index];
|
||||
buckets[index] = node;
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
free(sender->buckets);
|
||||
sender->buckets = buckets;
|
||||
sender->capacity = new_capacity;
|
||||
return true;
|
||||
}
|
||||
|
||||
static PlanNode* plan_ensure(DeletePlanSender* sender, const char* dir) {
|
||||
PlanNode* node = plan_find(sender, dir);
|
||||
if (node)
|
||||
return node;
|
||||
if (sender->count + 1 > sender->capacity * 3 / 4 && !plan_grow(sender))
|
||||
return NULL;
|
||||
node = calloc(1, sizeof(PlanNode));
|
||||
if (!node)
|
||||
return NULL;
|
||||
node->dir = str_dup(dir);
|
||||
node->files = array_list_create(free);
|
||||
node->dirs = array_list_create(free);
|
||||
if (!node->dir || !node->files || !node->dirs) {
|
||||
plan_node_destroy(node);
|
||||
return NULL;
|
||||
}
|
||||
size_t index = plan_hash(dir) & (sender->capacity - 1);
|
||||
node->hash_next = sender->buckets[index];
|
||||
sender->buckets[index] = node;
|
||||
sender->count++;
|
||||
return node;
|
||||
}
|
||||
|
||||
static char* path_parent_dir(const char* path) {
|
||||
const char* slash = strrchr(path, '/');
|
||||
if (!slash)
|
||||
return str_dup(".");
|
||||
if (slash == path)
|
||||
return str_dup(".");
|
||||
size_t len = (size_t)(slash - path);
|
||||
char* parent = malloc(len + 1);
|
||||
if (!parent)
|
||||
return NULL;
|
||||
memcpy(parent, path, len);
|
||||
parent[len] = '\0';
|
||||
return parent;
|
||||
}
|
||||
|
||||
static char* path_base_name(const char* path) {
|
||||
const char* slash = strrchr(path, '/');
|
||||
return str_dup(slash ? slash + 1 : path);
|
||||
}
|
||||
|
||||
/* Copy `path`, stripping a leading '/' and any trailing '/'. */
|
||||
static char* plan_clean_path(const char* path) {
|
||||
while (*path == '/')
|
||||
path++;
|
||||
size_t len = strlen(path);
|
||||
while (len > 0 && path[len - 1] == '/')
|
||||
len--;
|
||||
char* clean = malloc(len + 1);
|
||||
if (!clean)
|
||||
return NULL;
|
||||
memcpy(clean, path, len);
|
||||
clean[len] = '\0';
|
||||
return clean;
|
||||
}
|
||||
|
||||
static bool plan_ensure_ancestors(DeletePlanSender* sender, const char* dir) {
|
||||
char* current = str_dup(dir);
|
||||
if (!current)
|
||||
return false;
|
||||
bool ok = true;
|
||||
while (strcmp(current, ".") != 0) {
|
||||
char* parent = path_parent_dir(current);
|
||||
char* base = path_base_name(current);
|
||||
PlanNode* parent_node = parent ? plan_ensure(sender, parent) : NULL;
|
||||
if (!parent || !base || !parent_node || !list_add_str_unique(parent_node->dirs, base)) {
|
||||
ok = false;
|
||||
free(parent);
|
||||
free(base);
|
||||
break;
|
||||
}
|
||||
free(base);
|
||||
free(current);
|
||||
current = parent;
|
||||
}
|
||||
free(current);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool delete_plan_sender_add(DeletePlanSender* sender, const char* path, bool is_dir) {
|
||||
if (!sender || !path)
|
||||
return false;
|
||||
char* clean = plan_clean_path(path);
|
||||
if (!clean)
|
||||
return false;
|
||||
if (*clean == '\0') {
|
||||
free(clean);
|
||||
return true;
|
||||
}
|
||||
char* parent = path_parent_dir(clean);
|
||||
char* base = path_base_name(clean);
|
||||
PlanNode* parent_node = parent ? plan_ensure(sender, parent) : NULL;
|
||||
bool ok = parent && base && parent_node;
|
||||
if (ok) {
|
||||
if (is_dir) {
|
||||
ok = list_add_str_unique(parent_node->dirs, base) && plan_ensure(sender, clean) != NULL;
|
||||
} else {
|
||||
ok = list_add_str_unique(parent_node->files, base);
|
||||
}
|
||||
}
|
||||
if (ok)
|
||||
ok = plan_ensure_ancestors(sender, parent);
|
||||
if (ok) {
|
||||
sender->entries++;
|
||||
if (!is_dir)
|
||||
sender->file_entries++;
|
||||
}
|
||||
free(clean);
|
||||
free(parent);
|
||||
free(base);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void delete_plan_sender_finalize(DeletePlanSender* sender, const ArrayList* synced_dirs,
|
||||
const char* walk_root) {
|
||||
if (!sender)
|
||||
return;
|
||||
sender->synced_dirs = synced_dirs;
|
||||
sender->all_synced = synced_dirs == NULL && walk_root == NULL;
|
||||
sender->walk_root = walk_root;
|
||||
}
|
||||
|
||||
bool delete_plan_sender_empty(const DeletePlanSender* sender) {
|
||||
return !sender || sender->file_entries == 0;
|
||||
}
|
||||
|
||||
void delete_plan_sender_set_config(DeletePlanSender* sender, const ArrayList* protected_prefixes,
|
||||
const ArrayList* size_skipped, const ArrayList* missing_args) {
|
||||
if (!sender)
|
||||
return;
|
||||
sender->protected_prefixes = protected_prefixes;
|
||||
sender->size_skipped = size_skipped;
|
||||
sender->missing_args = missing_args;
|
||||
}
|
||||
|
||||
/* True when `dir` is `root` itself or a descendant of it (path-component
|
||||
* aware, so "foo" does not match "foobar"). */
|
||||
static bool path_at_or_under(const char* dir, const char* root) {
|
||||
if (!dir || !root)
|
||||
return false;
|
||||
size_t n = strlen(root);
|
||||
return strncmp(dir, root, n) == 0 && (dir[n] == '\0' || dir[n] == '/');
|
||||
}
|
||||
|
||||
static bool plan_is_allowed(const DeletePlanSender* sender, const char* dir) {
|
||||
if (sender->all_synced)
|
||||
return true;
|
||||
if (sender->walk_root)
|
||||
return path_at_or_under(dir, sender->walk_root);
|
||||
return list_contains_str(sender->synced_dirs, dir);
|
||||
}
|
||||
|
||||
static int send_str_section(int fd, const ArrayList* list) {
|
||||
int count = list ? list->size : 0;
|
||||
if (!send_int(fd, count))
|
||||
return -1;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (!send_wire_str(fd, (const char*)list->items[i]))
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int send_plan_node(int fd, DeletePlanSender* sender, PlanNode* node) {
|
||||
if (!send_status(fd, STATUS_DELETE_PLAN))
|
||||
return -1;
|
||||
if (!send_int(fd, sender->config_sent ? 0 : 1))
|
||||
return -1;
|
||||
if (!sender->config_sent) {
|
||||
if (send_str_section(fd, sender->protected_prefixes) != 0 ||
|
||||
send_str_section(fd, sender->size_skipped) != 0 ||
|
||||
send_str_section(fd, sender->missing_args) != 0)
|
||||
return -1;
|
||||
sender->config_sent = true;
|
||||
}
|
||||
if (!send_wire_str(fd, node->dir))
|
||||
return -1;
|
||||
if (send_str_section(fd, node->dirs) != 0 || send_str_section(fd, node->files) != 0)
|
||||
return -1;
|
||||
node->sent = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int send_prefix_plan(int fd, DeletePlanSender* sender, const char* dir) {
|
||||
PlanNode* node = plan_find(sender, dir);
|
||||
if (!node || node->sent)
|
||||
return 0;
|
||||
if (!plan_is_allowed(sender, dir))
|
||||
return 0;
|
||||
return send_plan_node(fd, sender, node);
|
||||
}
|
||||
|
||||
int delete_plan_send_root(int fd, DeletePlanSender* sender) {
|
||||
if (!sender)
|
||||
return -1;
|
||||
const char* root = sender->walk_root ? sender->walk_root : ".";
|
||||
if (!plan_ensure(sender, root))
|
||||
return -1;
|
||||
return send_prefix_plan(fd, sender, root);
|
||||
}
|
||||
|
||||
int delete_plan_send_for_path(int fd, DeletePlanSender* sender, const char* path, bool is_dir) {
|
||||
if (!sender || !path)
|
||||
return -1;
|
||||
char* clean = plan_clean_path(path);
|
||||
if (!clean)
|
||||
return -1;
|
||||
/* The walk root (the -R prefix, or ".") is sent up front by
|
||||
delete_plan_send_root(); never emit the receive-root plan for a scoped -R
|
||||
run, whose "." keep list would delete the prefix's siblings. */
|
||||
int rc = sender->walk_root ? 0 : send_prefix_plan(fd, sender, ".");
|
||||
if (rc == 0 && *clean != '\0') {
|
||||
size_t len = strlen(clean);
|
||||
size_t end = len;
|
||||
if (!is_dir) {
|
||||
const char* slash = strrchr(clean, '/');
|
||||
end = slash ? (size_t)(slash - clean) : 0;
|
||||
}
|
||||
for (size_t i = 1; i <= end && rc == 0; i++) {
|
||||
if (i == end || clean[i] == '/') {
|
||||
char* prefix = malloc(i + 1);
|
||||
if (!prefix) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
memcpy(prefix, clean, i);
|
||||
prefix[i] = '\0';
|
||||
rc = send_prefix_plan(fd, sender, prefix);
|
||||
free(prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
free(clean);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int delete_plan_send_remaining(int fd, DeletePlanSender* sender, const ArrayList* dirs) {
|
||||
if (!sender || !dirs)
|
||||
return 0;
|
||||
for (int i = 0; i < dirs->size; i++) {
|
||||
const char* dir = (const char*)dirs->items[i];
|
||||
if (delete_plan_send_for_path(fd, sender, dir, true) != 0)
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Receiver: delete session */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
struct DeletePlanSession {
|
||||
bool defer;
|
||||
bool dry_run;
|
||||
size_t max_delete;
|
||||
size_t deleted;
|
||||
size_t skipped;
|
||||
bool limit_hit;
|
||||
bool limit_logged;
|
||||
bool config_seen;
|
||||
bool missing_applied;
|
||||
ArrayList* protected_prefixes;
|
||||
ArrayList* size_skipped;
|
||||
ArrayList* missing;
|
||||
ArrayList* deferred;
|
||||
};
|
||||
|
||||
DeletePlanSession* delete_plan_session_create(const Config* config) {
|
||||
if (!config)
|
||||
return NULL;
|
||||
DeletePlanSession* session = calloc(1, sizeof(DeletePlanSession));
|
||||
if (!session)
|
||||
return NULL;
|
||||
session->defer = config->delete_delay;
|
||||
session->dry_run = config->dry_run;
|
||||
bool user_limited =
|
||||
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->protected_prefixes = array_list_create(free);
|
||||
session->size_skipped = array_list_create(free);
|
||||
session->missing = array_list_create(free);
|
||||
session->deferred = array_list_create(free);
|
||||
if (!session->protected_prefixes || !session->size_skipped || !session->missing ||
|
||||
!session->deferred) {
|
||||
delete_plan_session_destroy(session);
|
||||
return NULL;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
void delete_plan_session_destroy(DeletePlanSession* session) {
|
||||
if (!session)
|
||||
return;
|
||||
array_list_delete(session->protected_prefixes);
|
||||
array_list_delete(session->size_skipped);
|
||||
array_list_delete(session->missing);
|
||||
array_list_delete(session->deferred);
|
||||
free(session);
|
||||
}
|
||||
|
||||
bool delete_plan_session_limit_reached(const DeletePlanSession* session) {
|
||||
return session && session->limit_hit;
|
||||
}
|
||||
|
||||
size_t delete_plan_session_deleted(const DeletePlanSession* session) {
|
||||
return session ? session->deleted : 0;
|
||||
}
|
||||
|
||||
/* True for a destination-relative path section entry (non-empty, relative,
|
||||
* traversal-free). */
|
||||
static bool valid_rel_path(const char* value) {
|
||||
return value && value[0] != '\0' && value[0] != '/' && !has_path_traversal(value);
|
||||
}
|
||||
|
||||
/* True for a single child name (non-empty, no slash, not "."/".."). */
|
||||
static bool valid_name(const char* value) {
|
||||
return value && value[0] != '\0' && strcmp(value, ".") != 0 && strcmp(value, "..") != 0 &&
|
||||
strchr(value, '/') == NULL;
|
||||
}
|
||||
|
||||
/* Read one count-prefixed section. `bytes` is the running per-frame budget,
|
||||
* shared across every section of the frame so a hostile peer cannot retain more
|
||||
* than MAX_MANIFEST_BYTES from one STATUS_DELETE_PLAN frame. */
|
||||
static bool read_section(int fd, ArrayList* list, bool rel_path, size_t* bytes) {
|
||||
int count;
|
||||
if (!receive_int(fd, &count) || count < 0 || count > MAX_MANIFEST_ENTRIES)
|
||||
return false;
|
||||
for (int i = 0; i < count; i++) {
|
||||
char* value = receive_wire_str(fd);
|
||||
bool ok = value && (rel_path ? valid_rel_path(value) : valid_name(value));
|
||||
if (ok) {
|
||||
size_t entry_size = strlen(value) + sizeof(char*) + 16;
|
||||
if (entry_size > MAX_MANIFEST_BYTES - *bytes) {
|
||||
ok = false;
|
||||
} else {
|
||||
*bytes += entry_size;
|
||||
ok = array_list_add(list, value);
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
free(value);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int open_plan_dir(const Config* config, const char* dir) {
|
||||
char* full = (strcmp(dir, ".") == 0) ? str_dup(config->receive_root_directory)
|
||||
: path_cat(config->receive_root_directory, dir);
|
||||
if (!full)
|
||||
return -1;
|
||||
int root_fd = utils_get_authorized_root_fd();
|
||||
int fd = -1;
|
||||
if (root_fd >= 0) {
|
||||
if (utils_get_authorized_root_path())
|
||||
fd = utils_open_authorized_destination(full);
|
||||
else if (strcmp(dir, ".") == 0)
|
||||
fd = dup(root_fd);
|
||||
} else {
|
||||
fd = open(full, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
free(full);
|
||||
return fd;
|
||||
}
|
||||
|
||||
typedef struct PlanSkips {
|
||||
DeleteSkipEntry* entries;
|
||||
int count;
|
||||
} PlanSkips;
|
||||
|
||||
static bool build_plan_skips(const Config* config, const DeletePlanSession* session,
|
||||
PlanSkips* out) {
|
||||
out->entries = NULL;
|
||||
out->count = 0;
|
||||
int count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
||||
session->protected_prefixes->size + session->size_skipped->size;
|
||||
if (count == 0)
|
||||
return true;
|
||||
out->entries = calloc((size_t)count, sizeof(DeleteSkipEntry));
|
||||
if (!out->entries)
|
||||
return false;
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
out->entries[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
out->entries[idx].top_level_only = true;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
out->entries[idx].prefix = config->basis_dirs[i].path;
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < session->protected_prefixes->size; i++) {
|
||||
out->entries[idx].prefix = (const char*)session->protected_prefixes->items[i];
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < session->size_skipped->size; i++) {
|
||||
out->entries[idx].prefix = (const char*)session->size_skipped->items[i];
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
out->count = idx;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool budget_available(const DeletePlanSession* session) {
|
||||
return session->deleted < session->max_delete;
|
||||
}
|
||||
|
||||
static void note_skipped(DeletePlanSession* session) {
|
||||
session->limit_hit = true;
|
||||
session->skipped++;
|
||||
}
|
||||
|
||||
static void log_deleted(const char* rel) {
|
||||
char* escaped = output_escape(rel, log_get_8_bit_output());
|
||||
fprintf(stderr, " Deleted: %s\n", escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
}
|
||||
|
||||
/* Append a snapshot path for --delete-delay. */
|
||||
static bool defer_add(DeletePlanSession* session, const char* rel) {
|
||||
char* copy = str_dup(rel);
|
||||
if (!copy)
|
||||
return false;
|
||||
if (!array_list_add(session->deferred, copy)) {
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
session->deleted++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Process the direct children of one directory. `keep_dirs`/`keep_files`
|
||||
* (basenames) are the source entries that must be kept; NULL means every child
|
||||
* is an extra (the forced path used inside a removed extra directory tree).
|
||||
* `survives` reports that at least one child remains (kept, protected, or
|
||||
* skipped by the budget). `force_now` removes even in --delete-delay mode
|
||||
* (type conflicts must clear before the incoming data). */
|
||||
static bool process_children(int dirfd, const char* dir_rel, const ArrayList* keep_dirs,
|
||||
const ArrayList* keep_files, bool at_root, bool force_now,
|
||||
const PlanSkips* skips, DeletePlanSession* session, bool* survives);
|
||||
|
||||
static bool process_extra_dir(int dirfd, const char* name, const char* child_rel, bool force_now,
|
||||
const PlanSkips* skips, DeletePlanSession* session, bool* removed) {
|
||||
*removed = false;
|
||||
int childfd = openat(dirfd, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (childfd < 0) {
|
||||
if (errno == ENOENT) {
|
||||
*removed = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool survives = false;
|
||||
bool ok =
|
||||
process_children(childfd, child_rel, NULL, NULL, false, force_now, skips, session, &survives);
|
||||
close(childfd);
|
||||
if (!ok)
|
||||
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 (unlinkat(dirfd, name, AT_REMOVEDIR) == 0) {
|
||||
session->deleted++;
|
||||
log_deleted(child_rel);
|
||||
*removed = true;
|
||||
return true;
|
||||
}
|
||||
if (errno == ENOENT) {
|
||||
*removed = true;
|
||||
return true;
|
||||
}
|
||||
/* ENOTEMPTY/EEXIST: a protected entry the walker leaves behind survived, so
|
||||
the directory stays; any other errno is a genuine failure. */
|
||||
return errno == ENOTEMPTY || errno == EEXIST;
|
||||
}
|
||||
|
||||
static bool process_extra_file(int dirfd, const char* name, const char* child_rel, bool force_now,
|
||||
DeletePlanSession* session) {
|
||||
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++;
|
||||
log_deleted(child_rel);
|
||||
} else if (errno != ENOENT) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool process_children(int dirfd, const char* dir_rel, const ArrayList* keep_dirs,
|
||||
const ArrayList* keep_files, bool at_root, bool force_now,
|
||||
const PlanSkips* skips, DeletePlanSession* session, bool* survives) {
|
||||
*survives = false;
|
||||
int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (scanfd < 0)
|
||||
return false;
|
||||
DIR* dir = fdopendir(scanfd);
|
||||
if (!dir) {
|
||||
close(scanfd);
|
||||
return false;
|
||||
}
|
||||
bool operation_ok = true;
|
||||
bool local_survives = false;
|
||||
const struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
char* child_rel =
|
||||
(strcmp(dir_rel, ".") == 0) ? str_dup(entry->d_name) : path_cat(dir_rel, entry->d_name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
if (path_under_skip_prefix(child_rel, at_root, skips->entries, skips->count)) {
|
||||
local_survives = true;
|
||||
free(child_rel);
|
||||
continue;
|
||||
}
|
||||
struct stat st;
|
||||
if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
if (errno != ENOENT)
|
||||
operation_ok = false;
|
||||
free(child_rel);
|
||||
continue;
|
||||
}
|
||||
bool is_dir = S_ISDIR(st.st_mode);
|
||||
bool in_keep_dirs = is_dir && list_contains_str(keep_dirs, entry->d_name);
|
||||
bool in_keep_files = !is_dir && list_contains_str(keep_files, entry->d_name);
|
||||
if (in_keep_dirs) {
|
||||
local_survives = true;
|
||||
} else if (keep_dirs && !is_dir && list_contains_str(keep_dirs, entry->d_name)) {
|
||||
/* Destination file blocks a source directory: clear it now, whatever the
|
||||
delete timing, so the directory can be created. */
|
||||
if (!process_extra_file(dirfd, entry->d_name, child_rel, true, session))
|
||||
operation_ok = false;
|
||||
} else if (in_keep_files) {
|
||||
local_survives = true;
|
||||
} else if (keep_files && is_dir && list_contains_str(keep_files, entry->d_name)) {
|
||||
/* Destination directory blocks a source file: remove it now. */
|
||||
bool removed = false;
|
||||
if (!process_extra_dir(dirfd, entry->d_name, child_rel, true, skips, session, &removed))
|
||||
operation_ok = false;
|
||||
else if (!removed)
|
||||
local_survives = true;
|
||||
} else if (is_dir) {
|
||||
bool removed = false;
|
||||
if (!process_extra_dir(dirfd, entry->d_name, child_rel, force_now, skips, session, &removed))
|
||||
operation_ok = false;
|
||||
else if (!removed)
|
||||
local_survives = true;
|
||||
} else {
|
||||
if (!process_extra_file(dirfd, entry->d_name, child_rel, force_now, session))
|
||||
operation_ok = false;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
closedir(dir);
|
||||
*survives = local_survives;
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
static bool apply_plan_dir(DeletePlanSession* session, const Config* config, const char* dir,
|
||||
const ArrayList* dirs, const ArrayList* files) {
|
||||
int dirfd = open_plan_dir(config, dir);
|
||||
if (dirfd < 0) {
|
||||
/* An absent destination directory has nothing to delete. */
|
||||
return errno == ENOENT || errno == ENOTDIR;
|
||||
}
|
||||
PlanSkips skips;
|
||||
if (!build_plan_skips(config, session, &skips)) {
|
||||
close(dirfd);
|
||||
return false;
|
||||
}
|
||||
bool survives = false;
|
||||
bool ok = process_children(dirfd, dir, dirs, files, strcmp(dir, ".") == 0, false, &skips, session,
|
||||
&survives);
|
||||
free(skips.entries);
|
||||
close(dirfd);
|
||||
if (!ok)
|
||||
log_message(LOG_LEVEL_ERROR, "deletion failed while removing extraneous files");
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool apply_missing(DeletePlanSession* session, const Config* config) {
|
||||
if (session->missing_applied)
|
||||
return true;
|
||||
session->missing_applied = true;
|
||||
/* The server clears delete_missing_args when its --allow-delete policy is
|
||||
off; never honor the client's exact-path requests then. */
|
||||
if (!config->delete_missing_args || session->missing->size == 0)
|
||||
return true;
|
||||
DeleteManifest manifest = {
|
||||
.keeps = NULL, .protected = NULL, .missing = session->missing, .dirs = NULL};
|
||||
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(config, &manifest, remaining, &deleted, &skipped,
|
||||
&limit);
|
||||
session->deleted += deleted;
|
||||
session->skipped += skipped;
|
||||
if (limit)
|
||||
session->limit_hit = true;
|
||||
return ok;
|
||||
}
|
||||
|
||||
int delete_plan_session_receive(DeletePlanSession* session, const Config* config, int fd) {
|
||||
if (!session || !config) {
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return -1;
|
||||
}
|
||||
int has_config;
|
||||
if (!receive_int(fd, &has_config) || (has_config != 0 && has_config != 1)) {
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return -1;
|
||||
}
|
||||
size_t bytes = 0;
|
||||
if (has_config) {
|
||||
if (session->config_seen || !read_section(fd, session->protected_prefixes, true, &bytes) ||
|
||||
!read_section(fd, session->size_skipped, true, &bytes) ||
|
||||
!read_section(fd, session->missing, true, &bytes)) {
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return -1;
|
||||
}
|
||||
session->config_seen = true;
|
||||
}
|
||||
char* dir = receive_wire_str(fd);
|
||||
ArrayList* dirs = array_list_create(free);
|
||||
ArrayList* files = array_list_create(free);
|
||||
bool parsed = dir && (strcmp(dir, ".") == 0 || valid_rel_path(dir)) && dirs && files &&
|
||||
read_section(fd, dirs, false, &bytes) && read_section(fd, files, false, &bytes);
|
||||
if (!parsed) {
|
||||
free(dir);
|
||||
array_list_delete(dirs);
|
||||
array_list_delete(files);
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return -1;
|
||||
}
|
||||
bool enabled = config->use_delete || config->delete_missing_args;
|
||||
bool ok = true;
|
||||
if (!session->dry_run && enabled) {
|
||||
if (!session->defer && !apply_missing(session, config))
|
||||
ok = false;
|
||||
if (ok && !apply_plan_dir(session, config, dir, dirs, files))
|
||||
ok = false;
|
||||
}
|
||||
free(dir);
|
||||
array_list_delete(dirs);
|
||||
array_list_delete(files);
|
||||
if (!ok) {
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return -1;
|
||||
}
|
||||
if (session->limit_hit && !session->limit_logged) {
|
||||
session->limit_logged = true;
|
||||
log_message(LOG_LEVEL_WARNING, "Deletions stopped due to the delete limit (%zu skipped)",
|
||||
session->skipped);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Apply one snapshotted --delete-delay path (post-order: children precede their
|
||||
* 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;
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(full, &leaf, false);
|
||||
free(full);
|
||||
if (parent_fd < 0) {
|
||||
free(leaf);
|
||||
return errno == ENOENT || errno == ENOTDIR;
|
||||
}
|
||||
struct stat st;
|
||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
bool absent = errno == ENOENT;
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return absent;
|
||||
}
|
||||
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);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return ok;
|
||||
}
|
||||
|
||||
DeleteCommitResult delete_plan_session_commit(DeletePlanSession* session, const Config* config) {
|
||||
if (!session || !config)
|
||||
return DELETE_COMMIT_ERROR;
|
||||
/* Central no-mutation guard (mirrors manifest_delete_all): a dry-run never
|
||||
deletes. The receive path already skips plan application, but a hostile or
|
||||
buggy peer could still reach the commit, so treat it as a no-op. */
|
||||
if (session->dry_run)
|
||||
return DELETE_COMMIT_OK;
|
||||
bool ok = true;
|
||||
if (session->defer) {
|
||||
for (int i = 0; i < session->deferred->size && ok; i++)
|
||||
ok = apply_deferred_path(session, config, (const char*)session->deferred->items[i]);
|
||||
}
|
||||
if (ok)
|
||||
ok = apply_missing(session, config);
|
||||
if (!ok)
|
||||
return DELETE_COMMIT_ERROR;
|
||||
if (session->limit_hit)
|
||||
return DELETE_COMMIT_LIMIT_REACHED;
|
||||
return DELETE_COMMIT_OK;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#ifndef DELETE_PLAN_H
|
||||
#define DELETE_PLAN_H
|
||||
|
||||
#include "array_list.h"
|
||||
#include "config.h"
|
||||
#include "file_receive.h"
|
||||
#include "protocol.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Per-directory delete plans (protocol 2.24.0).
|
||||
*
|
||||
* rsync's --delete-during removes a directory's extras while the generator
|
||||
* processes that directory, and --delete-delay records the deletion list during
|
||||
* the scan but applies it only after a fully-successful transfer. FastSync has
|
||||
* no per-directory generator pass; instead the sender streams one plan per
|
||||
* source directory, in directory order, and the receiver applies it when it
|
||||
* arrives (during) or snapshots its extras and commits them at the end (delay).
|
||||
*
|
||||
* The sender side builds a plan set from the path-only pre-scan (it needs every
|
||||
* directory's complete direct-child list before the first data byte of that
|
||||
* directory). The receiver side is a session that carries the global protected
|
||||
* prefixes (filter-excluded and size-skipped source mirrors), the
|
||||
* --delete-missing-args exact deletions, the shared --max-delete budget and,
|
||||
* for --delete-delay, the snapshotted extras. */
|
||||
|
||||
/* ---- Sender: plan builder ---- */
|
||||
|
||||
typedef struct DeletePlanSender DeletePlanSender;
|
||||
|
||||
DeletePlanSender* delete_plan_sender_create(void);
|
||||
void delete_plan_sender_destroy(DeletePlanSender* sender);
|
||||
/* Record one transmitted entry. `path` is the destination-relative wire path;
|
||||
* is_dir marks an explicit directory entry (--dirs, a -x mount point). */
|
||||
bool delete_plan_sender_add(DeletePlanSender* sender, const char* path, bool is_dir);
|
||||
/* Drop plans for directories outside `synced_dirs` (the --files-from
|
||||
* synchronization scope; pass NULL when a full recursive transfer synchronized
|
||||
* every directory). The receive root is the "." sentinel.
|
||||
*
|
||||
* `walk_root` scopes a general -R transfer: when non-NULL it is the
|
||||
* reconstructed destination prefix the run actually transferred, and only the
|
||||
* plan for that prefix (and directories below it) is ever transmitted, so the
|
||||
* prefix's parent-directory siblings are never walked. Pass NULL for a plain
|
||||
* recursive transfer and for --files-from. */
|
||||
void delete_plan_sender_finalize(DeletePlanSender* sender, const ArrayList* synced_dirs,
|
||||
const char* walk_root);
|
||||
/* True when no transmitted FILE entry was recorded (an ambiguous empty scan).
|
||||
Directory keep entries do not count, so an I/O error that hid every file
|
||||
still refuses to delete. */
|
||||
bool delete_plan_sender_empty(const DeletePlanSender* sender);
|
||||
/* Attach the global config sections advertised on the first plan frame. */
|
||||
void delete_plan_sender_set_config(DeletePlanSender* sender, const ArrayList* protected_prefixes,
|
||||
const ArrayList* size_skipped, const ArrayList* missing_args);
|
||||
/* Send the root plan (even before any data, so root extras are handled like
|
||||
* rsync's first generator directory). Returns -1 on I/O error. */
|
||||
int delete_plan_send_root(int fd, DeletePlanSender* sender);
|
||||
/* Send the plans for every ancestor of `path` (root-first) and, when is_dir,
|
||||
* for `path` itself; already-sent plans are skipped. */
|
||||
int delete_plan_send_for_path(int fd, DeletePlanSender* sender, const char* path, bool is_dir);
|
||||
/* Send the plan for every directory in `dirs` that has not been transmitted
|
||||
* yet. Called after the data stream so an empty source directory's plan still
|
||||
* clears its destination extras even though no file frame triggered it. */
|
||||
int delete_plan_send_remaining(int fd, DeletePlanSender* sender, const ArrayList* dirs);
|
||||
|
||||
/* ---- Receiver: delete session ---- */
|
||||
|
||||
typedef struct DeletePlanSession DeletePlanSession;
|
||||
|
||||
DeletePlanSession* delete_plan_session_create(const Config* config);
|
||||
void delete_plan_session_destroy(DeletePlanSession* session);
|
||||
/* Read one STATUS_DELETE_PLAN frame (the leading status already consumed) and
|
||||
* act on it. Returns 0 on success (including a dry-run/disabled no-op) and -1
|
||||
* after signalling STATUS_ERROR on a malformed frame or a deletion failure. */
|
||||
int delete_plan_session_receive(DeletePlanSession* session, const Config* config, int fd);
|
||||
/* Apply the deferred snapshot (--delete-delay) and the missing-args deletions.
|
||||
* Safe to call once; returns the commit outcome. */
|
||||
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'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);
|
||||
|
||||
#endif
|
||||
+24
-16
@@ -40,19 +40,27 @@ static bool write_all(int fd, const void* data, unsigned long long size) {
|
||||
}
|
||||
|
||||
/* Preallocate `size` bytes on `fd` before any data is written (--preallocate).
|
||||
* posix_fallocate reserves real disk blocks, so an out-of-space condition
|
||||
* fallocate(2) reserves real disk blocks, so an out-of-space condition
|
||||
* (ENOSPC/EDQUOT) surfaces up front instead of partway through a transfer;
|
||||
* unavoidable fragmentation of a streamed file is also reduced. Some
|
||||
* filesystems (e.g. tmpfs, ZFS) do not support it and return EOPNOTSUPP/ENOSYS,
|
||||
* where we fall back to ftruncate, which still extends the logical size so the
|
||||
* fail-fast/contiguity intent degrades gracefully but never fails. Genuine
|
||||
* allocation failures are propagated as the error code (caller fails the write).
|
||||
* posix_fallocate leaves the fd's file offset unchanged, so the subsequent
|
||||
* write_all at offset 0 is unaffected. Returns 0 on success (including the
|
||||
* fallback) or a nonzero error code. */
|
||||
* unavoidable fragmentation of a streamed file is also reduced. rsync favors
|
||||
* the syscall over glibc posix_fallocate (whose emulation can be subtly
|
||||
* different), so try fallocate(2) first and only fall back to posix_fallocate,
|
||||
* then to ftruncate on filesystems (e.g. tmpfs, ZFS) that support neither. The
|
||||
* logical size is always extended, so the fail-fast/contiguity intent degrades
|
||||
* gracefully but never fails on an unsupported filesystem; genuine allocation
|
||||
* failures are propagated as the error code (caller fails the write). Neither
|
||||
* leaves the fd's file offset guaranteed, so the caller seeks back to 0 before
|
||||
* writing. Returns 0 on success (including the fallback) or a nonzero error
|
||||
* code. */
|
||||
static int preallocate_fd(int fd, unsigned long long size) {
|
||||
if (size == 0)
|
||||
return 0;
|
||||
#ifdef __linux__
|
||||
if (fallocate(fd, 0, 0, (off_t)size) == 0)
|
||||
return 0;
|
||||
if (errno != EOPNOTSUPP && errno != ENOSYS && errno != EINVAL)
|
||||
return errno;
|
||||
#endif
|
||||
int rc = posix_fallocate(fd, 0, (off_t)size);
|
||||
if (rc == EOPNOTSUPP || rc == ENOSYS) {
|
||||
if (ftruncate(fd, (off_t)size) == 0)
|
||||
@@ -178,6 +186,7 @@ File* file_create(const char* path) {
|
||||
file->rdev_minor = 0;
|
||||
file->xattrs = NULL;
|
||||
file->dest_state = (OutputDestState){0};
|
||||
file->matched_bytes = 0;
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -663,7 +672,7 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs)
|
||||
if (strcmp(component, ".") != 0) {
|
||||
int next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (next < 0 && create_dirs && errno == ENOENT) {
|
||||
bool created = mkdirat(fd, component, 0755) == 0;
|
||||
bool created = mkdirat(fd, component, (mode_t)(0777 & ~(mode_t)file_process_umask())) == 0;
|
||||
if (created || errno == EEXIST) {
|
||||
/* P7 Wave E: --copy-as owns EVERY entry, including the intermediate
|
||||
directories this walk creates implicitly. Its target ids are a
|
||||
@@ -792,7 +801,7 @@ bool file_ensure_directory_secure(const char* path) {
|
||||
int dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool created = false;
|
||||
if (dir_fd < 0 && errno == ENOENT) {
|
||||
if (mkdirat(parent_fd, leaf, 0755) == 0) {
|
||||
if (mkdirat(parent_fd, leaf, (mode_t)(0777 & ~(mode_t)file_process_umask())) == 0) {
|
||||
created = true;
|
||||
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
} else if (errno == EEXIST) {
|
||||
@@ -1066,11 +1075,10 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
} else {
|
||||
/* Preallocate the expected payload size before writing so an
|
||||
out-of-space condition fails cleanly up front (--preallocate).
|
||||
--sparse takes precedence: posix_fallocate would allocate every
|
||||
block, defeating the holes the sparse writer would create, so the
|
||||
two never combine here (the ftruncate presize below stays). */
|
||||
rsync lets --preallocate win over --sparse (the reserved blocks
|
||||
survive the sparse writer's seeks), so both flags can be active. */
|
||||
int prealloc_rc = 0;
|
||||
if (preallocate && !sparse && data_size > 0) {
|
||||
if (preallocate && data_size > 0) {
|
||||
prealloc_rc = preallocate_fd(fd, data_size);
|
||||
if (prealloc_rc != 0) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
@@ -1196,7 +1204,7 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
if (fd < 0)
|
||||
continue; /* EEXIST (or a transient open error): try a fresh name. */
|
||||
int prealloc_rc = 0;
|
||||
if (preallocate && !sparse && data_size > 0) {
|
||||
if (preallocate && data_size > 0) {
|
||||
prealloc_rc = preallocate_fd(fd, data_size);
|
||||
if (prealloc_rc != 0) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
|
||||
+415
-129
@@ -1,4 +1,5 @@
|
||||
#include <errno.h>
|
||||
#include <ctype.h>
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <libgen.h>
|
||||
@@ -1092,6 +1093,13 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
|
||||
*failed = true;
|
||||
return NULL;
|
||||
}
|
||||
/* Wire-stats tally: bytes taken straight from the basis file (matched
|
||||
delta blocks). Computed before the delta is destroyed. */
|
||||
unsigned long long matched = 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;
|
||||
}
|
||||
void* new_data = delta_apply(old_data, old_size, delta, config->delta_block_size);
|
||||
delta_destroy(delta);
|
||||
|
||||
@@ -1110,6 +1118,7 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
|
||||
*failed = true;
|
||||
return NULL;
|
||||
}
|
||||
file->matched_bytes = matched;
|
||||
|
||||
if (config->use_metadata) {
|
||||
int meta_ok = 1;
|
||||
@@ -1335,7 +1344,11 @@ static bool basis_match_find(const Config* config, const char* check_path,
|
||||
return false;
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
const BasisDest* entry = &config->basis_dirs[i];
|
||||
char* basis_dir = path_cat(config->receive_root_directory, entry->path);
|
||||
/* An absolute basis path is used verbatim (rsync semantics); a relative one
|
||||
is resolved below the receive root. Both remain subject to the receiver's
|
||||
authorized-root confinement inside file_open_secure_parent. */
|
||||
char* basis_dir = entry->path[0] == '/' ? str_dup(entry->path)
|
||||
: path_cat(config->receive_root_directory, entry->path);
|
||||
if (!basis_dir)
|
||||
continue;
|
||||
char* candidate = path_cat(basis_dir, check_path);
|
||||
@@ -1397,7 +1410,8 @@ static bool basis_match_find(const Config* config, const char* check_path,
|
||||
* transfer). A fuzzy basis can therefore waste bandwidth but never corrupt a
|
||||
* file.
|
||||
*
|
||||
* Similarity heuristic (deterministic, deliberately simpler than rsync's):
|
||||
* Similarity heuristic (rsync 3.4.1 parity, util1.c fuzzy_distance /
|
||||
* find_filename_suffix + generator.c find_fuzzy):
|
||||
* * candidates are the target's sibling entries in its destination
|
||||
* directory, opened through the confined root (file_open_secure_parent +
|
||||
* openat O_NOFOLLOW, fstatat AT_SYMLINK_NOFOLLOW) -- symlinks are never
|
||||
@@ -1406,12 +1420,15 @@ static bool basis_match_find(const Config* config, const char* check_path,
|
||||
* temp scratch names are never candidates;
|
||||
* * size gate = the delta engine's own bounds (delta_should_attempt: both
|
||||
* files >= DELTA_MIN_FILE_SIZE, <= delta_max_file_size, ratio <= 10x),
|
||||
* NOT rsync's ~1.5x size window;
|
||||
* * name gate = Levenshtein edit distance between the basenames, accepted
|
||||
* only when distance <= half the length of the longer basename;
|
||||
* * the single best candidate (smallest distance; tie-break: size closest
|
||||
* to the incoming file, then lexicographically smaller basename) is read
|
||||
* and returned as the basis.
|
||||
* because FastSync's delta engine cannot use a basis outside them;
|
||||
* * first pass = an exact size+mtime match wins regardless of name (rsync's
|
||||
* "fuzzy size/modtime match");
|
||||
* * otherwise the winner minimizes rsync's weighted Levenshtein distance
|
||||
* (substitution ± byte difference, insertion UNIT+byte, 16.16 fixed point)
|
||||
* plus ten times the suffix distance, accepted only when <= 25*UNIT; the
|
||||
* tie-break (smallest size gap, then lexical name) keeps the result
|
||||
* deterministic across filesystem readdir order (rsync leaves equal
|
||||
* distances to its file-list order).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* A directory scan is linear in the number of entries; the fuzzy search stops
|
||||
@@ -1429,107 +1446,108 @@ static bool basis_match_find(const Config* config, const char* check_path,
|
||||
typedef struct {
|
||||
char name[FUZZY_NAME_LIMIT + 1];
|
||||
unsigned long long size;
|
||||
size_t distance;
|
||||
uint32_t distance;
|
||||
unsigned long long size_gap;
|
||||
} FuzzyCandidate;
|
||||
|
||||
/* Two-row DP scratch, allocated once per directory scan (not per candidate) so
|
||||
* a 4096-entry directory never performs 4096 malloc/free pairs. */
|
||||
typedef struct {
|
||||
size_t* prev;
|
||||
size_t* cur;
|
||||
} FuzzyEditBuffer;
|
||||
/* rsync's fuzzy distance is a weighted Levenshtein variant in 16.16 fixed point
|
||||
* (util1.c fuzzy_distance): a substitution costs UNIT +/- the byte difference
|
||||
* and an insertion costs UNIT + the inserted byte, so similar names score low.
|
||||
* The search keeps only distances <= 25*UNIT. Ported verbatim for parity. */
|
||||
#define FUZZY_DIST_UNIT (1u << 16)
|
||||
#define FUZZY_DIST_REJECT (0xFFFFu * FUZZY_DIST_UNIT + 1)
|
||||
#define FUZZY_DIST_LIMIT (25u * FUZZY_DIST_UNIT)
|
||||
|
||||
static bool fuzzy_edit_buffer_init(FuzzyEditBuffer* buf) {
|
||||
buf->prev = malloc((FUZZY_NAME_LIMIT + 1) * sizeof(size_t));
|
||||
buf->cur = malloc((FUZZY_NAME_LIMIT + 1) * sizeof(size_t));
|
||||
if (!buf->prev || !buf->cur) {
|
||||
free(buf->prev);
|
||||
free(buf->cur);
|
||||
buf->prev = NULL;
|
||||
buf->cur = NULL;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void fuzzy_edit_buffer_destroy(FuzzyEditBuffer* buf) {
|
||||
free(buf->prev);
|
||||
free(buf->cur);
|
||||
buf->prev = NULL;
|
||||
buf->cur = NULL;
|
||||
}
|
||||
|
||||
/* Cheap lower bounds used to reject a candidate BEFORE the DP:
|
||||
* - any edit script must at least absorb the length gap: d >= |la - lb|;
|
||||
* - any character of `a` that does not occur in `b` at all must be deleted or
|
||||
* substituted at its own position: d >= (count of such characters).
|
||||
* The acceptance gate is d*2 <= longer, so a candidate whose max of these two
|
||||
* bounds already violates it can be skipped without computing the distance. */
|
||||
static size_t fuzzy_absent_char_bound(const char* a, size_t la, const char* b, size_t lb) {
|
||||
if (lb == 0)
|
||||
return la;
|
||||
bool present[256] = {false};
|
||||
for (size_t i = 0; i < lb; i++)
|
||||
present[(uint8_t)b[i]] = true;
|
||||
size_t absent = 0;
|
||||
for (size_t i = 0; i < la; i++)
|
||||
if (!present[(uint8_t)a[i]])
|
||||
absent++;
|
||||
return absent;
|
||||
}
|
||||
|
||||
/* Levenshtein edit distance between the two basenames. A shared prefix and a
|
||||
* (non-overlapping) shared suffix can always be aligned at no cost, so the DP
|
||||
* only runs over the differing middles; its two rows come from `buf` (allocated
|
||||
* once by the caller). Callers enforce la, lb <= FUZZY_NAME_LIMIT. */
|
||||
static size_t fuzzy_edit_distance(FuzzyEditBuffer* buf, const char* a, size_t la, const char* b,
|
||||
size_t lb) {
|
||||
size_t p = 0;
|
||||
while (p < la && p < lb && a[p] == b[p])
|
||||
p++;
|
||||
/* Trim the common suffix (never overlapping the prefix). Working with two
|
||||
moving end indices keeps the region arithmetic explicit and safe. */
|
||||
size_t ae = la;
|
||||
size_t be = lb;
|
||||
while (ae > p && be > p && a[ae - 1] == b[be - 1]) {
|
||||
ae--;
|
||||
be--;
|
||||
}
|
||||
size_t ma = ae - p;
|
||||
size_t mb = be - p;
|
||||
/* cppcheck-suppress knownConditionTrueFalse -- the prefix/suffix trims above
|
||||
only run while the corresponding ends match, so a middle can remain; the
|
||||
analysis unsoundly concludes the trims always consume everything. */
|
||||
if (ma == 0)
|
||||
return mb;
|
||||
if (mb == 0)
|
||||
return ma;
|
||||
const char* A = a + p;
|
||||
const char* B = b + p;
|
||||
size_t* prev = buf->prev;
|
||||
size_t* cur = buf->cur;
|
||||
for (size_t j = 0; j <= mb; j++)
|
||||
prev[j] = j;
|
||||
for (size_t i = 1; i <= ma; i++) {
|
||||
cur[0] = i;
|
||||
for (size_t j = 1; j <= mb; j++) {
|
||||
size_t cost = A[i - 1] == B[j - 1] ? 0 : 1;
|
||||
size_t del = prev[j] + 1;
|
||||
size_t ins = cur[j - 1] + 1;
|
||||
size_t sub = prev[j - 1] + cost;
|
||||
size_t m = del < ins ? del : ins;
|
||||
cur[j] = m < sub ? m : sub;
|
||||
static uint32_t fuzzy_distance(const char* s1, unsigned len1, const char* s2, unsigned len2,
|
||||
uint32_t upperlimit, uint32_t* scratch) {
|
||||
if ((len1 > len2 ? len1 - len2 : len2 - len1) * FUZZY_DIST_UNIT > upperlimit)
|
||||
return FUZZY_DIST_REJECT;
|
||||
if (!len1 || !len2) {
|
||||
if (!len1) {
|
||||
s1 = s2;
|
||||
len1 = len2;
|
||||
}
|
||||
size_t* tmp = prev;
|
||||
prev = cur;
|
||||
cur = tmp;
|
||||
uint32_t cost = 0;
|
||||
for (unsigned i = 0; i < len1; i++)
|
||||
cost += (uint8_t)s1[i];
|
||||
return (uint32_t)len1 * FUZZY_DIST_UNIT + cost;
|
||||
}
|
||||
return prev[mb];
|
||||
uint32_t* a = scratch;
|
||||
for (unsigned i2 = 0; i2 < len2; i2++)
|
||||
a[i2] = (i2 + 1) * FUZZY_DIST_UNIT;
|
||||
for (unsigned i1 = 0; i1 < len1; i1++) {
|
||||
uint32_t diag = i1 * FUZZY_DIST_UNIT;
|
||||
uint32_t above = (i1 + 1) * FUZZY_DIST_UNIT;
|
||||
for (unsigned i2 = 0; i2 < len2; i2++) {
|
||||
uint32_t left = a[i2];
|
||||
int32_t cost = (int32_t)(uint8_t)s1[i1] - (int32_t)(uint8_t)s2[i2];
|
||||
if (cost != 0)
|
||||
cost = cost < 0 ? (int32_t)(FUZZY_DIST_UNIT - (uint32_t)(-cost))
|
||||
: (int32_t)(FUZZY_DIST_UNIT + (uint32_t)cost);
|
||||
uint32_t diag_inc = diag + (uint32_t)cost;
|
||||
uint32_t left_inc = left + FUZZY_DIST_UNIT + (uint8_t)s1[i1];
|
||||
uint32_t above_inc = above + FUZZY_DIST_UNIT + (uint8_t)s2[i2];
|
||||
a[i2] = above = left < above ? (left_inc < diag_inc ? left_inc : diag_inc)
|
||||
: (above_inc < diag_inc ? above_inc : diag_inc);
|
||||
diag = left;
|
||||
}
|
||||
}
|
||||
return a[len2 - 1];
|
||||
}
|
||||
|
||||
/* Deterministic ordering of two fuzzy candidates: smallest edit distance,
|
||||
* then the size closest to the incoming file, then the lexical basename. */
|
||||
/* rsync's find_filename_suffix (util1.c): return the last significant filename
|
||||
* suffix (its dot included). Leading dots are not a suffix; a trailing "~" is
|
||||
* ignored; .bak/.old/.orig and a "~/<num>" backup marker are skipped. */
|
||||
static const char* fuzzy_find_suffix(const char* fn, int fn_len, int* len_ptr) {
|
||||
const char* suf;
|
||||
const char* s;
|
||||
bool had_tilde;
|
||||
|
||||
while (fn_len && *fn == '.') {
|
||||
fn++;
|
||||
fn_len--;
|
||||
}
|
||||
if (fn_len > 1 && fn[fn_len - 1] == '~') {
|
||||
fn_len--;
|
||||
had_tilde = true;
|
||||
} else {
|
||||
had_tilde = false;
|
||||
}
|
||||
suf = "";
|
||||
*len_ptr = 0;
|
||||
for (s = fn + fn_len; fn_len > 1;) {
|
||||
int s_len;
|
||||
while (--s != fn && *s != '.') {
|
||||
}
|
||||
if (s == fn)
|
||||
break;
|
||||
s_len = fn_len - (int)(s - fn);
|
||||
fn_len = (int)(s - fn);
|
||||
if (s_len == 4) {
|
||||
if (strcmp(s + 1, "bak") == 0 || strcmp(s + 1, "old") == 0)
|
||||
continue;
|
||||
} else if (s_len == 5) {
|
||||
if (strcmp(s + 1, "orig") == 0)
|
||||
continue;
|
||||
} else if (s_len > 2 && had_tilde && s[1] == '~' && isdigit((unsigned char)s[2])) {
|
||||
continue;
|
||||
}
|
||||
*len_ptr = s_len;
|
||||
suf = s;
|
||||
if (s_len == 1)
|
||||
break;
|
||||
for (s++, s_len--; s_len > 0; s++, s_len--) {
|
||||
if (!isdigit((unsigned char)*s))
|
||||
return suf;
|
||||
}
|
||||
s = suf;
|
||||
}
|
||||
return suf;
|
||||
}
|
||||
|
||||
/* Deterministic ordering of two fuzzy candidates with equal rsync distance:
|
||||
* smallest size gap, then the lexical basename (rsync itself takes the last
|
||||
* equal-distance candidate in file-list order). */
|
||||
static bool fuzzy_candidate_better(const FuzzyCandidate* cand, const FuzzyCandidate* best) {
|
||||
if (!best->name[0])
|
||||
return true;
|
||||
@@ -1546,8 +1564,8 @@ static bool fuzzy_candidate_better(const FuzzyCandidate* cand, const FuzzyCandid
|
||||
* = 0) when no candidate qualifies, which means the caller performs the normal
|
||||
* whole-file transfer. */
|
||||
static void* fuzzy_basis_find_and_load(const Config* config, const char* check_path,
|
||||
unsigned long long check_size,
|
||||
unsigned long long* out_size) {
|
||||
unsigned long long check_size, time_t check_mtime,
|
||||
long check_mtime_nsec, unsigned long long* out_size) {
|
||||
*out_size = 0;
|
||||
if (!config || !config->receive_root_directory || !config->fuzzy || !config->use_delta ||
|
||||
!check_path || check_size < DELTA_MIN_FILE_SIZE || check_size > config->delta_max_file_size ||
|
||||
@@ -1590,18 +1608,28 @@ static void* fuzzy_basis_find_and_load(const Config* config, const char* check_p
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The DP scratch rows are allocated once per scan (not once per candidate). */
|
||||
FuzzyEditBuffer ebuf;
|
||||
if (!fuzzy_edit_buffer_init(&ebuf)) {
|
||||
/* The weighted-distance scratch row is allocated once per scan (not once per
|
||||
candidate). */
|
||||
uint32_t* dist_scratch = malloc((FUZZY_NAME_LIMIT + 1) * sizeof(uint32_t));
|
||||
if (!dist_scratch) {
|
||||
closedir(dir);
|
||||
close(dir_fd);
|
||||
free(leaf);
|
||||
free(full_path);
|
||||
return NULL;
|
||||
}
|
||||
int fname_suf_len = 0;
|
||||
const char* fname_suf = fuzzy_find_suffix(leaf, (int)target_len, &fname_suf_len);
|
||||
|
||||
FuzzyCandidate best;
|
||||
memset(&best, 0, sizeof(best));
|
||||
uint32_t lowest_dist = FUZZY_DIST_LIMIT;
|
||||
/* rsync's fuzzy search runs an exact size+mtime pass before the name-distance
|
||||
pass; such a candidate is almost certainly the same content and wins
|
||||
regardless of how dissimilar its name is. The first one (directory order,
|
||||
deterministic) is kept. */
|
||||
FuzzyCandidate exact;
|
||||
memset(&exact, 0, sizeof(exact));
|
||||
const struct dirent* entry;
|
||||
size_t scanned = 0;
|
||||
/* readdir() yields entries in filesystem-dependent order, so the SET of
|
||||
@@ -1621,22 +1649,32 @@ static void* fuzzy_basis_find_and_load(const Config* config, const char* check_p
|
||||
if (cand_size == 0 || cand_size > MAX_RECEIVE_WHOLE_FILE_SIZE ||
|
||||
!delta_should_attempt(cand_size, check_size, config->delta_max_file_size))
|
||||
continue;
|
||||
/* Cheap pre-name gates run BEFORE the edit-distance DP. The edit distance
|
||||
is bounded below by the length gap |la-lb| and by the number of
|
||||
characters of one basename that are absent from the other (each such
|
||||
position costs at least one op), so a candidate whose acceptance gate
|
||||
(distance*2 <= longer) already fails on the max of those bounds is
|
||||
skipped without running the DP. */
|
||||
size_t longer = target_len > name_len ? target_len : name_len;
|
||||
size_t bound = longer - (target_len < name_len ? target_len : name_len);
|
||||
size_t absent = fuzzy_absent_char_bound(leaf, target_len, name, name_len);
|
||||
if (absent > bound)
|
||||
bound = absent;
|
||||
if (bound * 2 > longer)
|
||||
long cand_nsec = 0;
|
||||
#ifdef __linux__
|
||||
cand_nsec = st.st_mtim.tv_nsec;
|
||||
#endif
|
||||
if (!exact.name[0] && cand_size == check_size &&
|
||||
metadata_mtime_matches(st.st_mtime, cand_nsec, check_mtime, check_mtime_nsec,
|
||||
config->modify_window)) {
|
||||
memcpy(exact.name, name, name_len + 1);
|
||||
exact.size = cand_size;
|
||||
exact.size_gap = 0;
|
||||
continue;
|
||||
size_t distance = fuzzy_edit_distance(&ebuf, leaf, target_len, name, name_len);
|
||||
if (distance * 2 > longer)
|
||||
}
|
||||
/* rsync's name-distance pass: a weighted Levenshtein distance over the full
|
||||
basenames, plus ten times the same distance over the filename suffixes,
|
||||
accepted only when it does not exceed the running lowest distance. */
|
||||
int name_suf_len = 0;
|
||||
const char* name_suf = fuzzy_find_suffix(name, (int)name_len, &name_suf_len);
|
||||
uint32_t distance = fuzzy_distance(name, (unsigned)name_len, leaf, (unsigned)target_len,
|
||||
lowest_dist, dist_scratch);
|
||||
if (distance < 0xFFFF0000U)
|
||||
distance += fuzzy_distance(name_suf, (unsigned)name_suf_len, fname_suf,
|
||||
(unsigned)fname_suf_len, 0xFFFF0000U, dist_scratch) *
|
||||
10;
|
||||
if (distance > lowest_dist)
|
||||
continue;
|
||||
lowest_dist = distance;
|
||||
FuzzyCandidate cand;
|
||||
memcpy(cand.name, name, name_len + 1);
|
||||
cand.size = cand_size;
|
||||
@@ -1647,7 +1685,11 @@ static void* fuzzy_basis_find_and_load(const Config* config, const char* check_p
|
||||
}
|
||||
closedir(dir);
|
||||
free(leaf);
|
||||
fuzzy_edit_buffer_destroy(&ebuf);
|
||||
free(dist_scratch);
|
||||
|
||||
/* Prefer the exact size+mtime candidate over any name-distance winner. */
|
||||
if (exact.name[0])
|
||||
best = exact;
|
||||
|
||||
void* basis = NULL;
|
||||
if (best.name[0]) {
|
||||
@@ -1764,6 +1806,7 @@ typedef struct {
|
||||
long long check_mtime_nsec;
|
||||
uint8_t check_digest[CHECKSUM_MAX_DIGEST_LEN];
|
||||
size_t check_digest_len;
|
||||
bool dest_exists; /* any destination entry exists (lstat succeeded) */
|
||||
bool has_old_file;
|
||||
int old_fd;
|
||||
struct stat old_st;
|
||||
@@ -1860,6 +1903,9 @@ static IncrementalCheckOutcome incremental_check_open_destination(IncrementalChe
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(full_path, &leaf, false);
|
||||
if (parent_fd >= 0) {
|
||||
struct stat dest_st;
|
||||
if (fstatat(parent_fd, leaf, &dest_st, AT_SYMLINK_NOFOLLOW) == 0)
|
||||
state->dest_exists = true;
|
||||
/* O_NONBLOCK: an existing FIFO at the destination must not block this
|
||||
openat(); the S_ISREG gate below rejects the non-regular entry. */
|
||||
state->old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
|
||||
@@ -1903,6 +1949,82 @@ static IncrementalCheckOutcome incremental_check_report_dest_info(IncrementalChe
|
||||
return INCREMENTAL_CONTINUE;
|
||||
}
|
||||
|
||||
/* --ignore-existing short-circuit. The receiver must answer "skip" (STATUS_OK)
|
||||
BEFORE the sender transmits any payload, otherwise the whole file crosses the
|
||||
wire only to be discarded at write time. rsync skips an existing destination
|
||||
entry regardless of its content or type, so the reply depends only on the
|
||||
lstat existence probe; the ordinary --ignore-existing checks inside
|
||||
file_receive remain as defense-in-depth for the frame types that have no
|
||||
per-file check (directories/symlinks/specials/hard-links). */
|
||||
static IncrementalCheckOutcome
|
||||
incremental_check_ignore_existing(const IncrementalCheckState* state) {
|
||||
if (!state->config->ignore_existing || !state->dest_exists)
|
||||
return INCREMENTAL_CONTINUE;
|
||||
if (!send_status(state->fd, STATUS_OK))
|
||||
return INCREMENTAL_ERROR;
|
||||
return INCREMENTAL_SKIP;
|
||||
}
|
||||
|
||||
/* --link-dest relink of an already up-to-date destination. rsync hard-links a
|
||||
destination entry to a matching basis even when the entry is already correct,
|
||||
so a run over an existing tree still maximizes sharing with the basis. Only a
|
||||
link-dest basis triggers this (copy-dest/compare-dest leave an up-to-date
|
||||
destination untouched, matching rsync). The ordinary basis path further down
|
||||
handles every not-up-to-date case, so this helper only adds the relink that
|
||||
the quick-skip would otherwise short-circuit. */
|
||||
static IncrementalCheckOutcome incremental_check_link_dest_relink(IncrementalCheckState* state,
|
||||
File** out_file) {
|
||||
const Config* config = state->config;
|
||||
if (!config_has_basis(config) || config->ignore_times || config->dry_run)
|
||||
return INCREMENTAL_CONTINUE;
|
||||
if (!state->has_old_file)
|
||||
return INCREMENTAL_CONTINUE;
|
||||
BasisMatch basis;
|
||||
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
|
||||
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
|
||||
true, true, &basis);
|
||||
/* Only a link-dest hit relinks; a copy-dest/compare-dest hit (or a miss) lets
|
||||
the up-to-date check below keep the existing destination. */
|
||||
if (!basis.hit || basis.type != BASIS_DEST_LINK) {
|
||||
basis_match_free(&basis);
|
||||
return INCREMENTAL_CONTINUE;
|
||||
}
|
||||
/* Already the basis inode: nothing to do, leave the destination alone. */
|
||||
if (basis.st.st_dev == state->old_st.st_dev && basis.st.st_ino == state->old_st.st_ino) {
|
||||
basis_match_free(&basis);
|
||||
return INCREMENTAL_CONTINUE;
|
||||
}
|
||||
File* materialized = file_create(state->check_path);
|
||||
if (materialized && basis.content) {
|
||||
data_destroy(materialized->data);
|
||||
materialized->data = basis.content;
|
||||
basis.content = NULL;
|
||||
materialized->metadata = file_metadata_create(NULL, &basis.st, false, false);
|
||||
materialized->skip = true;
|
||||
materialized->basis_link = basis.basis_path;
|
||||
basis.basis_path = NULL;
|
||||
if (!materialized->metadata) {
|
||||
file_destroy(materialized);
|
||||
materialized = NULL;
|
||||
}
|
||||
} else {
|
||||
file_destroy(materialized);
|
||||
materialized = NULL;
|
||||
}
|
||||
if (materialized) {
|
||||
if (!send_status(state->fd, STATUS_OK)) {
|
||||
basis_match_free(&basis);
|
||||
file_destroy(materialized);
|
||||
return INCREMENTAL_ERROR;
|
||||
}
|
||||
basis_match_free(&basis);
|
||||
*out_file = materialized;
|
||||
return INCREMENTAL_FILE;
|
||||
}
|
||||
basis_match_free(&basis);
|
||||
return INCREMENTAL_CONTINUE;
|
||||
}
|
||||
|
||||
/* Metadata-only (and, when --checksum forces it, content) up-to-date decision.
|
||||
Loads the old contents only when a checksum comparison or delta needs them. */
|
||||
static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckState* state,
|
||||
@@ -2289,8 +2411,9 @@ static IncrementalCheckOutcome incremental_check_try_fuzzy(IncrementalCheckState
|
||||
if (!config->fuzzy || !config->use_delta)
|
||||
return INCREMENTAL_CONTINUE;
|
||||
unsigned long long fuzzy_size = 0;
|
||||
void* fuzzy_basis =
|
||||
fuzzy_basis_find_and_load(config, state->check_path, state->check_size, &fuzzy_size);
|
||||
void* fuzzy_basis = fuzzy_basis_find_and_load(config, state->check_path, state->check_size,
|
||||
(time_t)state->check_mtime,
|
||||
(long)state->check_mtime_nsec, &fuzzy_size);
|
||||
if (fuzzy_basis != NULL) {
|
||||
bool fuzzy_failed = false;
|
||||
File* fuzzy_file = receive_delta_file(state->fd, config, state->check_path, fuzzy_basis,
|
||||
@@ -2351,6 +2474,24 @@ File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped,
|
||||
if (outcome == INCREMENTAL_ERROR)
|
||||
goto done;
|
||||
|
||||
/* --ignore-existing must answer before any data is requested; it takes
|
||||
precedence over the metadata up-to-date check below. */
|
||||
outcome = incremental_check_ignore_existing(&state);
|
||||
if (outcome == INCREMENTAL_ERROR)
|
||||
goto done;
|
||||
if (outcome == INCREMENTAL_SKIP) {
|
||||
*skipped = true;
|
||||
goto done;
|
||||
}
|
||||
|
||||
/* A --link-dest hit relinks even an already up-to-date destination before the
|
||||
quick-skip can suppress it (rsync parity). */
|
||||
outcome = incremental_check_link_dest_relink(&state, &result);
|
||||
if (outcome == INCREMENTAL_ERROR)
|
||||
goto done;
|
||||
if (outcome == INCREMENTAL_FILE)
|
||||
goto done;
|
||||
|
||||
outcome = incremental_check_quick_skip(&state, &try_delta);
|
||||
if (outcome == INCREMENTAL_ERROR)
|
||||
goto done;
|
||||
@@ -3012,6 +3153,38 @@ typedef struct {
|
||||
bool limit_hit;
|
||||
} DeleteBudgetState;
|
||||
|
||||
/* Build the delete-walk protection prefix for one basis directory. The walker
|
||||
compares paths relative to the receive root, so a relative entry is already
|
||||
in the right form; an absolute entry that lies below the root is converted to
|
||||
its root-relative form, and one outside the root returns NULL (the walk
|
||||
cannot reach it, and it is not protected data beneath the root). Exposed so
|
||||
tests can exercise the root-of-"/" child mapping directly. */
|
||||
char* file_receive_basis_delete_relative(const Config* config, const char* path) {
|
||||
if (!path)
|
||||
return NULL;
|
||||
if (path[0] != '/')
|
||||
return str_dup(path);
|
||||
const char* root = config->receive_root_directory;
|
||||
if (!root || root[0] != '/')
|
||||
return NULL;
|
||||
size_t root_len = strlen(root);
|
||||
while (root_len > 1 && root[root_len - 1] == '/')
|
||||
root_len--;
|
||||
if (strncmp(path, root, root_len) != 0)
|
||||
return NULL;
|
||||
if (root_len == 1) {
|
||||
/* `root` is "/" (the only single-character absolute root): every absolute
|
||||
path is below it, and the child relative form is everything after the
|
||||
leading '/'. */
|
||||
if (path[1] == '\0')
|
||||
return NULL; /* identical to the root, not a child */
|
||||
return str_dup(path + 1);
|
||||
}
|
||||
if (path[root_len] != '/')
|
||||
return NULL; /* identical or a sibling sharing a name prefix */
|
||||
return str_dup(path + root_len + 1);
|
||||
}
|
||||
|
||||
/* Remove every destination entry under the receive root that is not in the
|
||||
keep-set, bounded by the shared budget (a smaller client --max-delete=NUM
|
||||
replaces the server hard bound; rsync deletes up to the bound and skips the
|
||||
@@ -3042,10 +3215,16 @@ static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifes
|
||||
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
||||
(manifest->protected ? manifest->protected->size : 0);
|
||||
DeleteSkipEntry* skips = NULL;
|
||||
char** owned_prefixes = NULL;
|
||||
int used = 0;
|
||||
if (skip_count > 0) {
|
||||
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
|
||||
if (!skips)
|
||||
owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*));
|
||||
if (!skips || (config->basis_count > 0 && !owned_prefixes)) {
|
||||
free(skips);
|
||||
free(owned_prefixes);
|
||||
return false;
|
||||
}
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
@@ -3053,7 +3232,13 @@ static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifes
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
skips[idx].prefix = config->basis_dirs[i].path;
|
||||
/* An absolute basis outside the receive root is unreachable by this walk,
|
||||
so it contributes no protection prefix (and no slot). */
|
||||
char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path);
|
||||
if (!prefix)
|
||||
continue;
|
||||
owned_prefixes[i] = prefix;
|
||||
skips[idx].prefix = prefix;
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
@@ -3062,6 +3247,7 @@ static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifes
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
used = idx;
|
||||
}
|
||||
/* Clamp rather than subtract: an accounting bug where deleted already exceeds
|
||||
max_delete must never underflow into an effectively unlimited budget. */
|
||||
@@ -3076,7 +3262,12 @@ static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifes
|
||||
size_t skipped = 0;
|
||||
DeleteWalkResult result =
|
||||
delete_extras_limited(config->receive_root_directory, manifest->keeps, manifest->dirs,
|
||||
remaining, skips, skip_count, &deleted, &skipped);
|
||||
remaining, skips, used, &deleted, &skipped);
|
||||
if (owned_prefixes) {
|
||||
for (int i = 0; i < config->basis_count; i++)
|
||||
free(owned_prefixes[i]);
|
||||
}
|
||||
free(owned_prefixes);
|
||||
free(skips);
|
||||
budget->deleted += deleted;
|
||||
budget->skipped += skipped;
|
||||
@@ -3113,10 +3304,16 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m
|
||||
fprintf(stderr, "Deleting destination mirrors of missing source arguments...\n");
|
||||
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count;
|
||||
DeleteSkipEntry* skips = NULL;
|
||||
char** owned_prefixes = NULL;
|
||||
int used = 0;
|
||||
if (skip_count > 0) {
|
||||
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
|
||||
if (!skips)
|
||||
owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*));
|
||||
if (!skips || (config->basis_count > 0 && !owned_prefixes)) {
|
||||
free(skips);
|
||||
free(owned_prefixes);
|
||||
return false;
|
||||
}
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
@@ -3124,10 +3321,15 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
skips[idx].prefix = config->basis_dirs[i].path;
|
||||
char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path);
|
||||
if (!prefix)
|
||||
continue;
|
||||
owned_prefixes[i] = prefix;
|
||||
skips[idx].prefix = prefix;
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
used = idx;
|
||||
}
|
||||
bool ok = true;
|
||||
for (int i = 0; i < manifest->missing->size; i++) {
|
||||
@@ -3140,7 +3342,7 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m
|
||||
continue;
|
||||
}
|
||||
bool at_root = strchr(rel, '/') == NULL;
|
||||
if (path_under_skip_prefix(rel, at_root, skips, skip_count)) {
|
||||
if (path_under_skip_prefix(rel, at_root, skips, used)) {
|
||||
char* escaped = output_escape(rel, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"missing-args path '%s' is protected (staging directory or basis snapshot); "
|
||||
@@ -3264,12 +3466,72 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m
|
||||
if (!ok)
|
||||
break;
|
||||
}
|
||||
if (owned_prefixes) {
|
||||
for (int i = 0; i < config->basis_count; i++)
|
||||
free(owned_prefixes[i]);
|
||||
}
|
||||
free(owned_prefixes);
|
||||
free(skips);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Public wrappers used outside the commit path (and by unit tests): no
|
||||
--max-delete budget. */
|
||||
bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, ArrayList* out,
|
||||
size_t* count_out) {
|
||||
if (count_out)
|
||||
*count_out = 0;
|
||||
if (!config || !manifest || !manifest->keeps || !out)
|
||||
return false;
|
||||
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
||||
(manifest->protected ? manifest->protected->size : 0);
|
||||
DeleteSkipEntry* skips = NULL;
|
||||
char** owned_prefixes = NULL;
|
||||
int used = 0;
|
||||
if (skip_count > 0) {
|
||||
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
|
||||
owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*));
|
||||
if (!skips || (config->basis_count > 0 && !owned_prefixes)) {
|
||||
free(skips);
|
||||
free(owned_prefixes);
|
||||
return false;
|
||||
}
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
skips[idx].top_level_only = true;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
/* Normalize exactly like the real commit path: a relative entry is
|
||||
already root-relative, an absolute one inside the receive root is
|
||||
converted, and one outside contributes no protection prefix. */
|
||||
char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path);
|
||||
if (!prefix)
|
||||
continue;
|
||||
owned_prefixes[i] = prefix;
|
||||
skips[idx].prefix = prefix;
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < manifest->protected->size; i++) {
|
||||
skips[idx].prefix = (const char*)manifest->protected->items[i];
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
used = idx;
|
||||
}
|
||||
bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs,
|
||||
skips, used, out, count_out);
|
||||
if (owned_prefixes) {
|
||||
for (int i = 0; i < config->basis_count; i++)
|
||||
free(owned_prefixes[i]);
|
||||
}
|
||||
free(owned_prefixes);
|
||||
free(skips);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) {
|
||||
DeleteBudgetState budget = {
|
||||
.max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
@@ -3282,6 +3544,21 @@ bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest
|
||||
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) {
|
||||
DeleteBudgetState budget = {
|
||||
.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
bool ok = delete_missing_args_budgeted(config, manifest, &budget);
|
||||
if (deleted)
|
||||
*deleted = budget.deleted;
|
||||
if (skipped)
|
||||
*skipped = budget.skipped;
|
||||
if (limit_hit)
|
||||
*limit_hit = budget.limit_hit;
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Commit every deletion family the manifest carries. The --delete-missing-args
|
||||
exact-path deletions run FIRST: they are explicit user requests and must not
|
||||
be blocked by the extras walker's filter-exclusion protection (a protected
|
||||
@@ -3290,6 +3567,13 @@ bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest
|
||||
Both draw from one --max-delete budget; the result reports a cap-stopped
|
||||
(partial) commit distinctly so the client can exit 25 like rsync. */
|
||||
DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* manifest) {
|
||||
return manifest_delete_all_counted(config, manifest, NULL);
|
||||
}
|
||||
|
||||
DeleteCommitResult manifest_delete_all_counted(const Config* config, DeleteManifest* manifest,
|
||||
size_t* deleted) {
|
||||
if (deleted)
|
||||
*deleted = 0;
|
||||
if (!config || !manifest)
|
||||
return DELETE_COMMIT_ERROR;
|
||||
/* Central no-mutation guard: a dry-run never deletes. No manifest is sent on
|
||||
@@ -3310,6 +3594,8 @@ DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* man
|
||||
return DELETE_COMMIT_ERROR;
|
||||
if (config->use_delete && !delete_extras_budgeted(config, manifest, &budget))
|
||||
return DELETE_COMMIT_ERROR;
|
||||
if (deleted)
|
||||
*deleted = budget.deleted;
|
||||
if (budget.limit_hit) {
|
||||
if (user_limited) {
|
||||
log_message(LOG_LEVEL_ERROR, "Deletions stopped due to --max-delete limit (%zu skipped)",
|
||||
|
||||
@@ -118,6 +118,14 @@ bool manifest_delete_extras(const Config* config, DeleteManifest* manifest);
|
||||
confinement or I/O error (the run then fails); tolerated per-path cases are
|
||||
reported and skipped. */
|
||||
bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest);
|
||||
/* Budgeted form of manifest_delete_missing_args for the per-directory delete
|
||||
session: each removed mirror draws from `max_delete` (SIZE_MAX = unlimited)
|
||||
and the tallies are accumulated into `*deleted`/`*skipped`. `*limit_hit` is set
|
||||
when the budget stopped the pass with entries left over. Returns false only
|
||||
on a genuine deletion error. */
|
||||
bool manifest_delete_missing_args_limited(const Config* config, DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted, size_t* skipped,
|
||||
bool* limit_hit);
|
||||
/* 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). */
|
||||
@@ -134,6 +142,22 @@ typedef enum {
|
||||
do or everything committed, DELETE_COMMIT_LIMIT_REACHED when the budget
|
||||
stopped part of the work, or DELETE_COMMIT_ERROR on a genuine failure. */
|
||||
DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* manifest);
|
||||
/* Like manifest_delete_all, but reports how many destination entries the commit
|
||||
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);
|
||||
|
||||
/* -n/--dry-run --delete would-delete reporting: walk the destination exactly as
|
||||
the delete pass would and append (strdup'd) destination-relative paths that
|
||||
WOULD be removed to `out`, without touching disk. Uses the same staging-dir,
|
||||
basis-dir and protected-prefix skips as the real commit. Returns true on a
|
||||
clean walk; `*count_out` receives the number of paths appended. */
|
||||
bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, ArrayList* out,
|
||||
size_t* count_out);
|
||||
/* Convert one basis-directory path to the receive-root-relative protection
|
||||
prefix the delete walker uses (NULL when it lies outside the root). Exposed
|
||||
for unit tests of the root-of-"/" and normalization edge cases. */
|
||||
char* file_receive_basis_delete_relative(const Config* config, const char* path);
|
||||
|
||||
/* Outcome of a single file_save_to_disk operation. The receiver needs to
|
||||
distinguish "written" from "skipped" so --remove-source-files can be told
|
||||
|
||||
@@ -182,6 +182,7 @@ bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_meta
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
protocol_note_bytes_written((unsigned long long)sent);
|
||||
}
|
||||
|
||||
close(fd);
|
||||
|
||||
@@ -93,6 +93,10 @@ typedef struct {
|
||||
* no report was requested/received, in which case -i/--out-format treats the
|
||||
* entry conservatively as newly created. */
|
||||
OutputDestState dest_state;
|
||||
/* Receiver-only wire-stats tally: the number of bytes reconstructed from the
|
||||
* 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;
|
||||
} File;
|
||||
|
||||
/* The path that should be sent on the wire and used for the receiver-side
|
||||
|
||||
+600
-225
@@ -1,163 +1,27 @@
|
||||
#include "filter.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ---- Single rule parsing ---- */
|
||||
|
||||
static bool rule_text_is_unsupported_word(const char* p, size_t len) {
|
||||
static const char* const words[] = {"merge", "dir-merge", "hide", "show",
|
||||
"protect", "risk", "clear"};
|
||||
for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) {
|
||||
size_t wl = strlen(words[i]);
|
||||
if (len == wl && strncmp(p, words[i], wl) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
/* Write a diagnostic message into the caller's optional buffer. A NULL `err`
|
||||
* (or a zero size) is a no-op, so a caller that only needs the boolean status
|
||||
* may pass NULL without the snprintf-on-NULL undefined behaviour. */
|
||||
static void filter_set_error(char* err, size_t err_size, const char* fmt, ...) {
|
||||
if (!err || err_size == 0)
|
||||
return;
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(err, err_size, fmt, ap);
|
||||
va_end(ap);
|
||||
}
|
||||
|
||||
/* rsync include/exclude rule modifiers we do NOT implement. A rule whose +/- is
|
||||
* immediately followed by one of these is rejected instead of being silently
|
||||
* parsed as a literal pattern. */
|
||||
static bool is_unsupported_rule_modifier(char c) {
|
||||
return c == '!' || c == 'C' || c == 's' || c == 'r' || c == 'p' || c == 'x';
|
||||
}
|
||||
|
||||
FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
if (!line)
|
||||
return NULL;
|
||||
char* text = str_dup(line);
|
||||
if (!text) {
|
||||
if (err)
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
size_t len = strlen(text);
|
||||
while (len > 0 && (text[len - 1] == '\n' || text[len - 1] == '\r'))
|
||||
text[--len] = '\0';
|
||||
|
||||
const char* p = text;
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
if (*p == '\0') {
|
||||
snprintf(err, err_size, "empty filter rule");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
FilterAction action = FILTER_ACTION_EXCLUDE;
|
||||
if (*p == '+' || *p == '-') {
|
||||
action = *p == '+' ? FILTER_ACTION_INCLUDE : FILTER_ACTION_EXCLUDE;
|
||||
p++;
|
||||
/* rsync attaches rule modifiers directly to the +/- (e.g. "-s foo"). Only
|
||||
* the '/' anchor modifier is supported; anything else is a clear error
|
||||
* rather than a silently-ignored literal. */
|
||||
if (*p != ' ' && *p != '\t' && *p != '\0' && is_unsupported_rule_modifier(*p)) {
|
||||
snprintf(err, err_size,
|
||||
"filter rule modifier '%c' is not supported (only the '/' anchor after +/- "
|
||||
"is implemented; put a space between +/- and the pattern)",
|
||||
*p);
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
} else {
|
||||
/* ':' (dir-merge) and '.' (merge) are rsync filter-rule shorthands. At the
|
||||
* start of a rule they mean "merge this file", so reject them instead of
|
||||
* silently turning them into inert exclude patterns. */
|
||||
if (*p == ':' || *p == '.' || *p == '!') {
|
||||
snprintf(err, err_size,
|
||||
"filter rule starting with '%c' is not supported (merge/dir-merge/list-clear "
|
||||
"shorthands are not implemented; use +/- include/exclude rules)",
|
||||
*p);
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
const char* sp = p;
|
||||
while (*sp != '\0' && *sp != ' ' && *sp != '\t')
|
||||
sp++;
|
||||
size_t word_len = (size_t)(sp - p);
|
||||
if (rule_text_is_unsupported_word(p, word_len)) {
|
||||
snprintf(err, err_size,
|
||||
"'%.*s' filter directives are not supported (only +/- include/exclude rules "
|
||||
"with an optional '/' anchor and trailing '/' dir marker)",
|
||||
(int)word_len, p);
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
if (word_len == strlen("include") && strncmp(p, "include", word_len) == 0) {
|
||||
action = FILTER_ACTION_INCLUDE;
|
||||
p = sp;
|
||||
} else if (word_len == strlen("exclude") && strncmp(p, "exclude", word_len) == 0) {
|
||||
action = FILTER_ACTION_EXCLUDE;
|
||||
p = sp;
|
||||
}
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
}
|
||||
|
||||
if (*p == '\0') {
|
||||
snprintf(err, err_size, "filter rule has no pattern");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A pattern beginning with '/' is anchored (either as "-/foo" or "- /foo"). */
|
||||
bool anchored = false;
|
||||
if (*p == '/') {
|
||||
anchored = true;
|
||||
p++;
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
}
|
||||
if (*p == '\0') {
|
||||
snprintf(err, err_size, "filter rule has no pattern after '/' anchor");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Pattern runs to the end of the rule; a single trailing '/' marks dir-only. */
|
||||
size_t pat_len = strlen(p);
|
||||
bool dir_only = false;
|
||||
if (pat_len > 1 && p[pat_len - 1] == '/') {
|
||||
dir_only = true;
|
||||
pat_len--;
|
||||
} else if (pat_len == 1 && p[0] == '/') {
|
||||
/* "//" anchored with nothing after: meaningless. */
|
||||
snprintf(err, err_size, "filter rule has no pattern");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
FilterRule* rule = calloc(1, sizeof(FilterRule));
|
||||
if (!rule) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
rule->pattern = malloc(pat_len + 1);
|
||||
if (!rule->pattern) {
|
||||
free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(rule->pattern, p, pat_len);
|
||||
rule->pattern[pat_len] = '\0';
|
||||
rule->action = action;
|
||||
rule->anchored = anchored;
|
||||
rule->dir_only = dir_only;
|
||||
rule->owner = NULL;
|
||||
free(text);
|
||||
return rule;
|
||||
}
|
||||
/* ---- Ordered rule lists ---- */
|
||||
|
||||
void filter_rule_free(FilterRule* rule) {
|
||||
if (!rule)
|
||||
@@ -167,8 +31,6 @@ void filter_rule_free(FilterRule* rule) {
|
||||
free(rule);
|
||||
}
|
||||
|
||||
/* ---- Ordered rule lists ---- */
|
||||
|
||||
FilterRuleList* filter_rule_list_create(void) {
|
||||
return calloc(1, sizeof(FilterRuleList));
|
||||
}
|
||||
@@ -190,28 +52,42 @@ bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line, char* err,
|
||||
size_t err_size) {
|
||||
FilterRule* rule = filter_rule_parse(line, err, err_size);
|
||||
if (!rule)
|
||||
return false;
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void filter_rule_list_free(FilterRuleList* list) {
|
||||
if (!list)
|
||||
return;
|
||||
for (int i = 0; i < list->count; i++)
|
||||
filter_rule_free(list->items[i]);
|
||||
for (int i = 0; i < list->dir_merge_count; i++)
|
||||
free(list->dir_merge_names[i]);
|
||||
free(list->dir_merge_names);
|
||||
free(list->items);
|
||||
free(list);
|
||||
}
|
||||
|
||||
/* Register a per-directory merge-file basename (for "dir-merge NAME"/": NAME"
|
||||
* and -F's .rsync-filter). Duplicate names are ignored. */
|
||||
bool filter_rule_list_add_dir_merge(FilterRuleList* list, const char* name) {
|
||||
if (!list || !name || name[0] == '\0')
|
||||
return false;
|
||||
for (int i = 0; i < list->dir_merge_count; i++) {
|
||||
if (strcmp(list->dir_merge_names[i], name) == 0)
|
||||
return true;
|
||||
}
|
||||
if (list->dir_merge_count == list->dir_merge_capacity) {
|
||||
int new_cap = list->dir_merge_capacity > 0 ? list->dir_merge_capacity * 2 : 4;
|
||||
char** grown = realloc(list->dir_merge_names, (size_t)new_cap * sizeof(char*));
|
||||
if (!grown)
|
||||
return false;
|
||||
list->dir_merge_names = grown;
|
||||
list->dir_merge_capacity = new_cap;
|
||||
}
|
||||
char* dup = str_dup(name);
|
||||
if (!dup)
|
||||
return false;
|
||||
list->dir_merge_names[list->dir_merge_count++] = dup;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool set_rule_owner(FilterRule* rule, const char* owner) {
|
||||
char* dup = str_dup(owner ? owner : "");
|
||||
if (!dup)
|
||||
@@ -221,7 +97,315 @@ static bool set_rule_owner(FilterRule* rule, const char* owner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- CVS default excludes (-C) ---- */
|
||||
/* ---- Rule parsing ---- */
|
||||
|
||||
/* A short rule prefix is a single character; a long rule name is alphabetic
|
||||
* (with '-'). `is_short` distinguishes the modifier-attachment rules. */
|
||||
typedef enum {
|
||||
RULE_KIND_EXCLUDE,
|
||||
RULE_KIND_INCLUDE,
|
||||
RULE_KIND_HIDE,
|
||||
RULE_KIND_SHOW,
|
||||
RULE_KIND_PROTECT,
|
||||
RULE_KIND_RISK,
|
||||
RULE_KIND_MERGE,
|
||||
RULE_KIND_DIR_MERGE,
|
||||
RULE_KIND_CLEAR,
|
||||
RULE_KIND_UNKNOWN,
|
||||
} RuleKind;
|
||||
|
||||
static bool short_rule_char(char c, RuleKind* kind) {
|
||||
switch (c) {
|
||||
case '-':
|
||||
*kind = RULE_KIND_EXCLUDE;
|
||||
return true;
|
||||
case '+':
|
||||
*kind = RULE_KIND_INCLUDE;
|
||||
return true;
|
||||
case 'H':
|
||||
*kind = RULE_KIND_HIDE;
|
||||
return true;
|
||||
case 'S':
|
||||
*kind = RULE_KIND_SHOW;
|
||||
return true;
|
||||
case 'P':
|
||||
*kind = RULE_KIND_PROTECT;
|
||||
return true;
|
||||
case 'R':
|
||||
*kind = RULE_KIND_RISK;
|
||||
return true;
|
||||
case '.':
|
||||
*kind = RULE_KIND_MERGE;
|
||||
return true;
|
||||
case ':':
|
||||
*kind = RULE_KIND_DIR_MERGE;
|
||||
return true;
|
||||
case '!':
|
||||
*kind = RULE_KIND_CLEAR;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool long_rule_name(const char* name, size_t len, RuleKind* kind) {
|
||||
struct {
|
||||
const char* word;
|
||||
RuleKind kind;
|
||||
} table[] = {
|
||||
{"exclude", RULE_KIND_EXCLUDE}, {"include", RULE_KIND_INCLUDE},
|
||||
{"hide", RULE_KIND_HIDE}, {"show", RULE_KIND_SHOW},
|
||||
{"protect", RULE_KIND_PROTECT}, {"risk", RULE_KIND_RISK},
|
||||
{"merge", RULE_KIND_MERGE}, {"dir-merge", RULE_KIND_DIR_MERGE},
|
||||
{"clear", RULE_KIND_CLEAR},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); i++) {
|
||||
if (strlen(table[i].word) == len && strncmp(name, table[i].word, len) == 0) {
|
||||
*kind = table[i].kind;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool is_modifier_char(char c) {
|
||||
return c == 's' || c == 'r' || c == 'p' || c == 'x' || c == '/' || c == '!' || c == 'C';
|
||||
}
|
||||
|
||||
/* Parse "RULE[,MODIFIERS] [PATTERN]". On success `kind`, `sides`,
|
||||
* `sides_explicit`, `negate`, `anchored_mod`, `perishable`, `xattr`,
|
||||
* `cvs_inject` and the pattern span (`pat_start`/`pat_len`, possibly 0 for
|
||||
* merge/clear) are filled. Returns true on success. */
|
||||
static bool parse_rule_syntax(const char* text, RuleKind* kind, unsigned* sides,
|
||||
bool* sides_explicit, bool* negate, bool* anchored_mod,
|
||||
bool* perishable, bool* xattr, bool* cvs_inject,
|
||||
const char** pat_start, size_t* pat_len) {
|
||||
const char* p = text;
|
||||
*sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER;
|
||||
*sides_explicit = false;
|
||||
*negate = false;
|
||||
*anchored_mod = false;
|
||||
*perishable = false;
|
||||
*xattr = false;
|
||||
*cvs_inject = false;
|
||||
*pat_start = NULL;
|
||||
*pat_len = 0;
|
||||
|
||||
bool is_short = false;
|
||||
if (short_rule_char(*p, kind)) {
|
||||
is_short = true;
|
||||
p++;
|
||||
} else {
|
||||
const char* name_start = p;
|
||||
while (isalpha((unsigned char)*p) || *p == '-')
|
||||
p++;
|
||||
size_t name_len = (size_t)(p - name_start);
|
||||
if (name_len == 0 || !long_rule_name(name_start, name_len, kind))
|
||||
return false;
|
||||
/* A long name must be followed by a separator, a comma or the end. */
|
||||
if (*p != '\0' && *p != ',' && *p != ' ' && *p != '_')
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Modifiers: long names require a comma; short names may attach directly.
|
||||
Only commit a modifier run that terminates at a separator or the end, so a
|
||||
pattern such as "*.tmp" written as "-*.tmp" is not mistaken for modifiers. */
|
||||
const char* mod_start = p;
|
||||
const char* mod_end = p;
|
||||
if (*p == ',') {
|
||||
p++;
|
||||
mod_start = p;
|
||||
while (is_modifier_char(*p))
|
||||
p++;
|
||||
mod_end = p;
|
||||
} else if (is_short) {
|
||||
const char* scan = p;
|
||||
while (is_modifier_char(*scan))
|
||||
scan++;
|
||||
if (*scan == '\0' || *scan == ' ' || *scan == '_') {
|
||||
mod_start = p;
|
||||
mod_end = scan;
|
||||
p = scan;
|
||||
}
|
||||
}
|
||||
for (const char* m = mod_start; m < mod_end; m++) {
|
||||
switch (*m) {
|
||||
case 's':
|
||||
*sides = FILTER_SIDE_SENDER;
|
||||
*sides_explicit = true;
|
||||
break;
|
||||
case 'r':
|
||||
*sides = FILTER_SIDE_RECEIVER;
|
||||
*sides_explicit = true;
|
||||
break;
|
||||
case '!':
|
||||
*negate = true;
|
||||
break;
|
||||
case '/':
|
||||
*anchored_mod = true;
|
||||
break;
|
||||
case 'p':
|
||||
*perishable = true;
|
||||
break;
|
||||
case 'x':
|
||||
*xattr = true;
|
||||
break;
|
||||
case 'C':
|
||||
*cvs_inject = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* A single space or underscore separates the rule/modifiers from the
|
||||
pattern; further spaces/underscores belong to the pattern. */
|
||||
const char* pat = p;
|
||||
if (*pat == ' ' || *pat == '_')
|
||||
pat++;
|
||||
/* Trim a trailing newline/CR (the caller may pass a raw file line). */
|
||||
*pat_start = pat;
|
||||
*pat_len = strlen(pat);
|
||||
while (*pat_len > 0 && (pat[*pat_len - 1] == '\n' || pat[*pat_len - 1] == '\r'))
|
||||
(*pat_len)--;
|
||||
return true;
|
||||
}
|
||||
|
||||
FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts, char* err,
|
||||
size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
if (!line)
|
||||
return NULL;
|
||||
const char* p = line;
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
if (*p == '\0' || *p == '\n' || *p == '\r') {
|
||||
filter_set_error(err, err_size, "empty filter rule");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
RuleKind kind = RULE_KIND_UNKNOWN;
|
||||
unsigned sides;
|
||||
bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject;
|
||||
const char* pat;
|
||||
size_t pat_len;
|
||||
if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable,
|
||||
&xattr, &cvs_inject, &pat, &pat_len)) {
|
||||
filter_set_error(err, err_size, "unrecognized filter rule syntax");
|
||||
return NULL;
|
||||
}
|
||||
if (cvs_inject) {
|
||||
/* The C modifier expands to the CVS defaults in place; the rule itself
|
||||
carries no pattern and is handled by the caller. */
|
||||
filter_set_error(err, err_size, "the C modifier is handled by the rule-list parser");
|
||||
return NULL;
|
||||
}
|
||||
if (xattr) {
|
||||
filter_set_error(err, err_size, "xattr-name filter rules (the x modifier) are not supported");
|
||||
return NULL;
|
||||
}
|
||||
if (kind == RULE_KIND_MERGE || kind == RULE_KIND_DIR_MERGE) {
|
||||
filter_set_error(err, err_size, "merge/dir-merge rules are handled by the rule-list parser");
|
||||
return NULL;
|
||||
}
|
||||
if (kind == RULE_KIND_CLEAR) {
|
||||
if (pat_len != 0) {
|
||||
filter_set_error(err, err_size, "clear takes no pattern");
|
||||
return NULL;
|
||||
}
|
||||
FilterRule* rule = calloc(1, sizeof(FilterRule));
|
||||
if (!rule) {
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
rule->action = FILTER_ACTION_NONE; /* clear marker: no pattern */
|
||||
rule->sides = 0;
|
||||
return rule;
|
||||
}
|
||||
|
||||
FilterAction action;
|
||||
switch (kind) {
|
||||
case RULE_KIND_INCLUDE:
|
||||
case RULE_KIND_SHOW:
|
||||
case RULE_KIND_RISK:
|
||||
action = FILTER_ACTION_INCLUDE;
|
||||
break;
|
||||
default:
|
||||
action = FILTER_ACTION_EXCLUDE;
|
||||
break;
|
||||
}
|
||||
if (kind == RULE_KIND_HIDE)
|
||||
sides = FILTER_SIDE_SENDER;
|
||||
else if (kind == RULE_KIND_SHOW)
|
||||
sides = FILTER_SIDE_SENDER;
|
||||
else if (kind == RULE_KIND_PROTECT)
|
||||
sides = FILTER_SIDE_RECEIVER;
|
||||
else if (kind == RULE_KIND_RISK)
|
||||
sides = FILTER_SIDE_RECEIVER;
|
||||
if (kind == RULE_KIND_HIDE || kind == RULE_KIND_SHOW || kind == RULE_KIND_PROTECT ||
|
||||
kind == RULE_KIND_RISK)
|
||||
sides_explicit = true;
|
||||
/* --delete-excluded turns an unqualified (no explicit s/r) rule into a
|
||||
sender-side-only rule, so it no longer protects the receiver. */
|
||||
if (opts && opts->delete_excluded && !sides_explicit)
|
||||
sides = FILTER_SIDE_SENDER;
|
||||
|
||||
if (pat_len == 0) {
|
||||
filter_set_error(err, err_size, "filter rule has no pattern");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool anchored = anchored_mod;
|
||||
const char* pat_begin = pat;
|
||||
if (*pat_begin == '/') {
|
||||
anchored = true;
|
||||
pat_begin++;
|
||||
/* Drop the spaces that could follow the anchor in the "-/ foo" form. */
|
||||
while (*pat_begin == ' ' || *pat_begin == '\t')
|
||||
pat_begin++;
|
||||
pat_len = strlen(pat_begin);
|
||||
while (pat_len > 0 && (pat_begin[pat_len - 1] == '\n' || pat_begin[pat_len - 1] == '\r'))
|
||||
pat_len--;
|
||||
}
|
||||
if (pat_len == 0) {
|
||||
filter_set_error(err, err_size, "filter rule has no pattern after '/' anchor");
|
||||
return NULL;
|
||||
}
|
||||
bool dir_only = false;
|
||||
if (pat_len > 1 && pat_begin[pat_len - 1] == '/') {
|
||||
dir_only = true;
|
||||
pat_len--;
|
||||
}
|
||||
if (pat_len == 0) {
|
||||
filter_set_error(err, err_size, "filter rule has no pattern");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
FilterRule* rule = calloc(1, sizeof(FilterRule));
|
||||
if (!rule) {
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
rule->pattern = malloc(pat_len + 1);
|
||||
if (!rule->pattern) {
|
||||
free(rule);
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
memcpy(rule->pattern, pat_begin, pat_len);
|
||||
rule->pattern[pat_len] = '\0';
|
||||
rule->action = action;
|
||||
rule->sides = sides;
|
||||
rule->anchored = anchored;
|
||||
rule->dir_only = dir_only;
|
||||
rule->negate = negate;
|
||||
rule->perishable = perishable;
|
||||
(void)xattr; /* xattr-name rules never match file/dir names; accepted/ignored */
|
||||
return rule;
|
||||
}
|
||||
|
||||
/* ---- CVS default excludes (-C and the C modifier) ---- */
|
||||
|
||||
typedef struct {
|
||||
const char* pattern;
|
||||
@@ -240,12 +424,13 @@ static const CvsDefaultRule CVS_DEFAULTS[] = {
|
||||
{".svn/", true}, {".git/", true}, {".hg/", true}, {".bzr/", true},
|
||||
};
|
||||
|
||||
static bool cvs_rule_list_append(FilterRuleList* list) {
|
||||
static bool filter_list_append_cvs(FilterRuleList* list, unsigned sides) {
|
||||
for (size_t i = 0; i < sizeof(CVS_DEFAULTS) / sizeof(CVS_DEFAULTS[0]); i++) {
|
||||
FilterRule* rule = calloc(1, sizeof(FilterRule));
|
||||
if (!rule)
|
||||
return false;
|
||||
rule->action = FILTER_ACTION_EXCLUDE;
|
||||
rule->sides = sides;
|
||||
rule->dir_only = CVS_DEFAULTS[i].dir_only;
|
||||
size_t plen = strlen(CVS_DEFAULTS[i].pattern);
|
||||
if (rule->dir_only && plen > 0 && CVS_DEFAULTS[i].pattern[plen - 1] == '/')
|
||||
@@ -269,76 +454,236 @@ static bool cvs_rule_list_append(FilterRuleList* list) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#define FILTER_MAX_MERGE_DEPTH 16
|
||||
|
||||
static bool filter_list_parse_append_depth(FilterRuleList* list, const char* line,
|
||||
const FilterParseOptions* opts, const char* base_dir,
|
||||
int depth, char* err, size_t err_size);
|
||||
|
||||
/* Read a merge file and splice its rules into `list`. A relative path is
|
||||
* resolved below `base_dir` when given, else used as-is (rsync resolves a
|
||||
* command-line merge file relative to the current directory). */
|
||||
static bool filter_list_merge_file(FilterRuleList* list, const char* name,
|
||||
const FilterParseOptions* opts, const char* base_dir, int depth,
|
||||
char* err, size_t err_size) {
|
||||
if (name[0] == '\0') {
|
||||
filter_set_error(err, err_size, "merge requires a filename");
|
||||
return false;
|
||||
}
|
||||
char* path =
|
||||
(base_dir && base_dir[0] && name[0] != '/') ? path_cat(base_dir, name) : str_dup(name);
|
||||
if (!path) {
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) {
|
||||
filter_set_error(err, err_size, "could not read merge file '%s': %s", path, strerror(errno));
|
||||
free(path);
|
||||
return false;
|
||||
}
|
||||
char* line = NULL;
|
||||
size_t cap = 0;
|
||||
bool ok = true;
|
||||
while (true) {
|
||||
ssize_t n = utils_getdelim_bounded(fp, &line, &cap, '\n', UTILS_MAX_LINE_LEN);
|
||||
if (n < 0) {
|
||||
filter_set_error(err, err_size, "error reading merge file '%s'", path);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (n == 0)
|
||||
break;
|
||||
const char* lp = line;
|
||||
while (*lp == ' ' || *lp == '\t')
|
||||
lp++;
|
||||
if (*lp == '\0' || *lp == '\n' || *lp == '\r' || *lp == '#')
|
||||
continue;
|
||||
if (!filter_list_parse_append_depth(list, lp, opts, base_dir, depth + 1, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
free(line);
|
||||
fclose(fp);
|
||||
free(path);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Parse one line and append/merge it into `list`. Handles clear, merge and
|
||||
* dir-merge at the list level. */
|
||||
static bool filter_list_parse_append_depth(FilterRuleList* list, const char* line,
|
||||
const FilterParseOptions* opts, const char* base_dir,
|
||||
int depth, char* err, size_t err_size) {
|
||||
if (depth > FILTER_MAX_MERGE_DEPTH) {
|
||||
filter_set_error(err, err_size, "merge files nested too deeply");
|
||||
return false;
|
||||
}
|
||||
const char* p = line;
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
if (*p == '\0' || *p == '\n' || *p == '\r')
|
||||
return true;
|
||||
|
||||
RuleKind kind = RULE_KIND_UNKNOWN;
|
||||
unsigned sides;
|
||||
bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject;
|
||||
const char* pat;
|
||||
size_t pat_len;
|
||||
if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable,
|
||||
&xattr, &cvs_inject, &pat, &pat_len)) {
|
||||
filter_set_error(err, err_size, "unrecognized filter rule syntax: %s", p);
|
||||
return false;
|
||||
}
|
||||
(void)sides_explicit;
|
||||
(void)negate;
|
||||
(void)anchored_mod;
|
||||
(void)perishable;
|
||||
(void)xattr;
|
||||
|
||||
if (cvs_inject) {
|
||||
/* "C" injects the CVS defaults in place; no pattern is expected. */
|
||||
return filter_list_append_cvs(list, sides);
|
||||
}
|
||||
if (kind == RULE_KIND_CLEAR) {
|
||||
if (pat_len != 0) {
|
||||
filter_set_error(err, err_size, "clear takes no pattern");
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < list->count; i++)
|
||||
filter_rule_free(list->items[i]);
|
||||
list->count = 0;
|
||||
return true;
|
||||
}
|
||||
if (kind == RULE_KIND_MERGE) {
|
||||
if (pat_len == 0) {
|
||||
filter_set_error(err, err_size, "merge requires a filename");
|
||||
return false;
|
||||
}
|
||||
char* name = malloc(pat_len + 1);
|
||||
if (!name) {
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
memcpy(name, pat, pat_len);
|
||||
name[pat_len] = '\0';
|
||||
bool ok = filter_list_merge_file(list, name, opts, base_dir, depth, err, err_size);
|
||||
free(name);
|
||||
return ok;
|
||||
}
|
||||
if (kind == RULE_KIND_DIR_MERGE) {
|
||||
if (pat_len == 0) {
|
||||
filter_set_error(err, err_size, "dir-merge requires a filename");
|
||||
return false;
|
||||
}
|
||||
char* name = malloc(pat_len + 1);
|
||||
if (!name) {
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
memcpy(name, pat, pat_len);
|
||||
name[pat_len] = '\0';
|
||||
bool ok = filter_rule_list_add_dir_merge(list, name);
|
||||
free(name);
|
||||
if (!ok) {
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
FilterRule* rule = filter_rule_parse(p, opts, err, err_size);
|
||||
if (!rule)
|
||||
return false;
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line,
|
||||
const FilterParseOptions* opts, const char* merge_base_dir,
|
||||
char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
if (!list)
|
||||
return false;
|
||||
return filter_list_parse_append_depth(list, line, opts, merge_base_dir, 0, err, err_size);
|
||||
}
|
||||
|
||||
FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude,
|
||||
char* err, size_t err_size) {
|
||||
bool delete_excluded, char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
FilterRuleList* list = filter_rule_list_create();
|
||||
if (!list) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
FilterParseOptions opts = {.delete_excluded = delete_excluded, .cvs_exclude = cvs_exclude};
|
||||
for (int i = 0; i < rule_count; i++) {
|
||||
if (!rule_texts || !rule_texts[i])
|
||||
continue;
|
||||
FilterRule* rule = filter_rule_parse(rule_texts[i], err, err_size);
|
||||
if (!rule) {
|
||||
if (!filter_rule_list_parse_append(list, rule_texts[i], &opts, NULL, err, err_size)) {
|
||||
filter_rule_list_free(list);
|
||||
return NULL;
|
||||
}
|
||||
if (!set_rule_owner(rule, "")) {
|
||||
filter_rule_free(rule);
|
||||
filter_rule_list_free(list);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
filter_rule_list_free(list);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
if (cvs_exclude && !cvs_rule_list_append(list)) {
|
||||
if (cvs_exclude && !filter_list_append_cvs(list, FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER)) {
|
||||
filter_rule_list_free(list);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ---- Per-directory .rsync-filter files ---- */
|
||||
/* ---- Per-directory merge files ---- */
|
||||
|
||||
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists,
|
||||
char* err, size_t err_size) {
|
||||
/* Undo the rules and dir-merge registrations that one merge file appended,
|
||||
* leaving the caller's earlier content intact. A "clear" rule inside the file
|
||||
* frees every rule, including the caller's; clamp to the surviving count so
|
||||
* those already-freed rules are never resurrected and freed a second time. */
|
||||
static void filter_file_rollback(FilterRuleList* list, int rules_before, int dir_merges_before) {
|
||||
int first = rules_before < list->count ? rules_before : list->count;
|
||||
for (int i = first; i < list->count; i++)
|
||||
filter_rule_free(list->items[i]);
|
||||
list->count = first;
|
||||
for (int i = dir_merges_before; i < list->dir_merge_count; i++)
|
||||
free(list->dir_merge_names[i]);
|
||||
list->dir_merge_count = dir_merges_before;
|
||||
}
|
||||
|
||||
bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* name,
|
||||
const char* owner_rel, const FilterParseOptions* opts, bool* exists,
|
||||
char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
if (exists)
|
||||
*exists = false;
|
||||
char* filter_path = path_cat(dir_path, ".rsync-filter");
|
||||
if (!list)
|
||||
return false;
|
||||
char* filter_path = path_cat(dir_path, name);
|
||||
if (!filter_path) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
FILE* fp = fopen(filter_path, "r");
|
||||
free(filter_path);
|
||||
if (!fp) {
|
||||
if (errno == ENOENT || errno == ENOTDIR)
|
||||
return filter_rule_list_create();
|
||||
return true;
|
||||
char* escaped_dir = output_escape(dir_path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "Could not read .rsync-filter in %s: %s",
|
||||
log_message(LOG_LEVEL_WARNING, "Could not read %s in %s: %s", name,
|
||||
escaped_dir ? escaped_dir : "<allocation failed>", strerror(errno));
|
||||
free(escaped_dir);
|
||||
return filter_rule_list_create();
|
||||
return true;
|
||||
}
|
||||
if (exists)
|
||||
*exists = true;
|
||||
FilterRuleList* list = filter_rule_list_create();
|
||||
if (!list) {
|
||||
fclose(fp);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
int rules_before = list->count;
|
||||
int dir_merges_before = list->dir_merge_count;
|
||||
char* line = NULL;
|
||||
size_t line_cap = 0;
|
||||
bool ok = true;
|
||||
@@ -346,9 +691,10 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
|
||||
ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, '\n', UTILS_MAX_LINE_LEN);
|
||||
if (n < 0) {
|
||||
if (errno == EFBIG) {
|
||||
snprintf(err, err_size, "line in .rsync-filter exceeds %d bytes", (int)UTILS_MAX_LINE_LEN);
|
||||
filter_set_error(err, err_size, "line in %s exceeds %d bytes", name,
|
||||
(int)UTILS_MAX_LINE_LEN);
|
||||
} else {
|
||||
snprintf(err, err_size, "error reading .rsync-filter: %s", strerror(errno));
|
||||
filter_set_error(err, err_size, "error reading %s: %s", name, strerror(errno));
|
||||
}
|
||||
ok = false;
|
||||
break;
|
||||
@@ -360,20 +706,9 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
|
||||
p++;
|
||||
if (*p == '\0' || *p == '\n' || *p == '\r' || *p == '#')
|
||||
continue;
|
||||
FilterRule* rule = filter_rule_parse(p, err, err_size);
|
||||
if (!rule) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!set_rule_owner(rule, owner_rel)) {
|
||||
filter_rule_free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
/* Merge files inside a per-directory file resolve relative to that
|
||||
directory. */
|
||||
if (!filter_list_parse_append_depth(list, p, opts, dir_path, 0, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
@@ -381,12 +716,40 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
|
||||
free(line);
|
||||
fclose(fp);
|
||||
if (!ok) {
|
||||
filter_file_rollback(list, rules_before, dir_merges_before);
|
||||
return false;
|
||||
}
|
||||
for (int i = rules_before; i < list->count; i++) {
|
||||
if (!set_rule_owner(list->items[i], owner_rel)) {
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
filter_file_rollback(list, rules_before, dir_merges_before);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
FilterRuleList* filter_file_read_named(const char* dir_path, const char* name,
|
||||
const char* owner_rel, const FilterParseOptions* opts,
|
||||
bool* exists, char* err, size_t err_size) {
|
||||
FilterRuleList* list = filter_rule_list_create();
|
||||
if (!list) {
|
||||
if (err && err_size > 0)
|
||||
filter_set_error(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
if (!filter_file_append(list, dir_path, name, owner_rel, opts, exists, err, err_size)) {
|
||||
filter_rule_list_free(list);
|
||||
return NULL;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists,
|
||||
char* err, size_t err_size) {
|
||||
return filter_file_read_named(dir_path, ".rsync-filter", owner_rel, NULL, exists, err, err_size);
|
||||
}
|
||||
|
||||
/* ---- Rule matching ---- */
|
||||
|
||||
/* Match a pattern that contains '/' (non-anchored) against the end of the
|
||||
@@ -402,10 +765,10 @@ static bool glob_suffix_match(const char* pattern, const char* str) {
|
||||
}
|
||||
|
||||
static FilterAction rule_matches(const FilterRule* rule, const char* rel_path, const char* leaf,
|
||||
bool is_dir) {
|
||||
bool is_dir, unsigned side) {
|
||||
if (!rule || !rule->pattern)
|
||||
return FILTER_ACTION_NONE;
|
||||
if (rule->dir_only && !is_dir)
|
||||
if (!(rule->sides & side))
|
||||
return FILTER_ACTION_NONE;
|
||||
/* A rule applies only to entries below its owner directory. */
|
||||
const char* rel2 = rel_path;
|
||||
@@ -420,24 +783,36 @@ static FilterAction rule_matches(const FilterRule* rule, const char* rel_path, c
|
||||
if (rel2[0] == '\0')
|
||||
return FILTER_ACTION_NONE;
|
||||
bool matched;
|
||||
if (rule->anchored) {
|
||||
if (rule->dir_only && !is_dir)
|
||||
matched = false;
|
||||
else if (rule->anchored)
|
||||
matched = glob_match(rule->pattern, rel2);
|
||||
} else if (strchr(rule->pattern, '/') != NULL) {
|
||||
else if (strchr(rule->pattern, '/') != NULL)
|
||||
matched = glob_suffix_match(rule->pattern, rel2);
|
||||
} else {
|
||||
else
|
||||
matched = glob_match(rule->pattern, leaf);
|
||||
}
|
||||
return matched ? rule->action : FILTER_ACTION_NONE;
|
||||
if (rule->negate)
|
||||
matched = !matched;
|
||||
if (!matched)
|
||||
return FILTER_ACTION_NONE;
|
||||
if (side == FILTER_SIDE_RECEIVER)
|
||||
return rule->action == FILTER_ACTION_EXCLUDE ? FILTER_ACTION_PROTECT : FILTER_ACTION_RISK;
|
||||
return rule->action;
|
||||
}
|
||||
|
||||
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf,
|
||||
bool is_dir) {
|
||||
FilterAction filter_rules_apply_side(const FilterRuleList* list, const char* rel_path,
|
||||
const char* leaf, bool is_dir, unsigned side) {
|
||||
if (!list)
|
||||
return FILTER_ACTION_NONE;
|
||||
for (int i = 0; i < list->count; i++) {
|
||||
FilterAction action = rule_matches(list->items[i], rel_path, leaf, is_dir);
|
||||
FilterAction action = rule_matches(list->items[i], rel_path, leaf, is_dir, side);
|
||||
if (action != FILTER_ACTION_NONE)
|
||||
return action;
|
||||
}
|
||||
return FILTER_ACTION_NONE;
|
||||
}
|
||||
|
||||
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf,
|
||||
bool is_dir) {
|
||||
return filter_rules_apply_side(list, rel_path, leaf, is_dir, FILTER_SIDE_SENDER);
|
||||
}
|
||||
+94
-40
@@ -4,79 +4,133 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* rsync-style filter rule engine (client-side file selection).
|
||||
/* rsync-style filter rule engine (client-side file selection and the
|
||||
* receiver-side protection set it feeds).
|
||||
*
|
||||
* Supported rule syntax (documented subset):
|
||||
* [+|-] [anchored '/' prefix] pattern [trailing '/' for dir-only]
|
||||
*
|
||||
* "+ PATTERN" include rule (first match wins)
|
||||
* "- PATTERN" exclude rule
|
||||
* "PATTERN" implicit exclude rule (rsync default)
|
||||
* "include PATTERN" / "exclude PATTERN" word forms
|
||||
* leading '/' after the +/- anchors the pattern to its owner directory
|
||||
* (the transfer root for command-line/-C rules, the directory that
|
||||
* contains a .rsync-filter file for per-directory rules)
|
||||
* a trailing '/' makes the rule match directories only
|
||||
*
|
||||
* Rejected explicitly (no silent no-ops): the rsync merge/dir-merge/list-clear
|
||||
* shorthands written as a rule that starts with ':' or '.' or '!', the
|
||||
* merge/dir-merge/hide/show/protect/risk/clear words, and every include/exclude
|
||||
* rule modifier other than '/' (! C s r p x). The pattern must be separated
|
||||
* from +/- by a space (or a single '/' anchor), exactly like rsync's
|
||||
* "-s foo"/"-p ..." modifier syntax is refused.
|
||||
* Rule syntax (see the rsync man page FILTER RULES section):
|
||||
* RULE [PATTERN_OR_FILENAME]
|
||||
* RULE,MODIFIERS [PATTERN_OR_FILENAME]
|
||||
* Short RULE names may attach MODIFIERS directly ("-sr foo"); the long name
|
||||
* form requires the comma. The pattern/filename is separated from the rule by
|
||||
* one space or underscore. Rule names:
|
||||
* exclude/- exclude (by default both sender-hide and receiver-protect)
|
||||
* include/+ include (by default both sender-show and receiver-risk)
|
||||
* hide/H sender-only exclude
|
||||
* show/S sender-only include
|
||||
* protect/P receiver-only exclude (protect from deletion)
|
||||
* risk/R receiver-only include (allow deletion)
|
||||
* merge/. read a client-side merge file for more rules
|
||||
* dir-merge/: per-directory merge file (registered for the scanner)
|
||||
* clear/! clear the current rule list (takes no argument)
|
||||
* Modifiers: '/' absolute anchor, '!' negate match, 'C' inject CVS defaults,
|
||||
* 's' sender side, 'r' receiver side, 'p' perishable, 'x' xattr name rule.
|
||||
* A trailing '/' makes a pattern match directories only. A leading '/' anchors
|
||||
* the pattern to its owner directory.
|
||||
*/
|
||||
|
||||
typedef enum {
|
||||
FILTER_ACTION_NONE = 0, /* no rule matched */
|
||||
FILTER_ACTION_EXCLUDE = -1,
|
||||
FILTER_ACTION_INCLUDE = 1
|
||||
FILTER_ACTION_INCLUDE = 1,
|
||||
/* Receiver-side-only verdicts: the entry is transferred but its destination
|
||||
* mirror is protected from --delete (PROTECT) or explicitly left at risk
|
||||
* (RISK). */
|
||||
FILTER_ACTION_PROTECT = 2,
|
||||
FILTER_ACTION_RISK = 3,
|
||||
} FilterAction;
|
||||
|
||||
#define FILTER_SIDE_SENDER 1u
|
||||
#define FILTER_SIDE_RECEIVER 2u
|
||||
|
||||
typedef struct {
|
||||
FilterAction action;
|
||||
bool anchored; /* pattern anchored to the rule's owner directory */
|
||||
bool dir_only; /* pattern had a trailing '/': matches directories only */
|
||||
char* owner; /* owning directory rel path ("" == transfer root) */
|
||||
char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */
|
||||
FilterAction action; /* EXCLUDE or INCLUDE (the base pattern action) */
|
||||
unsigned sides; /* FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER */
|
||||
bool anchored; /* pattern anchored to the rule's owner directory */
|
||||
bool dir_only; /* pattern had a trailing '/': matches directories only */
|
||||
bool negate; /* '!' modifier: match succeeds when the pattern does not */
|
||||
bool perishable; /* 'p' modifier (ignored in deleted directories) */
|
||||
char* owner; /* owning directory rel path ("" == transfer root) */
|
||||
char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */
|
||||
} FilterRule;
|
||||
|
||||
typedef struct {
|
||||
FilterRule** items; /* owned array of rule pointers */
|
||||
int count;
|
||||
int capacity;
|
||||
/* Per-directory merge-file basenames registered by "dir-merge NAME"/": NAME"
|
||||
* or by -F (.rsync-filter). Owned strings; the scanner reads each name in
|
||||
* every directory it traverses. */
|
||||
char** dir_merge_names;
|
||||
int dir_merge_count;
|
||||
int dir_merge_capacity;
|
||||
} FilterRuleList;
|
||||
|
||||
/* Context needed while parsing a rule list (merge files, --delete-excluded). */
|
||||
typedef struct {
|
||||
bool delete_excluded; /* --delete-excluded: default sides become sender-only */
|
||||
bool cvs_exclude; /* -C: expand the CVS default excludes */
|
||||
} FilterParseOptions;
|
||||
|
||||
/* Parse a single filter-rule line (no trailing newline required). Returns an
|
||||
* owned rule, or NULL on unsupported/invalid syntax with a message in `err`. */
|
||||
FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size);
|
||||
* owned rule, or NULL on unsupported/invalid syntax with a message in `err`.
|
||||
* `opts` may be NULL (no merge expansion / no delete-excluded). */
|
||||
FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts, char* err,
|
||||
size_t err_size);
|
||||
void filter_rule_free(FilterRule* rule);
|
||||
|
||||
FilterRuleList* filter_rule_list_create(void);
|
||||
/* Append a fully-parsed rule (takes ownership). Returns false on OOM. */
|
||||
bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule);
|
||||
/* Parse `line` and append it. Returns false and fills `err` on bad syntax. */
|
||||
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line, char* err,
|
||||
size_t err_size);
|
||||
/* Register a per-directory merge-file basename (idempotent). Returns false on
|
||||
* OOM. Used by the scanner to read custom "dir-merge" files. */
|
||||
bool filter_rule_list_add_dir_merge(FilterRuleList* list, const char* name);
|
||||
/* Parse `line` and append it. Handles "clear"/"!" (resets the list), "merge
|
||||
* FILE"/". FILE" (splices the file's rules) and "dir-merge NAME"/": NAME"
|
||||
* (registers a per-directory filename). Returns false and fills `err` on bad
|
||||
* syntax or an unreadable merge file. `merge_base_dir` resolves a relative
|
||||
* merge-file path (NULL means the process working directory). */
|
||||
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line,
|
||||
const FilterParseOptions* opts, const char* merge_base_dir,
|
||||
char* err, size_t err_size);
|
||||
void filter_rule_list_free(FilterRuleList* list);
|
||||
|
||||
/* Build the command-line filter set: `rule_texts` (--filter=RULE in the order
|
||||
* given, 0..rule_count) followed by the -C CVS default excludes when
|
||||
* cvs_exclude is true. All rules are owned by "" (the transfer root).
|
||||
* cvs_exclude is true. All rules are owned by "" (the transfer root).
|
||||
* Returns NULL on unsupported rule text (message in `err`). */
|
||||
FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude,
|
||||
char* err, size_t err_size);
|
||||
bool delete_excluded, char* err, size_t err_size);
|
||||
|
||||
/* Read "<dir_path>/.rsync-filter" and return its rules, each owned by
|
||||
* `owner_rel`. A missing file yields an empty list with *exists=false; an
|
||||
* unreadable file is treated as missing. Returns NULL only on parse or
|
||||
* allocation failure (message in `err`). */
|
||||
/* Read "<dir_path>/<name>" and return its rules, each owned by `owner_rel`. A
|
||||
* missing file yields an empty list with *exists=false; an unreadable file is
|
||||
* treated as missing. Returns NULL only on parse or allocation failure
|
||||
* (message in `err`). `opts` may be NULL. */
|
||||
FilterRuleList* filter_file_read_named(const char* dir_path, const char* name,
|
||||
const char* owner_rel, const FilterParseOptions* opts,
|
||||
bool* exists, char* err, size_t err_size);
|
||||
|
||||
/* Append the rules of "<dir_path>/<name>" into an existing list (each owned by
|
||||
* `owner_rel`). A missing file yields *exists=false and no error. Returns
|
||||
* false only on parse/allocation failure (message in `err`). */
|
||||
bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* name,
|
||||
const char* owner_rel, const FilterParseOptions* opts, bool* exists,
|
||||
char* err, size_t err_size);
|
||||
|
||||
/* filter_file_read_named with the default ".rsync-filter" name. */
|
||||
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists,
|
||||
char* err, size_t err_size);
|
||||
|
||||
/* Evaluate an entry against one ordered rule list. Returns FILTER_ACTION_NONE
|
||||
* when no rule matched, otherwise the first matching rule's action.
|
||||
* `rel_path` is the entry's path relative to the transfer root ("" == root),
|
||||
* `leaf` its final name, `is_dir` whether it is a directory. */
|
||||
/* Evaluate an entry against one ordered rule list for one side. Returns
|
||||
* FILTER_ACTION_NONE when no rule matched, otherwise the first matching rule's
|
||||
* action (for the receiver side an EXCLUDE is reported as
|
||||
* FILTER_ACTION_PROTECT and an INCLUDE as FILTER_ACTION_RISK). `rel_path` is
|
||||
* the entry's path relative to the transfer root ("" == root), `leaf` its final
|
||||
* name, `is_dir` whether it is a directory. */
|
||||
FilterAction filter_rules_apply_side(const FilterRuleList* list, const char* rel_path,
|
||||
const char* leaf, bool is_dir, unsigned side);
|
||||
|
||||
/* Sender-side convenience wrapper (kept for callers/tests that only need the
|
||||
* transfer decision). */
|
||||
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf,
|
||||
bool is_dir);
|
||||
|
||||
|
||||
@@ -101,3 +101,29 @@ bool format_dest_state_receive(int fd, OutputDestState* state) {
|
||||
state->gid = gid;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool format_stats_send(int fd, const ReceiverStats* stats) {
|
||||
if (!stats)
|
||||
return false;
|
||||
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 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 = matched;
|
||||
stats->deleted_files = deleted;
|
||||
stats->would_delete_count = would;
|
||||
return true;
|
||||
}
|
||||
@@ -56,4 +56,21 @@ bool format_rsync_datetime(time_t when, bool dash, char* buffer, size_t buffer_s
|
||||
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) 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;
|
||||
} ReceiverStats;
|
||||
|
||||
/* Fixed-width STATUS_STATS counter record. The status frame and the optional
|
||||
* would-delete path list are sent/received by the caller. Returns false on I/O
|
||||
* failure. */
|
||||
bool format_stats_send(int fd, const ReceiverStats* stats);
|
||||
bool format_stats_receive(int fd, ReceiverStats* stats);
|
||||
|
||||
#endif
|
||||
+68
-21
@@ -589,6 +589,67 @@ static int identity_split_chown(const char* value, char** puser, char** pgroup)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --chown is rsync's shorthand for "--usermap=*:USER --groupmap=*:GROUP", so a
|
||||
* name TO value must be resolved on the RECEIVER, not on the sender. Append the
|
||||
* equivalent map rule (FROM matches every id). The numeric/'*' forms are stored
|
||||
* numerically exactly as rsync's id_parse/user_to_uid would. Returns 0 on
|
||||
* success, -1 on a malformed numeric token or allocation failure. */
|
||||
static int identity_append_chown_rule(Config* config, bool is_group, const char* token) {
|
||||
IdentityMap rule;
|
||||
memset(&rule, 0, sizeof(rule));
|
||||
rule.from = IDENTITY_MATCH_ANY;
|
||||
rule.from_hi = IDENTITY_MATCH_ANY;
|
||||
if (strcmp(token, "*") == 0) {
|
||||
rule.to = IDENTITY_CURRENT;
|
||||
} else if (identity_all_digits(token[0] == '@' ? token + 1 : token)) {
|
||||
if (identity_resolve_token(token, is_group, &rule.to) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown numeric id is out of range: %s", token);
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
rule.to = 0;
|
||||
rule.to_name = str_dup(token);
|
||||
if (!rule.to_name)
|
||||
return -1;
|
||||
}
|
||||
if (identity_append_rule(is_group ? &config->groupmap : &config->usermap,
|
||||
is_group ? &config->groupmap_count : &config->usermap_count,
|
||||
&rule) != 0) {
|
||||
free(rule.to_name);
|
||||
log_message(LOG_LEVEL_ERROR, "--chown has too many rules (max %d)", MAX_IDENTITY_MAP);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Resolve/record one --chown side. The source-side numeric value is kept in
|
||||
* chown_uid/chown_gid purely as a fallback (the appended map rule resolves the
|
||||
* name on the receiver and wins); a name that does not exist on the sender is
|
||||
* accepted and left to receiver-side resolution, matching rsync. */
|
||||
static int identity_parse_chown_side(Config* config, bool is_group, const char* token) {
|
||||
if (identity_append_chown_rule(config, is_group, token) != 0)
|
||||
return -1;
|
||||
bool numeric = identity_all_digits(token[0] == '@' ? token + 1 : token);
|
||||
int32_t resolved;
|
||||
if (identity_resolve_token(token, is_group, &resolved) == 0) {
|
||||
if (is_group) {
|
||||
config->chown_gid = resolved;
|
||||
config->chown_gid_set = true;
|
||||
} else {
|
||||
config->chown_uid = resolved;
|
||||
config->chown_uid_set = true;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (numeric) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown could not resolve numeric id '%s'", token);
|
||||
return -1;
|
||||
}
|
||||
/* Unknown sender-side name: rsync accepts it and resolves it (or warns) on
|
||||
* the receiver; do the same instead of failing the whole run. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
int identity_parse_chown(Config* config, const char* value) {
|
||||
if (!config || !value || *value == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown requires a value (USER:GROUP, USER, or :GROUP)");
|
||||
@@ -627,32 +688,18 @@ int identity_parse_chown(Config* config, const char* value) {
|
||||
if (*user == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown requires a user or group (got '%s')", value);
|
||||
ret = -1;
|
||||
} else if (identity_resolve_token(user, false, &config->chown_uid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--chown could not resolve user '%s' (use a name that exists "
|
||||
"on the source, '*', or @N)",
|
||||
value);
|
||||
} else if (identity_parse_chown_side(config, false, user) != 0) {
|
||||
ret = -1;
|
||||
} else {
|
||||
config->chown_uid_set = true;
|
||||
}
|
||||
} else {
|
||||
/* --chown=USER:GROUP, --chown=:GROUP, --chown=USER: */
|
||||
if (*user != '\0') {
|
||||
if (identity_resolve_token(user, false, &config->chown_uid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown could not resolve user '%s'", value);
|
||||
ret = -1;
|
||||
goto done;
|
||||
}
|
||||
config->chown_uid_set = true;
|
||||
if (*user != '\0' && identity_parse_chown_side(config, false, user) != 0) {
|
||||
ret = -1;
|
||||
goto done;
|
||||
}
|
||||
if (*group != '\0') {
|
||||
if (identity_resolve_token(group, true, &config->chown_gid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown could not resolve group '%s'", value);
|
||||
ret = -1;
|
||||
goto done;
|
||||
}
|
||||
config->chown_gid_set = true;
|
||||
if (*group != '\0' && identity_parse_chown_side(config, true, group) != 0) {
|
||||
ret = -1;
|
||||
goto done;
|
||||
}
|
||||
if (!*user && !*group) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown must set a user, a group, or both (got '%s')", value);
|
||||
|
||||
@@ -32,10 +32,12 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que
|
||||
context->excluded_paths = NULL;
|
||||
context->size_skipped_paths = NULL;
|
||||
context->synced_dirs = NULL;
|
||||
context->plan_dirs = NULL;
|
||||
context->missing_args = NULL;
|
||||
context->scan_had_io_error = false;
|
||||
context->remove_source_files = NULL;
|
||||
context->early_delete = false;
|
||||
context->delete_plans = NULL;
|
||||
context->scan_stopped_early = false;
|
||||
context->total_files = 0;
|
||||
context->progress_bytes = 0;
|
||||
@@ -187,12 +189,16 @@ void pipeline_context_sender_destroy(PipelineContextSender* context) {
|
||||
if (context->manifest) {
|
||||
array_list_delete(context->manifest);
|
||||
}
|
||||
if (context->delete_plans)
|
||||
delete_plan_sender_destroy(context->delete_plans);
|
||||
if (context->excluded_paths)
|
||||
array_list_delete(context->excluded_paths);
|
||||
if (context->size_skipped_paths)
|
||||
array_list_delete(context->size_skipped_paths);
|
||||
if (context->synced_dirs)
|
||||
array_list_delete(context->synced_dirs);
|
||||
if (context->plan_dirs)
|
||||
array_list_delete(context->plan_dirs);
|
||||
if (context->missing_args)
|
||||
array_list_delete(context->missing_args);
|
||||
if (context->remove_source_files)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "array_list.h"
|
||||
#include "chunk.h"
|
||||
#include "config.h"
|
||||
#include "delete_plan.h"
|
||||
#include "file.h"
|
||||
#include "protocol.h"
|
||||
#include "queue.h"
|
||||
@@ -54,6 +55,11 @@ typedef struct {
|
||||
"delete only in synchronized directories" (notably for --files-from).
|
||||
Populated by the scanner thread or the early pre-scan. */
|
||||
ArrayList* synced_dirs;
|
||||
/* Destination-relative paths of every traversed source directory, for the
|
||||
per-directory delete plan keep set (so an empty source directory survives
|
||||
--delete rather than being removed as an extra). Prebuilt by the path-only
|
||||
pre-scan on the calling thread. */
|
||||
ArrayList* plan_dirs;
|
||||
/* --delete-missing-args: the destination-relative mirrors of the --files-from
|
||||
entries that are missing under the source. Computed by the preflight on
|
||||
the calling thread before the pipeline starts; the sender thread transmits
|
||||
@@ -66,11 +72,16 @@ typedef struct {
|
||||
--ignore-errors kept the run going. */
|
||||
bool scan_had_io_error;
|
||||
ArrayList* remove_source_files;
|
||||
/* True when --delete-before/--delete-during require the keep-set manifest to
|
||||
be transmitted before any file data: context->manifest is then prebuilt by
|
||||
a path-only pre-scan on the calling thread and the pipeline scanner must
|
||||
not append to it. Set once before the worker threads start. */
|
||||
/* True when --delete-before requires the whole-tree keep-set manifest to be
|
||||
transmitted before any file data: context->manifest is then prebuilt by a
|
||||
path-only pre-scan on the calling thread and the pipeline scanner must not
|
||||
append to it. Set once before the worker threads start. */
|
||||
bool early_delete;
|
||||
/* Non-NULL for --delete-during/--delete-delay: the per-directory plan set
|
||||
prebuilt by the path-only pre-scan on the calling thread. The sender
|
||||
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;
|
||||
mtx_t mutex_progress;
|
||||
int total_files;
|
||||
unsigned long long progress_bytes;
|
||||
|
||||
@@ -29,6 +29,13 @@ static unsigned long long io_bwlimit = 0;
|
||||
static mtx_t bw_mutex;
|
||||
static once_flag bw_mutex_once = ONCE_FLAG_INIT;
|
||||
|
||||
/* Process-wide wire byte counters, used by the client to render rsync's
|
||||
* --stats/--progress totals and the --out-format %b/%c tokens. The zero-copy
|
||||
* sendfile path bypasses protocol_send_n_data, so it reports its bytes through
|
||||
* protocol_note_bytes_written. */
|
||||
static atomic_ullong io_bytes_written = 0;
|
||||
static atomic_ullong io_bytes_read = 0;
|
||||
|
||||
static unsigned long long global_bwlimit(void);
|
||||
|
||||
static bool protocol_reserve_memory(ProtocolSession* session, size_t charge) {
|
||||
@@ -242,6 +249,18 @@ SSL* io_get_ssl(void) {
|
||||
return io_ssl;
|
||||
}
|
||||
|
||||
unsigned long long protocol_bytes_written(void) {
|
||||
return atomic_load(&io_bytes_written);
|
||||
}
|
||||
|
||||
unsigned long long protocol_bytes_read(void) {
|
||||
return atomic_load(&io_bytes_read);
|
||||
}
|
||||
|
||||
void protocol_note_bytes_written(unsigned long long bytes) {
|
||||
atomic_fetch_add(&io_bytes_written, bytes);
|
||||
}
|
||||
|
||||
static ProtocolSession* legacy_session(int read_fd, int write_fd) {
|
||||
if (bound_session)
|
||||
return bound_session;
|
||||
@@ -343,6 +362,7 @@ bool protocol_send_n_data(ProtocolSession* session, const void* data, size_t dat
|
||||
wait_events = POLLOUT;
|
||||
}
|
||||
log_debug_message(LOG_DEBUG_IO, " Send n Data: %zu", total_bytes_send);
|
||||
atomic_fetch_add(&io_bytes_written, (unsigned long long)total_bytes_send);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -430,6 +450,7 @@ static bool protocol_receive_n_data_until(ProtocolSession* session, void* data,
|
||||
wait_events = POLLIN;
|
||||
}
|
||||
log_debug_message(LOG_DEBUG_IO, " Received n Data: %zu", total_bytes_received);
|
||||
atomic_fetch_add(&io_bytes_read, (unsigned long long)total_bytes_received);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+30
-1
@@ -181,7 +181,28 @@ enum NET_STATUS {
|
||||
* (new vs modified, and which of size/time/perms/owner/group differ) without
|
||||
* changing the transfer decision itself. Appended after
|
||||
* STATUS_DELETE_LIMIT so no existing status is renumbered. */
|
||||
STATUS_DEST_INFO
|
||||
STATUS_DEST_INFO,
|
||||
/* Per-directory delete plan (protocol 2.24.0). The sender of a
|
||||
* --delete-during/--delete-delay transfer streams one frame per source
|
||||
* directory in directory order instead of a single whole-tree keep-set
|
||||
* manifest. The receiver applies the plan when it arrives
|
||||
* (--delete-during removes that directory's extras immediately) or records
|
||||
* the extras and applies them only after the whole transfer succeeded
|
||||
* (--delete-delay). Payload: an int32 has_config flag (1 on the first plan
|
||||
* of the run, 0 afterwards); when set, the three global config sections
|
||||
* (protected-prefix count+paths, size-skipped count+paths, missing-args
|
||||
* count+paths); then the destination-relative directory path wire string
|
||||
* ("." for the receive root); then the child-directory count + names and the
|
||||
* child-file count + names that must be kept. Appended after
|
||||
* STATUS_DEST_INFO so no existing status is renumbered. */
|
||||
STATUS_DELETE_PLAN,
|
||||
/* End-of-transfer receiver counter report (protocol 2.25.0). When the wire
|
||||
* config carries report_stats=true, the receiver sends this status once,
|
||||
* immediately before its terminal success status, followed by a fixed stats
|
||||
* record (see format_stats_send/receive in format.h) and, when the run is a
|
||||
* --dry-run with --delete, the would-delete path list. Appended after
|
||||
* STATUS_DELETE_PLAN so no existing status is renumbered. */
|
||||
STATUS_STATS
|
||||
};
|
||||
|
||||
void io_set_fds(int read_fd, int write_fd);
|
||||
@@ -189,6 +210,14 @@ void io_set_bwlimit(unsigned long long bytes_per_sec);
|
||||
void io_set_ssl(SSL* ssl);
|
||||
SSL* io_get_ssl(void);
|
||||
|
||||
/* Process-wide wire byte counters. protocol_send_n_data/protocol_receive_n_data
|
||||
* update them; the zero-copy sendfile path reports through
|
||||
* protocol_note_bytes_written. Used by the client to render rsync's
|
||||
* --stats/--progress totals and the --out-format %b/%c tokens. */
|
||||
unsigned long long protocol_bytes_written(void);
|
||||
unsigned long long protocol_bytes_read(void);
|
||||
void protocol_note_bytes_written(unsigned long long bytes);
|
||||
|
||||
void protocol_session_init(ProtocolSession* session, int read_fd, int write_fd);
|
||||
/* Transitional bridge for helpers whose signatures still carry only an fd. */
|
||||
void protocol_session_bind(ProtocolSession* session);
|
||||
|
||||
+144
-2
@@ -60,7 +60,7 @@ bool path_is_within_root(const char* root, const char* path) {
|
||||
* two differ in create-vs-no-create, in what path component they stop at, and
|
||||
* in the extra receiver policies they apply, so they are intentionally kept
|
||||
* separate. Both rely on the shared lexical path_is_within_root check. */
|
||||
static int open_authorized_destination(const char* dest_root) {
|
||||
int utils_open_authorized_destination(const char* dest_root) {
|
||||
int root_fd = utils_get_authorized_root_fd();
|
||||
const char* root_path = utils_get_authorized_root_path();
|
||||
if (root_fd < 0 || !root_path || !dest_root || !path_is_within_root(root_path, dest_root))
|
||||
@@ -725,6 +725,148 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
/* Read-only mirror of delete_extras_fd: records the paths that WOULD be removed
|
||||
without unlinking anything. A child directory is reported after its own
|
||||
reportable children (depth-first), matching the delete pass's ordering. */
|
||||
static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
||||
const PathIndex* dirs, ArrayList* out, size_t* recorded,
|
||||
const DeleteSkipEntry* skips, int skip_count, bool parent_deletable,
|
||||
bool* all_removed) {
|
||||
int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (scanfd < 0)
|
||||
return false;
|
||||
DIR* dir = fdopendir(scanfd);
|
||||
if (!dir) {
|
||||
close(scanfd);
|
||||
return false;
|
||||
}
|
||||
bool operation_ok = true;
|
||||
bool local_survives = false;
|
||||
bool deletable = parent_deletable || is_synced_dir(dirs, rel_path);
|
||||
const struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entry->d_name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
if (path_under_skip_prefix(child_rel, rel_path[0] == '\0', skips, skip_count)) {
|
||||
local_survives = true;
|
||||
free(child_rel);
|
||||
continue;
|
||||
}
|
||||
struct stat st;
|
||||
if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
if (errno != ENOENT)
|
||||
operation_ok = false;
|
||||
free(child_rel);
|
||||
continue;
|
||||
}
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count,
|
||||
deletable, &child_all_removed))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
bool child_synced = dirs && path_index_contains(dirs, child_rel);
|
||||
if (child_synced || keep_is_dir(keep, child_rel)) {
|
||||
local_survives = true;
|
||||
} else if (child_all_removed && deletable) {
|
||||
size_t len = strlen(child_rel);
|
||||
char* copy = malloc(len + 2);
|
||||
if (!copy) {
|
||||
operation_ok = false;
|
||||
} else {
|
||||
memcpy(copy, child_rel, len);
|
||||
copy[len] = '/';
|
||||
copy[len + 1] = '\0';
|
||||
if (!array_list_add(out, copy)) {
|
||||
free(copy);
|
||||
operation_ok = false;
|
||||
} else {
|
||||
(*recorded)++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
local_survives = true;
|
||||
}
|
||||
} else {
|
||||
bool found = keep_is_file(keep, child_rel);
|
||||
if (found || !deletable) {
|
||||
local_survives = true;
|
||||
} else {
|
||||
char* copy = str_dup(child_rel);
|
||||
if (!copy || !array_list_add(out, copy)) {
|
||||
free(copy);
|
||||
operation_ok = false;
|
||||
} else {
|
||||
(*recorded)++;
|
||||
}
|
||||
}
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
closedir(dir);
|
||||
*all_removed = !local_survives;
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||
ArrayList* out, size_t* count_out) {
|
||||
if (count_out)
|
||||
*count_out = 0;
|
||||
if (!manifest || !out)
|
||||
return false;
|
||||
PathIndex keep;
|
||||
if (!build_keep_index(manifest, &keep))
|
||||
return false;
|
||||
PathIndex dirs;
|
||||
bool have_dirs = synced_dirs != NULL;
|
||||
if (have_dirs &&
|
||||
!path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) {
|
||||
path_index_free(&keep);
|
||||
return false;
|
||||
}
|
||||
int rootfd;
|
||||
int root_fd = utils_get_authorized_root_fd();
|
||||
if (root_fd >= 0) {
|
||||
if (utils_get_authorized_root_path())
|
||||
rootfd = utils_open_authorized_destination(dest_root);
|
||||
else if (dest_root == NULL)
|
||||
rootfd = dup(root_fd);
|
||||
else
|
||||
rootfd = -1;
|
||||
} else {
|
||||
rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
if (rootfd < 0) {
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
return false;
|
||||
}
|
||||
bool all_removed = false;
|
||||
size_t recorded = 0;
|
||||
bool ok = list_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, out, &recorded, skips,
|
||||
skip_count, false, &all_removed);
|
||||
if (close(rootfd) != 0)
|
||||
ok = false;
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
if (count_out)
|
||||
*count_out = recorded;
|
||||
return 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,
|
||||
@@ -752,7 +894,7 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m
|
||||
int root_fd = utils_get_authorized_root_fd();
|
||||
if (root_fd >= 0) {
|
||||
if (utils_get_authorized_root_path())
|
||||
rootfd = open_authorized_destination(dest_root);
|
||||
rootfd = utils_open_authorized_destination(dest_root);
|
||||
else if (dest_root == NULL)
|
||||
rootfd = dup(root_fd);
|
||||
else
|
||||
|
||||
@@ -138,7 +138,20 @@ 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);
|
||||
/* 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
|
||||
would-delete reporting. Returns true on a clean walk; the caller owns the
|
||||
strings appended to `out` and receives their count in *count_out. */
|
||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||
ArrayList* out, size_t* count_out);
|
||||
bool delete_extras(const char* dest_root, const ArrayList* manifest);
|
||||
/* Open the existing destination directory at `dest_root`, confined to the
|
||||
authorized root with an O_NOFOLLOW component walk (the same confinement the
|
||||
deletion walker uses for its root). Returns a new fd the caller owns, or -1
|
||||
on error (including a destination that does not exist). */
|
||||
int utils_open_authorized_destination(const char* dest_root);
|
||||
bool utils_set_authorized_root(int fd, const char* canonical_path);
|
||||
/* The fd-only compatibility form is fail-closed for path-based operations;
|
||||
* callers should use utils_set_authorized_root with the canonical identity. */
|
||||
|
||||
+4
-4
@@ -409,10 +409,10 @@ bool fake_super_restore_fd(int fd, FileAttrPolicy policy) {
|
||||
(void)ul_gid;
|
||||
/* Mode is applied only when the per-attribute policy asks for it, through the
|
||||
SAME shared helper the normal metadata path uses (metadata_mode_for_policy):
|
||||
group/other write bits are never granted, so a recorded source mode of 0666
|
||||
restores as 0644 — identical to a non-fake-super --preserve run, never a
|
||||
privilege-granting regression — and the -E rule derives exec bits from the
|
||||
destination's read bits exactly like file_restore_metadata_fd. */
|
||||
under --perms the recorded source mode is copied exactly, including
|
||||
group/other write and setuid/setgid/sticky bits (rsync parity), and the -E
|
||||
rule derives exec bits from the destination's read bits exactly like
|
||||
file_restore_metadata_fd. */
|
||||
if (policy.perms || policy.executability) {
|
||||
struct stat cur;
|
||||
mode_t want = 0;
|
||||
|
||||
+3
-2
@@ -105,8 +105,9 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
||||
* absence of the xattr or a malformed record is a silent no-op that never fails
|
||||
* the transfer. The MODE leg is applied only when policy.perms||policy.
|
||||
* executability and the MTIME leg only when policy.times, so the fake-super
|
||||
* replay cannot bypass the per-attribute split; the mode is sanitized exactly
|
||||
* like the normal metadata path (group/other write bits never granted).
|
||||
* replay cannot bypass the per-attribute split; the mode follows the normal
|
||||
* metadata path exactly (under --perms the source mode is copied verbatim,
|
||||
* special and group/other write bits included).
|
||||
* Returns true when the xattr was present and parsed. */
|
||||
bool fake_super_restore_fd(int fd, FileAttrPolicy policy);
|
||||
|
||||
|
||||
@@ -101,9 +101,15 @@ class CountingProxy:
|
||||
return
|
||||
counter[0] += len(data)
|
||||
|
||||
def run(self, cmd):
|
||||
def run(self, cmd, join_timeout=20):
|
||||
"""Forward one client run (the full command list) to the real server and
|
||||
return the CompletedProcess after the counts have settled."""
|
||||
return the CompletedProcess after the counts have settled.
|
||||
|
||||
``join_timeout`` bounds how long to wait for the forwarding threads. The
|
||||
client->server count is published as soon as the client side reaches EOF
|
||||
(i.e. once the client process has exited), so callers that only need that
|
||||
count can pass a small value instead of waiting for the server to close
|
||||
its idle socket."""
|
||||
|
||||
def serve():
|
||||
try:
|
||||
@@ -119,15 +125,15 @@ class CountingProxy:
|
||||
a.start()
|
||||
b.start()
|
||||
a.join()
|
||||
b.join()
|
||||
self.client_to_server = c2s[0]
|
||||
b.join()
|
||||
self.server_to_client = s2c[0]
|
||||
self._listener.close()
|
||||
|
||||
thread = threading.Thread(target=serve)
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
thread.join(20)
|
||||
thread.join(join_timeout)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Differential tests for --checksum-choice / --compress-choice against rsync 3.4.1.
|
||||
|
||||
These pin the accepted/rejected algorithm matrix and exit codes to real rsync,
|
||||
and verify that every codec FastSync now offers still transfers byte-exactly.
|
||||
The rsync-based tests skip cleanly when rsync is not installed.
|
||||
|
||||
The FastSync server confines transfers to its authorized root (the project
|
||||
directory when the shared test server is launched), so every scratch tree lives
|
||||
under ``TEST_DATA_DIR`` rather than pytest's ``tmp_path``.
|
||||
"""
|
||||
import os
|
||||
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")
|
||||
|
||||
CHECKSUM_NAMES = ["xxh128", "xxh3", "xxh64", "md5", "md4", "sha1"]
|
||||
COMPRESS_NAMES = ["zstd", "lz4", "zlib", "zlibx"]
|
||||
|
||||
CODEC_ROOT = os.path.join(TEST_DATA_DIR, "codec_differential")
|
||||
|
||||
|
||||
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):
|
||||
"""A confined, uniquely named scratch directory under the project tree."""
|
||||
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(os.path.join(root, "sub"), exist_ok=True)
|
||||
# Highly compressible payload so each codec is actually exercised.
|
||||
with open(os.path.join(root, "big.bin"), "wb") as fh:
|
||||
fh.write(b"FastSync codec payload " * 4096)
|
||||
with open(os.path.join(root, "sub", "text.txt"), "wb") as fh:
|
||||
fh.write(b"hello codec world\n" * 128)
|
||||
with open(os.path.join(root, "empty"), "wb"):
|
||||
pass
|
||||
return root
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestCodecChoiceMatrix:
|
||||
"""The CLI accept/reject set and exit codes must match rsync 3.4.1."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("name", CHECKSUM_NAMES)
|
||||
def test_checksum_names_accepted_by_both(self, name, shared_server):
|
||||
src = _make_corpus(_scratch(f"cc_src_{name}"))
|
||||
rdst = _scratch(f"cc_rsync_{name}")
|
||||
rsync_result = _rsync(["-a", f"--cc={name}", src + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
fdst = _scratch(f"cc_fs_{name}")
|
||||
result, _ = run_client(src, fdst, flags=[f"--cc={name}"], port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("name", COMPRESS_NAMES)
|
||||
def test_compress_names_accepted_by_both(self, name, shared_server):
|
||||
src = _make_corpus(_scratch(f"zc_src_{name}"))
|
||||
rdst = _scratch(f"zc_rsync_{name}")
|
||||
rsync_result = _rsync(["-az", f"--zc={name}", src + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
fdst = _scratch(f"zc_fs_{name}")
|
||||
result, _ = run_client(src, fdst, flags=["-z", f"--zc={name}"], port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("choice", ["md4,sha1", "sha1,md4", "auto,md5", "none,md5"])
|
||||
def test_checksum_two_name_accepted_by_both(self, choice, shared_server):
|
||||
tag = choice.replace(",", "_")
|
||||
src = _make_corpus(_scratch(f"two_src_{tag}"))
|
||||
rdst = _scratch(f"two_rsync_{tag}")
|
||||
rsync_result = _rsync(["-a", "--checksum", f"--cc={choice}", src + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
fdst = _scratch(f"two_fs_{tag}")
|
||||
result, _ = run_client(src, fdst, flags=["--checksum", f"--cc={choice}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("name", ["sha256", "crc32", "md5,", "md4,md5,sha1"])
|
||||
def test_unknown_checksum_rejected_exit_4_both(self, name, shared_server):
|
||||
src = _make_corpus(_scratch(f"badcc_src_{name.replace(',', '_').replace(':', '_')}"))
|
||||
rdst = _scratch(f"badcc_rsync_{name.replace(',', '_').replace(':', '_')}")
|
||||
rsync_result = _rsync(["-a", f"--cc={name}", src + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 4, rsync_result.stderr
|
||||
|
||||
fdst = _scratch(f"badcc_fs_{name.replace(',', '_').replace(':', '_')}")
|
||||
result, _ = run_client(src, fdst, flags=[f"--cc={name}"], port=shared_server.port)
|
||||
assert result.returncode == 4, (result.stderr or result.stdout)[:200]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("choice", ["none", "md5,none"])
|
||||
def test_checksum_none_with_checksum_rejected_exit_4_both(self, choice, shared_server):
|
||||
tag = choice.replace(",", "_")
|
||||
src = _make_corpus(_scratch(f"nonecc_src_{tag}"))
|
||||
rdst = _scratch(f"nonecc_rsync_{tag}")
|
||||
rsync_result = _rsync(["-a", "--checksum", f"--cc={choice}", src + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 4, rsync_result.stderr
|
||||
|
||||
fdst = _scratch(f"nonecc_fs_{tag}")
|
||||
result, _ = run_client(src, fdst, flags=["--checksum", f"--cc={choice}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 4, (result.stderr or result.stdout)[:200]
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("name", ["bogus", "zstd,lz4"])
|
||||
def test_unknown_compress_rejected_exit_4_both(self, name, shared_server):
|
||||
tag = name.replace(",", "_")
|
||||
src = _make_corpus(_scratch(f"badzc_src_{tag}"))
|
||||
rdst = _scratch(f"badzc_rsync_{tag}")
|
||||
rsync_result = _rsync(["-az", f"--zc={name}", src + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 4, rsync_result.stderr
|
||||
|
||||
fdst = _scratch(f"badzc_fs_{tag}")
|
||||
result, _ = run_client(src, fdst, flags=["-z", f"--zc={name}"], port=shared_server.port)
|
||||
assert result.returncode == 4, (result.stderr or result.stdout)[:200]
|
||||
|
||||
|
||||
class TestCodecTransferDifferential:
|
||||
"""Each codec lands the same bytes rsync lands."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("name", COMPRESS_NAMES + ["none"])
|
||||
def test_compress_codec_matches_rsync_bytes(self, name, shared_server):
|
||||
src = _make_corpus(_scratch(f"byteszc_src_{name}"))
|
||||
rsync_dst = _scratch(f"byteszc_rsync_{name}")
|
||||
rsync_result = _rsync(["-a", "-z", f"--zc={name}", src + "/", rsync_dst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
fs_dst = _scratch(f"byteszc_fs_{name}")
|
||||
result, _ = run_client(src, fs_dst, flags=["-a", "-z", f"--zc={name}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
received = get_dest_received_dir(fs_dst, src)
|
||||
assert _tree_bytes(received) == _tree_bytes(rsync_dst)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("name", CHECKSUM_NAMES)
|
||||
def test_checksum_codec_matches_rsync_bytes(self, name, shared_server):
|
||||
src = _make_corpus(_scratch(f"bytescc_src_{name}"))
|
||||
rsync_dst = _scratch(f"bytescc_rsync_{name}")
|
||||
rsync_result = _rsync(["-a", "--checksum", f"--cc={name}", src + "/", rsync_dst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
fs_dst = _scratch(f"bytescc_fs_{name}")
|
||||
result, _ = run_client(src, fs_dst, flags=["-a", "--checksum", f"--cc={name}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
received = get_dest_received_dir(fs_dst, src)
|
||||
assert _tree_bytes(received) == _tree_bytes(rsync_dst)
|
||||
|
||||
|
||||
class TestCodecNegotiationFallback:
|
||||
"""FastSync's auto negotiation and deterministic fallback order."""
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_default_checksum_and_compression_agree(self, shared_server):
|
||||
"""A default transfer (auto on both peers) succeeds; the negotiated
|
||||
default is xxh128 + zstd."""
|
||||
src = _make_corpus(_scratch("auto_src"))
|
||||
fdst = _scratch("auto_fs")
|
||||
result, _ = run_client(src, fdst, flags=["-a", "-z"], port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
received = get_dest_received_dir(fdst, src)
|
||||
assert _tree_bytes(received) == _tree_bytes(src)
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_explicit_choice_overrides_auto(self, shared_server):
|
||||
"""An explicit --zc/--cc wins over the negotiated default on both ends,
|
||||
so the receiver decodes with the sender's codec."""
|
||||
src = _make_corpus(_scratch("explicit_src"))
|
||||
fdst = _scratch("explicit_fs")
|
||||
result, _ = run_client(src, fdst, flags=["-a", "-z", "--zc=lz4", "--cc=sha1"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
received = get_dest_received_dir(fdst, src)
|
||||
assert _tree_bytes(received) == _tree_bytes(src)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Differential + regression coverage for rsync's delete timing.
|
||||
|
||||
``--delete-during``/``--delete-delay`` stream a per-directory delete plan instead
|
||||
of one whole-tree manifest, so the timing is observable:
|
||||
|
||||
* ``--delete-during`` removes a directory's extras as it processes that
|
||||
directory (so an interrupted transfer has already removed the extras of the
|
||||
directories it reached);
|
||||
* ``--delete-delay`` snapshots those extras while scanning and commits the
|
||||
removals only after a fully-successful transfer (so an extra created in the
|
||||
destination after its directory's plan survives, and a failed transfer
|
||||
removes nothing);
|
||||
* ``--delete-after`` re-scans the destination at the end (so that same
|
||||
late-created extra is removed).
|
||||
|
||||
The final-state tests compare against real ``rsync 3.4.1`` where a deterministic
|
||||
comparison exists; the timing tests use a byte-slicing proxy to force a
|
||||
mid-transfer failure or to create a destination entry while the transfer is in
|
||||
flight.
|
||||
"""
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import ( # noqa: E402
|
||||
BUILD_DIR,
|
||||
TEST_DATA_DIR,
|
||||
ServerManager,
|
||||
clean_dir,
|
||||
get_dest_received_dir,
|
||||
run_client,
|
||||
)
|
||||
|
||||
# Every test here is deterministic (the proxy throttles until the delete-plan
|
||||
# frames are processed), so the PR gate runs the whole module.
|
||||
pytestmark = pytest.mark.ci
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
||||
|
||||
BIG_BYTES = 8 * 1024 * 1024
|
||||
# Forward/cut this far into the stream: past the (small) delete-plan frames and
|
||||
# well into the big payload, so the receiver has already processed the plan.
|
||||
MID_TRANSFER_BYTES = 256 * 1024
|
||||
# Throttle the proxy so the receiver keeps up with the (fast) client and the
|
||||
# plan frames are provably processed before the hook/cut offset is reached.
|
||||
PROXY_THROTTLE = 0.001
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
def _seed_pair(tag, big=False):
|
||||
"""Create a source tree and a destination mirror seeded with extras.
|
||||
|
||||
The tree is a single directory ``d`` containing the transferred files plus,
|
||||
in the destination, an extra ``d/old_extra``.
|
||||
"""
|
||||
source = os.path.join(TEST_DATA_DIR, f"dtp_{tag}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"dtp_{tag}_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
_write(os.path.join(source, "d", "keep.txt"), b"kept payload\n")
|
||||
if big:
|
||||
_write(os.path.join(source, "d", "big.bin"), b"B" * BIG_BYTES)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
os.makedirs(os.path.join(received, "d"), exist_ok=True)
|
||||
_write(os.path.join(received, "d", "old_extra"), b"stale extra\n")
|
||||
return source, dest, received
|
||||
|
||||
|
||||
def _tree(root):
|
||||
"""Sorted relative paths of every entry below root (files and dirs)."""
|
||||
out = []
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
for name in dirs:
|
||||
out.append(os.path.relpath(os.path.join(dirpath, name), root))
|
||||
for name in files:
|
||||
out.append(os.path.relpath(os.path.join(dirpath, name), root))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _rsync(args):
|
||||
env = dict(os.environ, LC_ALL="C")
|
||||
return subprocess.run([RSYNC] + args, capture_output=True, text=True, env=env, timeout=120)
|
||||
|
||||
|
||||
class _SlicingProxy:
|
||||
"""Forward the client stream to a server, optionally cutting it or invoking a
|
||||
hook after a byte threshold. ``forward_limit`` mode resets both ends after
|
||||
that many client bytes (a mid-transfer failure). ``hook`` mode calls the
|
||||
hook once and keeps forwarding to completion.
|
||||
|
||||
With ``wait_for_reply`` the hook is a real barrier, not a timing guess: it
|
||||
fires only after the server has sent *any* reply, which the receiver does
|
||||
only after it has consumed the frames that precede the payload (the
|
||||
per-directory delete plan for ``--delete-delay``). The caller pairs it with
|
||||
``--incremental`` so a per-file handshake reply is guaranteed mid-transfer.
|
||||
"""
|
||||
|
||||
def __init__(self, target_port, forward_limit=None, hook=None, hook_after=0,
|
||||
throttle=0.0, wait_for_reply=False):
|
||||
self.target = ("127.0.0.1", target_port)
|
||||
self.forward_limit = forward_limit
|
||||
self.hook = hook
|
||||
self.hook_after = hook_after
|
||||
self.throttle = throttle
|
||||
self.wait_for_reply = wait_for_reply
|
||||
self.server_replied = threading.Event()
|
||||
self.hook_called = threading.Event()
|
||||
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.listener.bind(("127.0.0.1", 0))
|
||||
self.listener.listen(1)
|
||||
self.listener.settimeout(20)
|
||||
self.port = self.listener.getsockname()[1]
|
||||
self._thread = threading.Thread(target=self._serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _serve(self):
|
||||
try:
|
||||
client, _ = self.listener.accept()
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
backend = socket.create_connection(self.target, timeout=10)
|
||||
except OSError:
|
||||
client.close()
|
||||
return
|
||||
client.settimeout(20)
|
||||
backend.settimeout(20)
|
||||
forwarded = 0
|
||||
socks = [client, backend]
|
||||
try:
|
||||
while socks:
|
||||
ready, _, _ = select.select(socks, [], [], 20)
|
||||
if not ready:
|
||||
break
|
||||
for sock in ready:
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
socks.remove(sock)
|
||||
peer = backend if sock is client else client
|
||||
try:
|
||||
peer.shutdown(socket.SHUT_WR)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if sock is client:
|
||||
if self.forward_limit is not None:
|
||||
room = self.forward_limit - forwarded
|
||||
if room <= 0:
|
||||
socks = []
|
||||
break
|
||||
data = data[:room]
|
||||
backend.sendall(data)
|
||||
forwarded += len(data)
|
||||
self._maybe_hook(forwarded)
|
||||
if self.forward_limit is not None and forwarded >= self.forward_limit:
|
||||
socks = []
|
||||
break
|
||||
if self.throttle > 0:
|
||||
time.sleep(self.throttle)
|
||||
else:
|
||||
client.sendall(data)
|
||||
# Any server reply proves the receiver consumed the
|
||||
# frames that precede it, so the hook barrier is met.
|
||||
self.server_replied.set()
|
||||
self._maybe_hook(forwarded)
|
||||
except OSError:
|
||||
pass
|
||||
for sock in (client, backend):
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.listener.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _maybe_hook(self, forwarded):
|
||||
"""Fire the one-shot hook once its barrier is satisfied: enough client
|
||||
bytes have been forwarded and, when ``wait_for_reply`` is set, the
|
||||
server has sent a reply proving it processed the preceding frames."""
|
||||
if self.hook is None or self.hook_called.is_set():
|
||||
return
|
||||
if forwarded < self.hook_after:
|
||||
return
|
||||
if self.wait_for_reply and not self.server_replied.is_set():
|
||||
return
|
||||
self.hook()
|
||||
self.hook_called.set()
|
||||
|
||||
def finish(self):
|
||||
self._thread.join(30)
|
||||
try:
|
||||
self.listener.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestDeleteTimingFinalStateParity:
|
||||
"""On a successful transfer the per-directory timings match rsync's result."""
|
||||
|
||||
def _run_fastsync(self, tag, timing):
|
||||
source, dest, received = _seed_pair(tag)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=[timing], port=server.port)
|
||||
return result, received
|
||||
|
||||
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
||||
@requires_rsync
|
||||
def test_success_final_state_matches_rsync(self, timing):
|
||||
# Build the rsync fixture from the same seed so both sides start equal.
|
||||
source, dest, received = _seed_pair("parity_rsync")
|
||||
source2 = source
|
||||
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")
|
||||
|
||||
rsync_result = _rsync(["-a", timing, source2 + "/", rsync_dst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_tree = _tree(rsync_dst)
|
||||
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=[timing], port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
fastsync_tree = _tree(received)
|
||||
assert fastsync_tree == rsync_tree, (
|
||||
f"{timing}: fastsync tree {fastsync_tree} != rsync tree {rsync_tree}"
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteTimingTypeConflictParity:
|
||||
"""A destination entry whose type differs from the source is replaced, in
|
||||
both per-directory timings and in both directions, exactly like rsync."""
|
||||
|
||||
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
||||
@requires_rsync
|
||||
def test_type_conflicts_match_rsync(self, timing):
|
||||
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")
|
||||
|
||||
def seed_dest(root):
|
||||
clean_dir(root)
|
||||
_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, "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, "dtc_dst")
|
||||
clean_dir(dest)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
seed_dest(received)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=[timing], port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert _tree(received) == rsync_tree, (
|
||||
f"{timing}: fastsync tree {_tree(received)} != rsync tree {rsync_tree}"
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteTimingFailure:
|
||||
"""A mid-transfer failure distinguishes during from delay."""
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_during_removes_delay_preserves_on_failure(self, mt):
|
||||
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:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
for timing, expect_removed in (("--delete-during", True),
|
||||
("--delete-delay", False)):
|
||||
# Re-seed the extra before each run.
|
||||
_write(extra, b"stale extra\n")
|
||||
proxy = _SlicingProxy(server.port, forward_limit=MID_TRANSFER_BYTES, throttle=PROXY_THROTTLE)
|
||||
flags = [timing] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
||||
proxy.finish()
|
||||
assert result.returncode != 0, f"{timing}: truncated transfer succeeded"
|
||||
present = os.path.exists(extra)
|
||||
assert present != expect_removed, (
|
||||
f"{timing} (mt={mt}): extra present={present}, expected "
|
||||
f"removed={expect_removed}"
|
||||
)
|
||||
|
||||
|
||||
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("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:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
for timing, new_survives in (("--delete-delay", True),
|
||||
("--delete-after", False)):
|
||||
_write(old_extra, b"stale extra\n")
|
||||
if os.path.exists(new_extra):
|
||||
os.unlink(new_extra)
|
||||
|
||||
def hook():
|
||||
# Runs on the proxy thread while the big file is in flight,
|
||||
# after the directory's plan (delay) has been processed.
|
||||
_write(new_extra, b"created mid-transfer\n")
|
||||
|
||||
# --incremental gives the receiver a mid-transfer handshake
|
||||
# reply; the proxy waits for it (wait_for_reply) so the hook is
|
||||
# causally after the plan frame, never a timing guess.
|
||||
# --ignore-times forces the big file to transfer on the second
|
||||
# timing too (the first run already installed it), keeping the
|
||||
# mid-transfer reply present in both iterations.
|
||||
proxy = _SlicingProxy(server.port, hook=hook,
|
||||
hook_after=MID_TRANSFER_BYTES,
|
||||
throttle=PROXY_THROTTLE, wait_for_reply=True)
|
||||
flags = [timing, "--incremental", "--ignore-times"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
||||
proxy.finish()
|
||||
assert result.returncode == 0, (
|
||||
f"{timing}: {(result.stderr or result.stdout)[:300]}"
|
||||
)
|
||||
assert proxy.hook_called.is_set(), f"{timing}: hook never fired"
|
||||
assert not os.path.exists(old_extra), f"{timing}: old extra survived"
|
||||
assert os.path.exists(new_extra) == new_survives, (
|
||||
f"{timing} (mt={mt}): new_extra present="
|
||||
f"{os.path.exists(new_extra)}, expected survives={new_survives}"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
|
||||
verify_transfer,
|
||||
)
|
||||
|
||||
PROTOCOL_VERSION = b"2.23.0"
|
||||
PROTOCOL_VERSION = b"2.26.0"
|
||||
STATUS_MANIFEST = 5
|
||||
STATUS_OK = 0
|
||||
|
||||
|
||||
@@ -1491,20 +1491,101 @@ class TestChecksumChoice:
|
||||
assert fh.read() == b"same content\n"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_checksum_choice_md4_single_name_rejected(self, shared_server):
|
||||
for bad in ("md4", "sha1", "none", "xxh64,md5"):
|
||||
@pytest.mark.parametrize("algo", ["xxh128", "xxh3", "xxh64", "md5", "md4", "sha1"])
|
||||
def test_checksum_choice_all_algorithms_transfer(self, shared_server, algo):
|
||||
"""Every rsync 3.4.1 checksum algorithm is accepted and transfers
|
||||
byte-exactly. 'none' is covered separately (it needs no digest)."""
|
||||
clean_dir(DEST_DIR)
|
||||
flags = ["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"]
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"checksum-choice={algo} failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_checksum_choice_two_name_form(self, shared_server):
|
||||
"""The rsync 'TRANSFER,PRE-TRANSFER' form is accepted; FastSync uses the
|
||||
second (pre-transfer) algorithm for its whole-file digest."""
|
||||
clean_dir(DEST_DIR)
|
||||
flags = ["--preserve", "--incremental", "--checksum", "--cc=md4,sha1"]
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"two-name --cc=md4,sha1 failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_checksum_choice_none_accepted_without_checksum(self, shared_server):
|
||||
clean_dir(DEST_DIR)
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR,
|
||||
flags=["--preserve", "--incremental", "--cc=none"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--cc=none failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_checksum_choice_none_rejected_with_checksum(self, shared_server):
|
||||
"""rsync rejects 'none' as the pre-transfer checksum with --checksum and
|
||||
exits 4; mirror both the rejection and the exit code."""
|
||||
for choice in ("none", "md5,none"):
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR,
|
||||
flags=["--checksum", f"--cc={choice}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 4, \
|
||||
f"--cc={choice} --checksum must exit 4, got {result.returncode}: " \
|
||||
f"{(result.stderr or result.stdout)[:200]}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_checksum_choice_unknown_rejected_exit_4(self, shared_server):
|
||||
for bad in ("sha256", "bogus", "md5,", "md4,md5,sha1"):
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR,
|
||||
flags=[f"--checksum-choice={bad}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, f"{bad} must be rejected"
|
||||
assert result.returncode == 4, \
|
||||
f"--checksum-choice={bad} must exit 4, got {result.returncode}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_compress_choice_unsupported_rejected(self, shared_server):
|
||||
for bad in ("lz4", "zlib", "zlibx"):
|
||||
@pytest.mark.parametrize("algo", ["zstd", "lz4", "zlib", "zlibx"])
|
||||
def test_compress_choice_all_algorithms_transfer(self, shared_server, algo):
|
||||
"""Every rsync 3.4.1 compression codec is accepted and transfers
|
||||
byte-exactly through its own codec."""
|
||||
clean_dir(DEST_DIR)
|
||||
flags = ["-z", f"--compress-choice={algo}"]
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--compress-choice={algo} failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_compress_choice_unknown_rejected_exit_4(self, shared_server):
|
||||
for bad in ("bogus", "zstd,lz4", ""):
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR,
|
||||
flags=[f"--compress-choice={bad}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, f"{bad} must be rejected"
|
||||
assert result.returncode == 4, \
|
||||
f"--compress-choice={bad} must exit 4, got {result.returncode}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_compress_choice_none_disables_compression(self, shared_server):
|
||||
clean_dir(DEST_DIR)
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR,
|
||||
flags=["-z", "--compress-choice=none"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--compress-choice=none failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_compress_choice_auto_transfers(self, shared_server):
|
||||
@@ -1855,8 +1936,8 @@ class TestDelete:
|
||||
)
|
||||
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}"
|
||||
output = result.stdout + result.stderr
|
||||
assert "Sent " in output and "MB" in output, "--progress produced no stable byte marker"
|
||||
assert "Done." in output, "--progress did not report completion"
|
||||
assert "sending incremental file list" in output, "--progress produced no rsync header"
|
||||
assert "(xfr#" in output, "--progress produced no per-file xfr block"
|
||||
|
||||
def test_human_readable_stats(self, shared_server):
|
||||
clean_dir(DEST_DIR)
|
||||
@@ -1891,8 +1972,8 @@ class TestDelete:
|
||||
)
|
||||
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}"
|
||||
output = result.stdout + result.stderr
|
||||
assert "Sent " in output
|
||||
assert "Done." in output
|
||||
assert "sending incremental file list" in output
|
||||
assert "(xfr#" in output
|
||||
|
||||
|
||||
class TestInfo:
|
||||
@@ -3157,15 +3238,21 @@ class TestMissingArgs:
|
||||
assert not os.path.exists(os.path.join(received, "gone1.txt"))
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_empty_list_stays_a_hard_error(self, shared_server, mt):
|
||||
def test_empty_list_succeeds_transferring_nothing(self, shared_server, mt):
|
||||
"""rsync 3.4.1 treats an empty --files-from list as "nothing to
|
||||
transfer" and exits 0 (verified with the real binary), so fastsync must
|
||||
too rather than reporting a hard error."""
|
||||
source = self._make_source("mg_empty_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "mg_empty_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"")
|
||||
flags = ["--files-from", lst, "--ignore-missing-args"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode != 0, "an empty --files-from list must stay a hard error"
|
||||
assert "contains no entries" in (result.stderr or result.stdout)
|
||||
assert result.returncode == 0, \
|
||||
f"an empty --files-from list must succeed like rsync: {result.stderr[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert not os.path.exists(os.path.join(received, "a.txt")), \
|
||||
"an empty --files-from list must transfer nothing"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_delete_missing_removes_mirror_not_unrelated(self, mt):
|
||||
@@ -3749,15 +3836,15 @@ class TestDeleteTiming:
|
||||
assert _read_file(os.path.join(received, "sub", "deep.txt")) == b"deeply nested file\n", \
|
||||
f"{flag}: nested file was not written after the early deletion"
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--delete", "--delete-after", "--delete-delay"])
|
||||
@pytest.mark.parametrize("flag", ["--delete", "--delete-after"])
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_late_flags_commit_only_after_success(self, flag, mt):
|
||||
"""Plain --delete/--delete-after/--delete-delay defer deletion until the
|
||||
whole transfer succeeds: a mid-transfer write failure must leave every
|
||||
extra in place (commit-style safety). The -m receiver must also keep
|
||||
the extras: the deferred keep-set is committed by the server only after
|
||||
the disk-writer thread has finished, and a failing writer means the
|
||||
manifest is freed, never applied."""
|
||||
"""Plain --delete/--delete-after defer deletion until the whole transfer
|
||||
succeeds: a mid-transfer write failure must leave every extra in place
|
||||
(commit-style safety). The -m receiver must also keep the extras: the
|
||||
deferred keep-set is committed by the server only after the disk-writer
|
||||
thread has finished, and a failing writer means the manifest is freed,
|
||||
never applied."""
|
||||
source = self._seed("late")
|
||||
dest = os.path.join(TEST_DATA_DIR, "deltiming_late_dst")
|
||||
clean_dir(dest)
|
||||
@@ -3783,10 +3870,42 @@ class TestDeleteTiming:
|
||||
assert os.path.isfile(blocker), \
|
||||
f"{flag} (mt={mt}) deleted the blocker although the transfer failed"
|
||||
|
||||
def test_early_flag_respected_when_server_refuses_delete(self, shared_server):
|
||||
"""With an --allow-delete-less server the client's early timing still
|
||||
completes (no deadlock on the pre-delete ack) and simply never deletes,
|
||||
exactly like the plain server policy."""
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_delete_delay_clears_type_conflict_like_rsync(self, mt):
|
||||
"""rsync clears a destination file that blocks a source directory even
|
||||
when the deletion itself is deferred (--delete-delay); the type conflict
|
||||
is resolved immediately so the nested write succeeds. The transfer must
|
||||
therefore succeed and the unrelated extra must still be removed."""
|
||||
source = self._seed("delayconflict")
|
||||
dest = os.path.join(TEST_DATA_DIR, "deltiming_delayconflict_dst")
|
||||
clean_dir(dest)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, port=server.port)
|
||||
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
extra = os.path.join(received, "extra.txt")
|
||||
with open(extra, "wb") as fh:
|
||||
fh.write(b"extra file")
|
||||
blocker = os.path.join(received, "sub")
|
||||
shutil.rmtree(blocker)
|
||||
with open(blocker, "wb") as fh:
|
||||
fh.write(b"blocks the nested destination directory")
|
||||
|
||||
flags = ["--delete-delay"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--delete-delay (mt={mt}) did not clear the type conflict: " \
|
||||
f"{(result.stderr or result.stdout)[:300]}"
|
||||
assert os.path.isdir(blocker), "blocker file was not replaced by the source directory"
|
||||
assert _read_file(os.path.join(received, "sub", "deep.txt")) == b"deeply nested file\n"
|
||||
assert not os.path.exists(extra), "--delete-delay did not remove the extra"
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--delete-before", "--delete-during", "--delete-delay"])
|
||||
def test_early_flag_respected_when_server_refuses_delete(self, flag, shared_server):
|
||||
"""With an --allow-delete-less server the client's timing still completes
|
||||
(no deadlock on the pre-delete ack, no per-directory deletion) and simply
|
||||
never deletes, exactly like the plain server policy."""
|
||||
source = self._seed("refused")
|
||||
dest = os.path.join(TEST_DATA_DIR, "deltiming_refused_dst")
|
||||
clean_dir(dest)
|
||||
@@ -3796,9 +3915,9 @@ class TestDeleteTiming:
|
||||
extra = os.path.join(received, "extra.txt")
|
||||
with open(extra, "wb") as fh:
|
||||
fh.write(b"extra file")
|
||||
result, _ = run_client(source, dest, flags=["--delete-before"], port=shared_server.port)
|
||||
result, _ = run_client(source, dest, flags=[flag], port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--delete-before against a refuse-delete server failed: {result.stderr[:300]}"
|
||||
f"{flag} against a refuse-delete server failed: {result.stderr[:300]}"
|
||||
assert os.path.exists(extra), "unauthorized delete removed an extra file"
|
||||
|
||||
|
||||
@@ -3893,6 +4012,31 @@ class TestDeleteScope:
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
||||
@pytest.mark.ci
|
||||
def test_files_from_per_dir_timing_confined_to_listed_dirs(self, mt, timing):
|
||||
"""The per-directory timings honor the same --files-from scope: an extra
|
||||
inside a listed directory is removed, while unlisted siblings and the
|
||||
receive-root extra survive."""
|
||||
source, dest, received, server = self._seed(f"pd_{timing.strip('-')}_{mt}")
|
||||
try:
|
||||
listed = _write_rel_list(b"listed.txt\nsub/\n")
|
||||
flags = ["--files-from", listed, timing] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, f"{timing} delete failed: {result.stderr[:300]}"
|
||||
assert not os.path.exists(os.path.join(received, "sub", "extra.txt")), \
|
||||
f"{timing} did not delete the in-scope extra"
|
||||
assert os.path.isfile(os.path.join(received, "sub", "x.txt"))
|
||||
assert os.path.exists(os.path.join(received, "unlisted.txt")), \
|
||||
f"{timing} deleted an unlisted path (data loss)"
|
||||
assert os.path.exists(os.path.join(received, "other", "c.txt")), \
|
||||
f"{timing} deleted an unlisted sibling directory (data loss)"
|
||||
assert os.path.exists(os.path.join(received, "rootextra.txt")), \
|
||||
f"{timing} deleted the receive-root extra (data loss)"
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
class TestDeleteExtraneousSymlinks:
|
||||
"""#290 (3): --delete unlinks extraneous destination symlinks (never follows
|
||||
@@ -3980,7 +4124,8 @@ class TestDeletePolicy:
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@pytest.mark.parametrize("timing",
|
||||
["--delete", "--delete-before", "--delete-after", "--delete-delay"])
|
||||
["--delete", "--delete-before", "--delete-after", "--delete-delay",
|
||||
"--delete-during"])
|
||||
def test_delete_protects_excluded_by_default_and_delete_excluded_removes(self, mt, timing):
|
||||
"""rsync parity: with a --delete timing the destination mirror path whose
|
||||
source was excluded survives (protected by default); --delete-excluded
|
||||
@@ -4064,7 +4209,7 @@ class TestDeletePolicy:
|
||||
"--delete-excluded did not remove the excluded dir subtree"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@pytest.mark.parametrize("timing", ["--delete", "--delete-before"])
|
||||
@pytest.mark.parametrize("timing", ["--delete", "--delete-before", "--delete-during"])
|
||||
def test_max_delete_exceeded_deletes_up_to_cap_and_exits_25(self, mt, timing):
|
||||
"""rsync parity: --max-delete=N deletes up to N extras, skips the rest and
|
||||
still succeeds as a transfer, exiting 25 with a diagnostic."""
|
||||
@@ -4957,15 +5102,20 @@ class TestFuzzy:
|
||||
"no-candidate fuzzy run should have sent the whole file"
|
||||
|
||||
def test_dissimilar_sibling_is_not_used(self, shared_server):
|
||||
# The destination holds a large sibling whose basename is too different
|
||||
# from the incoming name; the name gate must reject it and fall back to
|
||||
# a whole-file transfer.
|
||||
# A sibling whose basename is too different from the incoming name is
|
||||
# rejected by rsync's fuzzy distance window (the length gap exceeds
|
||||
# 25), so the run falls back to a whole-file transfer. A distinct
|
||||
# mtime keeps rsync's exact size+mtime first pass from accepting it.
|
||||
source, dest = self._prepare("dissim")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {"totally-unrelated-notes.bin": old_bytes},
|
||||
long_name = "totally-unrelated-notes-with-a-very-long-name.bin"
|
||||
self._seed_dest(source, dest, {long_name: old_bytes},
|
||||
shared_server.port)
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
received_dir = get_dest_received_dir(dest, source)
|
||||
os.utime(os.path.join(received_dir, long_name), (self.TS, self.TS))
|
||||
os.utime(os.path.join(source, self.NEW_NAME), (self.TS + 100000, self.TS + 100000))
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy dissimilar-sibling run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
@@ -4974,6 +5124,28 @@ class TestFuzzy:
|
||||
assert proxy.client_to_server > len(new_bytes) // 2, \
|
||||
"a dissimilar-named sibling must not be used as a fuzzy basis"
|
||||
|
||||
def test_exact_size_mtime_sibling_is_used(self, shared_server):
|
||||
# rsync's fuzzy first pass accepts a sibling with an exact size+mtime
|
||||
# match regardless of how unrelated its name is (its content is almost
|
||||
# certainly the same).
|
||||
source, dest = self._prepare("exact")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {"unrelated-blob.bin": old_bytes},
|
||||
shared_server.port)
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
received_dir = get_dest_received_dir(dest, source)
|
||||
ts = 1600000000
|
||||
os.utime(os.path.join(received_dir, "unrelated-blob.bin"), (ts, ts))
|
||||
os.utime(os.path.join(source, self.NEW_NAME), (ts, ts))
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy exact size+mtime run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert proxy.client_to_server < len(new_bytes) // 4, \
|
||||
"an exact size+mtime sibling should be used as a fuzzy basis"
|
||||
|
||||
def test_fuzzy_helps_when_dest_holds_an_unsuitable_file(self, shared_server):
|
||||
# The destination DOES hold the exact new name, but it is a tiny stale
|
||||
# file (below the delta engine's minimum, ratio far outside its window),
|
||||
|
||||
@@ -5,6 +5,7 @@ differential tests run the SAME transfer with real ``rsync 3.4.1`` and with
|
||||
fastsync and compare stdout, so they are skipped when rsync is unavailable.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -12,7 +13,7 @@ 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
|
||||
from common import TEST_DATA_DIR, run_client, clean_dir, get_dest_received_dir, ServerManager
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
||||
@@ -280,3 +281,304 @@ class TestListOnlyParity:
|
||||
assert fast_lines == rsync_lines, (
|
||||
f"rsync={rsync_lines}\nfastsync={fast_lines}"
|
||||
)
|
||||
|
||||
|
||||
def _make_one_file(root, name="f.bin", size=100):
|
||||
clean_dir(root)
|
||||
with open(os.path.join(root, name), "wb") as fh:
|
||||
fh.write(bytes((i * 7 + 3) & 0xFF for i in range(size)))
|
||||
|
||||
|
||||
class TestWireStatsParity:
|
||||
"""Wire-counter output parity: --out-format %b/%c/%C, --progress and
|
||||
--stats versus real rsync 3.4.1."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_out_format_checksum_matches_rsync(self, shared_server):
|
||||
"""%C (whole-file xxh128, seed 0) is protocol-independent, so the full
|
||||
`%C %l %n` line must be byte-identical to rsync."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_ck_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_ck_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_ck_rdst")
|
||||
_make_one_file(source, "f.bin", 200000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
fmt = "%C %l %n"
|
||||
rsync_result = _rsync(["-a", "--out-format=" + fmt, source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--out-format=" + fmt],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
|
||||
def file_lines(text):
|
||||
# Ignore the root directory entry: fastsync does not transfer the
|
||||
# source-root dir itself (a separate pre-existing divergence).
|
||||
return [
|
||||
line for line in text.splitlines() if not line.rsplit(" ", 1)[-1].endswith("/")
|
||||
]
|
||||
|
||||
assert file_lines(result.stdout) == file_lines(rsync_result.stdout), (
|
||||
f"rsync={rsync_result.stdout!r} fastsync={result.stdout!r}"
|
||||
)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_out_format_b_is_wire_bytes(self, shared_server):
|
||||
"""%b is the bytes actually transferred (wire), not the source length.
|
||||
|
||||
A differential run against rsync confirms both implementations report a
|
||||
framed value greater than %l. The exact numbers are not compared: each
|
||||
counts its own protocol framing and checksum trailer, so the two are
|
||||
protocol-specific and cannot be numerically equal (documented
|
||||
divergence)."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_b_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_b_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_b_rdst")
|
||||
_make_one_file(source, "f.bin", 5000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
fmt = "%b %l"
|
||||
rsync_result = _rsync(["-a", "--out-format=" + fmt, source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--out-format=" + fmt],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
rb, rl = (int(x) for x in rsync_result.stdout.split()[:2])
|
||||
fb, fl = (int(x) for x in result.stdout.split()[:2])
|
||||
assert rl == fl == 5000, (rsync_result.stdout, result.stdout)
|
||||
assert rb > rl, f"rsync %b must include framing: {rsync_result.stdout!r}"
|
||||
assert fb > fl, f"fastsync %b must include framing: {result.stdout!r}"
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_out_format_c_whole_file_matches_rsync(self, shared_server):
|
||||
"""%c is the block-checksum bytes received. rsync reports its 16-byte
|
||||
sum header even for a whole-file transfer (no basis), so `%c` must match
|
||||
rsync exactly for the whole-file case."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_c_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_c_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_c_rdst")
|
||||
_make_one_file(source, "f.bin", 5000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
fmt = "%c %l %n"
|
||||
rsync_result = _rsync(["-a", "--out-format=" + fmt, source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--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"rsync={rsync_result.stdout!r} fastsync={result.stdout!r}"
|
||||
)
|
||||
assert result.stdout.split()[0] == rsync_result.stdout.split()[0] == "16", (
|
||||
f"%c must be rsync's 16-byte sum header: {result.stdout!r}"
|
||||
)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_out_format_c_delta_mode_divergence(self, shared_server):
|
||||
"""Documented residual: with delta enabled, rsync's %c is its 16-byte sum
|
||||
header plus one checksum entry per block (protocol-specific, so it grows
|
||||
with the basis size), while FastSync's %c is the bytes of its own delta
|
||||
handshake. FastSync's delta %c therefore cannot match rsync numerically;
|
||||
only the whole-file case is aligned. Pinned here so a future change is
|
||||
noticed."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_cd_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_cd_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_cd_rdst")
|
||||
_make_one_file(source, "f.bin", 5000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
fmt = "%c %l"
|
||||
# rsync local default is whole-file; force the block-delta path.
|
||||
rsync_result = _rsync(["-a", "--no-whole-file", "--out-format=" + fmt,
|
||||
source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["-a", "--incremental", "--delta",
|
||||
"--out-format=" + fmt],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
rs_c = int(rsync_result.stdout.split()[0])
|
||||
fs_c = int(result.stdout.split()[0])
|
||||
# No basis exists, so rsync still reports only its sum header.
|
||||
assert rs_c == 16, rsync_result.stdout
|
||||
# FastSync reports its own handshake bytes and is not aligned.
|
||||
assert fs_c > 16, (
|
||||
f"FastSync delta %c changed to {fs_c}; the documented divergence "
|
||||
"may be closable now"
|
||||
)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@pytest.mark.parametrize("progress_flag", ["--progress", "-P"])
|
||||
def test_progress_first_frame_matches_rsync(self, shared_server, progress_flag, mt):
|
||||
"""For a sub-32 KiB file the first --progress/-P frame is deterministic
|
||||
(0.00 kB/s, 0:00:00) and must be byte-identical to rsync's, in both the
|
||||
single-threaded and --threads send paths."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_pg_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_pg_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_pg_rdst")
|
||||
_make_one_file(source, "f.bin", 100)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
rsync_result = _rsync(["-a", progress_flag, source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
flags = ["-a", progress_flag] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
|
||||
def frames(text):
|
||||
# subprocess text mode normalizes \r to \n (universal newlines).
|
||||
return [p for p in text.split("\n") if "%" in p]
|
||||
|
||||
rsync_frames = frames(rsync_result.stdout)
|
||||
fast_frames = frames(result.stdout)
|
||||
assert rsync_frames and fast_frames, (rsync_result.stdout, result.stdout)
|
||||
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
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_stats_selected_lines_match_rsync(self, shared_server, mt):
|
||||
"""The protocol-independent --stats lines must match rsync exactly, in
|
||||
both the single-threaded and --threads (multithreaded) send paths."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_st_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_st_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_st_rdst")
|
||||
_make_one_file(source, "f.bin", 6000)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
rsync_result = _rsync(["-a", "--stats", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
flags = ["-a", "--stats"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
keys = (
|
||||
"Number of regular files transferred",
|
||||
"Total file size",
|
||||
"Total transferred file size",
|
||||
"Literal data",
|
||||
"Matched data",
|
||||
"Number of deleted files",
|
||||
"File list size",
|
||||
)
|
||||
|
||||
def pick(text):
|
||||
out = {}
|
||||
for line in text.splitlines():
|
||||
for key in keys:
|
||||
if line.startswith(key + ":"):
|
||||
out[key] = line
|
||||
return out
|
||||
|
||||
assert pick(result.stdout) == pick(rsync_result.stdout), (
|
||||
f"rsync={pick(rsync_result.stdout)} fastsync={pick(result.stdout)}"
|
||||
)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
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)
|
||||
rsync_result = _rsync(["-a", "--stats", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--stats"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
|
||||
def stats_line(text, key):
|
||||
for line in text.splitlines():
|
||||
if line.startswith(key + ":"):
|
||||
return line
|
||||
return None
|
||||
|
||||
r_files = stats_line(rsync_result.stdout, "Number of files")
|
||||
r_created = stats_line(rsync_result.stdout, "Number of created files")
|
||||
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 re.match(r"Number of created files: 1 \(reg: 1\)$", r_created), r_created
|
||||
# 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
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_dry_run_delete_lines_match_rsync(self, mt):
|
||||
"""-n --delete emits transfer-relative `*deleting` lines like rsync
|
||||
(single-threaded and --threads)."""
|
||||
source = os.path.join(TEST_DATA_DIR, "wire_del_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "wire_del_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "wire_del_rdst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
||||
fh.write(b"a\n")
|
||||
for root, entries in (
|
||||
(rdst, {"extra.txt": b"x\n"}),
|
||||
(rdst, {"sub/y.txt": b"y\n", "extradir/z.txt": b"z\n"}),
|
||||
):
|
||||
for rel, data in entries.items():
|
||||
full = os.path.join(root, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(data)
|
||||
# FastSync mirrors the source's absolute path under dest.
|
||||
received = get_dest_received_dir(dest, source)
|
||||
for rel, data in (
|
||||
("extra.txt", b"x\n"),
|
||||
("sub/y.txt", b"y\n"),
|
||||
("extradir/z.txt", b"z\n"),
|
||||
):
|
||||
full = os.path.join(received, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(data)
|
||||
|
||||
rsync_result = _rsync(["-a", "-n", "--delete", "-i", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_del = sorted(
|
||||
line for line in rsync_result.stdout.splitlines() if line.startswith("*deleting")
|
||||
)
|
||||
# The shared session server refuses deletion; start one that allows it.
|
||||
flags = ["-a", "-n", "--delete", "-i"] + (["--threads"] if mt else [])
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
fast_del = sorted(
|
||||
line for line in result.stdout.splitlines() if line.startswith("*deleting")
|
||||
)
|
||||
assert fast_del == rsync_del, f"rsync={rsync_del}\nfastsync={fast_del}"
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Differential/regression coverage for the parity-completion review blockers.
|
||||
|
||||
Each test pins a fix against real ``rsync 3.4.1`` where a deterministic
|
||||
comparison exists; the differential tests skip cleanly when rsync is absent.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
RSYNC = shutil.which("rsync")
|
||||
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
def _tree(root):
|
||||
"""Sorted relative paths of every entry below root (files and dirs)."""
|
||||
out = []
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
for name in dirs:
|
||||
out.append(os.path.relpath(os.path.join(dirpath, name), root))
|
||||
for name in files:
|
||||
out.append(os.path.relpath(os.path.join(dirpath, name), root))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _rsync(args):
|
||||
env = dict(os.environ, LC_ALL="C")
|
||||
return subprocess.run([RSYNC] + args, capture_output=True, text=True, env=env, timeout=120)
|
||||
|
||||
|
||||
class TestRelativePerDirDeleteScope:
|
||||
"""Blocker #1: -R --delete-during/--delete-delay must not delete destination
|
||||
content outside the transferred prefix (rsync keeps sibling directories)."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
||||
def test_prefix_scoped_delete_matches_rsync(self, timing, mt):
|
||||
source = os.path.join(TEST_DATA_DIR, f"delblk_src{int(mt)}")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "foo", "a.txt"), b"payload\n")
|
||||
spec = source + "/./foo"
|
||||
|
||||
def seed(root):
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "foo", "extra.txt"), b"stale\n")
|
||||
_write(os.path.join(root, "unrelated", "keep.txt"), b"keep\n")
|
||||
|
||||
rdst = os.path.join(TEST_DATA_DIR, f"delblk_rdst{int(mt)}")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"delblk_dst{int(mt)}")
|
||||
seed(rdst)
|
||||
seed(dest)
|
||||
r = _rsync(["-aR", timing, spec, rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
flags = ["-a", "-R", timing] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(spec, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
#The prefix's parent-directory sibling survives on both sides.
|
||||
assert os.path.isfile(os.path.join(dest, "unrelated", "keep.txt"))
|
||||
assert os.path.isfile(os.path.join(rdst, "unrelated", "keep.txt"))
|
||||
#The in - scope extra is removed on both sides.
|
||||
assert not os.path.exists(os.path.join(dest, "foo", "extra.txt"))
|
||||
assert not os.path.exists(os.path.join(rdst, "foo", "extra.txt"))
|
||||
assert _tree(dest) == _tree(rdst)
|
||||
|
||||
|
||||
def _stats_value(text, label):
|
||||
for line in text.splitlines():
|
||||
if line.startswith(label + ":"):
|
||||
return int(line.split(":", 1)[1].strip().split()[0].replace(",", ""))
|
||||
return None
|
||||
|
||||
|
||||
def _seed_delta_pair(tag):
|
||||
"""Source file plus a same-size/basis destination file whose mtime differs,
|
||||
and an extra destination file to be deleted."""
|
||||
source = os.path.join(TEST_DATA_DIR, f"stats_{tag}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"stats_{tag}_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, f"stats_{tag}_rdst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
payload = (b"0123456789abcdef" * 16384)[:200000]
|
||||
_write(os.path.join(source, "f.bin"), payload)
|
||||
#Destination basis : same length, one byte changed, deliberately older.
|
||||
basis = bytearray(payload)
|
||||
basis[100000] ^= 0xFF
|
||||
received = get_dest_received_dir(dest, source)
|
||||
for root in (rdst, received):
|
||||
_write(os.path.join(root, "f.bin"), bytes(basis))
|
||||
_write(os.path.join(root, "extra.txt"), b"delete me\n")
|
||||
old = 1000000
|
||||
os.utime(os.path.join(root, "f.bin"), (old, old))
|
||||
return source, dest, rdst
|
||||
|
||||
|
||||
class TestReceiverWireStats:
|
||||
"""Blocker #3/#4: the receiver must populate the STATUS_STATS counters
|
||||
(matched data, deleted files) on both the single-threaded and -m paths."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("threads", [False, True])
|
||||
def test_stats_reports_matched_and_deleted(self, threads):
|
||||
source, dest, rdst = _seed_delta_pair(f"mt{int(threads)}")
|
||||
rsync_result = _rsync(["-a", "--stats", "--delete", "--no-whole-file", source + "/",
|
||||
rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
assert _stats_value(rsync_result.stdout, "Matched data") > 0
|
||||
assert _stats_value(rsync_result.stdout, "Number of deleted files") == 1
|
||||
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
flags = ["-a", "--stats", "--delete", "--delta", "--incremental"]
|
||||
if threads:
|
||||
flags.append("--threads")
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert _stats_value(result.stdout, "Matched data") > 0, result.stdout
|
||||
assert _stats_value(result.stdout, "Number of deleted files") == 1, result.stdout
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_threads_dry_run_delete_lines_match_rsync(self):
|
||||
"""-n --delete --threads must emit transfer-relative `*deleting` lines."""
|
||||
source = os.path.join(TEST_DATA_DIR, "stats_drydel_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "stats_drydel_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "stats_drydel_rdst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
_write(os.path.join(source, "a.txt"), b"a\n")
|
||||
for root in (rdst, get_dest_received_dir(dest, source)):
|
||||
_write(os.path.join(root, "extra.txt"), b"x\n")
|
||||
_write(os.path.join(root, "sub", "y.txt"), b"y\n")
|
||||
rsync_result = _rsync(["-a", "-n", "--delete", "-i", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
rsync_del = sorted(
|
||||
line for line in rsync_result.stdout.splitlines() if line.startswith("*deleting")
|
||||
)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["-a", "-n", "--delete", "-i", "--threads"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
fast_del = sorted(
|
||||
line for line in result.stdout.splitlines() if line.startswith("*deleting")
|
||||
)
|
||||
assert fast_del and fast_del == rsync_del, f"rsync={rsync_del}\nfastsync={fast_del}"
|
||||
|
||||
|
||||
class TestRelativeFilesFromProtect:
|
||||
"""Blocker #9: a -R + --files-from receiver-protect rule must record the bare
|
||||
relative wire path so the protected destination mirror survives --delete."""
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_hidden_protected_mirror_survives_delete(self, mt):
|
||||
source = os.path.join(TEST_DATA_DIR, "rfprot_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "rfprot_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
#Root - level entry exercises the parallel root scanner; the nested one
|
||||
#exercises the sequential worker scanner.
|
||||
_write(os.path.join(source, "root_secret.tmp"), b"root\n")
|
||||
_write(os.path.join(source, "sub", "nested_secret.tmp"), b"nested\n")
|
||||
_write(os.path.join(source, "sub", "keep.txt"), b"keep\n")
|
||||
listfile = os.path.join(TEST_DATA_DIR, "rfprot.list")
|
||||
with open(listfile, "w") as fh:
|
||||
fh.write(".\n")
|
||||
#H hides from the sender, P protects the receiver mirror from-- delete.
|
||||
filters = ["--filter=H root_secret.tmp", "--filter=P root_secret.tmp",
|
||||
"--filter=H sub/nested_secret.tmp", "--filter=P sub/nested_secret.tmp"]
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
seed = ["--files-from", listfile, "-R"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=seed, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert os.path.isfile(os.path.join(dest, "root_secret.tmp"))
|
||||
assert os.path.isfile(os.path.join(dest, "sub", "nested_secret.tmp"))
|
||||
_write(os.path.join(dest, "extra.txt"), b"extra\n")
|
||||
_write(os.path.join(dest, "sub", "extra.txt"), b"extra\n")
|
||||
flags = seed + ["--delete"] + filters
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert os.path.isfile(os.path.join(dest, "root_secret.tmp")), \
|
||||
"root-level protected mirror was deleted"
|
||||
assert os.path.isfile(os.path.join(dest, "sub", "nested_secret.tmp")), \
|
||||
"nested protected mirror was deleted"
|
||||
assert not os.path.exists(os.path.join(dest, "extra.txt"))
|
||||
assert not os.path.exists(os.path.join(dest, "sub", "extra.txt"))
|
||||
|
||||
|
||||
class TestInvalidPerDirFilter:
|
||||
"""Blocker #8: a per-directory filter file that fails to parse must fail the
|
||||
scan even when an earlier merge file in the same directory existed."""
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_invalid_dir_filter_fails_scan(self, mt):
|
||||
source = os.path.join(TEST_DATA_DIR, "badfilter_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "badfilter_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
#A valid.rsync - filter makes any_exists true for the directory; the
|
||||
#invalid.rules must not then be silently ignored.
|
||||
_write(os.path.join(source, ".rsync-filter"), b"- *.bak\n")
|
||||
_write(os.path.join(source, ".rules"), b"protect\n")
|
||||
_write(os.path.join(source, "a.txt"), b"a\n")
|
||||
flags = ["-a", "-F", "--filter=: .rules"]
|
||||
if mt:
|
||||
flags.append("--threads")
|
||||
with ServerManager() as server:
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode != 0, "invalid per-directory filter was silently ignored"
|
||||
assert "invalid per-directory filter" in (result.stderr + result.stdout)
|
||||
|
||||
|
||||
class TestWouldDeleteEscaping:
|
||||
"""Blocker #5: -n --delete --out-format must escape control bytes in a
|
||||
peer-supplied would-delete path so it cannot forge output lines."""
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_out_format_escapes_control_chars(self):
|
||||
source = os.path.join(TEST_DATA_DIR, "esc_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "esc_dst")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "a.txt"), b"a\n")
|
||||
received = get_dest_received_dir(dest, source)
|
||||
clean_dir(received)
|
||||
_write(os.path.join(received, "a.txt"), b"a\n")
|
||||
#A newline in a destination filename must not split the printed line.
|
||||
with open(os.path.join(received, "evil\nname.txt"), "wb") as fh:
|
||||
fh.write(b"x\n")
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["-a", "-n", "--delete", "--out-format=%n"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert "\\#012" in result.stdout, result.stdout
|
||||
assert "evil\nname.txt" not in result.stdout, result.stdout
|
||||
|
||||
|
||||
class TestEmptySourceDirectoryDelete:
|
||||
"""Blocker #10: an empty in-scope source directory must survive
|
||||
--delete-during/--delete-delay (rsync keeps it) while its extras are still
|
||||
removed."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
||||
def test_empty_source_dir_survives_matches_rsync(self, timing, mt):
|
||||
source = os.path.join(TEST_DATA_DIR, f"emptydir_src{int(mt)}")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"emptydir_dst{int(mt)}")
|
||||
rdst = os.path.join(TEST_DATA_DIR, f"emptydir_rdst{int(mt)}")
|
||||
clean_dir(source)
|
||||
os.makedirs(os.path.join(source, "empty"))
|
||||
_write(os.path.join(source, "keep.txt"), b"keep\n")
|
||||
received = get_dest_received_dir(dest, source)
|
||||
for root in (rdst, received):
|
||||
clean_dir(root)
|
||||
_write(os.path.join(root, "keep.txt"), b"keep\n")
|
||||
_write(os.path.join(root, "empty", "extra.txt"), b"extra\n")
|
||||
rsync_result = _rsync(["-a", timing, source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
assert os.path.isdir(os.path.join(rdst, "empty"))
|
||||
assert not os.path.exists(os.path.join(rdst, "empty", "extra.txt"))
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
flags = ["-a", timing] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert os.path.isdir(os.path.join(received, "empty")), \
|
||||
"empty source directory was removed"
|
||||
assert not os.path.exists(os.path.join(received, "empty", "extra.txt"))
|
||||
assert _tree(received) == _tree(rdst)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,287 @@
|
||||
"""rsync 3.4.1 parity for selection/path semantics and client option aliases.
|
||||
|
||||
Each test pins behaviour against real ``rsync 3.4.1``; the differential tests
|
||||
skip cleanly when rsync is not installed.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
TEST_DATA_DIR,
|
||||
CLIENT_CMD,
|
||||
ServerManager,
|
||||
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")
|
||||
|
||||
|
||||
def _tree(root):
|
||||
"""Sorted relative paths of directories (``D ``) and files (``F ``)."""
|
||||
out = []
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
rel = os.path.relpath(dirpath, root)
|
||||
for d in dirs:
|
||||
out.append("D " + (d if rel == "." else os.path.join(rel, d)))
|
||||
for f in files:
|
||||
out.append("F " + (f if rel == "." else os.path.join(rel, f)))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
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 _make_tree(root):
|
||||
clean_dir(root)
|
||||
for rel, content in {
|
||||
"top.txt": b"top\n",
|
||||
"foo/bar/baz/f.txt": b"deep\n",
|
||||
"sub/x.txt": b"x\n",
|
||||
}.items():
|
||||
full = os.path.join(root, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(content)
|
||||
return root
|
||||
|
||||
|
||||
class TestRelativeGeneral:
|
||||
"""#11: -R without --files-from uses rsync's '/./' cut and relative
|
||||
reconstruction instead of always mirroring the full source path."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("suffix", ["", "/./foo", "/./foo/bar", "/./"])
|
||||
def test_relative_cut_matches_rsync(self, shared_server, suffix):
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_rel_src"))
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_rel_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_rel_rdst")
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
spec = source + suffix
|
||||
r = _rsync(["-aR", spec, rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
result, _ = run_client(spec, dest, flags=["-R"], port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
assert _tree(rdst) == _tree(dest), f"layout mismatch for {spec!r}"
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_no_implied_dirs_matches_rsync(self, shared_server):
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_nid_src"))
|
||||
a = os.path.join(source, "foo")
|
||||
b = os.path.join(source, "foo", "bar")
|
||||
os.chmod(a, 0o700)
|
||||
os.chmod(b, 0o711)
|
||||
os.utime(a, (978307200, 978307200))
|
||||
os.utime(b, (978307200, 978307200))
|
||||
spec = source + "/./foo/bar"
|
||||
for extra in ([], ["--no-implied-dirs"]):
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_nid_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_nid_rdst")
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
r = _rsync(["-aR"] + extra + [spec, rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
result, _ = run_client(spec, dest, flags=["-a", "-R"] + extra,
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
for rel in ("foo", "foo/bar"):
|
||||
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 {rel} with {extra}"
|
||||
if extra == ["--no-implied-dirs"]:
|
||||
# The implied parent directory is created at run time (no
|
||||
# metadata applied), so rsync's and FastSync's separate runs
|
||||
# can differ by a second; compare with a tolerance.
|
||||
assert abs(rs.st_mtime - fs.st_mtime) <= 2, \
|
||||
f"mtime mismatch for {rel} with {extra}"
|
||||
else:
|
||||
assert int(rs.st_mtime) == int(fs.st_mtime), \
|
||||
f"mtime mismatch for {rel} with {extra}"
|
||||
|
||||
|
||||
class TestDirsOneLevel:
|
||||
"""#13: -d with a trailing slash (or '.') lists the source's immediate
|
||||
contents; FastSync mirrors them below the source-root mirror, so compare
|
||||
rsync's destination tree against that mirror."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_dirs_trailing_slash_matches_rsync(self, shared_server):
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_dirs_src"))
|
||||
os.makedirs(os.path.join(source, "empty"), exist_ok=True)
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_dirs_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_dirs_rdst")
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
r = _rsync(["-d", source + "/", rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
result, _ = run_client(source + "/", dest, flags=["-d"], port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
mirror = get_dest_received_dir(dest, source)
|
||||
assert _tree(rdst) == _tree(mirror)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_dirs_relative_matches_rsync(self, shared_server):
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_dirsr_src"))
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_dirsr_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_dirsr_rdst")
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
spec = source + "/./foo"
|
||||
r = _rsync(["-d", "-R", spec, rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
result, _ = run_client(spec, dest, flags=["-d", "-R"], port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
assert _tree(rdst) == _tree(dest)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_relative_delete_scope_matches_rsync(self):
|
||||
"""-R --delete must be confined to the transferred prefix subtree so a
|
||||
sibling destination directory survives (rsync parity)."""
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_delscope_src"))
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_delscope_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_delscope_rdst")
|
||||
spec = source + "/./foo"
|
||||
for root in (dest, rdst):
|
||||
clean_dir(root)
|
||||
os.makedirs(os.path.join(root, "foo"))
|
||||
with open(os.path.join(root, "foo", "extra.txt"), "wb") as fh:
|
||||
fh.write(b"extra\n")
|
||||
os.makedirs(os.path.join(root, "unrelated"))
|
||||
with open(os.path.join(root, "unrelated", "keep.txt"), "wb") as fh:
|
||||
fh.write(b"keep\n")
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
r = _rsync(["-aR", "--delete", spec, rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
result, _ = run_client(spec, dest, flags=["-a", "-R", "--delete"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
assert (os.path.isfile(os.path.join(dest, "unrelated", "keep.txt"))
|
||||
== os.path.isfile(os.path.join(rdst, "unrelated", "keep.txt")))
|
||||
assert _tree(dest) == _tree(rdst)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_relative_delete_protects_excluded_mirror(self, mt):
|
||||
"""-R --delete with --exclude must protect the destination mirror of an
|
||||
excluded source path (recorded as a prefix-relative wire path)."""
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_delexc_src"))
|
||||
with open(os.path.join(source, "foo", "secret.tmp"), "wb") as fh:
|
||||
fh.write(b"secret\n")
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_delexc_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_delexc_rdst")
|
||||
for root in (dest, rdst):
|
||||
clean_dir(root)
|
||||
os.makedirs(os.path.join(root, "foo"))
|
||||
with open(os.path.join(root, "foo", "secret.tmp"), "wb") as fh:
|
||||
fh.write(b"secret\n")
|
||||
with open(os.path.join(root, "foo", "extra.txt"), "wb") as fh:
|
||||
fh.write(b"extra\n")
|
||||
spec = source + "/./foo"
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
r = _rsync(["-aR", "--delete", "--exclude=*.tmp", spec, rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
flags = ["-a", "-R", "--delete", "--exclude=*.tmp"] + (["--threads"] if mt else [])
|
||||
result, _ = run_client(spec, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
assert _tree(dest) == _tree(rdst)
|
||||
assert os.path.isfile(os.path.join(dest, "foo", "secret.tmp"))
|
||||
assert not os.path.exists(os.path.join(dest, "foo", "extra.txt"))
|
||||
|
||||
|
||||
class TestClientAliases:
|
||||
"""#5: safe rsync option aliases accepted client-side."""
|
||||
|
||||
def _seed(self):
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_alias_src"))
|
||||
return source
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag",
|
||||
[
|
||||
"--ignore-non-existing",
|
||||
"--protect-args",
|
||||
"--msgs2stderr",
|
||||
"--no-msgs2stderr",
|
||||
"--no-iconv",
|
||||
"--iconv=.",
|
||||
"--iconv=-",
|
||||
],
|
||||
)
|
||||
def test_alias_accepted(self, shared_server, flag):
|
||||
source = self._seed()
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_alias_dst")
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, flags=[flag], port=shared_server.port)
|
||||
assert result.returncode == 0, f"{flag} rejected: {result.stderr[:300]}"
|
||||
|
||||
def test_lone_h_prints_help(self):
|
||||
result = subprocess.run([CLIENT_CMD[0], "-h"], capture_output=True, text=True,
|
||||
timeout=30)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Usage" in (result.stdout + result.stderr)
|
||||
|
||||
def test_h_with_args_still_human_readable(self, shared_server):
|
||||
source = self._seed()
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_h_dst")
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, flags=["-h"], port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert os.path.isfile(os.path.join(received, "top.txt"))
|
||||
|
||||
|
||||
class TestFilesFromEdges:
|
||||
"""#8/#58: --files-from empty list succeeds; rsync 3.4.1 rejects the
|
||||
--no-ignore-missing-args negation, so FastSync must reject it too."""
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_empty_files_from_list_succeeds(self, shared_server):
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_ff_src"))
|
||||
dest = os.path.join(TEST_DATA_DIR, "sel_ff_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "sel_ff_rdst")
|
||||
clean_dir(dest)
|
||||
clean_dir(rdst)
|
||||
lst = os.path.join(TEST_DATA_DIR, "sel_ff_empty")
|
||||
with open(lst, "w") as fh:
|
||||
fh.write("")
|
||||
r = _rsync(["-a", "--files-from=" + lst, source + "/", rdst + "/"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
result, _ = run_client(source, dest, flags=["--files-from", lst],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, result.stderr[:300]
|
||||
assert _tree(rdst) == []
|
||||
assert _tree(dest) == []
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_no_ignore_missing_args_rejected_like_rsync(self):
|
||||
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_nima_src"))
|
||||
r = _rsync(["-a", "--no-ignore-missing-args", source + "/",
|
||||
os.path.join(TEST_DATA_DIR, "sel_nima_rdst") + "/"])
|
||||
assert r.returncode != 0, "rsync unexpectedly accepted --no-ignore-missing-args"
|
||||
|
||||
cmd = CLIENT_CMD + ["--source-dir", source, "--dest-dir",
|
||||
os.path.join(TEST_DATA_DIR, "sel_nima_dst"), "--save-to-disk",
|
||||
"--no-ignore-missing-args"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode != 0, "FastSync unexpectedly accepted the negation"
|
||||
@@ -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.23.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.23.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]}"
|
||||
|
||||
+75
-4
@@ -91,6 +91,63 @@ static void test_checksum_md5_seed_ignored() {
|
||||
EXPECT_TRUE(memcmp(a, b, alen) == 0);
|
||||
}
|
||||
|
||||
static void test_checksum_md4_vectors() {
|
||||
uint8_t out[CHECKSUM_MAX_DIGEST_LEN];
|
||||
size_t len = 0;
|
||||
/* RFC 1320 / RFC 1321 test vectors. */
|
||||
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD4, 0, "", 0, out, sizeof(out), &len));
|
||||
EXPECT_TRUE(len == (size_t)16);
|
||||
const uint8_t expect_empty[16] = {0x31, 0xd6, 0xcf, 0xe0, 0xd1, 0x6a, 0xe9, 0x31,
|
||||
0xb7, 0x3c, 0x59, 0xd7, 0xe0, 0xc0, 0x89, 0xc0};
|
||||
EXPECT_TRUE(memcmp(out, expect_empty, 16) == 0);
|
||||
|
||||
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD4, 0, "abc", 3, out, sizeof(out), &len));
|
||||
const uint8_t expect_abc[16] = {0xa4, 0x48, 0x01, 0x7a, 0xaf, 0x21, 0xd8, 0x52,
|
||||
0x5f, 0xc1, 0x0a, 0xe8, 0x7a, 0xa6, 0x72, 0x9d};
|
||||
EXPECT_TRUE(memcmp(out, expect_abc, 16) == 0);
|
||||
|
||||
/* A longer input exercises the block loop and the padding boundary. */
|
||||
const char* msg =
|
||||
"12345678901234567890123456789012345678901234567890123456789012345678901234567890";
|
||||
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD4, 0, msg, strlen(msg), out, sizeof(out), &len));
|
||||
const uint8_t expect_long[16] = {0xe3, 0x3b, 0x4d, 0xdc, 0x9c, 0x38, 0xf2, 0x19,
|
||||
0x9c, 0x3e, 0x7b, 0x16, 0x4f, 0xcc, 0x05, 0x36};
|
||||
EXPECT_TRUE(memcmp(out, expect_long, 16) == 0);
|
||||
}
|
||||
|
||||
static void test_checksum_sha1_vectors() {
|
||||
uint8_t out[CHECKSUM_MAX_DIGEST_LEN];
|
||||
size_t len = 0;
|
||||
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_SHA1, 0, "abc", 3, out, sizeof(out), &len));
|
||||
EXPECT_TRUE(len == (size_t)20);
|
||||
const uint8_t expect_abc[20] = {0xa9, 0x99, 0x3e, 0x36, 0x47, 0x06, 0x81, 0x6a, 0xba, 0x3e,
|
||||
0x25, 0x71, 0x78, 0x50, 0xc2, 0x6c, 0x9c, 0xd0, 0xd8, 0x9d};
|
||||
EXPECT_TRUE(memcmp(out, expect_abc, 20) == 0);
|
||||
|
||||
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_SHA1, 0, "", 0, out, sizeof(out), &len));
|
||||
EXPECT_TRUE(len == (size_t)20);
|
||||
const uint8_t expect_empty[20] = {0xda, 0x39, 0xa3, 0xee, 0x5e, 0x6b, 0x4b, 0x0d, 0x32, 0x55,
|
||||
0xbf, 0xef, 0x95, 0x60, 0x18, 0x90, 0xaf, 0xd8, 0x07, 0x09};
|
||||
EXPECT_TRUE(memcmp(out, expect_empty, 20) == 0);
|
||||
|
||||
/* sha1 has no seed: the digest is seed-independent (documented). */
|
||||
uint8_t seeded[CHECKSUM_MAX_DIGEST_LEN];
|
||||
size_t seeded_len = 0;
|
||||
EXPECT_TRUE(
|
||||
checksum_digest(CHECKSUM_ALGO_SHA1, 12345, "abc", 3, seeded, sizeof(seeded), &seeded_len));
|
||||
EXPECT_TRUE(seeded_len == (size_t)20);
|
||||
EXPECT_TRUE(memcmp(expect_abc, seeded, 20) == 0);
|
||||
}
|
||||
|
||||
/* "none" is a successful no-digest: length 0, nothing written. */
|
||||
static void test_checksum_none_digest() {
|
||||
uint8_t out[CHECKSUM_MAX_DIGEST_LEN];
|
||||
size_t len = 99;
|
||||
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_NONE, 0, "data", 4, out, sizeof(out), &len));
|
||||
EXPECT_EQ_INT((int)len, 0);
|
||||
EXPECT_EQ_INT((int)checksum_digest_len(CHECKSUM_ALGO_NONE), 0);
|
||||
}
|
||||
|
||||
static void test_checksum_algo_name_mapping() {
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("xxh64"), (int)CHECKSUM_ALGO_XXH64);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("XXH64"), (int)CHECKSUM_ALGO_XXH64);
|
||||
@@ -102,12 +159,14 @@ static void test_checksum_algo_name_mapping() {
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("XXH3"), (int)CHECKSUM_ALGO_XXH3);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("xxh128"), (int)CHECKSUM_ALGO_XXH128);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("XXH128"), (int)CHECKSUM_ALGO_XXH128);
|
||||
/* rsync choices FastSync does not implement are rejected by name. */
|
||||
EXPECT_TRUE(checksum_algo_from_name("md4") < 0);
|
||||
EXPECT_TRUE(checksum_algo_from_name("sha1") < 0);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("md4"), (int)CHECKSUM_ALGO_MD4);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("MD4"), (int)CHECKSUM_ALGO_MD4);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("sha1"), (int)CHECKSUM_ALGO_SHA1);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("SHA1"), (int)CHECKSUM_ALGO_SHA1);
|
||||
EXPECT_EQ_INT(checksum_algo_from_name("none"), (int)CHECKSUM_ALGO_NONE);
|
||||
/* Names rsync does not offer (or FastSync cannot compute) are rejected. */
|
||||
EXPECT_TRUE(checksum_algo_from_name("sha256") < 0);
|
||||
EXPECT_TRUE(checksum_algo_from_name("crc32") < 0);
|
||||
EXPECT_TRUE(checksum_algo_from_name("none") < 0);
|
||||
EXPECT_TRUE(checksum_algo_from_name("") < 0);
|
||||
EXPECT_TRUE(checksum_algo_from_name(NULL) < 0);
|
||||
|
||||
@@ -115,11 +174,20 @@ static void test_checksum_algo_name_mapping() {
|
||||
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_MD5));
|
||||
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH3));
|
||||
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH128));
|
||||
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_MD4));
|
||||
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_SHA1));
|
||||
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_NONE));
|
||||
EXPECT_FALSE(checksum_algo_valid(99));
|
||||
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH64), "xxh64");
|
||||
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_MD5), "md5");
|
||||
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH3), "xxh3");
|
||||
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH128), "xxh128");
|
||||
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_MD4), "md4");
|
||||
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_SHA1), "sha1");
|
||||
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_NONE), "none");
|
||||
|
||||
/* rsync 3.4.1 auto-negotiates xxh128 first. */
|
||||
EXPECT_EQ_INT((int)checksum_negotiate_default(), (int)CHECKSUM_ALGO_XXH128);
|
||||
}
|
||||
|
||||
/* xxh3 is 8 bytes and seed-aware; xxh128 is 16 bytes and differs from both
|
||||
@@ -168,6 +236,9 @@ void test_checksum(void) {
|
||||
test_checksum_xxh64_seed_changes_digest();
|
||||
test_checksum_xxh64_seed_deterministic();
|
||||
test_checksum_md5_vectors();
|
||||
test_checksum_md4_vectors();
|
||||
test_checksum_sha1_vectors();
|
||||
test_checksum_none_digest();
|
||||
test_checksum_algo_lengths_distinct();
|
||||
test_checksum_md5_seed_ignored();
|
||||
test_checksum_algo_name_mapping();
|
||||
|
||||
+234
-26
@@ -317,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.23.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);
|
||||
@@ -327,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.23.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);
|
||||
@@ -776,7 +776,7 @@ static void test_parse_args_debug_help() {
|
||||
}
|
||||
|
||||
static void test_parse_args_debug_flags_validation() {
|
||||
static const char* const values[] = {"", "io,", ",io", "io,,proto", "acl", "tls", "unknown"};
|
||||
static const char* const values[] = {"", "io,", ",io", "io,,proto", "tls", "unknown"};
|
||||
for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char option[64];
|
||||
@@ -1044,10 +1044,11 @@ static void test_parse_args_basis_dirs() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Absolute, escaping, or degenerate basis-dir values must be rejected up
|
||||
front: they would resolve outside the destination root on the receiver. */
|
||||
/* Escaping or degenerate basis-dir values must be rejected up front (they would
|
||||
resolve outside the destination root on the receiver); an absolute path is
|
||||
accepted (rsync parity) and canonicalized with its leading '/' preserved. */
|
||||
static void test_parse_args_basis_invalid_paths() {
|
||||
static const char* const invalid[] = {"/abs", "..", "a/../b", "."};
|
||||
static const char* const invalid[] = {"..", "a/../b", ".", "/", ""};
|
||||
for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--link-dest", (char*)invalid[i], "/src", "/dst"};
|
||||
@@ -1056,6 +1057,15 @@ static void test_parse_args_basis_invalid_paths() {
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--link-dest=/abs/dir", "/src", "/dst"};
|
||||
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->basis_count, 1);
|
||||
EXPECT_EQ_STR(cfg->basis_dirs[0].path, "/abs/dir");
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Basis dirs require the per-file incremental handshake, which -s disables. */
|
||||
@@ -1317,7 +1327,84 @@ static void test_parse_args_rejects_invalid_info_flag() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* 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();
|
||||
char* argv[] = {"fastsync", "--info=name", "/src", "/dst"};
|
||||
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_COPY);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* help_argv[] = {"fastsync", "--info=help"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 2, help_argv, positional_args, &positional_count), 1);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* 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",
|
||||
"--debug=acl,backup,bind,chdir,cmd,connect,del,deltasum,dup,exit,"
|
||||
"filter,flist,fuzzy,genr,hash,hlink,iconv,nstr,own,recv,send,time,"
|
||||
"hl,owner",
|
||||
"/src", "/dst"};
|
||||
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, 0);
|
||||
EXPECT_EQ_INT(cfg->debug_level, 0);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Test parse_args with --archive flag */
|
||||
/* rsync accepts a trailing level digit on --debug/--info items (e.g. io2,
|
||||
* all4); level 0 silences the item. */
|
||||
static void test_parse_args_debug_info_levels() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--debug=io2,proto0,all", "/src", "/dst"};
|
||||
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->debug_level, LOG_DEBUG_ALL);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* io0_argv[] = {"fastsync", "--debug=all,io0", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, io0_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->debug_level, LOG_DEBUG_ALL & ~LOG_DEBUG_IO);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* info_argv[] = {"fastsync", "--info=stats2", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, info_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_STATS);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* bad_argv[] = {"fastsync", "--debug=123", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, bad_argv, positional_args, &positional_count), -1);
|
||||
config_delete(cfg);
|
||||
|
||||
/* rsync accepts category names case-insensitively. */
|
||||
cfg = config_create();
|
||||
char* upper_argv[] = {"fastsync", "--info=STATS2", "--debug=IO", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, upper_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_STATS);
|
||||
EXPECT_EQ_INT(cfg->debug_level, LOG_DEBUG_IO);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
static void test_parse_args_archive() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--archive", "/src", "/dst"};
|
||||
@@ -1440,22 +1527,25 @@ static void test_parse_args_checksum_choice_equals_forms() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* An algorithm FastSync does not support must be rejected, never a silent
|
||||
no-op. */
|
||||
/* An algorithm FastSync does not support, an empty half, a lone/extra comma or
|
||||
a malformed separator must be rejected, never a silent no-op. A single
|
||||
md4/sha1/none name and the two-name transfer,pre-transfer form are valid. */
|
||||
static void test_parse_args_checksum_choice_rejects_unsupported() {
|
||||
static const char* const bad[] = {"md4", "sha1", "sha256", "crc32",
|
||||
"none", "bogus", "xxh64,md5", "xxhash:md5"};
|
||||
static const char* const bad[] = {"sha256", "crc32", "bogus", "xxhash:md5",
|
||||
"md5,", ",md5", "md5,md4,sha1"};
|
||||
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--checksum-choice", (char*)bad[i], "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* xxh3/xxh128 are accepted; "auto" keeps the default algorithm. */
|
||||
/* xxh3/xxh128/md4/sha1/none and the two-name form are accepted; "auto"
|
||||
resolves to FastSync's negotiated default xxh128. */
|
||||
static void test_parse_args_checksum_choice_new_algos() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--checksum-choice=xxh3", "/src", "/dst"};
|
||||
@@ -1476,7 +1566,69 @@ static void test_parse_args_checksum_choice_new_algos() {
|
||||
char* argv3[] = {"fastsync", "--checksum-choice=auto", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH64);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH128);
|
||||
config_delete(cfg);
|
||||
|
||||
/* A single md4/sha1 name selects it for both transfer and pre-transfer. */
|
||||
static const int single[] = {(int)CHECKSUM_ALGO_MD4, (int)CHECKSUM_ALGO_SHA1};
|
||||
static const char* const single_names[] = {"md4", "sha1"};
|
||||
for (size_t i = 0; i < 2; i++) {
|
||||
cfg = config_create();
|
||||
char* arg = (char*)single_names[i];
|
||||
char* argv4[] = {"fastsync", "--cc", arg, "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, single[i]);
|
||||
EXPECT_EQ_INT(cfg->checksum_transfer_algo, single[i]);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Two-name form: first is the transfer checksum, second the pre-transfer one
|
||||
that FastSync actually uses. */
|
||||
cfg = config_create();
|
||||
char* argv5[] = {"fastsync", "--cc=sha1,md4", "/checksum/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv5, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_transfer_algo, (int)CHECKSUM_ALGO_SHA1);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_MD4);
|
||||
config_delete(cfg);
|
||||
|
||||
/* "none" is accepted without --checksum but forces --whole-file like rsync. */
|
||||
cfg = config_create();
|
||||
char* argv6[] = {"fastsync", "--cc=none", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv6, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_NONE);
|
||||
EXPECT_TRUE(cfg->whole_file);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* rsync rejects "none" as the pre-transfer checksum with --checksum (exit 4),
|
||||
regardless of option order. */
|
||||
static void test_parse_args_checksum_none_with_checksum_rejected() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--checksum", "--cc=none", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* argv2[] = {"fastsync", "--checksum", "--cc=md5,none", "/checksum/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
|
||||
/* "none" as the TRANSFER checksum with a real pre-transfer checksum is
|
||||
accepted (rsync allows none,md5 with -c). */
|
||||
cfg = config_create();
|
||||
char* argv3[] = {"fastsync", "--checksum", "--cc=none,md5", "/checksum/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv3, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_MD5);
|
||||
EXPECT_TRUE(cfg->whole_file);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
@@ -1535,28 +1687,40 @@ static void test_parse_args_timeout_zero_and_no_forms() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* rsync's --compress-choice choices FastSync does not implement are rejected by
|
||||
* name; zstd/none/auto are accepted. */
|
||||
/* Every rsync 3.4.1 --compress-choice name is accepted and mapped to a real
|
||||
* codec; "auto" resolves to the negotiated default (zstd). An unknown name is
|
||||
* rejected with rsync's exit code 4. */
|
||||
static void test_parse_args_compress_choice_parity() {
|
||||
static const char* const good[] = {"zstd", "none", "auto"};
|
||||
struct {
|
||||
const char* name;
|
||||
CompressionAlgo algo;
|
||||
bool enabled;
|
||||
} good[] = {
|
||||
{"zstd", COMPRESSION_ALGO_ZSTD, true}, {"lz4", COMPRESSION_ALGO_LZ4, true},
|
||||
{"zlib", COMPRESSION_ALGO_ZLIB, true}, {"zlibx", COMPRESSION_ALGO_ZLIBX, true},
|
||||
{"none", COMPRESSION_ALGO_NONE, false}, {"auto", COMPRESSION_ALGO_ZSTD, true},
|
||||
{"ZSTD", COMPRESSION_ALGO_ZSTD, true},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(good) / sizeof(good[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--compress-choice", (char*)good[i], "/src", "/dst"};
|
||||
char* argv[] = {"fastsync", "--compress-choice", (char*)good[i].name, "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
/* "auto" is normalized to the canonical "zstd" the receiver accepts. */
|
||||
EXPECT_EQ_STR(cfg->compress_choice, strcmp(good[i], "auto") == 0 ? "zstd" : good[i]);
|
||||
EXPECT_EQ_INT(cfg->use_compression, strcmp(good[i], "none") != 0 ? 1 : 0);
|
||||
EXPECT_EQ_STR(cfg->compress_choice, compression_algo_name(good[i].algo));
|
||||
EXPECT_EQ_INT(cfg->compression_algo, (int)good[i].algo);
|
||||
EXPECT_EQ_INT(cfg->use_compression, good[i].enabled ? 1 : 0);
|
||||
config_delete(cfg);
|
||||
}
|
||||
static const char* const bad[] = {"lz4", "zlib", "zlibx", "bogus"};
|
||||
static const char* const bad[] = {"bogus", "", "zstd,lz4"};
|
||||
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--compress-choice", (char*)bad[i], "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
@@ -2492,15 +2656,35 @@ static void test_parse_args_filter_rules() {
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, missing_argv, positional_args, &positional_count), -1);
|
||||
config_delete(cfg);
|
||||
|
||||
/* rsync shorthands/modifiers we do not support are rejected instead of being
|
||||
* silently parsed as literal patterns. */
|
||||
static const char* const unsupported[] = {
|
||||
": .rsync-filter", ". /tmp/rules", "-s foo", "-p bar", "-C", "-! *.o", "!",
|
||||
/* Full rsync grammar (rule words, modifiers, clear) is supported. */
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* grammar_argv[] = {"fastsync",
|
||||
"--filter=hide *.tmp",
|
||||
"--filter=show *.txt",
|
||||
"--filter=protect *.bak",
|
||||
"--filter=risk *.o",
|
||||
"--filter=-s foo",
|
||||
"--filter=-p bar",
|
||||
"--filter=-! *.o",
|
||||
"--filter=dir-merge .rules",
|
||||
"--filter=!",
|
||||
"/src",
|
||||
"/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 11, grammar_argv, positional_args, &positional_count), 0);
|
||||
config_delete(cfg);
|
||||
|
||||
/* Genuinely malformed rules are still rejected. */
|
||||
static const char* const malformed[] = {
|
||||
"merge", /* merge requires a filename */
|
||||
"dir-merge", /* dir-merge requires a filename */
|
||||
"clear extra", /* clear takes no pattern */
|
||||
"no-such-rule x", /* unknown rule word */
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(unsupported) / sizeof(unsupported[0]); i++) {
|
||||
for (size_t i = 0; i < sizeof(malformed) / sizeof(malformed[0]); i++) {
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* rule_argv[] = {"fastsync", "--filter", (char*)unsupported[i], "/src", "/dst"};
|
||||
char* rule_argv[] = {"fastsync", "--filter", (char*)malformed[i], "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, rule_argv, positional_args, &positional_count), -1);
|
||||
config_delete(cfg);
|
||||
}
|
||||
@@ -3087,6 +3271,27 @@ static void test_parse_args_chown() {
|
||||
EXPECT_TRUE(cfg->chown_gid_set);
|
||||
EXPECT_EQ_INT(cfg->chown_gid, IDENTITY_CURRENT);
|
||||
config_delete(cfg);
|
||||
|
||||
/* A --chown NAME is converted to the equivalent receiver-resolved map rule
|
||||
* (rsync implements --chown as --usermap=*:USER --groupmap=*:GROUP), so the
|
||||
* name is carried on the wire as to_name instead of being resolved on the
|
||||
* sender. A name that does not exist on the sender is accepted and left for
|
||||
* the receiver to resolve (or warn about), matching rsync. */
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* argv5[] = {"fastsync", "--chown=no_such_user_zzz:no_such_group_zzz", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv5, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->usermap_count, 1);
|
||||
EXPECT_NOT_NULL(cfg->usermap[0].to_name);
|
||||
if (cfg->usermap[0].to_name)
|
||||
EXPECT_EQ_STR(cfg->usermap[0].to_name, "no_such_user_zzz");
|
||||
EXPECT_FALSE(cfg->chown_uid_set);
|
||||
EXPECT_EQ_INT(cfg->groupmap_count, 1);
|
||||
EXPECT_NOT_NULL(cfg->groupmap[0].to_name);
|
||||
if (cfg->groupmap[0].to_name)
|
||||
EXPECT_EQ_STR(cfg->groupmap[0].to_name, "no_such_group_zzz");
|
||||
EXPECT_FALSE(cfg->chown_gid_set);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --copy-as=USER[:GROUP] (P7 Wave E): resolve the user/group against the local
|
||||
@@ -3161,7 +3366,6 @@ static void test_parse_args_rejects_malformed_identity() {
|
||||
{"--groupmap", "@1"},
|
||||
{"--groupmap", "no_such_group_qqq:x"},
|
||||
{"--chown", "a:b:c"},
|
||||
{"--chown", "no_such_user_zzz:"},
|
||||
{"--copy-as", ""},
|
||||
{"--copy-as", ":"},
|
||||
{"--copy-as", "a:b:c"},
|
||||
@@ -4255,6 +4459,8 @@ void test_client_cli() {
|
||||
test_parse_args_debug_flags();
|
||||
test_parse_args_debug_help();
|
||||
test_parse_args_debug_flags_validation();
|
||||
test_parse_args_rsync_flag_vocabulary_accepted();
|
||||
test_parse_args_debug_info_levels();
|
||||
test_parse_args_modify_window();
|
||||
test_parse_args_rejects_invalid_modify_window();
|
||||
test_parse_args_skip_compress();
|
||||
@@ -4278,6 +4484,7 @@ void test_client_cli() {
|
||||
test_parse_args_info_flags();
|
||||
test_parse_args_info_verbose_order();
|
||||
test_parse_args_rejects_invalid_info_flag();
|
||||
test_parse_args_info_name_and_help();
|
||||
test_parse_args_archive();
|
||||
test_parse_args_preserve_attributes_are_independent();
|
||||
test_parse_args_preserve_long_form();
|
||||
@@ -4339,6 +4546,7 @@ void test_client_cli() {
|
||||
test_parse_args_checksum_choice_equals_forms();
|
||||
test_parse_args_checksum_choice_rejects_unsupported();
|
||||
test_parse_args_checksum_choice_new_algos();
|
||||
test_parse_args_checksum_none_with_checksum_rejected();
|
||||
test_parse_args_checksum_implies_incremental_only();
|
||||
test_parse_args_no_whole_file();
|
||||
test_parse_args_timeout_zero_and_no_forms();
|
||||
|
||||
@@ -157,20 +157,22 @@ static void test_chunk_compress_decompress_roundtrip() {
|
||||
|
||||
/* Build a zstd frame whose header omits the content size (the content size
|
||||
* flag is cleared), which ZSTD_getFrameContentSize reports as
|
||||
* ZSTD_CONTENTSIZE_UNKNOWN. */
|
||||
* ZSTD_CONTENTSIZE_UNKNOWN. The frame carries the codec-id prefix the
|
||||
* decompressor dispatches on. */
|
||||
static Data* make_unknown_size_frame(const void* src, size_t len) {
|
||||
ZSTD_CCtx* cctx = ZSTD_createCCtx();
|
||||
if (!cctx)
|
||||
return NULL;
|
||||
ZSTD_CCtx_setParameter(cctx, ZSTD_c_contentSizeFlag, 0);
|
||||
size_t cap = ZSTD_compressBound(len);
|
||||
Data* out = data_create_empty(cap);
|
||||
Data* out = data_create_empty(cap + 1);
|
||||
if (!out) {
|
||||
ZSTD_freeCCtx(cctx);
|
||||
return NULL;
|
||||
}
|
||||
((uint8_t*)out->data)[0] = (uint8_t)COMPRESSION_ALGO_ZSTD;
|
||||
ZSTD_inBuffer in = {src, len, 0};
|
||||
ZSTD_outBuffer ob = {out->data, cap, 0};
|
||||
ZSTD_outBuffer ob = {(uint8_t*)out->data + 1, cap, 0};
|
||||
size_t ret;
|
||||
do {
|
||||
ret = ZSTD_compressStream2(cctx, &ob, &in, ZSTD_e_end);
|
||||
@@ -180,7 +182,7 @@ static Data* make_unknown_size_frame(const void* src, size_t len) {
|
||||
return NULL;
|
||||
}
|
||||
} while (ret > 0);
|
||||
out->size = ob.pos;
|
||||
out->size = ob.pos + 1;
|
||||
ZSTD_freeCCtx(cctx);
|
||||
return out;
|
||||
}
|
||||
@@ -199,8 +201,9 @@ static void test_data_decompress_unknown_size_frame() {
|
||||
Data* frame = make_unknown_size_frame(buf, len);
|
||||
free(buf);
|
||||
EXPECT_NOT_NULL(frame);
|
||||
/* Guard the premise of the test: the frame really has no stored size. */
|
||||
EXPECT_EQ_INT((int)ZSTD_getFrameContentSize(frame->data, frame->size),
|
||||
/* Guard the premise of the test: the frame (after the codec byte) really has
|
||||
* no stored size. */
|
||||
EXPECT_EQ_INT((int)ZSTD_getFrameContentSize((uint8_t*)frame->data + 1, frame->size - 1),
|
||||
(int)ZSTD_CONTENTSIZE_UNKNOWN);
|
||||
|
||||
Data* decompressed = data_decompress(frame);
|
||||
@@ -326,6 +329,89 @@ static void test_data_decompress_truncated_frame_fails() {
|
||||
data_destroy(input);
|
||||
}
|
||||
|
||||
/* Every codec must round-trip byte-exactly through the self-describing frame,
|
||||
* including the empty and a highly compressible large payload. */
|
||||
static void codec_roundtrip(CompressionAlgo algo) {
|
||||
const char* samples[] = {
|
||||
"",
|
||||
"Hello, World! This is test data for compression round-trip!",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
};
|
||||
for (size_t s = 0; s < sizeof(samples) / sizeof(samples[0]); s++) {
|
||||
size_t len = strlen(samples[s]);
|
||||
Data* original = data_create_empty(len);
|
||||
EXPECT_NOT_NULL(original);
|
||||
if (len > 0)
|
||||
memcpy(original->data, samples[s], len);
|
||||
original->size = len;
|
||||
|
||||
Data* compressed = data_compress_codec(original, algo, 3, 0);
|
||||
EXPECT_NOT_NULL(compressed);
|
||||
EXPECT_EQ_INT((int)((uint8_t*)compressed->data)[0], (int)algo);
|
||||
Data* decompressed = data_decompress(compressed);
|
||||
EXPECT_NOT_NULL(decompressed);
|
||||
EXPECT_EQ_INT((int)decompressed->size, (int)len);
|
||||
EXPECT_EQ_INT(memcmp(decompressed->data, original->data, len), 0);
|
||||
data_destroy(decompressed);
|
||||
data_destroy(compressed);
|
||||
data_destroy(original);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_codec_roundtrips() {
|
||||
codec_roundtrip(COMPRESSION_ALGO_NONE);
|
||||
codec_roundtrip(COMPRESSION_ALGO_ZSTD);
|
||||
codec_roundtrip(COMPRESSION_ALGO_LZ4);
|
||||
codec_roundtrip(COMPRESSION_ALGO_ZLIB);
|
||||
codec_roundtrip(COMPRESSION_ALGO_ZLIBX);
|
||||
}
|
||||
|
||||
static void test_codec_name_mapping() {
|
||||
EXPECT_EQ_INT(compression_algo_from_name("zstd"), (int)COMPRESSION_ALGO_ZSTD);
|
||||
EXPECT_EQ_INT(compression_algo_from_name("ZSTD"), (int)COMPRESSION_ALGO_ZSTD);
|
||||
EXPECT_EQ_INT(compression_algo_from_name("lz4"), (int)COMPRESSION_ALGO_LZ4);
|
||||
EXPECT_EQ_INT(compression_algo_from_name("zlib"), (int)COMPRESSION_ALGO_ZLIB);
|
||||
EXPECT_EQ_INT(compression_algo_from_name("zlibx"), (int)COMPRESSION_ALGO_ZLIBX);
|
||||
EXPECT_EQ_INT(compression_algo_from_name("none"), (int)COMPRESSION_ALGO_NONE);
|
||||
EXPECT_TRUE(compression_algo_from_name("bogus") < 0);
|
||||
EXPECT_TRUE(compression_algo_from_name(NULL) < 0);
|
||||
EXPECT_TRUE(compression_algo_valid((int)COMPRESSION_ALGO_LZ4));
|
||||
EXPECT_TRUE(compression_algo_valid((int)COMPRESSION_ALGO_ZLIB));
|
||||
EXPECT_TRUE(compression_algo_valid((int)COMPRESSION_ALGO_ZLIBX));
|
||||
EXPECT_FALSE(compression_algo_valid(99));
|
||||
EXPECT_EQ_STR(compression_algo_name(COMPRESSION_ALGO_ZSTD), "zstd");
|
||||
EXPECT_EQ_STR(compression_algo_name(COMPRESSION_ALGO_LZ4), "lz4");
|
||||
EXPECT_EQ_STR(compression_algo_name(COMPRESSION_ALGO_ZLIB), "zlib");
|
||||
EXPECT_EQ_STR(compression_algo_name(COMPRESSION_ALGO_ZLIBX), "zlibx");
|
||||
EXPECT_EQ_STR(compression_algo_name(COMPRESSION_ALGO_NONE), "none");
|
||||
/* rsync 3.4.1 auto-negotiates zstd first. */
|
||||
EXPECT_EQ_INT((int)compression_negotiate_default(), (int)COMPRESSION_ALGO_ZSTD);
|
||||
EXPECT_FALSE(compression_algo_enabled(COMPRESSION_ALGO_NONE));
|
||||
EXPECT_TRUE(compression_algo_enabled(COMPRESSION_ALGO_ZSTD));
|
||||
}
|
||||
|
||||
/* The process-global codec selects what the legacy wrappers produce. */
|
||||
static void test_codec_global_selection() {
|
||||
Data* original = data_create_empty(64);
|
||||
EXPECT_NOT_NULL(original);
|
||||
memset(original->data, 'q', 64);
|
||||
original->size = 64;
|
||||
|
||||
compression_set_algo(COMPRESSION_ALGO_LZ4);
|
||||
Data* compressed = data_compress(original, 3);
|
||||
EXPECT_NOT_NULL(compressed);
|
||||
EXPECT_EQ_INT((int)((uint8_t*)compressed->data)[0], (int)COMPRESSION_ALGO_LZ4);
|
||||
Data* decompressed = data_decompress(compressed);
|
||||
EXPECT_NOT_NULL(decompressed);
|
||||
EXPECT_TRUE(memcmp(decompressed->data, original->data, 64) == 0);
|
||||
data_destroy(decompressed);
|
||||
data_destroy(compressed);
|
||||
|
||||
/* Restore the default so later tests are unaffected. */
|
||||
compression_set_algo(COMPRESSION_ALGO_ZSTD);
|
||||
data_destroy(original);
|
||||
}
|
||||
|
||||
void test_compression() {
|
||||
test_data_compress_decompress_roundtrip();
|
||||
test_data_compress_decompress_large();
|
||||
@@ -335,4 +421,7 @@ void test_compression() {
|
||||
test_data_compress_with_threads_roundtrip();
|
||||
test_data_compress_reused_contexts_multithreaded();
|
||||
test_chunk_compress_decompress_roundtrip();
|
||||
test_codec_roundtrips();
|
||||
test_codec_name_mapping();
|
||||
test_codec_global_selection();
|
||||
}
|
||||
+83
-11
@@ -852,13 +852,15 @@ static void test_config_delete_timing_early_helper() {
|
||||
cfg->use_delete = true;
|
||||
cfg->delete_before = true;
|
||||
EXPECT_TRUE(config_delete_timing_early(cfg));
|
||||
EXPECT_FALSE(config_delete_timing_per_dir(cfg));
|
||||
EXPECT_TRUE(config_has_valid_delete_timing(cfg));
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
cfg->use_delete = true;
|
||||
cfg->delete_during = true;
|
||||
EXPECT_TRUE(config_delete_timing_early(cfg));
|
||||
EXPECT_FALSE(config_delete_timing_early(cfg));
|
||||
EXPECT_TRUE(config_delete_timing_per_dir(cfg));
|
||||
EXPECT_TRUE(config_has_valid_delete_timing(cfg));
|
||||
config_delete(cfg);
|
||||
|
||||
@@ -866,6 +868,7 @@ static void test_config_delete_timing_early_helper() {
|
||||
cfg->use_delete = true;
|
||||
cfg->delete_delay = true;
|
||||
EXPECT_FALSE(config_delete_timing_early(cfg));
|
||||
EXPECT_TRUE(config_delete_timing_per_dir(cfg));
|
||||
EXPECT_TRUE(config_has_valid_delete_timing(cfg));
|
||||
config_delete(cfg);
|
||||
|
||||
@@ -873,6 +876,7 @@ static void test_config_delete_timing_early_helper() {
|
||||
cfg->use_delete = true;
|
||||
cfg->delete_after = true;
|
||||
EXPECT_FALSE(config_delete_timing_early(cfg));
|
||||
EXPECT_FALSE(config_delete_timing_per_dir(cfg));
|
||||
EXPECT_TRUE(config_has_valid_delete_timing(cfg));
|
||||
config_delete(cfg);
|
||||
|
||||
@@ -1192,7 +1196,10 @@ static void test_config_basis_wire_rejects_escaping() {
|
||||
c->basis_dirs = calloc(1, sizeof(BasisDest));
|
||||
c->basis_dirs[0].type = BASIS_DEST_LINK;
|
||||
c->basis_dirs[0].path = str_dup("/abs");
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
/* An absolute basis dir is accepted (rsync parity); it is only usable when it
|
||||
lies within the receiver's authorized root, which file_open_secure_parent
|
||||
enforces at lookup time. */
|
||||
EXPECT_TRUE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
|
||||
/* A well-formed list still round-trips even with a manually built struct. */
|
||||
@@ -1225,8 +1232,13 @@ static void test_config_basis_normalization() {
|
||||
/* Degenerate values that normalize away to nothing stay rejected. */
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "."), -1);
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, ".."), -1);
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/abs"), -1);
|
||||
/* An absolute path is canonicalized (leading '/' preserved) and accepted. */
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/abs"), 0);
|
||||
EXPECT_EQ_STR(c->basis_dirs[c->basis_count - 1].path, "/abs");
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/a//b/"), 0);
|
||||
EXPECT_EQ_STR(c->basis_dirs[c->basis_count - 1].path, "/a/b");
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "a/../b"), -1);
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/"), -1);
|
||||
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, ""), -1);
|
||||
config_delete(c);
|
||||
}
|
||||
@@ -1356,6 +1368,61 @@ static void test_config_receive_rejects_invalid_checksum_algo() {
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* The negotiated codec id and the human --compress-choice spelling must agree,
|
||||
* and the id itself must be a known codec. */
|
||||
static void test_config_receive_rejects_invalid_compression_algo() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->compression_algo = 99;
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
static void test_config_receive_rejects_codec_mismatch() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
free(c->compress_choice);
|
||||
c->compress_choice = str_dup("lz4");
|
||||
c->use_compression = true;
|
||||
c->compression_algo = (int)COMPRESSION_ALGO_ZSTD; /* does not match lz4 */
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
static void test_config_receive_rejects_none_codec_with_compression() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->use_compression = true;
|
||||
c->compression_algo = (int)COMPRESSION_ALGO_NONE;
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
static void test_config_receive_rejects_checksum_none_with_checksum() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->checksum = true;
|
||||
c->checksum_algo = (int)CHECKSUM_ALGO_NONE;
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
/* The identity-mapping fields (--numeric-ids / --usermap / --groupmap /
|
||||
--chown) cross the config wire unchanged: the receiver needs them to apply
|
||||
ownership with the same policy the client requested. */
|
||||
@@ -2537,6 +2604,7 @@ static bool basis_equal(const Config* a, const Config* b) {
|
||||
#define CONFIG_CMP_STR_MODULE(a, b, name) str_opt_equal((a)->name, (b)->name)
|
||||
#define CONFIG_CMP_STR_REDACTED_AUTH(a, b, name) str_opt_equal((a)->name, (b)->name)
|
||||
#define CONFIG_CMP_INT_CHECKSUM_ALGO(a, b, name) ((a)->name == (b)->name)
|
||||
#define CONFIG_CMP_INT_COMPRESSION_ALGO(a, b, name) ((a)->name == (b)->name)
|
||||
#define CONFIG_CMP_SUPERMODE(a, b, name) ((a)->name == (b)->name)
|
||||
#define CONFIG_CMP_INT_IDENTITY(a, b, name) ((a)->name == (b)->name)
|
||||
#define CONFIG_CMP_INT_SKIPCOUNT(a, b, name) ((a)->name == (b)->name)
|
||||
@@ -2763,14 +2831,14 @@ static void golden_config_populate(Config* c) {
|
||||
c->copy_as_gid = 222;
|
||||
}
|
||||
|
||||
/* The pinned golden frame (protocol 2.23.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.23.0
|
||||
* rsync-parity wave changes the config-frame layout (map-entry range + TO name,
|
||||
* one report_dest_info bool, and other wire changes landing in this version);
|
||||
* the byte-exact values are recomputed for the merged layout. */
|
||||
#define GOLDEN_WIRE_LEN 697
|
||||
#define GOLDEN_WIRE_HASH 7835017034643051109ULL
|
||||
* 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 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;
|
||||
@@ -2852,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.23.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())
|
||||
@@ -3147,6 +3215,10 @@ void test_config() {
|
||||
test_config_basis_normalization();
|
||||
test_config_checksum_options_wire_roundtrip();
|
||||
test_config_receive_rejects_invalid_checksum_algo();
|
||||
test_config_receive_rejects_invalid_compression_algo();
|
||||
test_config_receive_rejects_codec_mismatch();
|
||||
test_config_receive_rejects_none_codec_with_compression();
|
||||
test_config_receive_rejects_checksum_none_with_checksum();
|
||||
test_config_identity_wire_roundtrip();
|
||||
test_config_receive_rejects_invalid_identity();
|
||||
test_config_metadata_times_wire_roundtrip();
|
||||
|
||||
@@ -2002,6 +2002,100 @@ static void test_manifest_delete_missing_dir_budget_double_count() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* Blocker #7: when the receive root is "/", every absolute basis path is below
|
||||
it and its child relative form must drop only the single leading slash. */
|
||||
static void test_basis_delete_relative_root_slash() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->receive_root_directory = str_dup("/");
|
||||
|
||||
char* rel = file_receive_basis_delete_relative(cfg, "/a");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "a");
|
||||
free(rel);
|
||||
rel = file_receive_basis_delete_relative(cfg, "/a/b");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "a/b");
|
||||
free(rel);
|
||||
/* The root itself is not a child. */
|
||||
EXPECT_NULL(file_receive_basis_delete_relative(cfg, "/"));
|
||||
/* A relative entry is already root-relative. */
|
||||
rel = file_receive_basis_delete_relative(cfg, "x/y");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "x/y");
|
||||
free(rel);
|
||||
/* An absolute path outside a non-"/" root is unreachable. */
|
||||
free(cfg->receive_root_directory);
|
||||
cfg->receive_root_directory = str_dup("/root");
|
||||
EXPECT_NULL(file_receive_basis_delete_relative(cfg, "/other/a"));
|
||||
rel = file_receive_basis_delete_relative(cfg, "/root/a");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "a");
|
||||
free(rel);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Blocker #6: -n --delete would-delete enumeration must normalize an absolute
|
||||
basis directory under the receive root exactly like the real commit path, so
|
||||
the basis snapshot is protected rather than reported as a deletable extra. */
|
||||
static void test_manifest_would_delete_protects_absolute_basis() {
|
||||
char root[PATH_MAX];
|
||||
snprintf(root, sizeof(root), "/tmp/fastsync_wdbasis_%d", (int)getpid());
|
||||
char* basis = path_cat(root, "basis");
|
||||
char* basis_file = path_cat(basis, "snapshot.bin");
|
||||
char* extra = path_cat(root, "extra.txt");
|
||||
EXPECT_NOT_NULL(basis);
|
||||
EXPECT_NOT_NULL(basis_file);
|
||||
EXPECT_NOT_NULL(extra);
|
||||
mkdir(root, 0755);
|
||||
mkdir(basis, 0755);
|
||||
EXPECT_TRUE(file_write_to_disk(basis_file, "x", 1, false, false));
|
||||
EXPECT_TRUE(file_write_to_disk(extra, "e", 1, false, false));
|
||||
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->receive_root_directory = str_dup(root);
|
||||
cfg->use_delete = true;
|
||||
EXPECT_EQ_INT(config_basis_append(cfg, BASIS_DEST_COMPARE, basis), 0);
|
||||
|
||||
const char* synced[] = {"."};
|
||||
DeleteManifest manifest = {0};
|
||||
manifest.keeps = make_manifest_string_list(NULL, 0);
|
||||
manifest.protected = make_manifest_string_list(NULL, 0);
|
||||
manifest.dirs = make_manifest_string_list(synced, 1);
|
||||
EXPECT_NOT_NULL(manifest.keeps);
|
||||
EXPECT_NOT_NULL(manifest.protected);
|
||||
EXPECT_NOT_NULL(manifest.dirs);
|
||||
ArrayList* out = array_list_create(free);
|
||||
EXPECT_NOT_NULL(out);
|
||||
size_t count = 0;
|
||||
EXPECT_TRUE(manifest_would_delete_list(cfg, &manifest, out, &count));
|
||||
bool saw_basis = false;
|
||||
bool saw_extra = false;
|
||||
for (int i = 0; i < out->size; i++) {
|
||||
const char* p = (const char*)out->items[i];
|
||||
if (strcmp(p, "basis") == 0 || strncmp(p, "basis/", 6) == 0)
|
||||
saw_basis = true;
|
||||
if (strcmp(p, "extra.txt") == 0)
|
||||
saw_extra = true;
|
||||
}
|
||||
EXPECT_FALSE(saw_basis);
|
||||
EXPECT_TRUE(saw_extra);
|
||||
|
||||
array_list_delete(out);
|
||||
array_list_delete(manifest.keeps);
|
||||
array_list_delete(manifest.protected);
|
||||
array_list_delete(manifest.dirs);
|
||||
config_delete(cfg);
|
||||
unlink(basis_file);
|
||||
rmdir(basis);
|
||||
unlink(extra);
|
||||
rmdir(root);
|
||||
free(basis);
|
||||
free(basis_file);
|
||||
free(extra);
|
||||
}
|
||||
|
||||
void test_file() {
|
||||
test_file_create();
|
||||
test_file_special_rdev_valid();
|
||||
@@ -2057,4 +2151,6 @@ void test_file() {
|
||||
test_inplace_refuses_fifo_destination();
|
||||
test_inplace_refuses_device_destination();
|
||||
test_manifest_delete_missing_dir_budget_double_count();
|
||||
test_basis_delete_relative_root_slash();
|
||||
test_manifest_would_delete_protects_absolute_basis();
|
||||
}
|
||||
+16
-15
@@ -18,9 +18,10 @@
|
||||
|
||||
/* P8 config-frame tail: super_mode (4) + copy-as presence (4) + uid (4) + gid (4). */
|
||||
#define P8_TAIL_BYTES 16
|
||||
/* Protocol 2.23.0 appends one trailing bool (report_dest_info) AFTER the P8
|
||||
* tail, so the P8 fields sit this many bytes before the end of the frame. */
|
||||
#define OUTPUT_TAIL_BYTES 4
|
||||
/* Bytes after the P8 tail: report_dest_info (4), report_stats (4, wire-stats
|
||||
* wave) and compression_algo (4, codec wave). The P8 fields sit this many
|
||||
* bytes before the end of the frame. */
|
||||
#define POST_P8_TAIL_BYTES 12
|
||||
|
||||
/* Smoke test for chunk_deserialize fuzz target */
|
||||
static void test_fuzz_chunk_deserialize() {
|
||||
@@ -323,7 +324,7 @@ static void test_fuzz_config_receive_p8_tail() {
|
||||
size_t len = 0;
|
||||
bool captured = capture_config_frame(c, &frame, &len);
|
||||
config_delete(c);
|
||||
if (!captured || len <= P8_TAIL_BYTES) {
|
||||
if (!captured || len <= P8_TAIL_BYTES + POST_P8_TAIL_BYTES) {
|
||||
free(frame);
|
||||
EXPECT_TRUE(false);
|
||||
return;
|
||||
@@ -337,31 +338,31 @@ static void test_fuzz_config_receive_p8_tail() {
|
||||
|
||||
/* super_mode outside the 0..2 tri-state is refused. */
|
||||
memcpy(mut, frame, len);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES, 99);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES, 99);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES, -1);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES, -1);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
|
||||
/* A negative (sentinel) and an extreme copy-as uid/gid are refused. */
|
||||
memcpy(mut, frame, len);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES, SUPER_MODE_AUTO);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES + 4, 1);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES + 8, -1);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES + 12, 0);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES, SUPER_MODE_AUTO);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES + 4, 1);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES + 8, -1);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES + 12, 0);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES + 8, 0);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES + 12, INT32_MIN);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES + 8, 0);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES + 12, INT32_MIN);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
|
||||
/* A presence int that is not a wire bool is refused. */
|
||||
memcpy(mut, frame, len);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES, SUPER_MODE_AUTO);
|
||||
put_i32(mut, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES + 4, 2);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES, SUPER_MODE_AUTO);
|
||||
put_i32(mut, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES + 4, 2);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
|
||||
/* Truncating anywhere inside the P8 tail is refused. */
|
||||
EXPECT_FALSE(receive_config_frame(frame, len - 2));
|
||||
EXPECT_FALSE(receive_config_frame(frame, len - OUTPUT_TAIL_BYTES - P8_TAIL_BYTES));
|
||||
EXPECT_FALSE(receive_config_frame(frame, len - POST_P8_TAIL_BYTES - P8_TAIL_BYTES));
|
||||
|
||||
free(mut);
|
||||
free(frame);
|
||||
|
||||
+4
-1
@@ -213,5 +213,8 @@ void test_iconv() {
|
||||
test_iconv_wire_sender_converts_local_to_remote();
|
||||
test_iconv_wire_receiver_converts_remote_to_local();
|
||||
test_iconv_wire_disabled_passthrough();
|
||||
test_iconv_wire_str_roundtrip();
|
||||
// This subtest forks to exercise the wire string handshake; the instrumented
|
||||
// parent is too slow under valgrind for the child's blocking reads.
|
||||
if (!is_running_under_valgrind())
|
||||
test_iconv_wire_str_roundtrip();
|
||||
}
|
||||
@@ -243,6 +243,7 @@ static void test_write_thread_done() {
|
||||
* However, pipeline_context_receiver_destroy will call config_delete
|
||||
* 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);
|
||||
mtx_destroy(&ctx->mutex);
|
||||
cnd_destroy(&ctx->condition_not_full);
|
||||
cnd_destroy(&ctx->condition_not_empty);
|
||||
@@ -327,6 +328,7 @@ static void test_receiver_enqueue_byte_budget() {
|
||||
EXPECT_EQ_INT((int)ctx->queued_bytes, 2000); /* second payload now in flight */
|
||||
|
||||
/* Tear down: the second file is still queued and is freed by queue_destroy. */
|
||||
array_list_delete(ctx->would_delete);
|
||||
mtx_destroy(&ctx->mutex);
|
||||
cnd_destroy(&ctx->condition_not_full);
|
||||
cnd_destroy(&ctx->condition_not_empty);
|
||||
|
||||
@@ -65,7 +65,7 @@ static void test_receiver_aborts_idle_keepalive() {
|
||||
ssize_t wrote = write(sv[0], &keepalive, sizeof(keepalive));
|
||||
int result = -2;
|
||||
if (wrote == (ssize_t)sizeof(keepalive))
|
||||
result = receiver_process_pending(config, sv[1], &sink, NULL);
|
||||
result = receiver_process_pending(config, sv[1], &sink, NULL, NULL);
|
||||
Status reply = STATUS_OK;
|
||||
ssize_t got = -1;
|
||||
if (result == -1)
|
||||
|
||||
+106
-6
@@ -855,7 +855,7 @@ static void test_filter_rules(bool parallel) {
|
||||
/* - *.tmp excludes only the tmp file; other files remain (default include). */
|
||||
const char* exclude_only[] = {"- *.tmp"};
|
||||
char err[160];
|
||||
FilterRuleList* base = filter_base_build(exclude_only, 1, false, err, sizeof(err));
|
||||
FilterRuleList* base = filter_base_build(exclude_only, 1, false, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(base);
|
||||
ScannerOptions options = {0};
|
||||
options.base_filters = base;
|
||||
@@ -875,7 +875,7 @@ static void test_filter_rules(bool parallel) {
|
||||
|
||||
/* Anchored include then exclude-all: only root-level keep* survives. */
|
||||
const char* anchored[] = {"+ /a.txt", "- *"};
|
||||
base = filter_base_build(anchored, 2, false, err, sizeof(err));
|
||||
base = filter_base_build(anchored, 2, false, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(base);
|
||||
options.base_filters = base;
|
||||
rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
|
||||
@@ -889,7 +889,7 @@ static void test_filter_rules(bool parallel) {
|
||||
/* The common include idiom (the exact rule order the CLI compiles from
|
||||
* --include='*.txt' --exclude='*'): only .txt files survive. */
|
||||
const char* idiom[] = {"+ *.txt", "- *"};
|
||||
base = filter_base_build(idiom, 2, false, err, sizeof(err));
|
||||
base = filter_base_build(idiom, 2, false, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(base);
|
||||
options.base_filters = base;
|
||||
rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
|
||||
@@ -905,7 +905,7 @@ static void test_filter_rules(bool parallel) {
|
||||
/* An include rule alone is NOT a mandatory whitelist (rsync semantics): only
|
||||
* the matching file is affected, everything else is still transferred. */
|
||||
const char* include_alone[] = {"+ *.txt"};
|
||||
base = filter_base_build(include_alone, 1, false, err, sizeof(err));
|
||||
base = filter_base_build(include_alone, 1, false, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(base);
|
||||
options.base_filters = base;
|
||||
rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
|
||||
@@ -932,7 +932,7 @@ static void test_filter_dir_only_and_anchored(bool parallel) {
|
||||
|
||||
const char* rules[] = {"- /sub/"};
|
||||
char err[160];
|
||||
FilterRuleList* base = filter_base_build(rules, 1, false, err, sizeof(err));
|
||||
FilterRuleList* base = filter_base_build(rules, 1, false, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(base);
|
||||
ScannerOptions options = {0};
|
||||
options.base_filters = base;
|
||||
@@ -966,7 +966,7 @@ static void test_cvs_defaults(bool parallel) {
|
||||
create_test_file("test_scan_cvs/keep.txt", "keep");
|
||||
|
||||
char err[160];
|
||||
FilterRuleList* base = filter_base_build(NULL, 0, true, err, sizeof(err));
|
||||
FilterRuleList* base = filter_base_build(NULL, 0, true, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(base);
|
||||
ScannerOptions options = {0};
|
||||
options.base_filters = base;
|
||||
@@ -1005,6 +1005,7 @@ static void test_per_dir_filter(bool parallel) {
|
||||
|
||||
ScannerOptions options = {0};
|
||||
options.per_dir_filters = true;
|
||||
options.exclude_per_dir_filter_files = true; /* -FF */
|
||||
if (parallel)
|
||||
options.num_threads = 2;
|
||||
char** paths = NULL;
|
||||
@@ -1069,6 +1070,59 @@ static void test_scanner_path_relative() {
|
||||
EXPECT_NULL(scanner_path_relative("/tmp/foo", "/tmp/foobar"));
|
||||
}
|
||||
|
||||
/* -R/--relative destination prefix: the '/./' cut point and normalization. */
|
||||
static void test_scanner_relative_prefix() {
|
||||
char* p = NULL;
|
||||
|
||||
/* No cut: the whole spec with leading/trailing slashes removed. */
|
||||
p = scanner_relative_prefix("/tmp/src/foo/");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "tmp/src/foo");
|
||||
free(p);
|
||||
|
||||
p = scanner_relative_prefix("src/foo");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "src/foo");
|
||||
free(p);
|
||||
|
||||
/* Trailing "/." is the directory itself, not a cut. */
|
||||
p = scanner_relative_prefix("src/foo/.");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "src/foo");
|
||||
free(p);
|
||||
|
||||
/* The first "/./" cuts everything before it. */
|
||||
p = scanner_relative_prefix("/a/./b/c");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "b/c");
|
||||
free(p);
|
||||
|
||||
p = scanner_relative_prefix("src/./");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "");
|
||||
free(p);
|
||||
|
||||
/* A later "." component is normalized away. */
|
||||
p = scanner_relative_prefix("a/./b/./c");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "b/c");
|
||||
free(p);
|
||||
|
||||
/* A leading "./" is the cut at the start. */
|
||||
p = scanner_relative_prefix("./s2");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "s2");
|
||||
free(p);
|
||||
|
||||
p = scanner_relative_prefix(".");
|
||||
EXPECT_NOT_NULL(p);
|
||||
EXPECT_EQ_STR(p, "");
|
||||
free(p);
|
||||
|
||||
EXPECT_NULL(scanner_relative_prefix(NULL));
|
||||
EXPECT_NULL(scanner_relative_prefix(""));
|
||||
}
|
||||
|
||||
/* rsync precedence: a deeper .rsync-filter overrides a shallower one, so an
|
||||
* inner "+ *.tmp" re-includes what the outer "- *.tmp" excluded. */
|
||||
static void test_per_dir_filter_override(bool parallel) {
|
||||
@@ -1084,6 +1138,7 @@ static void test_per_dir_filter_override(bool parallel) {
|
||||
|
||||
ScannerOptions options = {0};
|
||||
options.per_dir_filters = true;
|
||||
options.exclude_per_dir_filter_files = true; /* -FF */
|
||||
if (parallel)
|
||||
options.num_threads = 2;
|
||||
char** paths = NULL;
|
||||
@@ -1533,6 +1588,49 @@ static void test_scanner_entry_classification() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* A dereferenced symlink with no referent (broken/unreadable) must record a
|
||||
* non-fatal I/O error so the run can exit 23 like rsync, without aborting the
|
||||
* scan or treating the condition as a fatal failure. */
|
||||
static void test_scanner_broken_referent_io_error(void) {
|
||||
const char* root = "test_scan_broken_ref";
|
||||
const char* good = "test_scan_broken_ref/good.txt";
|
||||
const char* broken = "test_scan_broken_ref/broken";
|
||||
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
create_test_file(good, "hello");
|
||||
EXPECT_EQ_INT(symlink("/nonexistent/quickwins/target", broken), 0);
|
||||
|
||||
{
|
||||
ScannerOptions options = {0};
|
||||
options.copy_links = true;
|
||||
DirectoryScanner* scanner = directory_scanner_create_with_options(root, &options);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
Chunk* chunk;
|
||||
while ((chunk = directory_scanner_next(scanner)) != NULL)
|
||||
chunk_destroy(chunk);
|
||||
EXPECT_FALSE(directory_scanner_failed(scanner));
|
||||
EXPECT_TRUE(directory_scanner_had_io_error(scanner));
|
||||
directory_scanner_destroy(scanner);
|
||||
}
|
||||
|
||||
{
|
||||
ScannerOptions options = {0};
|
||||
options.copy_links = true;
|
||||
ParallelScanner* scanner = parallel_scanner_create_with_options(root, &options, NULL);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
Chunk* chunk;
|
||||
while ((chunk = parallel_scanner_next(scanner)) != NULL)
|
||||
chunk_destroy(chunk);
|
||||
EXPECT_FALSE(parallel_scanner_failed(scanner));
|
||||
EXPECT_TRUE(parallel_scanner_had_io_error(scanner));
|
||||
parallel_scanner_destroy(scanner);
|
||||
}
|
||||
|
||||
unlink(broken);
|
||||
unlink(good);
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
void test_scanner() {
|
||||
test_scanner_single_file();
|
||||
test_scanner_multiple_files();
|
||||
@@ -1551,6 +1649,7 @@ void test_scanner() {
|
||||
test_scanner_one_file_system_decision();
|
||||
test_scanner_one_file_system_same_device();
|
||||
test_parallel_scanner_one_file_system_same_device();
|
||||
test_scanner_broken_referent_io_error();
|
||||
test_scanner_one_file_system_cross_device();
|
||||
test_files_from_subset(false);
|
||||
test_files_from_subset(true);
|
||||
@@ -1563,6 +1662,7 @@ void test_scanner() {
|
||||
test_per_dir_filter(false);
|
||||
test_per_dir_filter(true);
|
||||
test_scanner_path_relative();
|
||||
test_scanner_relative_prefix();
|
||||
test_per_dir_filter_override(false);
|
||||
test_per_dir_filter_override(true);
|
||||
test_dirs_no_descent();
|
||||
|
||||
+56
-5
@@ -566,7 +566,7 @@ static Config* make_late_delete_config(const char* root) {
|
||||
|
||||
static int run_pending_receiver(Config* cfg, int fd, DeleteManifest** pending) {
|
||||
ReceiverSink sink = {0};
|
||||
return receiver_process_pending(cfg, fd, &sink, pending);
|
||||
return receiver_process_pending(cfg, fd, &sink, pending, NULL);
|
||||
}
|
||||
|
||||
static void test_late_manifest_abort_frees_keepset() {
|
||||
@@ -797,7 +797,7 @@ static void test_receiver_pending_commits_missing_args() {
|
||||
/* NULL pending: the single-threaded commit path deletes at FINISHED. The
|
||||
sink sends the terminal STATUS_OK success frame. */
|
||||
ReceiverSink sink = {.send_success = true};
|
||||
EXPECT_EQ_INT(receiver_process_pending(cfg, p[0], &sink, NULL), 0);
|
||||
EXPECT_EQ_INT(receiver_process_pending(cfg, p[0], &sink, NULL, NULL), 0);
|
||||
Status ack;
|
||||
EXPECT_TRUE(receive_status(p[1], &ack));
|
||||
EXPECT_EQ_INT(ack, STATUS_OK);
|
||||
@@ -1072,10 +1072,10 @@ static void test_incremental_check_basis_fifo_does_not_hang() {
|
||||
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
|
||||
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
|
||||
/* config_has_basis() makes the request carry the source digest. */
|
||||
uint8_t wire_len = 8;
|
||||
uint8_t digest[8] = {0};
|
||||
uint8_t wire_len = checksum_digest_len((ChecksumAlgo)cfg->checksum_algo);
|
||||
uint8_t digest[CHECKSUM_MAX_DIGEST_LEN] = {0};
|
||||
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
|
||||
EXPECT_TRUE(send_n_data(p[1], digest, sizeof(digest)));
|
||||
EXPECT_TRUE(send_n_data(p[1], digest, wire_len));
|
||||
Status s;
|
||||
EXPECT_TRUE(receive_status(p[1], &s));
|
||||
EXPECT_EQ_INT(s, STATUS_NEXT);
|
||||
@@ -1128,6 +1128,56 @@ static void test_receive_manifest_total_entry_cap() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* A server-contacting --dry-run must never delete, even on the per-directory
|
||||
(--delete-during/--delete-delay) commit path. The receive path already skips
|
||||
plan application under -n, but a plan frame carrying --delete-missing-args
|
||||
exact deletions used to be honored by delete_plan_session_commit(). Seed a
|
||||
destination mirror, stream a plan naming it, and prove it survives. */
|
||||
static void test_dry_run_delete_plan_commit_does_not_delete() {
|
||||
char* root = make_check_root("drydelplan");
|
||||
EXPECT_NOT_NULL(root);
|
||||
write_check_file(root, "victim.txt", "must survive");
|
||||
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup(root);
|
||||
cfg->use_delete = true;
|
||||
cfg->delete_during = true;
|
||||
cfg->delete_missing_args = true;
|
||||
cfg->dry_run = true;
|
||||
|
||||
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);
|
||||
|
||||
EXPECT_TRUE(send_status(p[1], STATUS_DELETE_PLAN));
|
||||
EXPECT_TRUE(send_int(p[1], 1)); /* first frame carries the config sections */
|
||||
EXPECT_TRUE(send_int(p[1], 0)); /* protected prefixes */
|
||||
EXPECT_TRUE(send_int(p[1], 0)); /* size-skipped prefixes */
|
||||
EXPECT_TRUE(send_int(p[1], 1)); /* missing-args exact deletions */
|
||||
EXPECT_TRUE(send_str(p[1], "victim.txt"));
|
||||
EXPECT_TRUE(send_str(p[1], ".")); /* receive root plan */
|
||||
EXPECT_TRUE(send_int(p[1], 0)); /* kept child directories */
|
||||
EXPECT_TRUE(send_int(p[1], 0)); /* kept child files */
|
||||
EXPECT_TRUE(send_status(p[1], STATUS_FINISHED));
|
||||
|
||||
ReceiverSink sink = {.send_success = true};
|
||||
EXPECT_EQ_INT(receiver_process_pending(cfg, p[0], &sink, NULL, NULL), 0);
|
||||
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/victim.txt", root);
|
||||
EXPECT_EQ_INT(access(path, F_OK), 0);
|
||||
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
config_delete(cfg);
|
||||
remove(path);
|
||||
rmdir(root);
|
||||
free(root);
|
||||
}
|
||||
|
||||
void test_server() {
|
||||
test_special_socket_path_log_escaped();
|
||||
if (!is_running_under_valgrind()) {
|
||||
@@ -1150,5 +1200,6 @@ void test_server() {
|
||||
test_receive_manifest_three_sections();
|
||||
test_manifest_delete_missing_args();
|
||||
test_receiver_pending_commits_missing_args();
|
||||
test_dry_run_delete_plan_commit_does_not_delete();
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -6,10 +6,22 @@
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
// Detect if running under valgrind by checking /proc/self/maps for vgpreload.
|
||||
// This is used to skip fork-based tests that are incompatible with valgrind
|
||||
// (the instrumented parent runs too slowly, causing pipe timeouts).
|
||||
// Reset the thread-local protocol descriptor redirection installed by
|
||||
// io_set_fds(), so a suite that leaks a test pipe's fds cannot redirect a later
|
||||
// suite's raw send_n_data()/receive_n_data() to the wrong descriptor.
|
||||
void io_set_fds(int read_fd, int write_fd);
|
||||
|
||||
// Detect if running under valgrind. The CI valgrind step exports
|
||||
// FASTSYNC_UNDER_VALGRIND=1; a /proc/self/maps scan is the fallback for a local
|
||||
// valgrind run (newer valgrind versions can hide their own mappings from the
|
||||
// guest, so the "vgpreload" match is not reliable on every version -- set
|
||||
// FASTSYNC_UNDER_VALGRIND=1 when invoking valgrind by hand). Used to skip
|
||||
// fork-based tests incompatible with valgrind, whose instrumented parent runs
|
||||
// too slowly and causes pipe timeouts.
|
||||
static inline bool is_running_under_valgrind(void) {
|
||||
const char* env = getenv("FASTSYNC_UNDER_VALGRIND");
|
||||
if (env && env[0] != '\0' && strcmp(env, "0") != 0)
|
||||
return true;
|
||||
FILE* f = fopen("/proc/self/maps", "r");
|
||||
if (!f)
|
||||
return false;
|
||||
@@ -31,6 +43,7 @@ extern bool current_test_failed;
|
||||
printf("Running %s...\n", #test_func); \
|
||||
tests_run++; \
|
||||
current_test_failed = false; \
|
||||
io_set_fds(-1, -1); \
|
||||
test_func(); \
|
||||
if (current_test_failed) { \
|
||||
tests_failed++; \
|
||||
|
||||
Reference in new issue
Block a user