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
+61
View File
@@ -22,6 +22,10 @@ static __thread ProtocolSession* bound_session;
static __thread ProtocolSession legacy_io_session = {
.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 mtx_t bw_mutex;
static once_flag bw_mutex_once = ONCE_FLAG_INIT;
@@ -446,6 +450,8 @@ static const char* status_to_string(Status status) {
return "AUTH_OK";
case STATUS_AUTH_FAILED:
return "AUTH_FAILED";
case STATUS_ERROR_DETAIL:
return "ERROR_DETAIL";
default:
return "UNKNOWN";
}
@@ -600,9 +606,40 @@ bool protocol_send_status(ProtocolSession* session, Status status) {
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) {
if (!protocol_receive_n_data(session, status, sizeof(Status)))
return false;
protocol_capture_error_detail(session, status);
log_debug_message(LOG_DEBUG_PROTO, "Received Status: %s", status_to_string(*status));
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) {
if (!protocol_receive_n_data_timed(session, status, sizeof(Status), timeout_sec))
return false;
protocol_capture_error_detail(session, status);
log_debug_message(LOG_DEBUG_PROTO, "Received Status: %s", status_to_string(*status));
return true;
}
@@ -725,6 +763,7 @@ bool protocol_receive_status_keepalive(ProtocolSession* session, Status* status,
Status received;
if (!protocol_read_status_until(session, &received, &deadline))
return false;
protocol_capture_error_detail(session, &received);
if (received == STATUS_KEEPALIVE) {
/* The receiver's answer to one of our keepalives. */
replies_seen++;
@@ -751,6 +790,7 @@ bool protocol_receive_status_keepalive(ProtocolSession* session, Status* status,
keepalives_sent - replies_seen);
break;
}
protocol_capture_error_detail(session, &drained);
if (drained != STATUS_KEEPALIVE) {
log_message(LOG_LEVEL_ERROR, "Unexpected status while draining keepalive replies");
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,
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';
}