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
+15
View File
@@ -6,6 +6,21 @@ run the same version because the handshake is strict.
## [Unreleased] ## [Unreleased]
### Added
- **Server-contacting `--dry-run` (protocol 2.21.0).** `--dry-run` now performs
a real handshake with a remote/daemon receiver and reports exactly what WOULD
change based on receiver state (existing destination files, mtimes, checksums,
basis dirs). The wire config carries the dry-run intent (`Config.dry_run`) and
the receiver answers each per-file check with `STATUS_DRY_RUN_TRANSFER` (would
transfer) or `STATUS_OK` (already up to date); the sender prints the
would-transfer set and its trailer without sending any file data. The receiver
performs the normal read-only incremental decision but mutates nothing: no temp
files, writes, renames, deletes, metadata/xattr/chown, or directory creation.
A plain local destination (no explicit `--server-port`/remote) keeps the
original client-side dry-run. Would-delete reporting for `--delete*` is
deferred to a follow-up; dry-run never deletes.
### Security ### Security
- Enforce the daemon's per-module `max connections` cap and add a global - Enforce the daemon's per-module `max connections` cap and add a global
+1 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.22) cmake_minimum_required(VERSION 3.22)
project(FastFileTransfer VERSION 2.20.0) project(FastFileTransfer VERSION 2.21.0)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
+1 -1
View File
@@ -582,7 +582,7 @@ before the module list, before authentication, and the connecting peer address
## Protocol and Security ## Protocol and Security
FastSync protocol version `2.20.0` is shared by the client and server. The FastSync protocol version `2.21.0` is shared by the client and server. The
current protocol is sender-driven and includes configuration negotiation, current protocol is sender-driven and includes configuration negotiation,
including the maximum allocation limit, incremental checks, checksums, including the maximum allocation limit, incremental checks, checksums,
manifests, keep-alives, abort handling, per-file remove-source results, and manifests, keep-alives, abort handling, per-file remove-source results, and
+1 -1
View File
@@ -93,7 +93,7 @@ This document maps rsync's full feature set to FastSync's current implementation
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `-n`, `--dry-run` | Trial run with no changes | ✅ Implemented | `dry_run` config field | | `-n`, `--dry-run` | Trial run with no changes | ✅ Implemented | Server-contacting since protocol 2.21.0: with a remote/daemon destination (or an explicit `--server-port`) the client handshakes with the receiver, which runs the normal read-only per-file check and answers `STATUS_DRY_RUN_TRANSFER`/`STATUS_OK` without mutating anything. A plain local destination keeps the client-side manifest. Would-delete reporting for `--delete*` is deferred (dry-run never deletes). |
| `-b`, `--backup` | Make backups of overwritten files | ✅ Implemented | Backup before overwrite | | `-b`, `--backup` | Make backups of overwritten files | ✅ Implemented | Backup before overwrite |
| `--backup-dir=DIR` | Backup directory hierarchy | ✅ Implemented | `backup_dir` config field | | `--backup-dir=DIR` | Backup directory hierarchy | ✅ Implemented | `backup_dir` config field |
| `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Implemented | `suffix` config field | | `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Implemented | `suffix` config field |
+1
View File
@@ -1389,6 +1389,7 @@ static int set_server_port_option(Config* config, const char* value, const char*
return -1; return -1;
} }
config->server_port = port; config->server_port = port;
config->server_port_set = true;
return 0; return 0;
} }
+193 -2
View File
@@ -481,6 +481,21 @@ static void disconnect_transfer_client(Client* client) {
client_delete(client); client_delete(client);
} }
/* True when --dry-run should contact a receiver rather than running the
* client-side local manifest. A remote (SSH host:path), daemon
* (host::module/path), or an explicit --server-port/--port selects the
* server-contacting path; a plain local destination 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;
return config->server_port_set;
}
static bool add_chunk_to_manifest(ArrayList* manifest, const Chunk* chunk) { static bool add_chunk_to_manifest(ArrayList* manifest, const Chunk* chunk) {
if (!manifest) if (!manifest)
return true; return true;
@@ -1024,6 +1039,12 @@ static int incremental_check(Client* client, File* file, const Config* config,
*resume_offset = offset; *resume_offset = offset;
return 3; return 3;
} }
/* Server-contacting --dry-run: the receiver decided the file is not up to
date and answered "would transfer" WITHOUT expecting any data. The caller
only uses this in the dry-run path; a non-dry-run sender never receives it
because the receiver only emits it when the wire config sets dry_run. */
if (s == STATUS_DRY_RUN_TRANSFER)
return 4;
if (s != STATUS_NEXT) { if (s != STATUS_NEXT) {
log_message(LOG_LEVEL_ERROR, "Unexpected server status"); log_message(LOG_LEVEL_ERROR, "Unexpected server status");
send_status(client->file_descriptor, STATUS_ERROR); send_status(client->file_descriptor, STATUS_ERROR);
@@ -1163,6 +1184,174 @@ static int send_append(const Client* client, File* file, Config* config,
return ok ? 0 : -1; 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). // Send a single file directly (non-incremental path).
static bool send_file_direct(File* file, int fd, bool use_metadata, int compression_level, static bool send_file_direct(File* file, int fd, bool use_metadata, int compression_level,
const Config* config) { const Config* config) {
@@ -1931,7 +2120,8 @@ int send_files(Config* config) {
if (config->list_only) if (config->list_only)
return send_list_only(config); return send_list_only(config);
if (config->dry_run) 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; ArrayList* missing_args = NULL;
int skipped = 0; int skipped = 0;
if (config->delete_missing_args) { if (config->delete_missing_args) {
@@ -2243,7 +2433,8 @@ int send_files_multithreaded(Config** config_ptr) {
if (config->list_only) if (config->list_only)
return send_list_only(config); return send_list_only(config);
if (config->dry_run) 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; ArrayList* missing_args = NULL;
int skipped = 0; int skipped = 0;
if (config->delete_missing_args) { if (config->delete_missing_args) {
+30 -4
View File
@@ -288,10 +288,19 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
goto fail; goto fail;
} }
if (status == STATUS_CHECK) { if (status == STATUS_CHECK) {
bool skipped; bool skipped = false;
File* file = receive_incremental_check(file_descriptor, config, &skipped); bool would_transfer = false;
if (!skipped && (!file || !sink->store_file(file, sink->context))) 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; goto receive_error;
}
} else if (status == STATUS_CHUNK) { } else if (status == STATUS_CHUNK) {
Chunk* chunk = receive_chunk_data(file_descriptor, config); Chunk* chunk = receive_chunk_data(file_descriptor, config);
if (!chunk || !receiver_process_chunk(chunk, sink)) 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); DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
if (!manifest) if (!manifest)
goto fail; /* receive_manifest_entries already sent STATUS_ERROR */ 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) { if (early_delete) {
/* --delete-before / --delete-during: the manifest is authoritative the /* --delete-before / --delete-during: the manifest is authoritative the
moment it arrives, before any file data. Delete now and acknowledge 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) { static bool receiver_save_file(File* file, void* context_pointer) {
ReceiverSaveContext* context = context_pointer; ReceiverSaveContext* context = context_pointer;
FileSaveResult result = FILE_SAVE_ERROR; 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 /* Nothing is stored; report the file as not-written so a
--remove-source-files sender keeps its source. */ --remove-source-files sender keeps its source. */
result = FILE_SAVE_SKIPPED; result = FILE_SAVE_SKIPPED;
@@ -469,6 +491,10 @@ static bool receiver_save_file(File* file, void* context_pointer) {
static bool receiver_send_success_frame(int fd, void* context_pointer) { static bool receiver_send_success_frame(int fd, void* context_pointer) {
ReceiverSaveContext* context = 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 /* --delay-updates: the whole protocol stream (including manifest/delete
handling, which ran inside receiver_process) has succeeded and every handling, which ran inside receiver_process) has succeeded and every
staged file was fully written. Publish them atomically now, before the 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; size_t file_bytes = file->data ? file->data->size : 0;
FileSaveResult result = FILE_SAVE_SKIPPED; 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); result = file_save_to_disk_full(root_directory, file, context->config);
if (result == FILE_SAVE_ERROR) { if (result == FILE_SAVE_ERROR) {
file_destroy(file); 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 /* 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 write would clobber them); accumulate the metadata here and let the
caller apply it once every writer has drained. */ 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_times_should_capture(context->config) &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) { !dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file); file_destroy(file);
@@ -234,8 +238,8 @@ int write_thread(void* pipeline_context) {
which sources were actually written versus skipped on the receiver. which sources were actually written versus skipped on the receiver.
Explicit directory entries and recreated device/special nodes have no Explicit directory entries and recreated device/special nodes have no
source and are never acknowledged (mirrors receiver.c). */ source and are never acknowledged (mirrors receiver.c). */
if (context->config->remove_source_files && !file->is_dir && !file->is_special && !file->skip && if (!dry_run && context->config->remove_source_files && !file->is_dir && !file->is_special &&
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) { !file->skip && !receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
file_destroy(file); file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes); pipeline_context_receiver_note_bytes_released(context, file_bytes);
mtx_lock(&context->mutex); mtx_lock(&context->mutex);
+13 -8
View File
@@ -756,9 +756,12 @@ void handler(int file_descriptor) {
skipped via its implied --ignore-missing-args, but nothing is deleted). */ skipped via its implied --ignore-missing-args, but nothing is deleted). */
config->delete_missing_args = config->delete_missing_args && allow_delete; config->delete_missing_args = config->delete_missing_args && allow_delete;
/* --mkpath: create the destination root (and its missing leading components) /* --mkpath: create the destination root (and its missing leading components)
before anything else; without it the root must pre-exist. A failure here * before anything else; without it the root must pre-exist. A failure here
aborts the connection cleanly before any file data is exchanged. */ * aborts the connection cleanly before any file data is exchanged. A
if (!ensure_receive_root(config)) { * server-contacting --dry-run must NOT create anything: the root is only
* read for the would-transfer/skip decision (an absent root simply means
* "everything would transfer"). */
if (!config->dry_run && !ensure_receive_root(config)) {
char* escaped_root = output_escape(config->receive_root_directory, log_get_8_bit_output()); 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", log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
escaped_root ? escaped_root : "<allocation failed>"); escaped_root ? escaped_root : "<allocation failed>");
@@ -767,8 +770,9 @@ void handler(int file_descriptor) {
} }
/* A --delay-updates transfer stages under a private 0700 directory inside /* A --delay-updates transfer stages under a private 0700 directory inside
the receive root. Create it up front (wiping leftovers of any previously the receive root. Create it up front (wiping leftovers of any previously
interrupted delayed transfer) so a fully-skipped run also starts clean. */ interrupted delayed transfer) so a fully-skipped run also starts clean.
if (config->delay_updates) { 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); config->delay_context = delay_updates_context_create(config->receive_root_directory);
if (!config->delay_context || !delay_updates_prepare(config->delay_context)) { if (!config->delay_context || !delay_updates_prepare(config->delay_context)) {
log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area"); log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area");
@@ -864,12 +868,13 @@ void handler(int file_descriptor) {
thrd_join(receiver, &receiver_result); thrd_join(receiver, &receiver_result);
thrd_join(writer, &writer_result); thrd_join(writer, &writer_result);
bool transfer_ok = receiver_result == thrd_success && writer_result == thrd_success; 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 /* Commit-style (late) deletion: receive_thread handed the keep-set
manifest here instead of deleting while write_thread might still be 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 draining, so by now every file is on disk and the whole transfer is
known to have succeeded. Remove the extras before publishing a 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 (context->deferred_manifest) {
if (!manifest_delete_all(config, context->deferred_manifest)) { if (!manifest_delete_all(config, context->deferred_manifest)) {
transfer_ok = false; transfer_ok = false;
@@ -878,7 +883,7 @@ void handler(int file_descriptor) {
context->deferred_manifest = NULL; context->deferred_manifest = NULL;
} }
} }
if (transfer_ok) { if (transfer_ok && !config->dry_run) {
/* --delay-updates: receive_thread has finished the whole protocol stream /* --delay-updates: receive_thread has finished the whole protocol stream
(including manifest/delete handling) and write_thread has drained its (including manifest/delete handling) and write_thread has drained its
queue, so every staged file is complete. Publish atomically before the queue, so every staged file is complete. Publish atomically before the
+6 -6
View File
@@ -21,7 +21,6 @@ static void config_set_defaults(Config* config) {
config->scanner_threads = 0; config->scanner_threads = 0;
config->metadata_explicitly_disabled = false; config->metadata_explicitly_disabled = false;
config->show_progress = false; config->show_progress = false;
config->dry_run = false;
config->compression_threads = 0; config->compression_threads = 0;
config->ssh_port = 22; config->ssh_port = 22;
config->transport = TRANSPORT_TCP; config->transport = TRANSPORT_TCP;
@@ -42,6 +41,7 @@ static void config_set_defaults(Config* config) {
config->tls_ca = NULL; config->tls_ca = NULL;
config->server_host = str_dup("127.0.0.1"); config->server_host = str_dup("127.0.0.1");
config->server_port = 8080; config->server_port = 8080;
config->server_port_set = false;
/* 0 means "--timeout not given": the transport keeps its own built-in 30 s /* 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 * socket timeout (tcp_set_timeouts ignores non-positive values) and the
* protocol layer keeps its built-in 60 s per-message deadline. A positive * 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->delay_updates) && valid_wire_bool(config->mkpath) &&
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) && valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) && 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->dry_run) && checksum_algo_valid(config->checksum_algo) &&
valid_wire_bool(config->preserve_atimes) && valid_wire_bool(config->preserve_crtimes) && identity_wire_valid(config) && valid_wire_bool(config->preserve_atimes) &&
valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->preserve_crtimes) && valid_wire_bool(config->omit_dir_times) &&
valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->munge_links) &&
valid_wire_bool(config->fake_super) && 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->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
(!config->use_compression || (!config->use_compression ||
(config->compression_level >= 1 && config->compression_level <= 22)) && (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; 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 * 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 * 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) #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) \ #define CONFIG_WIRE_CORE_FIELDS(X) \
X(eight_bit_output, bool, false, BOOL_8BIT) \ X(eight_bit_output, bool, false, BOOL_8BIT) \
X(max_alloc, unsigned long long, DEFAULT_MAX_ALLOC, RAW_MAXALLOC) \ 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(use_executability, bool, false, BOOL) \
X(compression_level, int, 5, INT) \ X(compression_level, int, 5, INT) \
X(chunk_size, unsigned long long, DEFAULT_CHUNK_SIZE, RAW) \ 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) \ #define CONFIG_WIRE_DELTA_FIELDS(X) \
X(use_delete, bool, false, BOOL) \ X(use_delete, bool, false, BOOL) \
@@ -261,7 +267,6 @@ typedef struct Config {
int scanner_threads; int scanner_threads;
bool metadata_explicitly_disabled; bool metadata_explicitly_disabled;
bool show_progress; bool show_progress;
bool dry_run;
int compression_threads; int compression_threads;
int ssh_port; int ssh_port;
TransportType transport; TransportType transport;
@@ -281,6 +286,12 @@ typedef struct Config {
bool use_tls; bool use_tls;
char* server_host; char* server_host;
int server_port; 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_cert;
char* tls_key; char* tls_key;
char* tls_ca; char* tls_ca;
@@ -756,8 +767,23 @@ typedef struct Config {
* The bump is therefore a deliberate lockstep-release marker, not a * The bump is therefore a deliberate lockstep-release marker, not a
* desynchronization fix — the strict same-version handshake still rejects 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 * mixed 2.19/2.20 deployment. The chunk codec, which already used the packed
* metadata_to_buf()/metadata_from_buf() form, is unchanged. */ * metadata_to_buf()/metadata_from_buf() form, is unchanged.
#define PROTOCOL_VERSION "2.20.0" *
* 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) #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64 #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; 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) { if (!config || !skipped) {
send_status(fd, STATUS_ERROR); send_status(fd, STATUS_ERROR);
return NULL; return NULL;
@@ -1799,6 +1806,44 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return NULL; 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 ---- */ /* ---- Alternate basis directories ---- */
if (config_has_basis(config)) { if (config_has_basis(config)) {
BasisMatch basis; BasisMatch basis;
@@ -2181,6 +2226,10 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return file; 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) { File* file_receive(const Config* config, int file_descriptor) {
char* path = receive_wire_str(file_descriptor); char* path = receive_wire_str(file_descriptor);
if (path == NULL) 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); File* file_receive_special(int file_descriptor);
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode); 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); 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 /* P7 Wave D directory-time accumulator. The receiver collects the metadata of
* every directory it creates/receives (STATUS_MKDIR with metadata and/or the * 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"; return "AUTH_OK";
case STATUS_AUTH_FAILED: case STATUS_AUTH_FAILED:
return "AUTH_FAILED"; return "AUTH_FAILED";
case STATUS_DRY_RUN_TRANSFER:
return "DRY_RUN_TRANSFER";
default: default:
return "UNKNOWN"; return "UNKNOWN";
} }
+9 -1
View File
@@ -132,7 +132,15 @@ enum NET_STATUS {
STATUS_AUTH_CHALLENGE, STATUS_AUTH_CHALLENGE,
STATUS_AUTH_RESPONSE, STATUS_AUTH_RESPONSE,
STATUS_AUTH_OK, 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); void io_set_fds(int read_fd, int write_fd);
+1 -1
View File
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
verify_transfer, verify_transfer,
) )
PROTOCOL_VERSION = b"2.20.0" PROTOCOL_VERSION = b"2.21.0"
STATUS_MANIFEST = 5 STATUS_MANIFEST = 5
STATUS_OK = 0 STATUS_OK = 0
+165
View File
@@ -370,6 +370,171 @@ class TestDryRun:
assert not mismatches, f"Mismatch: {mismatches}" assert not mismatches, f"Mismatch: {mismatches}"
def _snapshot_tree(root):
"""Return {relpath: (size, mtime_ns, content_bytes)} for a directory tree.
Used to prove a dry-run left the destination byte-for-byte and
timestamp-for-timestamp unchanged. Returns an empty dict for a missing
root so "nothing was created" is also observable."""
snapshot = {}
if not os.path.exists(root):
return snapshot
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root)
st = os.lstat(path)
if stat.S_ISLNK(st.st_mode):
snapshot[rel] = ("symlink", os.readlink(path), st.st_mtime_ns)
continue
with open(path, "rb") as fh:
data = fh.read()
snapshot[rel] = (st.st_size, st.st_mtime_ns, data)
return snapshot
class TestRemoteDryRun:
"""Server-contacting --dry-run (protocol 2.21.0): contacts the receiver,
reports what WOULD transfer/skip based on receiver state, and mutates
nothing on either side."""
def _seed(self, source):
clean_dir(source)
os.makedirs(os.path.join(source, "nested"), exist_ok=True)
with open(os.path.join(source, "keep.txt"), "wb") as f:
f.write(b"unchanged content\n")
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"original content\n")
with open(os.path.join(source, "nested", "deep.txt"), "wb") as f:
f.write(b"deep file\n")
@pytest.mark.ci
def test_remote_dry_run_reports_changes_and_mutates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_dst")
self._seed(source)
clean_dir(dest)
# Populate the destination with a real transfer, then make exactly one
# file differ (content+size) and add a brand-new file.
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"a much longer replacement payload\n")
with open(os.path.join(source, "added.txt"), "wb") as f:
f.write(b"newly added\n")
before = _snapshot_tree(received)
# --checksum makes the up-to-date decision content-based (the seed
# transfer did not preserve mtimes), so keep.txt/deep.txt report skip.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum"],
port=shared_server.port)
assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}"
assert "Dry run:" in result.stdout, result.stdout[:200]
assert "changed.txt" in result.stdout, result.stdout
assert "added.txt" in result.stdout, result.stdout
assert "keep.txt" not in result.stdout, (
f"up-to-date file must not be reported as would-transfer: {result.stdout}"
)
assert "deep.txt" not in result.stdout, result.stdout
assert _snapshot_tree(received) == before, "remote dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_empty_dst")
self._seed(source)
clean_dir(dest)
received = get_dest_received_dir(dest, source)
assert not os.path.exists(received)
result, _ = run_client(source, dest, flags=["--dry-run"], port=shared_server.port)
assert result.returncode == 0, f"exit {result.returncode}: {result.stderr[:300]}"
assert "keep.txt" in result.stdout
assert "changed.txt" in result.stdout
assert "deep.txt" in result.stdout
# Nowhere may the receiver have created the destination mirror.
assert not os.path.exists(received), "dry-run created directories on the receiver"
assert _snapshot_tree(received) == {}
@pytest.mark.ci
def test_remote_dry_run_mkpath_does_not_create_root(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_mk_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_mk_dst")
self._seed(source)
shutil.rmtree(dest, ignore_errors=True)
assert not os.path.exists(dest)
result, _ = run_client(source, dest, flags=["--dry-run", "--mkpath"],
port=shared_server.port)
assert result.returncode == 0, f"exit {result.returncode}: {result.stderr[:300]}"
assert "changed.txt" in result.stdout
assert not os.path.exists(dest), "dry-run --mkpath created the destination root"
@pytest.mark.ci
def test_remote_dry_run_with_delete_does_not_delete(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_del_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_del_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
extra = os.path.join(received, "extra.txt")
with open(extra, "wb") as f:
f.write(b"must survive a dry-run delete\n")
before = _snapshot_tree(received)
for flags in (["--dry-run", "--delete"], ["--dry-run", "--delete-after"]):
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"{flags}: {result.stderr[:300]}"
assert os.path.exists(extra), f"{flags} deleted an extra in dry-run"
assert _snapshot_tree(received) == before, f"{flags} mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_quiet_is_silent(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["-q", "--dry-run"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert result.stdout == ""
assert result.stderr == ""
@pytest.mark.ci
def test_remote_dry_run_threaded_routes_to_server(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_mt_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_mt_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--dry-run", "--threads"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "changed.txt" in result.stdout
assert _snapshot_tree(get_dest_received_dir(dest, source)) == {}
@pytest.mark.ci
def test_normal_transfer_unaffected_by_dry_run(self, shared_server):
"""A real transfer after dry-run still installs the changes."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_normal_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_normal_dst")
self._seed(source)
clean_dir(dest)
run_client(source, dest, port=shared_server.port)
received = get_dest_received_dir(dest, source)
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"updated payload for the real transfer\n")
run_client(source, dest, flags=["--dry-run"], port=shared_server.port)
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
with open(os.path.join(received, "changed.txt"), "rb") as f:
assert f.read() == b"updated payload for the real transfer\n"
class TestRemoveSourceFiles: class TestRemoveSourceFiles:
def test_removes_only_transferred_regular_files(self, shared_server): def test_removes_only_transferred_regular_files(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remove_source") source = os.path.join(TEST_DATA_DIR, "remove_source")
+3 -3
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol: class TestProtocol:
@pytest.mark.ci @pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server): def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.20.0 (the current PROTOCOL_VERSION) is accepted and the """--protocol=2.21.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally.""" transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src") source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst") dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest) os.makedirs(dest)
_seed_protocol_source(source) _seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.20.0"], result, _ = run_client(source, dest, flags=["--protocol=2.21.0"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}" f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -118,7 +118,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest) os.makedirs(dest)
_seed_protocol_source(source) _seed_protocol_source(source)
for bad in ("2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"): for bad in ("2.20.0", "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"], result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port) port=shared_server.port)
assert result.returncode != 0, f"--protocol={bad} should be rejected" assert result.returncode != 0, f"--protocol={bad} should be rejected"
+5 -4
View File
@@ -306,7 +306,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src", char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.20.0"}; "--dest-dir", "/dst", "--protocol=2.21.0"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -316,7 +316,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config(); cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir", char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.20.0"}; "/dst", "--protocol", "2.21.0"};
positional_count = 0; positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -326,8 +326,9 @@ static void test_parse_args_protocol_accept_current() {
/* Any --protocol value other than the current PROTOCOL_VERSION must end in /* Any --protocol value other than the current PROTOCOL_VERSION must end in
* failure (parse_args simply stores it; validate_config rejects it up front). */ * failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() { static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = { static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0",
"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", "2.18.0", "2.19.0", "216", "31", "abc", ""}; "2.17.0", "2.18.0", "2.19.0", "2.20.0",
"216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
+6 -4
View File
@@ -2347,6 +2347,7 @@ static void golden_config_populate(Config* c) {
c->compression_level = 7; c->compression_level = 7;
c->chunk_size = 65536; c->chunk_size = 65536;
c->use_sendfile = false; c->use_sendfile = false;
c->dry_run = true;
c->use_delete = true; c->use_delete = true;
c->use_incremental = true; c->use_incremental = true;
c->size_only = false; c->size_only = false;
@@ -2443,11 +2444,12 @@ static void golden_config_populate(Config* c) {
c->copy_as_gid = 222; c->copy_as_gid = 222;
} }
/* The pinned golden frame (protocol 2.20.0). The values below are the only /* The pinned golden frame (protocol 2.21.0). The values below are the only
* thing that ties the generated table to the historical wire format; update * thing that ties the generated table to the historical wire format; update
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. */ * them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.21.0
#define GOLDEN_WIRE_LEN 633 * bump appends the serialized dry_run bool to CONFIG_WIRE_CORE_FIELDS. */
#define GOLDEN_WIRE_HASH 9160991280011164139ULL #define GOLDEN_WIRE_LEN 637
#define GOLDEN_WIRE_HASH 13228626061067899189ULL
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) { static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
unsigned long long h = 1469598103934665603ULL; unsigned long long h = 1469598103934665603ULL;
+84
View File
@@ -6,6 +6,7 @@
#include "protocol.h" #include "protocol.h"
#include "test_utils.h" #include "test_utils.h"
#include "utils.h" #include "utils.h"
#include <dirent.h>
#include <fcntl.h> #include <fcntl.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -376,6 +377,88 @@ static void test_incremental_check_size_mismatch_full_transfer() {
} }
} }
/* Server-contacting --dry-run: with the wire config's dry_run set, a file that
is NOT up to date makes the receiver answer STATUS_DRY_RUN_TRANSFER and
return immediately; no data body is read and the destination file is left
byte-for-byte unchanged (no temp file, no write, no rename). */
static void test_incremental_check_dry_run_reports_transfer_without_writing() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->dry_run = true;
char* root = make_check_root("dryw");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
write_check_file(root, "file.txt", "0123456789abcdef");
char path[1024];
snprintf(path, sizeof(path), "%s/file.txt", root);
struct stat st;
EXPECT_EQ_INT(stat(path, &st), 0);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
bool would_transfer = false;
File* file = receive_incremental_check_ex(p[0], cfg, &skipped, &would_transfer);
bool ok = file == NULL && !skipped && would_transfer;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = (unsigned long long)st.st_size + 1;
long long mtime = (long long)st.st_mtime;
long long mtime_nsec = 0;
#ifdef __linux__
mtime_nsec = (long long)st.st_mtim.tv_nsec;
#endif
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_DRY_RUN_TRANSFER);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
/* The destination file must be untouched and no temp sibling may appear. */
char buf[32] = {0};
int fd = open(path, O_RDONLY);
EXPECT_TRUE(fd >= 0);
ssize_t got = read(fd, buf, sizeof(buf) - 1);
EXPECT_EQ_INT((int)got, 16);
EXPECT_EQ_STR(buf, "0123456789abcdef");
close(fd);
DIR* d = opendir(root);
EXPECT_NOT_NULL(d);
int entries = 0;
const struct dirent* e;
while ((e = readdir(d)) != NULL) {
if (strcmp(e->d_name, ".") != 0 && strcmp(e->d_name, "..") != 0)
entries++;
}
closedir(d);
EXPECT_EQ_INT(entries, 1);
unlink(path);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* Issue #256: when a received delta claims a result above the whole-file cap, /* Issue #256: when a received delta claims a result above the whole-file cap,
receive_delta_file must mark the operation failed so the caller aborts with receive_delta_file must mark the operation failed so the caller aborts with
STATUS_ERROR instead of emitting STATUS_NEXT and waiting for a body that STATUS_ERROR instead of emitting STATUS_NEXT and waiting for a body that
@@ -771,6 +854,7 @@ void test_server() {
test_receive_incremental_check_rejects_invalid_nanoseconds(); test_receive_incremental_check_rejects_invalid_nanoseconds();
test_incremental_check_quick_skip_by_mtime(); test_incremental_check_quick_skip_by_mtime();
test_incremental_check_size_mismatch_full_transfer(); test_incremental_check_size_mismatch_full_transfer();
test_incremental_check_dry_run_reports_transfer_without_writing();
test_incremental_check_delta_oversize_reports_failure(); test_incremental_check_delta_oversize_reports_failure();
test_late_manifest_abort_frees_keepset(); test_late_manifest_abort_frees_keepset();
test_late_manifest_eof_frees_keepset(); test_late_manifest_eof_frees_keepset();