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
+26 -1
View File
@@ -9,6 +9,12 @@
/* Maximum allowed string size for receive_str (64 KB) */
#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
* paths. A single whole file is charged against the per-connection memory
* reservation (MAX_CONNECTION_MEMORY) and against the server allocation
@@ -132,7 +138,15 @@ enum NET_STATUS {
STATUS_AUTH_CHALLENGE,
STATUS_AUTH_RESPONSE,
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);
@@ -192,6 +206,17 @@ bool send_int(int file_descriptor, int data);
bool receive_int(int file_descriptor, int* data);
bool send_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
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