diff --git a/CHANGELOG.md b/CHANGELOG.md index fe26d5a..4ceec06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ run the same version because the handshake is strict. ## [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 - Enforce the daemon's per-module `max connections` cap and add a global diff --git a/CMakeLists.txt b/CMakeLists.txt index 479cbca..39c0301 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ 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_C_STANDARD 11) diff --git a/README.md b/README.md index d19ed99..b7d8a0e 100644 --- a/README.md +++ b/README.md @@ -582,7 +582,7 @@ before the module list, before authentication, and the connecting peer address ## 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, including the maximum allocation limit, incremental checks, checksums, manifests, keep-alives, abort handling, per-file remove-source results, and diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index df03870..0aab6aa 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -93,7 +93,7 @@ This document maps rsync's full feature set to FastSync's current implementation | 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 | | `--backup-dir=DIR` | Backup directory hierarchy | ✅ Implemented | `backup_dir` config field | | `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Implemented | `suffix` config field | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 3fa2004..dd5f2ad 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -1389,6 +1389,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; } diff --git a/src/client/client_send.c b/src/client/client_send.c index e220fd4..9168ed1 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -481,6 +481,21 @@ 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. 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) { if (!manifest) return true; @@ -1024,6 +1039,12 @@ 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. 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) { log_message(LOG_LEVEL_ERROR, "Unexpected server status"); 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; } +/* 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) { @@ -1931,7 +2120,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) { @@ -2243,7 +2433,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) { diff --git a/src/server/receiver.c b/src/server/receiver.c index a0a3071..2fd803e 100644 --- a/src/server/receiver.c +++ b/src/server/receiver.c @@ -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; @@ -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) { 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 diff --git a/src/server/receiver_pipeline.c b/src/server/receiver_pipeline.c index abe8719..7c500da 100644 --- a/src/server/receiver_pipeline.c +++ b/src/server/receiver_pipeline.c @@ -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); diff --git a/src/server/server.c b/src/server/server.c index 35e0bd2..8adb60a 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -756,9 +756,12 @@ 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. A failure here + * aborts the connection cleanly before any file data is exchanged. A + * 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()); log_message(LOG_LEVEL_ERROR, "destination root is not available: %s", escaped_root ? escaped_root : ""); @@ -767,8 +770,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 +868,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 +883,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 diff --git a/src/shared/config.c b/src/shared/config.c index 8ac4938..3f9d3a2 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -21,7 +21,6 @@ static void config_set_defaults(Config* config) { config->scanner_threads = 0; config->metadata_explicitly_disabled = false; config->show_progress = false; - config->dry_run = false; config->compression_threads = 0; config->ssh_port = 22; config->transport = TRANSPORT_TCP; @@ -42,6 +41,7 @@ static void config_set_defaults(Config* config) { config->tls_ca = NULL; config->server_host = str_dup("127.0.0.1"); config->server_port = 8080; + config->server_port_set = false; /* 0 means "--timeout not given": the transport keeps its own built-in 30 s * socket timeout (tcp_set_timeouts ignores non-positive values) and the * protocol layer keeps its built-in 60 s per-message deadline. A positive @@ -190,11 +190,11 @@ static bool validate_received_config(const Config* config) { valid_wire_bool(config->delay_updates) && valid_wire_bool(config->mkpath) && valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) && valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) && - checksum_algo_valid(config->checksum_algo) && identity_wire_valid(config) && - valid_wire_bool(config->preserve_atimes) && valid_wire_bool(config->preserve_crtimes) && - valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) && - valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) && - valid_wire_bool(config->fake_super) && + valid_wire_bool(config->dry_run) && checksum_algo_valid(config->checksum_algo) && + identity_wire_valid(config) && valid_wire_bool(config->preserve_atimes) && + valid_wire_bool(config->preserve_crtimes) && valid_wire_bool(config->omit_dir_times) && + valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->munge_links) && + valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->fake_super) && (!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) && (!config->use_compression || (config->compression_level >= 1 && config->compression_level <= 22)) && diff --git a/src/shared/config.h b/src/shared/config.h index 6e2e425..6180598 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -76,7 +76,7 @@ typedef struct { typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode; /* =========================================================================== - * Config wire-field table (single source of truth for protocol 2.20.0). + * Config wire-field table (single source of truth for protocol 2.21.0). * * Every field below crosses the wire. The table is the ONLY place a * serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare @@ -109,6 +109,11 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF * =========================================================================== */ #define CONFIG_WIRE_HEADER_FIELDS(X) X(version, char*, str_dup(PROTOCOL_VERSION), STR) +/* dry_run (--dry-run) is CLIENT-INTENT that now CROSSES the wire (protocol + * 2.21.0): the receiver needs it to answer what WOULD transfer/skip without + * touching disk. The client-only launch behavior (no server contact for a + * local destination) is decided separately in client_send.c before the frame + * is ever sent. */ #define CONFIG_WIRE_CORE_FIELDS(X) \ X(eight_bit_output, bool, false, BOOL_8BIT) \ X(max_alloc, unsigned long long, DEFAULT_MAX_ALLOC, RAW_MAXALLOC) \ @@ -122,7 +127,8 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF X(use_executability, bool, false, BOOL) \ X(compression_level, int, 5, INT) \ X(chunk_size, unsigned long long, DEFAULT_CHUNK_SIZE, RAW) \ - X(use_sendfile, bool, false, BOOL) + X(use_sendfile, bool, false, BOOL) \ + X(dry_run, bool, false, BOOL) #define CONFIG_WIRE_DELTA_FIELDS(X) \ X(use_delete, bool, false, BOOL) \ @@ -261,7 +267,6 @@ typedef struct Config { int scanner_threads; bool metadata_explicitly_disabled; bool show_progress; - bool dry_run; int compression_threads; int ssh_port; TransportType transport; @@ -281,6 +286,12 @@ typedef struct Config { bool use_tls; char* server_host; int server_port; + /* True when --server-port/--port was explicitly given. CLIENT-ONLY (never + * serialized): --dry-run uses it to decide whether a real server handshake + * was requested, so a plain local destination (no explicit port) keeps the + * existing client-side dry-run behavior instead of dialing the default + * 127.0.0.1:8080. */ + bool server_port_set; char* tls_cert; char* tls_key; char* tls_ca; @@ -756,8 +767,23 @@ typedef struct Config { * The bump is therefore a deliberate lockstep-release marker, not a * desynchronization fix — the strict same-version handshake still rejects a * mixed 2.19/2.20 deployment. The chunk codec, which already used the packed - * metadata_to_buf()/metadata_from_buf() form, is unchanged. */ -#define PROTOCOL_VERSION "2.20.0" + * metadata_to_buf()/metadata_from_buf() form, is unchanged. + * + * Server-contacting Dry-run Wave: 2.20.0 -> 2.21.0. + * + * WHY the bump, grounded in the wire: this wave makes --dry-run contact the + * receiver and report exactly what WOULD change. The binary config frame + * gains one serialized bool (Config->dry_run) appended to CONFIG_WIRE_CORE_ + * FIELDS after use_sendfile, and the frame stream gains one terminal status + * (STATUS_DRY_RUN_TRANSFER) sent in reply to a per-file STATUS_CHECK when the + * file is not already up to date. The receiver performs the normal read-only + * incremental decision but no mutation; the sender then skips the data. Any + * config-frame layout or frame-sequence change must bump the protocol version: + * a 2.20 peer would desynchronize on the extra trailing byte and the unknown + * status, and the strict same-version handshake (config_receive rejects a + * mismatched version before parsing anything else) is what keeps a 2.21 client + * and a 2.20 server from ever reaching that state. */ +#define PROTOCOL_VERSION "2.21.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ #define MAX_BASIS_DIRS 64 diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 477fa68..6fb154c 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -1647,7 +1647,14 @@ static File* receive_full_file(int fd, const Config* config, const char* path) { return file; } -File* receive_incremental_check(int fd, const Config* config, bool* skipped) { +/* Core implementation. `would_transfer` (may be NULL) is set true only on the + * server-contacting --dry-run path, when the file is not up to date and the + * receiver answered STATUS_DRY_RUN_TRANSFER; the caller then knows no File is + * returned and nothing was stored. */ +File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped, + bool* would_transfer) { + if (would_transfer) + *would_transfer = false; if (!config || !skipped) { send_status(fd, STATUS_ERROR); return NULL; @@ -1799,6 +1806,44 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { return NULL; } + /* ---- Server-contacting --dry-run ---- + * The destination does not already hold this file. In dry-run the receiver + * must NOT materialize anything (no basis link/copy, no append/delta/full + * transfer) and the sender must NOT send any data, so answer + * STATUS_DRY_RUN_TRANSFER and return immediately. The one exception is a + * --compare-dest exact hit with no destination copy: a real run would + * suppress the data without changing the destination, so it reports as a + * skip (STATUS_OK) exactly as the full path below would. Everything read + * here (destination file, basis candidates) is read-only. */ + if (config->dry_run) { + bool skip_via_compare = false; + if (config_has_basis(config) && !config->ignore_times) { + BasisMatch basis; + basis_match_find(config, check_path, check_size, (time_t)check_mtime, (long)check_mtime_nsec, + check_digest, check_digest_len, false, &basis); + if (basis.hit && basis.type == BASIS_DEST_COMPARE && !has_old_file) + skip_via_compare = true; + basis_match_free(&basis); + } + Status reply = skip_via_compare ? STATUS_OK : STATUS_DRY_RUN_TRANSFER; + if (!send_status(fd, reply)) { + free(old_data); + close(old_fd); + free(full_path); + free(check_path); + return NULL; + } + if (skip_via_compare) + *skipped = true; + else if (would_transfer) + *would_transfer = true; + free(old_data); + close(old_fd); + free(full_path); + free(check_path); + return NULL; + } + /* ---- Alternate basis directories ---- */ if (config_has_basis(config)) { BasisMatch basis; @@ -2181,6 +2226,10 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { return file; } +File* receive_incremental_check(int fd, const Config* config, bool* skipped) { + return receive_incremental_check_ex(fd, config, skipped, NULL); +} + File* file_receive(const Config* config, int file_descriptor) { char* path = receive_wire_str(file_descriptor); if (path == NULL) diff --git a/src/shared/file_receive.h b/src/shared/file_receive.h index ec6ccfa..83fa910 100644 --- a/src/shared/file_receive.h +++ b/src/shared/file_receive.h @@ -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 diff --git a/src/shared/protocol.c b/src/shared/protocol.c index c65c4f8..c945765 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -446,6 +446,8 @@ static const char* status_to_string(Status status) { return "AUTH_OK"; case STATUS_AUTH_FAILED: return "AUTH_FAILED"; + case STATUS_DRY_RUN_TRANSFER: + return "DRY_RUN_TRANSFER"; default: return "UNKNOWN"; } diff --git a/src/shared/protocol.h b/src/shared/protocol.h index e55a742..f278aed 100644 --- a/src/shared/protocol.h +++ b/src/shared/protocol.h @@ -132,7 +132,15 @@ enum NET_STATUS { STATUS_AUTH_CHALLENGE, STATUS_AUTH_RESPONSE, STATUS_AUTH_OK, - STATUS_AUTH_FAILED + STATUS_AUTH_FAILED, + /* Server-contacting --dry-run (protocol 2.21.0). Sent by the receiver in + * response to a per-file STATUS_CHECK when the wire config carries + * dry_run=true and the file is NOT already up to date: it tells the sender + * the file WOULD be transferred, and the sender must NOT transmit any data + * (the receiver reads none in dry-run). STATUS_OK keeps its meaning in this + * path ("already up to date / nothing to do"). Appended after + * STATUS_AUTH_FAILED so no existing status is renumbered. */ + STATUS_DRY_RUN_TRANSFER }; void io_set_fds(int read_fd, int write_fd); diff --git a/tests/integration/test_fault_injection.py b/tests/integration/test_fault_injection.py index 8978daf..fc0cc1d 100644 --- a/tests/integration/test_fault_injection.py +++ b/tests/integration/test_fault_injection.py @@ -36,7 +36,7 @@ from common import ( # noqa: E402 verify_transfer, ) -PROTOCOL_VERSION = b"2.20.0" +PROTOCOL_VERSION = b"2.21.0" STATUS_MANIFEST = 5 STATUS_OK = 0 diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 7e2ea6e..bf72dbc 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -370,6 +370,171 @@ class TestDryRun: 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: def test_removes_only_transferred_regular_files(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remove_source") diff --git a/tests/integration/test_preflight.py b/tests/integration/test_preflight.py index 4cbfda3..1995b91 100644 --- a/tests/integration/test_preflight.py +++ b/tests/integration/test_preflight.py @@ -94,14 +94,14 @@ def _seed_protocol_source(source): class TestProtocol: @pytest.mark.ci 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.""" source = os.path.join(TEST_DATA_DIR, "proto_ok_src") dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst") shutil.rmtree(dest, ignore_errors=True) os.makedirs(dest) _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) assert result.returncode == 0, \ f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}" @@ -118,7 +118,7 @@ class TestProtocol: shutil.rmtree(dest, ignore_errors=True) os.makedirs(dest) _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}"], port=shared_server.port) assert result.returncode != 0, f"--protocol={bad} should be rejected" diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index b2a7021..ca0a990 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -306,7 +306,7 @@ static void test_parse_args_protocol_accept_current() { Config* cfg = valid_client_config(); EXPECT_NOT_NULL(cfg); 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_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(); EXPECT_NOT_NULL(cfg); char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir", - "/dst", "--protocol", "2.20.0"}; + "/dst", "--protocol", "2.21.0"}; positional_count = 0; EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0); 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 * failure (parse_args simply stores it; validate_config rejects it up front). */ static void test_parse_args_protocol_rejects_other_versions() { - static const char* const bad_versions[] = { - "2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", "2.18.0", "2.19.0", "216", "31", "abc", ""}; + static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", + "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++) { Config* cfg = valid_client_config(); EXPECT_NOT_NULL(cfg); diff --git a/tests/test_config.c b/tests/test_config.c index db35101..80e1198 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -2347,6 +2347,7 @@ static void golden_config_populate(Config* c) { c->compression_level = 7; c->chunk_size = 65536; c->use_sendfile = false; + c->dry_run = true; c->use_delete = true; c->use_incremental = true; c->size_only = false; @@ -2443,11 +2444,12 @@ static void golden_config_populate(Config* c) { 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 - * them ONLY with a PROTOCOL_VERSION bump and a documented reason. */ -#define GOLDEN_WIRE_LEN 633 -#define GOLDEN_WIRE_HASH 9160991280011164139ULL + * them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.21.0 + * bump appends the serialized dry_run bool to CONFIG_WIRE_CORE_FIELDS. */ +#define GOLDEN_WIRE_LEN 637 +#define GOLDEN_WIRE_HASH 13228626061067899189ULL static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) { unsigned long long h = 1469598103934665603ULL; diff --git a/tests/test_server.c b/tests/test_server.c index 82b842e..b2246e8 100644 --- a/tests/test_server.c +++ b/tests/test_server.c @@ -6,6 +6,7 @@ #include "protocol.h" #include "test_utils.h" #include "utils.h" +#include #include #include #include @@ -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, 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 @@ -771,6 +854,7 @@ void test_server() { test_receive_incremental_check_rejects_invalid_nanoseconds(); test_incremental_check_quick_skip_by_mtime(); test_incremental_check_size_mismatch_full_transfer(); + test_incremental_check_dry_run_reports_transfer_without_writing(); test_incremental_check_delta_oversize_reports_failure(); test_late_manifest_abort_frees_keepset(); test_late_manifest_eof_frees_keepset();