feat(dry-run): server-contacting --dry-run (protocol 2.21.0)

--dry-run now handshakes with a remote/daemon receiver and reports what
WOULD transfer/skip based on receiver state, mutating nothing on either
side.

- Serialize Config.dry_run into the wire config frame and append
  STATUS_DRY_RUN_TRANSFER to the status enum (no renumbering); bump
  PROTOCOL_VERSION/CMake VERSION/CHANGELOG/golden wire to 2.21.0.
- Receiver: receive_incremental_check_ex runs the normal read-only
  decision and answers STATUS_OK (skip) or STATUS_DRY_RUN_TRANSFER
  (would transfer) with no basis materialization/append/delta/full
  transfer.  All mutation sites are guarded by !dry_run: file store,
  manifest deletes, --mkpath root creation, --delay-updates staging,
  publication, directory-time application, and outcome acks.
- Client: send_dry_run_remote connects, sends the config, checks each
  regular file and prints the would-transfer set + trailer; no file data
  or delete manifest is sent.  Plain local destinations keep the
  client-side manifest.
This commit is contained in:
2026-09-13 11:56:05 +02:00
parent 72cccaa256
commit 6f974eff19
21 changed files with 632 additions and 46 deletions
+6 -6
View File
@@ -21,7 +21,6 @@ static void config_set_defaults(Config* config) {
config->scanner_threads = 0;
config->metadata_explicitly_disabled = false;
config->show_progress = false;
config->dry_run = false;
config->compression_threads = 0;
config->ssh_port = 22;
config->transport = TRANSPORT_TCP;
@@ -42,6 +41,7 @@ static void config_set_defaults(Config* config) {
config->tls_ca = NULL;
config->server_host = str_dup("127.0.0.1");
config->server_port = 8080;
config->server_port_set = false;
/* 0 means "--timeout not given": the transport keeps its own built-in 30 s
* socket timeout (tcp_set_timeouts ignores non-positive values) and the
* protocol layer keeps its built-in 60 s per-message deadline. A positive
@@ -190,11 +190,11 @@ static bool validate_received_config(const Config* config) {
valid_wire_bool(config->delay_updates) && valid_wire_bool(config->mkpath) &&
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) &&
checksum_algo_valid(config->checksum_algo) && identity_wire_valid(config) &&
valid_wire_bool(config->preserve_atimes) && valid_wire_bool(config->preserve_crtimes) &&
valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) &&
valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) &&
valid_wire_bool(config->fake_super) &&
valid_wire_bool(config->dry_run) && checksum_algo_valid(config->checksum_algo) &&
identity_wire_valid(config) && valid_wire_bool(config->preserve_atimes) &&
valid_wire_bool(config->preserve_crtimes) && valid_wire_bool(config->omit_dir_times) &&
valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->munge_links) &&
valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->fake_super) &&
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
(!config->use_compression ||
(config->compression_level >= 1 && config->compression_level <= 22)) &&
+31 -5
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
@@ -109,6 +109,11 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
* =========================================================================== */
#define CONFIG_WIRE_HEADER_FIELDS(X) X(version, char*, str_dup(PROTOCOL_VERSION), STR)
/* dry_run (--dry-run) is CLIENT-INTENT that now CROSSES the wire (protocol
* 2.21.0): the receiver needs it to answer what WOULD transfer/skip without
* touching disk. The client-only launch behavior (no server contact for a
* local destination) is decided separately in client_send.c before the frame
* is ever sent. */
#define CONFIG_WIRE_CORE_FIELDS(X) \
X(eight_bit_output, bool, false, BOOL_8BIT) \
X(max_alloc, unsigned long long, DEFAULT_MAX_ALLOC, RAW_MAXALLOC) \
@@ -122,7 +127,8 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
X(use_executability, bool, false, BOOL) \
X(compression_level, int, 5, INT) \
X(chunk_size, unsigned long long, DEFAULT_CHUNK_SIZE, RAW) \
X(use_sendfile, bool, false, BOOL)
X(use_sendfile, bool, false, BOOL) \
X(dry_run, bool, false, BOOL)
#define CONFIG_WIRE_DELTA_FIELDS(X) \
X(use_delete, bool, false, BOOL) \
@@ -261,7 +267,6 @@ typedef struct Config {
int scanner_threads;
bool metadata_explicitly_disabled;
bool show_progress;
bool dry_run;
int compression_threads;
int ssh_port;
TransportType transport;
@@ -281,6 +286,12 @@ typedef struct Config {
bool use_tls;
char* server_host;
int server_port;
/* True when --server-port/--port was explicitly given. CLIENT-ONLY (never
* serialized): --dry-run uses it to decide whether a real server handshake
* was requested, so a plain local destination (no explicit port) keeps the
* existing client-side dry-run behavior instead of dialing the default
* 127.0.0.1:8080. */
bool server_port_set;
char* tls_cert;
char* tls_key;
char* tls_ca;
@@ -756,8 +767,23 @@ 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.
*
* Server-contacting Dry-run Wave: 2.20.0 -> 2.21.0.
*
* WHY the bump, grounded in the wire: this wave makes --dry-run contact the
* receiver and report exactly what WOULD change. The binary config frame
* gains one serialized bool (Config->dry_run) appended to CONFIG_WIRE_CORE_
* FIELDS after use_sendfile, and the frame stream gains one terminal status
* (STATUS_DRY_RUN_TRANSFER) sent in reply to a per-file STATUS_CHECK when the
* file is not already up to date. The receiver performs the normal read-only
* incremental decision but no mutation; the sender then skips the data. Any
* config-frame layout or frame-sequence change must bump the protocol version:
* a 2.20 peer would desynchronize on the extra trailing byte and the unknown
* status, and 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. */
#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
+50 -1
View File
@@ -1647,7 +1647,14 @@ static File* receive_full_file(int fd, const Config* config, const char* path) {
return file;
}
File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
/* Core implementation. `would_transfer` (may be NULL) is set true only on the
* server-contacting --dry-run path, when the file is not up to date and the
* receiver answered STATUS_DRY_RUN_TRANSFER; the caller then knows no File is
* returned and nothing was stored. */
File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped,
bool* would_transfer) {
if (would_transfer)
*would_transfer = false;
if (!config || !skipped) {
send_status(fd, STATUS_ERROR);
return NULL;
@@ -1799,6 +1806,44 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return NULL;
}
/* ---- Server-contacting --dry-run ----
* The destination does not already hold this file. In dry-run the receiver
* must NOT materialize anything (no basis link/copy, no append/delta/full
* transfer) and the sender must NOT send any data, so answer
* STATUS_DRY_RUN_TRANSFER and return immediately. The one exception is a
* --compare-dest exact hit with no destination copy: a real run would
* suppress the data without changing the destination, so it reports as a
* skip (STATUS_OK) exactly as the full path below would. Everything read
* here (destination file, basis candidates) is read-only. */
if (config->dry_run) {
bool skip_via_compare = false;
if (config_has_basis(config) && !config->ignore_times) {
BasisMatch basis;
basis_match_find(config, check_path, check_size, (time_t)check_mtime, (long)check_mtime_nsec,
check_digest, check_digest_len, false, &basis);
if (basis.hit && basis.type == BASIS_DEST_COMPARE && !has_old_file)
skip_via_compare = true;
basis_match_free(&basis);
}
Status reply = skip_via_compare ? STATUS_OK : STATUS_DRY_RUN_TRANSFER;
if (!send_status(fd, reply)) {
free(old_data);
close(old_fd);
free(full_path);
free(check_path);
return NULL;
}
if (skip_via_compare)
*skipped = true;
else if (would_transfer)
*would_transfer = true;
free(old_data);
close(old_fd);
free(full_path);
free(check_path);
return NULL;
}
/* ---- Alternate basis directories ---- */
if (config_has_basis(config)) {
BasisMatch basis;
@@ -2181,6 +2226,10 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return file;
}
File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return receive_incremental_check_ex(fd, config, skipped, NULL);
}
File* file_receive(const Config* config, int file_descriptor) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
+7
View File
@@ -24,6 +24,13 @@ File* file_receive_symlink(int file_descriptor, const Config* config);
File* file_receive_special(int file_descriptor);
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode);
File* receive_incremental_check(int fd, const Config* config, bool* skipped);
/* Extended variant used by the receiver. `would_transfer` (may be NULL) is set
* true only on the server-contacting --dry-run path when the file is not up to
* date: the receiver has already sent STATUS_DRY_RUN_TRANSFER and returns NULL
* without storing anything. On that path `*skipped` is true for an up-to-date
* (STATUS_OK) file and both flags are false for a genuine error. */
File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped,
bool* would_transfer);
/* P7 Wave D directory-time accumulator. The receiver collects the metadata of
* every directory it creates/receives (STATUS_MKDIR with metadata and/or the
+2
View File
@@ -446,6 +446,8 @@ static const char* status_to_string(Status status) {
return "AUTH_OK";
case STATUS_AUTH_FAILED:
return "AUTH_FAILED";
case STATUS_DRY_RUN_TRANSFER:
return "DRY_RUN_TRANSFER";
default:
return "UNKNOWN";
}
+9 -1
View File
@@ -132,7 +132,15 @@ enum NET_STATUS {
STATUS_AUTH_CHALLENGE,
STATUS_AUTH_RESPONSE,
STATUS_AUTH_OK,
STATUS_AUTH_FAILED
STATUS_AUTH_FAILED,
/* Server-contacting --dry-run (protocol 2.21.0). Sent by the receiver in
* response to a per-file STATUS_CHECK when the wire config carries
* dry_run=true and the file is NOT already up to date: it tells the sender
* the file WOULD be transferred, and the sender must NOT transmit any data
* (the receiver reads none in dry-run). STATUS_OK keeps its meaning in this
* path ("already up to date / nothing to do"). Appended after
* STATUS_AUTH_FAILED so no existing status is renumbered. */
STATUS_DRY_RUN_TRANSFER
};
void io_set_fds(int read_fd, int write_fd);