Release v2.26.0 #284

Merged
TapTap merged 210 commits from dev into main 2026-09-18 19:05:52 +02:00
19 changed files with 356 additions and 43 deletions
Showing only changes of commit 88aee6ce94 - Show all commits
+1 -1
View File
@@ -16,7 +16,7 @@ Ask the user or determine from context:
- **Minor** (x.Y.0) — new features, backward compatible - **Minor** (x.Y.0) — new features, backward compatible
- **Patch** (x.y.Z) — bug fixes, no protocol changes - **Patch** (x.y.Z) — bug fixes, no protocol changes
Current version: `PROTOCOL_VERSION "2.20.0"` in `src/shared/config.h` Current version: `PROTOCOL_VERSION "2.21.0"` in `src/shared/config.h`
### Step 2: Check Protocol Version ### Step 2: Check Protocol Version
+20
View File
@@ -23,6 +23,26 @@ run the same version because the handshake is strict.
shared NAT/proxy still share a single per-host budget and lockout, which is shared NAT/proxy still share a single per-host budget and lockout, which is
documented. documented.
## [2.21.0] - 2026-09-13
### Added
- Optional server→client rejection detail (protocol 2.21.0). A rejected
operation may now carry a bounded human-readable reason via
`STATUS_ERROR_DETAIL` instead of a bare `STATUS_ERROR`, so the client can
report *why* the server refused (daemon module gate, config validation,
receiver-side path/node validation). `receive_status()` transparently maps the
new status back to `STATUS_ERROR` for every existing call site and captures
the reason into a thread-local buffer exposed by `protocol_last_error()`. The
detail body is always consumed, so the stream cannot desynchronize, and
messages are sliced to `MAX_ERROR_DETAIL_BYTES` (4096) on send.
### Changed
- `receive_incremental_check()` (the per-file `STATUS_CHECK` fast path) is split
into small static helpers with a short linear orchestrator. Pure refactor: the
wire byte stream and all cleanup are unchanged.
## [2.20.0] - 2026-09-13 ## [2.20.0] - 2026-09-13
### Security ### Security
+2 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.22) cmake_minimum_required(VERSION 3.22)
project(FastFileTransfer VERSION 2.20.0) project(FastFileTransfer VERSION 2.21.0)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
@@ -223,6 +223,7 @@ set(TEST_SRCS
tests/test_multiprocessing.c tests/test_multiprocessing.c
tests/test_property.c tests/test_property.c
tests/test_protocol.c tests/test_protocol.c
tests/test_protocol_error.c
tests/test_queue.c tests/test_queue.c
tests/test_receiver_timeout.c tests/test_receiver_timeout.c
tests/test_robustness.c tests/test_robustness.c
+1 -1
View File
@@ -582,7 +582,7 @@ before the module list, before authentication, and the connecting peer address
## Protocol and Security ## Protocol and Security
FastSync protocol version `2.20.0` is shared by the client and server. The FastSync protocol version `2.21.0` is shared by the client and server. The
current protocol is sender-driven and includes configuration negotiation, current protocol is sender-driven and includes configuration negotiation,
including the maximum allocation limit, incremental checks, checksums, including the maximum allocation limit, incremental checks, checksums,
manifests, keep-alives, abort handling, per-file remove-source results, and manifests, keep-alives, abort handling, per-file remove-source results, and
+2 -2
View File
@@ -679,7 +679,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | 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-after=MINS` | Stop after N minutes | ✅ Implemented | 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 | ✅ Implemented | 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 | ✅ Implemented | 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 |
| `--fsync` | Fsync every written file before publication | ✅ Implemented | | | `--fsync` | Fsync every written file before publication | ✅ Implemented | |
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.20.0) with no downgrade/backward-compat code paths, so `--protocol=2.20.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.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 | | `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.21.0) with no downgrade/backward-compat code paths, so `--protocol=2.21.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.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 | ✅ Implemented | 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 | | `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | 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 |
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--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`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior | | `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--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`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
| `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | 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`. | | `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | 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`. |
@@ -795,7 +795,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 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.20.0` (the current `PROTOCOL_VERSION`, as of the packed-metadata wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `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) 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: `--protocol=2.21.0` (the current `PROTOCOL_VERSION`, as of the error-detail wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `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) 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). **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).
+25 -5
View File
@@ -48,6 +48,17 @@
Always cast to double when dividing so the output stays fractional. */ Always cast to double when dividing so the output stays fractional. */
#define BYTES_PER_MIB (1024ULL * 1024ULL) #define BYTES_PER_MIB (1024ULL * 1024ULL)
/* Surface a server rejection to the user. When the last status exchange
carried a STATUS_ERROR_DETAIL reason (protocol 2.21.0) it is appended to the
client-side context; a bare STATUS_ERROR still logs the context alone. */
static void log_server_rejection(const char* context) {
const char* detail = protocol_last_error();
if (detail && detail[0] != '\0')
log_message(LOG_LEVEL_ERROR, "%s: %s", context, detail);
else
log_message(LOG_LEVEL_ERROR, "%s", context);
}
/* Forward declaration for progress-reporting thread used in multithreaded send. */ /* Forward declaration for progress-reporting thread used in multithreaded send. */
static int progress_thread_fn(void* arg); static int progress_thread_fn(void* arg);
@@ -608,8 +619,10 @@ static bool finalize_transfer(Client* client, const Config* config, ArrayList* r
Status per_file; Status per_file;
if (!receive_status(client->file_descriptor, &per_file)) if (!receive_status(client->file_descriptor, &per_file))
return false; return false;
if (per_file == STATUS_ERROR) if (per_file == STATUS_ERROR) {
log_server_rejection("Receiver reported a per-file error");
return false; return false;
}
if (per_file == STATUS_OK) { if (per_file == STATUS_OK) {
((SourceFile*)remove_sources->items[i])->skipped = true; ((SourceFile*)remove_sources->items[i])->skipped = true;
} else if (per_file != STATUS_NEXT) { } else if (per_file != STATUS_NEXT) {
@@ -619,7 +632,13 @@ static bool finalize_transfer(Client* client, const Config* config, ArrayList* r
} }
} }
Status status; Status status;
return receive_status(client->file_descriptor, &status) && status == STATUS_OK; if (!receive_status(client->file_descriptor, &status))
return false;
if (status != STATUS_OK) {
log_server_rejection("Receiver reported transfer failure");
return false;
}
return true;
} }
static void pipeline_cancel(PipelineContextSender* context) { static void pipeline_cancel(PipelineContextSender* context) {
@@ -914,7 +933,7 @@ static bool send_delete_manifest_early(Client* client, ArrayList* manifest,
return false; return false;
} }
if (ack != STATUS_OK) { if (ack != STATUS_OK) {
log_message(LOG_LEVEL_ERROR, "Server failed to delete files before the transfer"); log_server_rejection("Server failed to delete files before the transfer");
return false; return false;
} }
return true; return true;
@@ -991,7 +1010,7 @@ static int incremental_check(Client* client, File* file, const Config* config,
if (!receive_status(client->file_descriptor, &s)) if (!receive_status(client->file_descriptor, &s))
return -1; return -1;
if (s == STATUS_ERROR) { if (s == STATUS_ERROR) {
log_message(LOG_LEVEL_ERROR, "Server reported error for file"); log_server_rejection("Server reported error for file");
return -1; return -1;
} }
if (s == STATUS_OK) if (s == STATUS_OK)
@@ -1025,7 +1044,7 @@ static int incremental_check(Client* client, File* file, const Config* config,
return 3; return 3;
} }
if (s != STATUS_NEXT) { if (s != STATUS_NEXT) {
log_message(LOG_LEVEL_ERROR, "Unexpected server status"); log_server_rejection("Unexpected server status");
send_status(client->file_descriptor, STATUS_ERROR); send_status(client->file_descriptor, STATUS_ERROR);
return -1; return -1;
} }
@@ -1119,6 +1138,7 @@ static int send_append(const Client* client, File* file, Config* config,
return rc; return rc;
} }
if (resp != STATUS_APPEND_OK) { if (resp != STATUS_APPEND_OK) {
log_server_rejection("Unexpected append-verify response");
send_status(fd, STATUS_ERROR); send_status(fd, STATUS_ERROR);
return -1; return -1;
} }
+1 -1
View File
@@ -899,7 +899,7 @@ void handler(int file_descriptor) {
if (!receiver_send_final_success(file_descriptor, config, &context->outcomes)) if (!receiver_send_final_success(file_descriptor, config, &context->outcomes))
transfer_ok = false; transfer_ok = false;
} else { } else {
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "transfer failed on receiver");
} }
if (!transfer_ok) if (!transfer_ok)
log_message(LOG_LEVEL_ERROR, "Transfer failed"); log_message(LOG_LEVEL_ERROR, "Transfer failed");
+15 -5
View File
@@ -773,8 +773,8 @@ static bool config_receive_module(int fd, Config* c, ConfigStringBudget* budget)
return false; return false;
if (*module != '\0' && !daemon_module_name_valid(module)) { if (*module != '\0' && !daemon_module_name_valid(module)) {
log_message(LOG_LEVEL_WARNING, "Daemon client sent an invalid or over-long module name"); log_message(LOG_LEVEL_WARNING, "Daemon client sent an invalid or over-long module name");
send_error_detail(fd, "invalid or over-long daemon module name");
free(module); free(module);
send_status(fd, STATUS_ERROR);
return false; return false;
} }
if (*module != '\0') { if (*module != '\0') {
@@ -1238,6 +1238,10 @@ bool config_send(int file_descriptor, const Config* config) {
return false; return false;
} }
if (status != STATUS_OK) { if (status != STATUS_OK) {
const char* detail = protocol_last_error();
if (detail && detail[0] != '\0')
log_message(LOG_LEVEL_ERROR, "Error transmitting config: %s", detail);
else
log_message(LOG_LEVEL_ERROR, "Error transmitting config"); log_message(LOG_LEVEL_ERROR, "Error transmitting config");
return false; return false;
} }
@@ -1258,8 +1262,11 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
char* escaped_version = output_escape(config->version, false); char* escaped_version = output_escape(config->version, false);
log_message(LOG_LEVEL_ERROR, "Protocol version mismatch: client=%s, server=%s", log_message(LOG_LEVEL_ERROR, "Protocol version mismatch: client=%s, server=%s",
escaped_version ? escaped_version : "<allocation failed>", PROTOCOL_VERSION); escaped_version ? escaped_version : "<allocation failed>", PROTOCOL_VERSION);
char detail[160];
snprintf(detail, sizeof(detail), "protocol version mismatch (client=%s, server=%s)",
escaped_version ? escaped_version : "<allocation failed>", PROTOCOL_VERSION);
send_error_detail(file_descriptor, detail);
free(escaped_version); free(escaped_version);
send_status(file_descriptor, STATUS_ERROR);
goto error; goto error;
} }
if (!receive_core_fields(file_descriptor, config, &budget) || if (!receive_core_fields(file_descriptor, config, &budget) ||
@@ -1285,13 +1292,16 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output); char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output);
log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s", log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s",
escaped_choice ? escaped_choice : "<allocation failed>"); 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); free(escaped_choice);
send_status(file_descriptor, STATUS_ERROR);
goto error; goto error;
} }
if (!validate_received_config(config)) { if (!validate_received_config(config)) {
log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client"); log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client");
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "invalid configuration received from client");
goto error; goto error;
} }
if (validate) { if (validate) {
@@ -1305,7 +1315,7 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
* written. */ * written. */
if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) { if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) {
log_message(LOG_LEVEL_ERROR, "%s", rejection); log_message(LOG_LEVEL_ERROR, "%s", rejection);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, rejection);
} }
goto error; goto error;
} }
+17 -3
View File
@@ -76,7 +76,7 @@ typedef struct {
typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode; 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.20.0). * Config wire-field table (single source of truth for protocol 2.21.0).
* *
* Every field below crosses the wire. The table is the ONLY place a * 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 * serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare
@@ -756,8 +756,22 @@ typedef struct Config {
* The bump is therefore a deliberate lockstep-release marker, not a * The bump is therefore a deliberate lockstep-release marker, not a
* desynchronization fix — the strict same-version handshake still rejects a * desynchronization fix — the strict same-version handshake still rejects a
* mixed 2.19/2.20 deployment. The chunk codec, which already used the packed * mixed 2.19/2.20 deployment. The chunk codec, which already used the packed
* metadata_to_buf()/metadata_from_buf() form, is unchanged. */ * metadata_to_buf()/metadata_from_buf() form, is unchanged.
#define PROTOCOL_VERSION "2.20.0" *
* Error-Detail Wave: 2.20.0 -> 2.21.0.
*
* WHY the bump, grounded in the wire: a server may now answer a rejected
* operation with STATUS_ERROR_DETAIL followed by a bounded (<=
* MAX_ERROR_DETAIL_BYTES) length-prefixed string instead of a bare
* STATUS_ERROR (see protocol.h). The config-frame LAYOUT is unchanged, but the
* FRAME STREAM gains a new framed body after a status, so a 2.20 peer that does
* not consume it would desynchronize on the following exchange. The strict
* same-version handshake (config_receive rejects a mismatched version before
* parsing anything else) is what keeps a 2.21 client and a 2.20 server from ever
* reaching that state. receive_status() transparently maps STATUS_ERROR_DETAIL
* back to STATUS_ERROR for every existing call site and captures the reason into
* a thread-local buffer consulted via protocol_last_error(). */
#define PROTOCOL_VERSION "2.21.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64 #define MAX_BASIS_DIRS 64
+12 -12
View File
@@ -1721,7 +1721,7 @@ static IncrementalCheckOutcome incremental_check_receive_request(IncrementalChec
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
if (!receive_n_data(fd, &state->check_mtime_nsec, sizeof(state->check_mtime_nsec)) || if (!receive_n_data(fd, &state->check_mtime_nsec, sizeof(state->check_mtime_nsec)) ||
state->check_mtime_nsec < 0 || state->check_mtime_nsec >= 1000000000LL) { state->check_mtime_nsec < 0 || state->check_mtime_nsec >= 1000000000LL) {
send_status(fd, STATUS_ERROR); send_error_detail(fd, "invalid check mtime nanoseconds");
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
} }
if ((config->checksum || config_has_basis(config))) { if ((config->checksum || config_has_basis(config))) {
@@ -1729,7 +1729,7 @@ static IncrementalCheckOutcome incremental_check_receive_request(IncrementalChec
if (!receive_n_data(fd, &wire_len, sizeof(wire_len)) || wire_len == 0 || if (!receive_n_data(fd, &wire_len, sizeof(wire_len)) || wire_len == 0 ||
wire_len > CHECKSUM_MAX_DIGEST_LEN || wire_len > CHECKSUM_MAX_DIGEST_LEN ||
wire_len != checksum_digest_len((ChecksumAlgo)config->checksum_algo)) { wire_len != checksum_digest_len((ChecksumAlgo)config->checksum_algo)) {
send_status(fd, STATUS_ERROR); send_error_detail(fd, "invalid check digest length");
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
} }
state->check_digest_len = wire_len; state->check_digest_len = wire_len;
@@ -1738,7 +1738,7 @@ static IncrementalCheckOutcome incremental_check_receive_request(IncrementalChec
} }
if (state->check_size > MAX_RECEIVE_WHOLE_FILE_SIZE) { if (state->check_size > MAX_RECEIVE_WHOLE_FILE_SIZE) {
send_status(fd, STATUS_ERROR); send_error_detail(fd, "check size exceeds receiver limit");
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
} }
@@ -1757,7 +1757,7 @@ static IncrementalCheckOutcome incremental_check_receive_request(IncrementalChec
static IncrementalCheckOutcome incremental_check_open_destination(IncrementalCheckState* state) { static IncrementalCheckOutcome incremental_check_open_destination(IncrementalCheckState* state) {
char* full_path = path_cat(state->config->receive_root_directory, state->check_path); char* full_path = path_cat(state->config->receive_root_directory, state->check_path);
if (!full_path) { if (!full_path) {
send_status(state->fd, STATUS_ERROR); send_error_detail(state->fd, "could not build destination path");
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
} }
state->full_path = full_path; state->full_path = full_path;
@@ -1911,7 +1911,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
File** out_file) { File** out_file) {
int fd = state->fd; int fd = state->fd;
const Config* config = state->config; const Config* config = state->config;
char* check_path = state->check_path; const char* check_path = state->check_path;
unsigned long long old_size = state->old_size; unsigned long long old_size = state->old_size;
unsigned long long check_size = state->check_size; unsigned long long check_size = state->check_size;
@@ -2464,7 +2464,7 @@ File* file_receive_hardlink(int file_descriptor) {
escaped_path ? escaped_path : "<allocation failed>"); escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path); free(escaped_path);
free(path); free(path);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "invalid hard-link path");
return NULL; return NULL;
} }
int gid; int gid;
@@ -2484,7 +2484,7 @@ File* file_receive_hardlink(int file_descriptor) {
free(escaped); free(escaped);
free(target); free(target);
free(path); free(path);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "invalid hard-link target path");
return NULL; return NULL;
} }
File* file = file_create(path); File* file = file_create(path);
@@ -2514,7 +2514,7 @@ File* file_receive_symlink(int file_descriptor, const Config* config) {
escaped_path ? escaped_path : "<allocation failed>"); escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path); free(escaped_path);
free(path); free(path);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "invalid symlink path");
return NULL; return NULL;
} }
char* target = receive_wire_str(file_descriptor); char* target = receive_wire_str(file_descriptor);
@@ -2529,7 +2529,7 @@ File* file_receive_symlink(int file_descriptor, const Config* config) {
free(escaped); free(escaped);
free(target); free(target);
free(path); free(path);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "invalid symlink target");
return NULL; return NULL;
} }
File* file = file_create(path); File* file = file_create(path);
@@ -2569,7 +2569,7 @@ File* file_receive_special(int file_descriptor) {
escaped_path ? escaped_path : "<allocation failed>"); escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path); free(escaped_path);
free(path); free(path);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "invalid special path");
return NULL; return NULL;
} }
int meta_ok = 1; int meta_ok = 1;
@@ -2593,14 +2593,14 @@ File* file_receive_special(int file_descriptor) {
if (!metadata) { if (!metadata) {
log_message(LOG_LEVEL_ERROR, "Special node sent without metadata (mode)"); log_message(LOG_LEVEL_ERROR, "Special node sent without metadata (mode)");
free(path); free(path);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "special node sent without metadata");
return NULL; return NULL;
} }
if (!file_special_rdev_valid(major, minor, metadata->mode)) { if (!file_special_rdev_valid(major, minor, metadata->mode)) {
log_message(LOG_LEVEL_ERROR, "Invalid special rdev received (%d:%d)", (int)major, (int)minor); log_message(LOG_LEVEL_ERROR, "Invalid special rdev received (%d:%d)", (int)major, (int)minor);
free(path); free(path);
file_metadata_destroy(metadata); file_metadata_destroy(metadata);
send_status(file_descriptor, STATUS_ERROR); send_error_detail(file_descriptor, "invalid special device rdev");
return NULL; return NULL;
} }
File* file = file_create(path); File* file = file_create(path);
+61
View File
@@ -22,6 +22,10 @@ static __thread ProtocolSession* bound_session;
static __thread ProtocolSession legacy_io_session = { static __thread ProtocolSession legacy_io_session = {
.read_fd = -1, .write_fd = -1, .max_alloc = DEFAULT_MAX_ALLOC}; .read_fd = -1, .write_fd = -1, .max_alloc = DEFAULT_MAX_ALLOC};
/* Last STATUS_ERROR_DETAIL reason received on this thread (protocol 2.21.0).
* Empty when the last status read carried no detail. */
static __thread char io_error_detail[MAX_ERROR_DETAIL_BYTES + 1];
static unsigned long long io_bwlimit = 0; static unsigned long long io_bwlimit = 0;
static mtx_t bw_mutex; static mtx_t bw_mutex;
static once_flag bw_mutex_once = ONCE_FLAG_INIT; static once_flag bw_mutex_once = ONCE_FLAG_INIT;
@@ -446,6 +450,8 @@ static const char* status_to_string(Status status) {
return "AUTH_OK"; return "AUTH_OK";
case STATUS_AUTH_FAILED: case STATUS_AUTH_FAILED:
return "AUTH_FAILED"; return "AUTH_FAILED";
case STATUS_ERROR_DETAIL:
return "ERROR_DETAIL";
default: default:
return "UNKNOWN"; return "UNKNOWN";
} }
@@ -600,9 +606,40 @@ bool protocol_send_status(ProtocolSession* session, Status status) {
return true; return true;
} }
/* Consume the optional detail body of a STATUS_ERROR_DETAIL frame and map the
* status back to STATUS_ERROR for existing callers. Invoked for EVERY status
* read (bare STATUS_OK/STATUS_ERROR too) so a stale detail from an earlier
* exchange is never reported for a later one. The body is always read, even
* when the caller ignores protocol_last_error(), so the stream never
* desynchronizes. */
static void protocol_capture_error_detail(ProtocolSession* session, Status* status) {
io_error_detail[0] = '\0';
if (*status != STATUS_ERROR_DETAIL)
return;
*status = STATUS_ERROR;
/* The body MUST be drained even when the session's allocation ceiling is
* smaller than the message (e.g. a tiny --max-alloc), otherwise the string
* body would be left on the stream and desynchronize the next exchange.
* Lift the ceiling for this one bounded string read and restore it. */
unsigned long long saved_max_alloc = session->max_alloc;
if (saved_max_alloc < MAX_STRING_SIZE + 1)
session->max_alloc = MAX_STRING_SIZE + 1;
char* detail = protocol_receive_str(session);
session->max_alloc = saved_max_alloc;
if (!detail)
return;
size_t len = strlen(detail);
if (len > MAX_ERROR_DETAIL_BYTES)
len = MAX_ERROR_DETAIL_BYTES;
memcpy(io_error_detail, detail, len);
io_error_detail[len] = '\0';
free(detail);
}
bool protocol_receive_status(ProtocolSession* session, Status* status) { bool protocol_receive_status(ProtocolSession* session, Status* status) {
if (!protocol_receive_n_data(session, status, sizeof(Status))) if (!protocol_receive_n_data(session, status, sizeof(Status)))
return false; return false;
protocol_capture_error_detail(session, status);
log_debug_message(LOG_DEBUG_PROTO, "Received Status: %s", status_to_string(*status)); log_debug_message(LOG_DEBUG_PROTO, "Received Status: %s", status_to_string(*status));
return true; return true;
} }
@@ -614,6 +651,7 @@ bool protocol_receive_status(ProtocolSession* session, Status* status) {
bool protocol_receive_status_timed(ProtocolSession* session, Status* status, int timeout_sec) { bool protocol_receive_status_timed(ProtocolSession* session, Status* status, int timeout_sec) {
if (!protocol_receive_n_data_timed(session, status, sizeof(Status), timeout_sec)) if (!protocol_receive_n_data_timed(session, status, sizeof(Status), timeout_sec))
return false; return false;
protocol_capture_error_detail(session, status);
log_debug_message(LOG_DEBUG_PROTO, "Received Status: %s", status_to_string(*status)); log_debug_message(LOG_DEBUG_PROTO, "Received Status: %s", status_to_string(*status));
return true; return true;
} }
@@ -725,6 +763,7 @@ bool protocol_receive_status_keepalive(ProtocolSession* session, Status* status,
Status received; Status received;
if (!protocol_read_status_until(session, &received, &deadline)) if (!protocol_read_status_until(session, &received, &deadline))
return false; return false;
protocol_capture_error_detail(session, &received);
if (received == STATUS_KEEPALIVE) { if (received == STATUS_KEEPALIVE) {
/* The receiver's answer to one of our keepalives. */ /* The receiver's answer to one of our keepalives. */
replies_seen++; replies_seen++;
@@ -751,6 +790,7 @@ bool protocol_receive_status_keepalive(ProtocolSession* session, Status* status,
keepalives_sent - replies_seen); keepalives_sent - replies_seen);
break; break;
} }
protocol_capture_error_detail(session, &drained);
if (drained != STATUS_KEEPALIVE) { if (drained != STATUS_KEEPALIVE) {
log_message(LOG_LEVEL_ERROR, "Unexpected status while draining keepalive replies"); log_message(LOG_LEVEL_ERROR, "Unexpected status while draining keepalive replies");
return false; return false;
@@ -807,3 +847,24 @@ bool receive_status_keepalive(int fd, Status* status, int timeout_sec, int keepa
return protocol_receive_status_keepalive(legacy_session(fd, -1), status, timeout_sec, return protocol_receive_status_keepalive(legacy_session(fd, -1), status, timeout_sec,
keepalive_interval_sec, abort_check); keepalive_interval_sec, abort_check);
} }
bool send_error_detail(int fd, const char* message) {
if (!message)
message = "";
char bounded[MAX_ERROR_DETAIL_BYTES + 1];
size_t len = strlen(message);
if (len > MAX_ERROR_DETAIL_BYTES) {
memcpy(bounded, message, MAX_ERROR_DETAIL_BYTES);
bounded[MAX_ERROR_DETAIL_BYTES] = '\0';
message = bounded;
}
return send_status(fd, STATUS_ERROR_DETAIL) && send_str(fd, message);
}
const char* protocol_last_error(void) {
return io_error_detail;
}
void protocol_clear_last_error(void) {
io_error_detail[0] = '\0';
}
+26 -1
View File
@@ -9,6 +9,12 @@
/* Maximum allowed string size for receive_str (64 KB) */ /* Maximum allowed string size for receive_str (64 KB) */
#define MAX_STRING_SIZE (64 * 1024) #define MAX_STRING_SIZE (64 * 1024)
/* Hard cap on the optional server->client rejection detail carried by
* STATUS_ERROR_DETAIL (protocol 2.21.0). A longer message is sliced to this
* many bytes before it is sent, so a peer can never be made to retain more than
* this for a rejection and the detail frame stays a small, fixed bound. */
#define MAX_ERROR_DETAIL_BYTES 4096
/* Maximum uncompressed file payload accepted by the receiver's whole-file /* Maximum uncompressed file payload accepted by the receiver's whole-file
* paths. A single whole file is charged against the per-connection memory * paths. A single whole file is charged against the per-connection memory
* reservation (MAX_CONNECTION_MEMORY) and against the server allocation * reservation (MAX_CONNECTION_MEMORY) and against the server allocation
@@ -132,7 +138,15 @@ enum NET_STATUS {
STATUS_AUTH_CHALLENGE, STATUS_AUTH_CHALLENGE,
STATUS_AUTH_RESPONSE, STATUS_AUTH_RESPONSE,
STATUS_AUTH_OK, STATUS_AUTH_OK,
STATUS_AUTH_FAILED STATUS_AUTH_FAILED,
/* Optional server->client rejection detail (protocol 2.21.0). When the
* server refuses a transfer for a concrete reason it may send
* STATUS_ERROR_DETAIL followed by a length-prefixed, bounded string instead
* of a bare STATUS_ERROR. receive_status() consumes the string and maps the
* status back to STATUS_ERROR, so every pre-2.21 call site keeps working;
* callers that want the human-readable reason consult protocol_last_error().
* Appended last so the existing wire values never move. */
STATUS_ERROR_DETAIL
}; };
void io_set_fds(int read_fd, int write_fd); void io_set_fds(int read_fd, int write_fd);
@@ -192,6 +206,17 @@ bool send_int(int file_descriptor, int data);
bool receive_int(int file_descriptor, int* data); bool receive_int(int file_descriptor, int* data);
bool send_status(int file_descriptor, Status status); bool send_status(int file_descriptor, Status status);
bool receive_status(int file_descriptor, Status* status); bool receive_status(int file_descriptor, Status* status);
/* Send STATUS_ERROR_DETAIL followed by a bounded (<= MAX_ERROR_DETAIL_BYTES)
* length-prefixed string. Over-long messages are sliced and NULL is treated
* as "". Returns false if the status or the string could not be sent. */
bool send_error_detail(int file_descriptor, const char* message);
/* Human-readable reason captured from the most recent STATUS_ERROR_DETAIL
* received on this thread, or "" when the last status was a bare STATUS_ERROR
* (or no detail was seen). Thread-local, and valid until the next status read
* on the same thread. */
const char* protocol_last_error(void);
/* Clear the thread-local last-error buffer. */
void protocol_clear_last_error(void);
/* receive_status with an explicit per-message deadline in seconds, instead of /* receive_status with an explicit per-message deadline in seconds, instead of
the default RECEIVE_TIMEOUT_SEC. A reply that may legitimately take longer the default RECEIVE_TIMEOUT_SEC. A reply that may legitimately take longer
(e.g. the early-delete ACK after a large receiver-side deletion) must use (e.g. the early-delete ACK after a large receiver-side deletion) must use
+1 -1
View File
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
verify_transfer, verify_transfer,
) )
PROTOCOL_VERSION = b"2.20.0" PROTOCOL_VERSION = b"2.21.0"
STATUS_MANIFEST = 5 STATUS_MANIFEST = 5
STATUS_OK = 0 STATUS_OK = 0
+2 -2
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol: class TestProtocol:
@pytest.mark.ci @pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server): def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.20.0 (the current PROTOCOL_VERSION) is accepted and the """--protocol=2.21.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally.""" transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src") source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst") dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest) os.makedirs(dest)
_seed_protocol_source(source) _seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.20.0"], result, _ = run_client(source, dest, flags=["--protocol=2.21.0"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}" f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
+2
View File
@@ -25,6 +25,7 @@
#include "test_multiprocessing.h" #include "test_multiprocessing.h"
#include "test_property.h" #include "test_property.h"
#include "test_protocol.h" #include "test_protocol.h"
#include "test_protocol_error.h"
#include "test_queue.h" #include "test_queue.h"
#include "test_receiver_timeout.h" #include "test_receiver_timeout.h"
#include "test_robustness.h" #include "test_robustness.h"
@@ -65,6 +66,7 @@ int main() {
RUN_TEST(test_delta); RUN_TEST(test_delta);
RUN_TEST(test_data); RUN_TEST(test_data);
RUN_TEST(test_protocol); RUN_TEST(test_protocol);
RUN_TEST(test_protocol_error);
RUN_TEST(test_receiver_timeout); RUN_TEST(test_receiver_timeout);
RUN_TEST(test_metadata); RUN_TEST(test_metadata);
RUN_TEST(test_glob); RUN_TEST(test_glob);
+5 -4
View File
@@ -306,7 +306,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src", char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.20.0"}; "--dest-dir", "/dst", "--protocol=2.21.0"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -316,7 +316,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config(); cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir", char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.20.0"}; "/dst", "--protocol", "2.21.0"};
positional_count = 0; positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -326,8 +326,9 @@ static void test_parse_args_protocol_accept_current() {
/* Any --protocol value other than the current PROTOCOL_VERSION must end in /* Any --protocol value other than the current PROTOCOL_VERSION must end in
* failure (parse_args simply stores it; validate_config rejects it up front). */ * failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() { static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = { static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0",
"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", "2.18.0", "2.19.0", "216", "31", "abc", ""}; "2.17.0", "2.18.0", "2.19.0", "2.20.0",
"216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
+3 -3
View File
@@ -2443,11 +2443,11 @@ static void golden_config_populate(Config* c) {
c->copy_as_gid = 222; c->copy_as_gid = 222;
} }
/* The pinned golden frame (protocol 2.20.0). The values below are the only /* The pinned golden frame (protocol 2.21.0). The values below are the only
* thing that ties the generated table to the historical wire format; update * thing that ties the generated table to the historical wire format; update
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. */ * them ONLY with a PROTOCOL_VERSION bump and a documented reason. */
#define GOLDEN_WIRE_LEN 633 #define GOLDEN_WIRE_LEN 633
#define GOLDEN_WIRE_HASH 9160991280011164139ULL #define GOLDEN_WIRE_HASH 7591559741712449854ULL
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) { static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
unsigned long long h = 1469598103934665603ULL; unsigned long long h = 1469598103934665603ULL;
@@ -2529,7 +2529,7 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
return h; return h;
} }
/* Byte-for-byte wire compatibility guard (protocol 2.20.0). The expected hash /* Byte-for-byte wire compatibility guard (protocol 2.21.0). The expected hash
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */ * pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
static void test_config_wire_golden() { static void test_config_wire_golden() {
if (is_running_under_valgrind()) if (is_running_under_valgrind())
+153
View File
@@ -0,0 +1,153 @@
/* Unit tests for the protocol 2.21.0 STATUS_ERROR_DETAIL frame API:
* send_error_detail() / receive_status() mapping / protocol_last_error(). */
#include "protocol.h"
#include "test_utils.h"
#include <string.h>
#include <threads.h>
#include <unistd.h>
/* A detail frame maps back to STATUS_ERROR for the caller and its body is
* captured verbatim. */
static void test_error_detail_maps_and_captures(void) {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
protocol_clear_last_error();
EXPECT_TRUE(send_error_detail(0, "module is read-only"));
Status received = STATUS_OK;
EXPECT_TRUE(receive_status(0, &received));
EXPECT_EQ_INT((int)received, (int)STATUS_ERROR);
EXPECT_EQ_STR(protocol_last_error(), "module is read-only");
close(p[0]);
close(p[1]);
}
/* An over-long message is sliced to the hard cap before it goes on the wire, so
* the receiver never retains more than MAX_ERROR_DETAIL_BYTES. */
static void test_error_detail_over_long_is_bounded(void) {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
char big[MAX_ERROR_DETAIL_BYTES + 512];
memset(big, 'x', sizeof(big) - 1);
big[sizeof(big) - 1] = '\0';
EXPECT_TRUE(send_error_detail(0, big));
Status received = STATUS_OK;
EXPECT_TRUE(receive_status(0, &received));
EXPECT_EQ_INT((int)received, (int)STATUS_ERROR);
EXPECT_EQ_INT((int)strlen(protocol_last_error()), (int)MAX_ERROR_DETAIL_BYTES);
close(p[0]);
close(p[1]);
}
/* A bare STATUS_ERROR (no detail body) must not leave a stale reason visible. */
static void test_bare_error_clears_last_error(void) {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
EXPECT_TRUE(send_error_detail(0, "stale reason"));
Status received = STATUS_OK;
EXPECT_TRUE(receive_status(0, &received));
EXPECT_EQ_STR(protocol_last_error(), "stale reason");
EXPECT_TRUE(send_status(0, STATUS_ERROR));
EXPECT_TRUE(receive_status(0, &received));
EXPECT_EQ_INT((int)received, (int)STATUS_ERROR);
EXPECT_EQ_STR(protocol_last_error(), "");
close(p[0]);
close(p[1]);
}
/* A tiny --max-alloc must not prevent the bounded detail body from being
* drained: the status still maps to STATUS_ERROR with the full reason, and the
* following frame is read intact (no desync). */
static void test_error_detail_drains_despite_tiny_max_alloc(void) {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
ProtocolSession receiver;
protocol_session_init(&receiver, p[0], p[1]);
protocol_session_set_max_alloc(&receiver, 4);
ProtocolSession sender;
protocol_session_init(&sender, -1, p[1]);
EXPECT_TRUE(protocol_send_status(&sender, STATUS_ERROR_DETAIL));
EXPECT_TRUE(protocol_send_str(&sender, "reason"));
Status status = STATUS_OK;
EXPECT_TRUE(protocol_receive_status(&receiver, &status));
EXPECT_EQ_INT((int)status, (int)STATUS_ERROR);
EXPECT_EQ_STR(protocol_last_error(), "reason");
EXPECT_TRUE(protocol_send_status(&sender, STATUS_NEXT));
EXPECT_TRUE(protocol_receive_status(&receiver, &status));
EXPECT_EQ_INT((int)status, (int)STATUS_NEXT);
close(p[0]);
close(p[1]);
}
typedef struct {
ProtocolSession* receiver;
} DetailWorkerArg;
static int detail_worker(void* arg) {
DetailWorkerArg* worker = arg;
Status status = STATUS_OK;
if (!protocol_receive_status(worker->receiver, &status) || status != STATUS_ERROR)
return thrd_error;
return strcmp(protocol_last_error(), "worker reason") == 0 ? thrd_success : thrd_error;
}
/* Each thread keeps its own last-error buffer: a detail captured on a worker
* must not overwrite the one captured on the main thread. */
static void test_last_error_is_thread_local(void) {
int main_pipe[2];
int worker_pipe[2];
EXPECT_EQ_INT(pipe(main_pipe), 0);
EXPECT_EQ_INT(pipe(worker_pipe), 0);
io_set_fds(main_pipe[0], main_pipe[1]);
io_set_bwlimit(0);
EXPECT_TRUE(send_error_detail(0, "main reason"));
Status received = STATUS_OK;
EXPECT_TRUE(receive_status(0, &received));
EXPECT_EQ_STR(protocol_last_error(), "main reason");
ProtocolSession receiver;
protocol_session_init(&receiver, worker_pipe[0], -1);
ProtocolSession sender;
protocol_session_init(&sender, -1, worker_pipe[1]);
EXPECT_TRUE(protocol_send_status(&sender, STATUS_ERROR_DETAIL));
EXPECT_TRUE(protocol_send_str(&sender, "worker reason"));
DetailWorkerArg arg = {.receiver = &receiver};
thrd_t thread;
EXPECT_EQ_INT(thrd_create(&thread, detail_worker, &arg), thrd_success);
int result = 0;
EXPECT_EQ_INT(thrd_join(thread, &result), thrd_success);
EXPECT_EQ_INT(result, thrd_success);
/* The worker's capture must not have disturbed this thread's buffer. */
EXPECT_EQ_STR(protocol_last_error(), "main reason");
close(main_pipe[0]);
close(main_pipe[1]);
close(worker_pipe[0]);
close(worker_pipe[1]);
}
void test_protocol_error(void) {
test_error_detail_maps_and_captures();
test_error_detail_over_long_is_bounded();
test_bare_error_clears_last_error();
test_error_detail_drains_despite_tiny_max_alloc();
test_last_error_is_thread_local();
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef TEST_PROTOCOL_ERROR_H
#define TEST_PROTOCOL_ERROR_H
void test_protocol_error(void);
#endif