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
+16 -6
View File
@@ -773,8 +773,8 @@ static bool config_receive_module(int fd, Config* c, ConfigStringBudget* budget)
return false;
if (*module != '\0' && !daemon_module_name_valid(module)) {
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);
send_status(fd, STATUS_ERROR);
return false;
}
if (*module != '\0') {
@@ -1238,7 +1238,11 @@ bool config_send(int file_descriptor, const Config* config) {
return false;
}
if (status != STATUS_OK) {
log_message(LOG_LEVEL_ERROR, "Error transmitting config");
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");
return false;
}
return true;
@@ -1258,8 +1262,11 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
char* escaped_version = output_escape(config->version, false);
log_message(LOG_LEVEL_ERROR, "Protocol version mismatch: client=%s, server=%s",
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);
send_status(file_descriptor, STATUS_ERROR);
goto error;
}
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);
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);
send_status(file_descriptor, STATUS_ERROR);
goto error;
}
if (!validate_received_config(config)) {
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;
}
if (validate) {
@@ -1305,7 +1315,7 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
* written. */
if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) {
log_message(LOG_LEVEL_ERROR, "%s", rejection);
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, rejection);
}
goto error;
}