feat(protocol): add optional STATUS_ERROR_DETAIL rejection reason (2.21.0)

Today a server rejection sends a bare STATUS_ERROR and the reason only
reaches the server log, so the client cannot say why a transfer was
refused.  Add an optional, bounded server->client error-detail frame:

  - Status gains STATUS_ERROR_DETAIL appended LAST so existing wire
    values are unchanged.
  - send_error_detail(fd, msg) sends STATUS_ERROR_DETAIL followed by the
    existing length-prefixed string primitive, slicing over-long messages
    to MAX_ERROR_DETAIL_BYTES (4096).
  - receive_status() (and the timed/keepalive status readers) always
    consume the detail body and map the status back to STATUS_ERROR,
    capturing the text into a thread-local buffer exposed by
    protocol_last_error(); a bare STATUS_ERROR leaves it cleared.  Every
    existing call site keeps working and the stream cannot desync.
  - Upgrade the daemon module gate / config validation (config.c), the
    final transfer failure (server.c) and receiver-side path/node
    validation (file_receive.c) to send a concrete reason; surface it on
    the client in client_send.c/config.c.
  - Bump PROTOCOL_VERSION to 2.21.0 (CMake VERSION, CHANGELOG, docs) and
    update the pinned config wire golden hash / CLI-version tests.
  - Add tests/test_protocol_error.c covering mapping+capture, the
    over-long bound, bare-error clearing, and thread-locality.
This commit is contained in:
2026-09-13 12:19:46 +02:00
parent f6e8b6ddc4
commit 88aee6ce94
19 changed files with 356 additions and 43 deletions
+12 -12
View File
@@ -1721,7 +1721,7 @@ static IncrementalCheckOutcome incremental_check_receive_request(IncrementalChec
return INCREMENTAL_ERROR;
if (!receive_n_data(fd, &state->check_mtime_nsec, sizeof(state->check_mtime_nsec)) ||
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;
}
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 ||
wire_len > CHECKSUM_MAX_DIGEST_LEN ||
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;
}
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) {
send_status(fd, STATUS_ERROR);
send_error_detail(fd, "check size exceeds receiver limit");
return INCREMENTAL_ERROR;
}
@@ -1757,7 +1757,7 @@ static IncrementalCheckOutcome incremental_check_receive_request(IncrementalChec
static IncrementalCheckOutcome incremental_check_open_destination(IncrementalCheckState* state) {
char* full_path = path_cat(state->config->receive_root_directory, state->check_path);
if (!full_path) {
send_status(state->fd, STATUS_ERROR);
send_error_detail(state->fd, "could not build destination path");
return INCREMENTAL_ERROR;
}
state->full_path = full_path;
@@ -1911,7 +1911,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
File** out_file) {
int fd = state->fd;
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 check_size = state->check_size;
@@ -2464,7 +2464,7 @@ File* file_receive_hardlink(int file_descriptor) {
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "invalid hard-link path");
return NULL;
}
int gid;
@@ -2484,7 +2484,7 @@ File* file_receive_hardlink(int file_descriptor) {
free(escaped);
free(target);
free(path);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "invalid hard-link target path");
return NULL;
}
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>");
free(escaped_path);
free(path);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "invalid symlink path");
return NULL;
}
char* target = receive_wire_str(file_descriptor);
@@ -2529,7 +2529,7 @@ File* file_receive_symlink(int file_descriptor, const Config* config) {
free(escaped);
free(target);
free(path);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "invalid symlink target");
return NULL;
}
File* file = file_create(path);
@@ -2569,7 +2569,7 @@ File* file_receive_special(int file_descriptor) {
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "invalid special path");
return NULL;
}
int meta_ok = 1;
@@ -2593,14 +2593,14 @@ File* file_receive_special(int file_descriptor) {
if (!metadata) {
log_message(LOG_LEVEL_ERROR, "Special node sent without metadata (mode)");
free(path);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "special node sent without metadata");
return NULL;
}
if (!file_special_rdev_valid(major, minor, metadata->mode)) {
log_message(LOG_LEVEL_ERROR, "Invalid special rdev received (%d:%d)", (int)major, (int)minor);
free(path);
file_metadata_destroy(metadata);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "invalid special device rdev");
return NULL;
}
File* file = file_create(path);