Merge branch 'feat/w9-dryrun' into fix/w9-integration

This commit is contained in:
2026-09-13 13:27:32 +02:00
20 changed files with 987 additions and 62 deletions
+6
View File
@@ -1106,6 +1106,11 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
}
config->use_metadata = true;
}
/* Remember that --server-host was explicitly given (the field itself
defaults to 127.0.0.1, so a value check cannot distinguish it). Used
by --dry-run to route an explicit remote target to the server. */
if (entry->offset == offsetof(Config, server_host))
config->server_host_set = true;
}
} else if (apply_table_option(config, entry, NULL) != 0) {
ctx->exit_code = -1;
@@ -1389,6 +1394,7 @@ static int set_server_port_option(Config* config, const char* value, const char*
return -1;
}
config->server_port = port;
config->server_port_set = true;
return 0;
}
+225 -2
View File
@@ -497,6 +497,28 @@ static void disconnect_transfer_client(Client* client) {
client_delete(client);
}
/* True when --dry-run should contact a receiver rather than running the
* client-side local manifest. Any target a real run would reach over the wire
* selects the server-contacting path: a remote (SSH host:path), a daemon
* (host::module/path), an explicit --server-host, --server-port/--port, TLS, or
* a source-bind --address. A plain local destination (none of these) keeps the
* original client-side behavior, which never dials the default 127.0.0.1:8080. */
static bool dry_run_targets_server(const Config* config) {
if (!config)
return false;
if (config->transport == TRANSPORT_SSH)
return true;
if (config->module && config->module[0] != '\0')
return true;
if (config->server_host_set || config->server_port_set)
return true;
if (config->use_tls)
return true;
if (config->address != NULL)
return true;
return false;
}
static bool add_chunk_to_manifest(ArrayList* manifest, const Chunk* chunk) {
if (!manifest)
return true;
@@ -1048,6 +1070,19 @@ static int incremental_check(Client* client, File* file, const Config* config,
*resume_offset = offset;
return 3;
}
/* Server-contacting --dry-run: the receiver decided the file is not up to
date and answered "would transfer" WITHOUT expecting any data. Treat it as
the dry-run code ONLY when this session actually requested dry-run. A
hostile/buggy peer that emits it outside dry-run is a protocol error: fail
closed (and send STATUS_ERROR) rather than fall through to the normal path,
which would transmit file data the receiver is not reading and desync. */
if (s == STATUS_DRY_RUN_TRANSFER) {
if (config->dry_run)
return 4;
log_message(LOG_LEVEL_ERROR, "Unexpected DRY_RUN_TRANSFER status outside a --dry-run session");
send_status(client->file_descriptor, STATUS_ERROR);
return -1;
}
if (s != STATUS_NEXT) {
log_server_rejection("Unexpected server status");
send_status(client->file_descriptor, STATUS_ERROR);
@@ -1188,6 +1223,174 @@ static int send_append(const Client* client, File* file, Config* config,
return ok ? 0 : -1;
}
/* Server-contacting --dry-run. Connects to the configured remote/daemon and
* runs the normal per-file incremental decision WITHOUT transmitting any file
* data: the receiver (which also sees dry_run=true on the wire) answers
* STATUS_OK for an up-to-date file and STATUS_DRY_RUN_TRANSFER for a file it
* would otherwise write, mutating nothing on either side. The would-transfer
* set and the same trailer as the local dry-run are printed. A
* --compare-dest exact basis hit with no destination copy is reported as a
* skip by the receiver.
*
* Only regular files take the receiver-consulted check; directory / symlink /
* special / hard-link-sibling entries have no per-file content check, so they
* are reported conservatively as would-transfer and their frames are never
* sent (which is what keeps the receiver mutation-free). --delete* is
* deliberately NOT transmitted in dry-run, so no deletion can occur; the
* would-delete manifest report is a documented follow-up.
*
* Returns 0 on success, 1 on error. */
static int send_dry_run_remote(Config* config) {
int from_skipped = 0;
ArrayList* missing_args = NULL;
if (config->delete_missing_args) {
missing_args = array_list_create(free);
if (!missing_args)
return 1;
}
if (!files_from_list_check(config, missing_args, &from_skipped)) {
if (missing_args)
array_list_delete(missing_args);
return 1;
}
if (missing_args)
array_list_delete(missing_args);
/* Alternate basis dirs force the whole-file per-file check on the real
receiver; refuse an oversize source up front exactly as send_files does so
dry-run reports the same clear diagnostic instead of aborting mid-stream. */
if (config_has_basis(config) && !basis_oversize_preflight(config))
return 1;
/* Would-delete reporting requires a receiver-side read-only extras walk that
is not implemented yet; be explicit that --delete is a no-op in dry-run
rather than silently ignoring it. */
if ((config->use_delete || config->delete_missing_args) && !config->quiet)
log_message(LOG_LEVEL_WARNING,
"--dry-run: would-delete reporting is not available in this release; nothing is "
"deleted");
/* A live session may follow, so arm graceful abort handling. */
client_set_abort_armed(true);
Client* client = connect_transfer_client(config);
if (!client) {
if (config->transport == TRANSPORT_TCP)
log_message(LOG_LEVEL_ERROR, "could not connect to server%s",
config->use_tls ? " via TLS" : "");
client_set_abort_armed(false);
return 1;
}
ProtocolSession session;
protocol_session_init(&session, client->file_descriptor, client->file_descriptor);
protocol_session_set_io_timeout(&session, config->timeout);
protocol_session_set_ssl(&session, (SSL*)client->ssl);
protocol_session_bind(&session);
int ret = 1;
PreparedScanner prepared;
memset(&prepared, 0, sizeof(prepared));
DirectoryScanner* scanner = NULL;
if (!config_send(client->file_descriptor, config))
goto dry_fail;
receive_daemon_motd(client, config);
if (!prepare_scanner(config, 0, &prepared))
goto dry_fail;
scanner = directory_scanner_create_with_options(config->send_directory, &prepared.options);
if (!scanner)
goto dry_fail;
int file_count = 0;
unsigned long long total_bytes = 0;
char size_buffer[32];
if (!config->quiet)
printf("Dry run: files to be transferred\n");
Chunk* chunk;
while ((chunk = directory_scanner_next(scanner)) != NULL) {
for (int i = 0; i < chunk->element_count; i++) {
File* f = chunk->items[i];
if (!f)
continue;
unsigned long long fsize = f->data ? f->data->size : 0;
bool would;
if (f->is_dir || f->is_symlink || f->is_special ||
(f->link_group != 0 && !f->link_first && f->hardlink_target != NULL)) {
/* No receiver-side content check exists for these frame types; a real
run would (re)create them, so report would-transfer and send no
frame (the receiver must stay mutation-free). */
would = true;
} else if (fsize > MAX_RECEIVE_WHOLE_FILE_SIZE && !config->use_incremental &&
!config_has_basis(config)) {
/* A non-incremental run streams a >whole-file-limit source without the
STATUS_CHECK handshake, so no read-only receiver decision is possible
(and none is needed: a real run would transfer it). */
would = true;
} else {
DeltaSignature* sig = NULL;
unsigned long long resume_offset = 0;
int rc = incremental_check(client, f, config, &sig, &resume_offset);
delta_signature_destroy(sig);
if (rc < 0) {
chunk_destroy(chunk);
goto dry_fail;
}
if (rc == 1)
continue; /* up to date; nothing to report */
if (rc != 4) {
log_message(LOG_LEVEL_ERROR, "Unexpected receiver reply during dry-run");
chunk_destroy(chunk);
goto dry_fail;
}
would = true;
}
if (would) {
if (!config->quiet) {
char* escaped_path = output_escape(file_wire_path(f), config->eight_bit_output);
if (!escaped_path) {
chunk_destroy(chunk);
goto dry_fail;
}
if (config->human_readable)
printf(" %s (%s)\n", escaped_path,
display_bytes(fsize, true, size_buffer, sizeof(size_buffer)));
else
printf(" %s (%llu bytes)\n", escaped_path, fsize);
free(escaped_path);
}
total_bytes += fsize;
file_count++;
}
}
chunk_destroy(chunk);
}
bool io_error = directory_scanner_had_io_error(scanner);
if (directory_scanner_failed(scanner))
goto dry_fail;
if (io_error)
log_message(LOG_LEVEL_WARNING, "source scan hit an unreadable directory");
/* Terminate the stream so the receiver emits its success frame; no data
frame and no delete manifest are ever sent in dry-run. */
if (!send_status(client->file_descriptor, STATUS_FINISHED))
goto dry_fail;
Status status;
if (!receive_status(client->file_descriptor, &status) || status != STATUS_OK)
goto dry_fail;
if (!config->quiet) {
if (config->human_readable)
printf("Total: %d files, %s\n", file_count,
display_bytes(total_bytes, true, size_buffer, sizeof(size_buffer)));
else
printf("Total: %d files, %.1f MB\n", file_count, (double)total_bytes / (double)BYTES_PER_MIB);
}
ret = io_error ? 1 : 0;
dry_fail:
if (scanner)
directory_scanner_destroy(scanner);
prepared_scanner_destroy(&prepared);
disconnect_transfer_client(client);
protocol_session_unbind();
client_set_abort_armed(false);
return ret;
}
// Send a single file directly (non-incremental path).
static bool send_file_direct(File* file, int fd, bool use_metadata, int compression_level,
const Config* config) {
@@ -1314,6 +1517,16 @@ static int send_single_file(Client* client, File* file, Config* config, bool use
}
return arc == 0 ? 0 : -1;
}
// rc == 4: the receiver answered DRY_RUN_TRANSFER, which is only valid in
// incremental_check's dedicated dry-run consumer. send_single_file never
// runs a dry-run session, so this is a protocol error: abort instead of
// falling through and sending data the receiver is not reading.
if (rc == 4) {
log_message(LOG_LEVEL_ERROR, "Receiver answered DRY_RUN_TRANSFER in a non-dry-run transfer");
delta_signature_destroy(sig);
send_status(client->file_descriptor, STATUS_ERROR);
return -1;
}
// rc == 0: unchanged file, skip
// rc == 2: server sent delta signature but sendfile doesn't support delta
delta_signature_destroy(sig);
@@ -1355,6 +1568,14 @@ static int send_single_file(Client* client, File* file, Config* config, bool use
}
return arc == 0 ? 0 : -1;
}
if (rc == 4) {
/* See the sendfile branch above: DRY_RUN_TRANSFER is only valid in the
dedicated dry-run consumer, never in the normal per-file send path. */
log_message(LOG_LEVEL_ERROR, "Receiver answered DRY_RUN_TRANSFER in a non-dry-run transfer");
delta_signature_destroy(sig);
send_status(client->file_descriptor, STATUS_ERROR);
return -1;
}
if (rc == 2 && config->use_delta && !config->whole_file) {
int drc = send_delta(client, file, sig, config);
delta_signature_destroy(sig);
@@ -1956,7 +2177,8 @@ int send_files(Config* config) {
if (config->list_only)
return send_list_only(config);
if (config->dry_run)
return send_dry_run_manifest(config);
return dry_run_targets_server(config) ? send_dry_run_remote(config)
: send_dry_run_manifest(config);
ArrayList* missing_args = NULL;
int skipped = 0;
if (config->delete_missing_args) {
@@ -2268,7 +2490,8 @@ int send_files_multithreaded(Config** config_ptr) {
if (config->list_only)
return send_list_only(config);
if (config->dry_run)
return send_dry_run_manifest(config);
return dry_run_targets_server(config) ? send_dry_run_remote(config)
: send_dry_run_manifest(config);
ArrayList* missing_args = NULL;
int skipped = 0;
if (config->delete_missing_args) {
+11
View File
@@ -22,6 +22,17 @@ bool validate_config(const Config* config) {
"--write-batch, --only-write-batch, and --read-batch are mutually exclusive");
return false;
}
/* A dry-run of a local batch apply is not meaningful: --read-batch bypasses
the client-side scan/server decision entirely, so dry-run would have no
wire state to report (and must not be used as a mutation escape hatch).
--only-write-batch likewise never contacts a receiver. Reject both up
front instead of silently ignoring --dry-run. */
if (config->dry_run && (read_batch || only_write_batch)) {
log_message(LOG_LEVEL_ERROR,
"--dry-run cannot be combined with --read-batch or --only-write-batch; "
"a dry-run of a local batch apply is not meaningful");
return false;
}
if (read_batch) {
if (!config->receive_root_directory) {
log_message(LOG_LEVEL_ERROR, "--read-batch requires a destination directory");
+36 -6
View File
@@ -288,10 +288,19 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
goto fail;
}
if (status == STATUS_CHECK) {
bool skipped;
File* file = receive_incremental_check(file_descriptor, config, &skipped);
if (!skipped && (!file || !sink->store_file(file, sink->context)))
bool skipped = false;
bool would_transfer = false;
File* file = receive_incremental_check_ex(file_descriptor, config, &skipped, &would_transfer);
if (config->dry_run) {
/* Server-contacting --dry-run: the reply has already been sent
(STATUS_OK = up to date, STATUS_DRY_RUN_TRANSFER = would transfer) and
nothing may be stored. Both flags false means a genuine protocol
error (STATUS_ERROR already sent or sent by receive_error below). */
if (!skipped && !would_transfer)
goto receive_error;
} else if (!skipped && (!file || !sink->store_file(file, sink->context))) {
goto receive_error;
}
} else if (status == STATUS_CHUNK) {
Chunk* chunk = receive_chunk_data(file_descriptor, config);
if (!chunk || !receiver_process_chunk(chunk, sink))
@@ -323,6 +332,15 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
if (!manifest)
goto fail; /* receive_manifest_entries already sent STATUS_ERROR */
if (config->dry_run) {
/* Server-contacting --dry-run mutates nothing, so a keep-set manifest
is consumed and discarded. The early-delete mode still needs its ACK
so a sender blocked on the delete handshake is not left hanging. */
delete_manifest_free(manifest);
if (early_delete && !send_status(file_descriptor, STATUS_OK))
goto fail;
goto next_status;
}
if (early_delete) {
/* --delete-before / --delete-during: the manifest is authoritative the
moment it arrives, before any file data. Delete now and acknowledge
@@ -441,7 +459,11 @@ typedef struct {
static bool receiver_save_file(File* file, void* context_pointer) {
ReceiverSaveContext* context = context_pointer;
FileSaveResult result = FILE_SAVE_ERROR;
if (!context->config->save_to_disk) {
if (context->config->dry_run) {
/* Defense in depth: a dry-run receiver mutates nothing even if a data
frame reaches the sink (the sender is not supposed to send one). */
result = FILE_SAVE_SKIPPED;
} else if (!context->config->save_to_disk) {
/* Nothing is stored; report the file as not-written so a
--remove-source-files sender keeps its source. */
result = FILE_SAVE_SKIPPED;
@@ -457,8 +479,12 @@ static bool receiver_save_file(File* file, void* context_pointer) {
file_destroy(file);
return false;
}
if (result != FILE_SAVE_ERROR && context->config->remove_source_files && !file->is_dir &&
!file->is_special && !file->skip &&
/* A dry-run receiver mutates nothing AND records no per-file outcomes: a
hostile dry-run client that streamed data frames anyway must not be able to
grow `outcomes` without bound (receiver_outcomes_append reallocs uncharged)
or force a per-frame ack. */
if (!context->config->dry_run && result != FILE_SAVE_ERROR &&
context->config->remove_source_files && !file->is_dir && !file->is_special && !file->skip &&
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
file_destroy(file);
return false;
@@ -469,6 +495,10 @@ static bool receiver_save_file(File* file, void* context_pointer) {
static bool receiver_send_success_frame(int fd, void* context_pointer) {
ReceiverSaveContext* context = context_pointer;
/* Server-contacting --dry-run: nothing was staged or written, so there is
nothing to publish and no directory times to stamp. */
if (context->config->dry_run)
return receiver_send_final_success(fd, context->config, &context->outcomes);
/* --delay-updates: the whole protocol stream (including manifest/delete
handling, which ran inside receiver_process) has succeeded and every
staged file was fully written. Publish them atomically now, before the
+8 -4
View File
@@ -196,7 +196,11 @@ int write_thread(void* pipeline_context) {
}
size_t file_bytes = file->data ? file->data->size : 0;
FileSaveResult result = FILE_SAVE_SKIPPED;
if (save_to_disk) {
/* Server-contacting --dry-run: never write. The receiver thread does not
enqueue anything on the dry-run path, but this keeps the writer thread
provably mutation-free if a data frame ever reached it. */
bool dry_run = context->config->dry_run;
if (save_to_disk && !dry_run) {
result = file_save_to_disk_full(root_directory, file, context->config);
if (result == FILE_SAVE_ERROR) {
file_destroy(file);
@@ -215,7 +219,7 @@ int write_thread(void* pipeline_context) {
/* P7 Wave D: a directory's times are never applied inline (a later child
write would clobber them); accumulate the metadata here and let the
caller apply it once every writer has drained. */
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
if (!dry_run && result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
dir_times_should_capture(context->config) &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file);
@@ -234,8 +238,8 @@ int write_thread(void* pipeline_context) {
which sources were actually written versus skipped on the receiver.
Explicit directory entries and recreated device/special nodes have no
source and are never acknowledged (mirrors receiver.c). */
if (context->config->remove_source_files && !file->is_dir && !file->is_special && !file->skip &&
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
if (!dry_run && context->config->remove_source_files && !file->is_dir && !file->is_special &&
!file->skip && !receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes);
mtx_lock(&context->mutex);
+38 -16
View File
@@ -217,12 +217,24 @@ static bool path_is_within(const char* root, const char* path) {
root is rejected up front instead of being silently invented by a later
write. Both paths are confined to the authorized root by the secure file
helpers. */
/* Existence-only half of the precondition: the destination root must already
resolve to a directory below the authorized root. Never creates anything, so
a server-contacting --dry-run can apply the exact same fail-closed check a
real run would without mutating the tree. */
static bool receive_root_exists(const Config* config) {
if (!config || !config->receive_root_directory)
return false;
return file_directory_exists_secure(config->receive_root_directory);
}
/* Full precondition for a real run: --mkpath creates the root (and missing
leading components), otherwise it must already exist as a directory. */
static bool ensure_receive_root(const Config* config) {
if (!config || !config->receive_root_directory)
return false;
if (config->mkpath)
return file_ensure_directory_secure(config->receive_root_directory);
return file_directory_exists_secure(config->receive_root_directory);
return receive_root_exists(config);
}
static bool configure_authorization(const char* root) {
@@ -263,9 +275,11 @@ typedef enum {
} ModuleAuthResult;
/* Looks up the daemon module selected by the client's config frame and rejects
* a `read only` one (every FastSync network transfer writes; there is no
* read-only wire operation yet). Returns the module, or NULL with *error set
* to the caller-facing rejection message. */
* a `read only` one for a real write transfer. A server-contacting --dry-run
* IS a read-only wire operation (it reports what would transfer/skip and
* mutates nothing), so a `read only` module is the safest possible dry-run
* target and is accepted. Returns the module, or NULL with *error set to the
* caller-facing rejection message. */
static const DaemonModule* module_gate_lookup_module(const Config* config, const char** error) {
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module);
if (module == NULL) {
@@ -276,7 +290,7 @@ static const DaemonModule* module_gate_lookup_module(const Config* config, const
*error = "requested daemon module does not exist";
return NULL;
}
if (module->read_only) {
if (module->read_only && !config->dry_run) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer",
config->module);
*error = "requested daemon module is read only";
@@ -556,9 +570,10 @@ static const char* module_gate_install_root(const Config* config, const DaemonMo
* becomes the authorized root via configure_authorization -- exactly the same
* root confinement the standalone server applies to its single
* --destination-root, but per-module and NEVER client-chosen. The module is
* refused (with a clear log) when it is unknown, when it is `read only` (every
* FastSync network transfer writes; there is no read-only wire operation yet),
* when it requests client-chosen ownership without the module's
* refused (with a clear log) when it is unknown, when it is `read only` for a
* real write transfer (a server-contacting --dry-run is a read-only wire
* operation and may target a `read only` module), when it requests
* client-chosen ownership without the module's
* `client owner = yes` opt-in (P7 Wave E hardening), or when the presented
* daemon credentials fail for a module that declares `auth users`. Wave A
* refused every auth-required module (auth was not yet implemented); Wave B
@@ -756,9 +771,14 @@ void handler(int file_descriptor) {
skipped via its implied --ignore-missing-args, but nothing is deleted). */
config->delete_missing_args = config->delete_missing_args && allow_delete;
/* --mkpath: create the destination root (and its missing leading components)
before anything else; without it the root must pre-exist. A failure here
aborts the connection cleanly before any file data is exchanged. */
if (!ensure_receive_root(config)) {
* before anything else; without it the root must pre-exist. The precondition
* is UNCONDITIONAL: a server-contacting --dry-run must reject exactly the
* root a real session would reject, so a client cannot set the wire dry_run
* bit to relax it. Dry-run only runs the existence/directory check (never
* --mkpath) so it creates nothing while still failing closed. A failure here
* aborts the connection cleanly before any file data is exchanged. */
bool root_ok = config->dry_run ? receive_root_exists(config) : ensure_receive_root(config);
if (!root_ok) {
char* escaped_root = output_escape(config->receive_root_directory, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
escaped_root ? escaped_root : "<allocation failed>");
@@ -767,8 +787,9 @@ void handler(int file_descriptor) {
}
/* A --delay-updates transfer stages under a private 0700 directory inside
the receive root. Create it up front (wiping leftovers of any previously
interrupted delayed transfer) so a fully-skipped run also starts clean. */
if (config->delay_updates) {
interrupted delayed transfer) so a fully-skipped run also starts clean.
A dry-run stages nothing, so the staging tree is never created. */
if (config->delay_updates && !config->dry_run) {
config->delay_context = delay_updates_context_create(config->receive_root_directory);
if (!config->delay_context || !delay_updates_prepare(config->delay_context)) {
log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area");
@@ -864,12 +885,13 @@ void handler(int file_descriptor) {
thrd_join(receiver, &receiver_result);
thrd_join(writer, &writer_result);
bool transfer_ok = receiver_result == thrd_success && writer_result == thrd_success;
if (transfer_ok) {
if (transfer_ok && !config->dry_run) {
/* Commit-style (late) deletion: receive_thread handed the keep-set
manifest here instead of deleting while write_thread might still be
draining, so by now every file is on disk and the whole transfer is
known to have succeeded. Remove the extras before publishing a
--delay-updates run; the walker skips the staging directory. */
--delay-updates run; the walker skips the staging directory. A
server-contacting --dry-run deletes nothing (no manifest is sent). */
if (context->deferred_manifest) {
if (!manifest_delete_all(config, context->deferred_manifest)) {
transfer_ok = false;
@@ -878,7 +900,7 @@ void handler(int file_descriptor) {
context->deferred_manifest = NULL;
}
}
if (transfer_ok) {
if (transfer_ok && !config->dry_run) {
/* --delay-updates: receive_thread has finished the whole protocol stream
(including manifest/delete handling) and write_thread has drained its
queue, so every staged file is complete. Publish atomically before the
+7 -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,8 @@ 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;
config->server_host_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 +191,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)) &&
+47 -14
View File
@@ -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,18 @@ 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;
/* True when --server-host was explicitly given. CLIENT-ONLY (never
* serialized), and distinct from the "127.0.0.1" default: --dry-run uses it
* to route an explicit remote target to the server so it reports receiver
* state exactly like a real run, instead of silently running the client-side
* manifest. */
bool server_host_set;
char* tls_cert;
char* tls_key;
char* tls_ca;
@@ -758,19 +775,35 @@ typedef struct Config {
* mixed 2.19/2.20 deployment. The chunk codec, which already used the packed
* metadata_to_buf()/metadata_from_buf() form, is unchanged.
*
* Error-Detail Wave: 2.20.0 -> 2.21.0.
* Error-Detail + Server-contacting Dry-run 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(). */
* WHY the bump, grounded in the wire: this release combines two changes on the
* same lockstep version.
*
* (1) Error detail: 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. 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().
*
* (2) --dry-run: --dry-run now contacts the receiver and reports 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, the
* unknown status, or the unconsumed detail body, 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). */
+75 -5
View File
@@ -569,6 +569,13 @@ static FileSaveResult file_save_write_device(const char* root_directory, const F
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
const Config* config) {
/* Central no-mutation guard: a server-contacting --dry-run (or a local batch
apply that somehow carries dry_run) must never touch the destination, no
matter which caller reached this primitive. The per-caller guards remain,
but this is the last line of defense for every save path. Report SKIPPED
so a --remove-source-files sender correctly keeps its source. */
if (config && config->dry_run)
return FILE_SAVE_SKIPPED;
/* Backups are incompatible with ignore-existing: moving the entry first
would make a concurrent no-replace commit overwrite its old name. */
bool backup_enabled = config && config->backup && !config->ignore_existing;
@@ -1651,12 +1658,15 @@ static File* receive_full_file(int fd, const Config* config, const char* path) {
* receive_incremental_check() decomposition.
*
* The per-file STATUS_CHECK fast path is split into the small helpers below,
* called in order by a short linear orchestrator. Each helper owns one
* decision: request validation, secure destination open, metadata-only skip,
* called in order by a short linear orchestrator (receive_incremental_check_ex).
* Each helper owns one decision: request validation, secure destination open,
* metadata-only skip, server-contacting --dry-run no-mutation short-circuit,
* alternate-basis match, --append tail resume, block delta, --fuzzy basis, and
* the final "send the whole file" fallback. Every protocol send/receive and
* every resource cleanup is preserved exactly; the wire is byte-for-byte
* unchanged.
* every resource cleanup is preserved exactly; the non-dry-run wire is
* byte-for-byte unchanged. receive_incremental_check_ex additionally exposes a
* `would_transfer` out-param for the dry-run caller; the 3-arg
* receive_incremental_check wrapper passes NULL.
* ------------------------------------------------------------------------- */
/* Owned state threaded through the helpers below. */
@@ -1681,6 +1691,7 @@ typedef enum {
INCREMENTAL_CONTINUE, /* proceed to the next helper */
INCREMENTAL_ERROR, /* protocol/validation failure: return NULL */
INCREMENTAL_SKIP, /* up to date: *skipped = true, return NULL */
INCREMENTAL_DRY_RUN, /* --dry-run resolved: flags set, return NULL */
INCREMENTAL_FILE, /* a File* was produced (out_file) */
} IncrementalCheckOutcome;
@@ -1845,6 +1856,42 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
return INCREMENTAL_CONTINUE;
}
/* Server-contacting --dry-run no-mutation short-circuit. Runs after the
quick-skip decision and before any path that could touch the destination.
When dry_run is set and the file is not already up to date the receiver must
materialize nothing (no basis link/copy, no append/delta/full transfer) and
the sender must send no data, so answer STATUS_DRY_RUN_TRANSFER and stop.
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 basis path below would.
Everything read here (destination file, basis candidates) is read-only. */
static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalCheckState* state,
bool* skipped,
bool* would_transfer) {
const Config* config = state->config;
if (!config->dry_run)
return INCREMENTAL_CONTINUE;
bool skip_via_compare = false;
if (config_has_basis(config) && !config->ignore_times) {
BasisMatch basis;
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
false, &basis);
if (basis.hit && basis.type == BASIS_DEST_COMPARE && !state->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(state->fd, reply))
return INCREMENTAL_ERROR;
if (skip_via_compare)
*skipped = true;
else if (would_transfer)
*would_transfer = true;
return INCREMENTAL_DRY_RUN;
}
/* Alternate basis directories (--compare-dest/--copy-dest/--link-dest): a hit
either suppresses the transfer (compare-dest) or materializes the file from
the basis without a data frame. */
@@ -2133,7 +2180,14 @@ static File* incremental_check_receive_full(IncrementalCheckState* state) {
return receive_full_file(state->fd, state->config, state->check_path);
}
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;
@@ -2163,6 +2217,13 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
goto done;
}
/* Dry-run resolves here (no mutation) or falls through to the normal path. */
outcome = incremental_check_dry_run_shortcut(&state, skipped, would_transfer);
if (outcome == INCREMENTAL_ERROR)
goto done;
if (outcome != INCREMENTAL_CONTINUE)
goto done;
outcome = incremental_check_try_basis(&state, &result);
if (outcome == INCREMENTAL_ERROR)
goto done;
@@ -2198,6 +2259,10 @@ done:
return result;
}
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)
@@ -2909,6 +2974,11 @@ bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest
bool manifest_delete_all(const Config* config, DeleteManifest* manifest) {
if (!config || !manifest)
return false;
/* Central no-mutation guard: a dry-run never deletes. No manifest is sent on
the dry-run path, but a hostile/buggy peer could; treat it as a no-op so
the receiver can never remove anything. */
if (config->dry_run)
return true;
if (config->delete_missing_args && !manifest_delete_missing_args(config, manifest))
return false;
if (config->use_delete && !manifest_delete_extras(config, manifest))
+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
@@ -466,6 +466,8 @@ static const char* status_to_string(Status status) {
return "AUTH_FAILED";
case STATUS_ERROR_DETAIL:
return "ERROR_DETAIL";
case STATUS_DRY_RUN_TRANSFER:
return "DRY_RUN_TRANSFER";
default:
return "UNKNOWN";
}
+11 -2
View File
@@ -145,8 +145,17 @@ enum NET_STATUS {
* 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
* Appended immediately after STATUS_AUTH_FAILED so the existing wire values
* never move. */
STATUS_ERROR_DETAIL,
/* 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_ERROR_DETAIL so no existing status is renumbered. */
STATUS_DRY_RUN_TRANSFER
};
void io_set_fds(int read_fd, int write_fd);