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
+25 -5
View File
@@ -48,6 +48,17 @@
Always cast to double when dividing so the output stays fractional. */
#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. */
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;
if (!receive_status(client->file_descriptor, &per_file))
return false;
if (per_file == STATUS_ERROR)
if (per_file == STATUS_ERROR) {
log_server_rejection("Receiver reported a per-file error");
return false;
}
if (per_file == STATUS_OK) {
((SourceFile*)remove_sources->items[i])->skipped = true;
} else if (per_file != STATUS_NEXT) {
@@ -619,7 +632,13 @@ static bool finalize_transfer(Client* client, const Config* config, ArrayList* r
}
}
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) {
@@ -914,7 +933,7 @@ static bool send_delete_manifest_early(Client* client, ArrayList* manifest,
return false;
}
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 true;
@@ -991,7 +1010,7 @@ static int incremental_check(Client* client, File* file, const Config* config,
if (!receive_status(client->file_descriptor, &s))
return -1;
if (s == STATUS_ERROR) {
log_message(LOG_LEVEL_ERROR, "Server reported error for file");
log_server_rejection("Server reported error for file");
return -1;
}
if (s == STATUS_OK)
@@ -1025,7 +1044,7 @@ static int incremental_check(Client* client, File* file, const Config* config,
return 3;
}
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);
return -1;
}
@@ -1119,6 +1138,7 @@ static int send_append(const Client* client, File* file, Config* config,
return rc;
}
if (resp != STATUS_APPEND_OK) {
log_server_rejection("Unexpected append-verify response");
send_status(fd, STATUS_ERROR);
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))
transfer_ok = false;
} else {
send_status(file_descriptor, STATUS_ERROR);
send_error_detail(file_descriptor, "transfer failed on receiver");
}
if (!transfer_ok)
log_message(LOG_LEVEL_ERROR, "Transfer failed");
+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;
}
+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;
/* ===========================================================================
* 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
* 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
* desynchronization fix — the strict same-version handshake still rejects a
* mixed 2.19/2.20 deployment. The chunk codec, which already used the packed
* metadata_to_buf()/metadata_from_buf() form, is unchanged. */
#define PROTOCOL_VERSION "2.20.0"
* metadata_to_buf()/metadata_from_buf() form, is unchanged.
*
* 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)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64
+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);
+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';
}
+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