From ea28e25535e53641eb1286e5d5899a25df2b19c9 Mon Sep 17 00:00:00 2001 From: TapTap Date: Wed, 16 Sep 2026 22:55:26 +0200 Subject: [PATCH] feat(parity): receiver STATUS_STATS report and -n --delete lines Add the STATUS_STATS end-of-transfer receiver report (matched/deleted counters plus a would-delete path list) behind the report_stats wire bool, and a read-only delete_extras_list walker. --stats now renders true wire byte totals and the receiver-reported deleted count; a server-contacting -n --delete prints transfer-relative '*deleting' lines matching rsync's itemize layout. --- src/client/change_list.c | 5 + src/client/change_list.h | 1 + src/client/client_cli.c | 15 +++ src/client/client_send.c | 208 ++++++++++++++++++++++++++++++++------ src/server/receiver.c | 44 +++++++- src/server/receiver.h | 13 +++ src/shared/file_receive.c | 36 +++++++ src/shared/file_receive.h | 8 ++ src/shared/format.c | 27 +++++ src/shared/format.h | 17 ++++ src/shared/utils.c | 142 ++++++++++++++++++++++++++ src/shared/utils.h | 8 ++ 12 files changed, 493 insertions(+), 31 deletions(-) diff --git a/src/client/change_list.c b/src/client/change_list.c index 6f272a7..52beb15 100644 --- a/src/client/change_list.c +++ b/src/client/change_list.c @@ -300,6 +300,11 @@ char* change_render_format(const char* format, const Config* config, const Chang ok = strbuf_append_char(&line, '%'); break; case 'i': { + if (event->deleted) { + /* rsync's ITEM_DELETED itemize code: `*deleting ` (11 chars). */ + ok = strbuf_append(&line, "*deleting "); + break; + } char code[12]; itemize_code(config, event, code); ok = strbuf_append(&line, code); diff --git a/src/client/change_list.h b/src/client/change_list.h index 8825666..6771d63 100644 --- a/src/client/change_list.h +++ b/src/client/change_list.h @@ -35,6 +35,7 @@ typedef struct { bool is_symlink; bool is_special; bool is_hardlink; /* a hard-link sibling (linked, no data sent) */ + bool deleted; /* a would-delete report (-n --delete); no source file */ const char* symlink_target; const char* hardlink_target; unsigned long long size; /* source file length in bytes */ diff --git a/src/client/client_cli.c b/src/client/client_cli.c index d38078d..237b5db 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -2366,6 +2366,21 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool * check. This is a wire field. */ config->report_dest_info = config->itemize_changes || config->out_format != NULL || (config->log_file != NULL && config->log_file_format != NULL); + /* Wire-stats parity: --stats, --progress/-P, an --out-format token that needs + * a wire counter (%b/%c), or a dry-run --delete need the receiver's + * end-of-transfer STATUS_STATS report. This is a wire field (protocol + * 2.25.0). */ + bool format_needs_wire = false; + if (config->out_format != NULL) { + for (const char* p = config->out_format; *p != '\0'; p++) { + if (p[0] == '%' && (p[1] == 'b' || p[1] == 'c')) { + format_needs_wire = true; + break; + } + } + } + config->report_stats = config->stats || config->show_progress || format_needs_wire || + (config->dry_run && config->use_delete); return 0; } diff --git a/src/client/client_send.c b/src/client/client_send.c index 54b947b..090ce56 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -85,20 +85,31 @@ static const char* stats_bytes(const Config* config, unsigned long long bytes, c return buffer; } -/* Print the rsync `--stats` block on stdout. FastSync is a push sender, so a - few receiver-only counters (matched data, file-list bytes, deletion count) - are not observable and are reported as 0; the labels and layout match rsync - 3.4.1. Shared by the single-threaded and multithreaded send paths. */ +/* Print the rsync `--stats` block on stdout. Byte totals use the process-wide + wire counters and the receiver-only counters come from the STATUS_STATS frame; + the labels, layout and rate/speedup formulas match rsync 3.4.1. Shared by the + single-threaded and multithreaded send paths. */ static void report_transfer_stats(const Config* config, int total_files, - unsigned long long total_bytes, time_t start) { + unsigned long long total_bytes, time_t start, + const ReceiverStats* recv) { if (!config->stats || config->quiet) return; + ReceiverStats none = {0}; + if (recv == NULL) + recv = &none; + unsigned long long sent = protocol_bytes_written(); + unsigned long long received = protocol_bytes_read(); + /* rsync: bytes_per_sec = (written + read) / (0.5 + (end - start)). */ double elapsed = difftime(time(NULL), start); - double rate = elapsed > 0.0 ? (double)total_bytes / elapsed : 0.0; + double rate = (double)(sent + received) / (0.5 + elapsed); char total_buffer[32]; + char sent_buffer[32]; + char recv_buffer[32]; char rate_buffer[32] = {0}; char human_rate[32] = {0}; const char* total = stats_bytes(config, total_bytes, total_buffer, sizeof(total_buffer)); + const char* sent_s = stats_bytes(config, sent, sent_buffer, sizeof(sent_buffer)); + const char* recv_s = stats_bytes(config, received, recv_buffer, sizeof(recv_buffer)); const char* rate_str = rate_buffer; if (config->human_readable) { if (!format_human_size_decimal((unsigned long long)rate, human_rate, sizeof(human_rate))) @@ -107,23 +118,25 @@ static void report_transfer_stats(const Config* config, int total_files, } else { snprintf(rate_buffer, sizeof(rate_buffer), "%.2f", rate); } + double speedup = (sent + received) > 0 ? (double)total_bytes / (double)(sent + received) : 0.0; printf("\n"); printf("Number of files: %d\n", total_files); printf("Number of created files: %d\n", total_files); - printf("Number of deleted files: 0\n"); + printf("Number of deleted files: %llu\n", recv->deleted_files); printf("Number of regular files transferred: %d\n", total_files); printf("Total file size: %s bytes\n", total); printf("Total transferred file size: %s bytes\n", total); printf("Literal data: %s bytes\n", total); - printf("Matched data: 0 bytes\n"); + printf("Matched data: %llu bytes\n", recv->matched_data); printf("File list size: 0\n"); printf("File list generation time: 0.000 seconds\n"); printf("File list transfer time: 0.000 seconds\n"); - printf("Total bytes sent: %s\n", total); - printf("Total bytes received: 0\n"); + printf("Total bytes sent: %s\n", sent_s); + printf("Total bytes received: %s\n", recv_s); printf("\n"); - printf("sent %s bytes received 0 bytes %s bytes/sec\n", total, rate_str); - printf("total size is %s speedup is %.2f\n", total, 1.0); + printf("sent %s bytes received %s bytes %s bytes/sec\n", sent_s, recv_s, rate_str); + printf("total size is %s speedup is %.2f%s\n", total, speedup, + config->dry_run ? " (DRY RUN)" : ""); fflush(stdout); } @@ -686,13 +699,59 @@ static void mark_sender_done(PipelineContextSender* context) { mtx_unlock(&context->mutex_progress); } +/* Read the optional STATUS_STATS record (protocol 2.25.0) that the receiver + * sends just before its terminal status when report_stats was negotiated. + * Consumes the would-delete path list into `would_delete` (optional). */ +static bool receive_stats_record(int fd, ReceiverStats* stats, ArrayList* would_delete) { + if (!format_stats_receive(fd, stats)) + return false; + int count = 0; + if (!receive_int(fd, &count) || count < 0 || count > MAX_MANIFEST_ENTRIES) + return false; + for (int i = 0; i < count; i++) { + char* path = receive_wire_str(fd); + if (!path) + return false; + if (would_delete) { + char* copy = str_dup(path); + free(path); + if (!copy || !array_list_add(would_delete, copy)) { + free(copy); + return false; + } + } else { + free(path); + } + } + return true; +} + +/* Strip the transfer-root prefix from a receiver-reported destination-relative + * delete path so a `*deleting` line matches rsync's transfer-relative name + * (FastSync's destination mirror includes the source's absolute path). */ +static const char* delete_display_path(const Config* config, const char* path) { + if (!config || !path || !config->send_directory) + return path; + const char* root = config->send_directory; + while (*root == '/') + root++; + size_t root_len = strlen(root); + while (root_len > 0 && root[root_len - 1] == '/') + root_len--; + if (root_len == 0) + return path; + if (strncmp(path, root, root_len) == 0 && (path[root_len] == '/' || path[root_len] == '\0')) + return path + root_len + (path[root_len] == '/' ? 1 : 0); + return path; +} + /* Send the final STATUS_FINISHED frame and await the receiver's verdict. When --remove-source-files is active the receiver acknowledges each data file it processed, in send order: STATUS_NEXT means the file was written, STATUS_OK means the file was skipped/unchanged. Skipped sources are marked so the later removal pass keeps them. */ static bool finalize_transfer(Client* client, const Config* config, ArrayList* remove_sources, - bool* delete_limit_out) { + bool* delete_limit_out, ReceiverStats* stats_out) { if (delete_limit_out) *delete_limit_out = false; if (!send_status(client->file_descriptor, STATUS_FINISHED)) @@ -717,6 +776,14 @@ static bool finalize_transfer(Client* client, const Config* config, ArrayList* r Status status; if (!receive_status(client->file_descriptor, &status)) return false; + /* Optional wire-stats frame (protocol 2.25.0) precedes the terminal status. */ + if (status == STATUS_STATS) { + if (!receive_stats_record(client->file_descriptor, stats_out ? stats_out : &(ReceiverStats){0}, + NULL)) + return false; + if (!receive_status(client->file_descriptor, &status)) + return false; + } /* A capped --max-delete commit is a successful transfer that the client must report with rsync's exit code 25 (not an error). */ if (status == STATUS_DELETE_LIMIT) { @@ -1433,13 +1500,6 @@ static int send_dry_run_remote(Config* config) { 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); @@ -1461,6 +1521,8 @@ static int send_dry_run_remote(Config* config) { PreparedScanner prepared; memset(&prepared, 0, sizeof(prepared)); DirectoryScanner* scanner = NULL; + ArrayList* dry_manifest = NULL; + ArrayList* dry_dirs = NULL; if (!config_send(client->file_descriptor, config)) goto dry_fail; receive_daemon_motd(client, config); @@ -1473,10 +1535,34 @@ static int send_dry_run_remote(Config* config) { int file_count = 0; unsigned long long total_bytes = 0; char size_buffer[32]; + /* -n --delete: build the same keep-set manifest a real run would send so the + receiver can enumerate (read-only) the destination extras. Filter-excluded + and size-pruned protections are not propagated here, so a filtered dry-run + may over-report; the no-filter case is exact. */ + dry_manifest = config->use_delete ? array_list_create(free) : NULL; + if (config->use_delete && !dry_manifest) + goto dry_fail; + /* Scope the receiver-side extras walk to the receive root (the "." sentinel), + exactly as the recursive transfer path does. */ + if (config->use_delete) { + dry_dirs = array_list_create(free); + char* root_marker = dry_dirs ? str_dup(".") : NULL; + if (!dry_dirs || !root_marker || !array_list_add(dry_dirs, root_marker)) { + free(root_marker); + if (dry_dirs) + array_list_delete(dry_dirs); + dry_dirs = NULL; + goto dry_fail; + } + } if (!config->quiet) printf("Dry run: files to be transferred\n"); Chunk* chunk; while ((chunk = directory_scanner_next(scanner)) != NULL) { + if (dry_manifest && !add_chunk_to_manifest(dry_manifest, chunk)) { + chunk_destroy(chunk); + goto dry_fail; + } for (int i = 0; i < chunk->element_count; i++) { File* f = chunk->items[i]; if (!f) @@ -1538,12 +1624,69 @@ static int send_dry_run_remote(Config* config) { 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. */ + /* Send the keep-set manifest (no data frames) so the receiver can enumerate + the destination extras; an early-timing delete ACKs before it will accept + the terminal FINISHED. */ + bool early_delete = config->use_delete && config_delete_timing_early(config); + if (dry_manifest) { + if (send_delete_manifest(client->file_descriptor, dry_manifest, NULL, NULL, NULL, dry_dirs) != 0) + goto dry_fail; + if (early_delete) { + Status ack; + if (!receive_status_keepalive(client->file_descriptor, &ack, DELETE_ACK_TIMEOUT_SEC, + DELETE_ACK_KEEPALIVE_SEC, client_abort_pending) || + ack != STATUS_OK) + goto dry_fail; + } + } + /* Terminate the stream so the receiver emits its success frame; no data frame + is 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) + if (!receive_status(client->file_descriptor, &status)) + goto dry_fail; + if (status == STATUS_STATS) { + ReceiverStats stats; + memset(&stats, 0, sizeof(stats)); + ArrayList* would_delete = array_list_create(free); + if (!would_delete) + goto dry_fail; + if (!receive_stats_record(client->file_descriptor, &stats, would_delete)) { + array_list_delete(would_delete); + goto dry_fail; + } + /* rsync prints `*deleting PATH` when itemizing (or `deleting PATH` with + --out-format / -v); the plain-total output used here has no delete + counterpart, so only the itemize/out-format cases are rendered. */ + if (!config->quiet && (config->itemize_changes || config->out_format != NULL)) { + for (int i = 0; i < would_delete->size; i++) { + const char* raw = (const char*)would_delete->items[i]; + const char* path = delete_display_path(config, raw); + if (config->out_format != NULL) { + ChangeEvent event; + memset(&event, 0, sizeof(event)); + event.decision = CHANGE_SENT; + event.deleted = true; + event.name = path; + event.path = path; + char* line = change_render_format(config->out_format, config, &event); + if (line) { + printf("%s\n", line); + free(line); + } + } else { + char* escaped = output_escape(path, config->eight_bit_output); + printf("*deleting %s\n", escaped ? escaped : path); + free(escaped); + } + } + } + array_list_delete(would_delete); + if (!receive_status(client->file_descriptor, &status)) + goto dry_fail; + } + if (status != STATUS_OK) goto dry_fail; if (!config->quiet) { if (config->human_readable) @@ -1555,6 +1698,10 @@ static int send_dry_run_remote(Config* config) { ret = io_error ? 1 : 0; dry_fail: + if (dry_manifest) + array_list_delete(dry_manifest); + if (dry_dirs) + array_list_delete(dry_dirs); if (scanner) directory_scanner_destroy(scanner); prepared_scanner_destroy(&prepared); @@ -2055,7 +2202,10 @@ static int send_chunks_multithreaded(void* pipeline_context) { !send_dir_times(client, context->config, context->dir_entries)) goto send_fail; bool delete_limit = false; - bool ok = finalize_transfer(client, context->config, context->remove_source_files, &delete_limit); + ReceiverStats recv_stats; + memset(&recv_stats, 0, sizeof(recv_stats)); + bool ok = finalize_transfer(client, context->config, context->remove_source_files, &delete_limit, + &recv_stats); context->delete_limit = delete_limit; if (!ok && context->config->use_delete) log_message(LOG_LEVEL_ERROR, @@ -2066,7 +2216,7 @@ static int send_chunks_multithreaded(void* pipeline_context) { int total_files = context->total_files; unsigned long long total_bytes = context->total_bytes; mtx_unlock(&context->mutex_progress); - report_transfer_stats(context->config, total_files, total_bytes, start); + report_transfer_stats(context->config, total_files, total_bytes, start, &recv_stats); log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files, (double)total_bytes / (double)BYTES_PER_MIB); disconnect_transfer_client(client); @@ -2670,15 +2820,15 @@ int send_files(Config* config) { if (!send_dir_times(client, config, dir_entries)) goto send_fail; bool delete_limit = false; - bool ok = finalize_transfer(client, config, remove_sources, &delete_limit); + ReceiverStats recv_stats; + memset(&recv_stats, 0, sizeof(recv_stats)); + bool ok = finalize_transfer(client, config, remove_sources, &delete_limit, &recv_stats); if (!ok && config->use_delete) log_message(LOG_LEVEL_ERROR, "server reported a deletion failure (--delete); see the server log for the reason"); if (ok) remove_transferred_sources(config, remove_sources); - if (config->show_progress && !config->quiet) - print_transfer_progress(total_bytes, start, "Done.\n", config->human_readable); - report_transfer_stats(config, total_files, total_bytes, start); + report_transfer_stats(config, total_files, total_bytes, start, &recv_stats); log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files, (double)total_bytes / (double)BYTES_PER_MIB); /* A skipped source entry (--ignore-errors past an unreadable directory, or a diff --git a/src/server/receiver.c b/src/server/receiver.c index 3d43890..469f086 100644 --- a/src/server/receiver.c +++ b/src/server/receiver.c @@ -11,6 +11,7 @@ #include "protocol.h" #include "utils.h" #include +#include #include #include @@ -58,6 +59,28 @@ bool receiver_send_final_success(int fd, const Config* config, const ReceiverOut return send_status(fd, final_status); } +bool receiver_send_stats_frame(int fd, const Config* config, const ReceiverStats* stats, + const struct ArrayList* would_delete) { + if (!config->report_stats) + return true; + ReceiverStats local; + memset(&local, 0, sizeof(local)); + const ReceiverStats* out = stats ? stats : &local; + size_t count = would_delete ? (size_t)would_delete->size : 0; + if (count > (size_t)MAX_MANIFEST_ENTRIES) + count = MAX_MANIFEST_ENTRIES; + ReceiverStats record = *out; + record.would_delete_count = count; + if (!send_status(fd, STATUS_STATS) || !format_stats_send(fd, &record) || !send_int(fd, (int)count)) + return false; + for (size_t i = 0; i < count; i++) { + const char* path = (const char*)would_delete->items[i]; + if (!send_wire_str(fd, path ? path : "")) + return false; + } + return true; +} + static bool receiver_process_chunk(Chunk* chunk, const ReceiverSink* sink) { if (!chunk || !sink || !sink->store_file) return false; @@ -337,7 +360,14 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver 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. */ + so a sender blocked on the delete handshake is not left hanging. + When would-delete reporting is armed, enumerate (read-only) the + destination extras so the terminal STATUS_STATS frame can list them. */ + if (config->use_delete && sink->would_delete) { + size_t count = 0; + if (!manifest_would_delete_list(config, manifest, sink->would_delete, &count)) + log_message(LOG_LEVEL_WARNING, "dry-run: could not enumerate would-delete paths"); + } delete_manifest_free(manifest); if (early_delete && !send_status(file_descriptor, STATUS_OK)) goto fail; @@ -465,6 +495,10 @@ typedef struct { /* Set when a --max-delete commit was capped; the terminal frame then carries STATUS_DELETE_LIMIT so the sender exits 25 like rsync. */ bool delete_limit_reached; + /* End-of-transfer wire counters (protocol 2.25.0) and the -n/--dry-run + --delete would-delete path list collected while processing the manifest. */ + ReceiverStats stats; + ArrayList* would_delete; } ReceiverSaveContext; static bool receiver_save_file(File* file, void* context_pointer) { @@ -513,6 +547,8 @@ static void receiver_note_delete_limit(void* context_pointer) { static bool receiver_send_success_frame(int fd, void* context_pointer) { ReceiverSaveContext* context = context_pointer; Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK; + if (!receiver_send_stats_frame(fd, context->config, &context->stats, context->would_delete)) + return false; /* 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) @@ -540,12 +576,16 @@ static bool receiver_send_success_frame(int fd, void* context_pointer) { int receiver_receive_files(Config* config, int file_descriptor) { ReceiverSaveContext context = {.config = config, .outcomes = {0}}; dir_time_list_init(&context.dir_times); + context.would_delete = array_list_create(free); + if (!context.would_delete) + return -1; ReceiverSink sink = {receiver_save_file, &context, true, true, receiver_send_success_frame, - receiver_note_delete_limit}; + receiver_note_delete_limit, &context.stats, context.would_delete}; int ret = receiver_process(config, file_descriptor, &sink); if (ret != 0 && config->delay_updates && config->delay_context) delay_updates_cleanup(config->delay_context); receiver_outcomes_destroy(&context.outcomes); dir_time_list_free(&context.dir_times); + array_list_delete(context.would_delete); return ret; } diff --git a/src/server/receiver.h b/src/server/receiver.h index e169c41..6643721 100644 --- a/src/server/receiver.h +++ b/src/server/receiver.h @@ -39,15 +39,28 @@ typedef struct { ReceiverSuccessFrame send_success_frame; /* Optional; may be NULL when the sink has no --max-delete handling. */ ReceiverNoteDeleteLimit note_delete_limit; + /* Optional end-of-transfer wire counters (protocol 2.25.0). When non-NULL + and the wire config carries report_stats, the success frame is preceded by + a STATUS_STATS record; `would_delete` (optional, receiver-owned strings) + carries the -n/--dry-run --delete path list. */ + ReceiverStats* stats; + struct ArrayList* would_delete; } ReceiverSink; bool receiver_outcomes_append(ReceiverOutcomes* outcomes, unsigned char code); void receiver_outcomes_destroy(ReceiverOutcomes* outcomes); + /* Send the terminal success frame. `final_status` is usually STATUS_OK, or STATUS_DELETE_LIMIT when a --max-delete commit was capped. */ bool receiver_send_final_success(int fd, const Config* config, const ReceiverOutcomes* outcomes, Status final_status); +/* Emit STATUS_STATS (a fixed ReceiverStats record plus, when `would_delete` is + non-NULL, a count and that many wire strings) when the wire config requested + report_stats. A no-op otherwise. */ +bool receiver_send_stats_frame(int fd, const Config* config, const ReceiverStats* stats, + const struct ArrayList* would_delete); + int receiver_process(Config* config, int file_descriptor, const ReceiverSink* sink); /* receiver_process with an escape hatch for the commit-style (late) deletion: when `pending_manifest` is non-NULL the receiver does NOT delete at diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 6da4325..cfd026e 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -3270,6 +3270,42 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m /* Public wrappers used outside the commit path (and by unit tests): no --max-delete budget. */ +bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, ArrayList* out, + size_t* count_out) { + if (count_out) + *count_out = 0; + if (!config || !manifest || !manifest->keeps || !out) + return false; + int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count + + (manifest->protected ? manifest->protected->size : 0); + DeleteSkipEntry* skips = NULL; + if (skip_count > 0) { + skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry)); + if (!skips) + return false; + int idx = 0; + if (config->delay_updates) { + skips[idx].prefix = DELAY_UPDATES_STAGING_DIR; + skips[idx].top_level_only = true; + idx++; + } + for (int i = 0; i < config->basis_count; i++) { + skips[idx].prefix = config->basis_dirs[i].path; + skips[idx].top_level_only = false; + idx++; + } + for (int i = 0; i < manifest->protected->size; i++) { + skips[idx].prefix = (const char*)manifest->protected->items[i]; + skips[idx].top_level_only = false; + idx++; + } + } + bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs, skips, + skip_count, out, count_out); + free(skips); + return ok; +} + bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) { DeleteBudgetState budget = { .max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false}; diff --git a/src/shared/file_receive.h b/src/shared/file_receive.h index 4f2b281..d9a767d 100644 --- a/src/shared/file_receive.h +++ b/src/shared/file_receive.h @@ -135,6 +135,14 @@ typedef enum { stopped part of the work, or DELETE_COMMIT_ERROR on a genuine failure. */ DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* manifest); +/* -n/--dry-run --delete would-delete reporting: walk the destination exactly as + the delete pass would and append (strdup'd) destination-relative paths that + WOULD be removed to `out`, without touching disk. Uses the same staging-dir, + basis-dir and protected-prefix skips as the real commit. Returns true on a + clean walk; `*count_out` receives the number of paths appended. */ +bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, ArrayList* out, + size_t* count_out); + /* Outcome of a single file_save_to_disk operation. The receiver needs to distinguish "written" from "skipped" so --remove-source-files can be told which sources were actually stored. */ diff --git a/src/shared/format.c b/src/shared/format.c index d690e1b..ae6e36c 100644 --- a/src/shared/format.c +++ b/src/shared/format.c @@ -101,3 +101,30 @@ bool format_dest_state_receive(int fd, OutputDestState* state) { state->gid = gid; return true; } + +bool format_stats_send(int fd, const ReceiverStats* stats) { + if (!stats) + return false; + unsigned long long matched = stats->matched_data; + unsigned long long deleted = stats->deleted_files; + unsigned long long would = stats->would_delete_count; + return send_n_data(fd, &matched, sizeof(matched)) && send_n_data(fd, &deleted, sizeof(deleted)) && + send_n_data(fd, &would, sizeof(would)); +} + +bool format_stats_receive(int fd, ReceiverStats* stats) { + if (!stats) + return false; + unsigned long long matched = 0; + unsigned long long deleted = 0; + unsigned long long would = 0; + if (!receive_n_data(fd, &matched, sizeof(matched)) || + !receive_n_data(fd, &deleted, sizeof(deleted)) || + !receive_n_data(fd, &would, sizeof(would))) + return false; + memset(stats, 0, sizeof(*stats)); + stats->matched_data = matched; + stats->deleted_files = deleted; + stats->would_delete_count = would; + return true; +} diff --git a/src/shared/format.h b/src/shared/format.h index 7f4fc13..fc904d4 100644 --- a/src/shared/format.h +++ b/src/shared/format.h @@ -56,4 +56,21 @@ bool format_rsync_datetime(time_t when, bool dash, char* buffer, size_t buffer_s bool format_dest_state_send(int fd, const OutputDestState* state); bool format_dest_state_receive(int fd, OutputDestState* state); +/* End-of-transfer receiver counters reported through STATUS_STATS (protocol + * 2.25.0) when the wire config carries report_stats. `would_delete_count` is + * the number of destination-relative paths the receiver would have deleted in a + * -n/--dry-run --delete run; that many wire strings immediately follow the + * fixed record (sent/read by the caller). */ +typedef struct { + unsigned long long matched_data; + unsigned long long deleted_files; + unsigned long long would_delete_count; +} ReceiverStats; + +/* Fixed-width STATUS_STATS counter record. The status frame and the optional + * would-delete path list are sent/received by the caller. Returns false on I/O + * failure. */ +bool format_stats_send(int fd, const ReceiverStats* stats); +bool format_stats_receive(int fd, ReceiverStats* stats); + #endif diff --git a/src/shared/utils.c b/src/shared/utils.c index 00704d0..40e4d2f 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -725,6 +725,148 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k return operation_ok; } +/* Read-only mirror of delete_extras_fd: records the paths that WOULD be removed + without unlinking anything. A child directory is reported after its own + reportable children (depth-first), matching the delete pass's ordering. */ +static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep, + const PathIndex* dirs, ArrayList* out, size_t* recorded, + const DeleteSkipEntry* skips, int skip_count, bool parent_deletable, + bool* all_removed) { + int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (scanfd < 0) + return false; + DIR* dir = fdopendir(scanfd); + if (!dir) { + close(scanfd); + return false; + } + bool operation_ok = true; + bool local_survives = false; + bool deletable = parent_deletable || is_synced_dir(dirs, rel_path); + const struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) + continue; + char* child_rel = path_cat((char*)rel_path, entry->d_name); + if (!child_rel) { + operation_ok = false; + continue; + } + if (path_under_skip_prefix(child_rel, rel_path[0] == '\0', skips, skip_count)) { + local_survives = true; + free(child_rel); + continue; + } + struct stat st; + if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { + if (errno != ENOENT) + operation_ok = false; + free(child_rel); + continue; + } + if (S_ISDIR(st.st_mode)) { + int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + bool child_all_removed = false; + if (childfd >= 0) { + if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count, deletable, + &child_all_removed)) + operation_ok = false; + close(childfd); + } else if (errno != ENOENT) { + operation_ok = false; + } + bool child_synced = dirs && path_index_contains(dirs, child_rel); + if (child_synced || keep_is_dir(keep, child_rel)) { + local_survives = true; + } else if (child_all_removed && deletable) { + size_t len = strlen(child_rel); + char* copy = malloc(len + 2); + if (!copy) { + operation_ok = false; + } else { + memcpy(copy, child_rel, len); + copy[len] = '/'; + copy[len + 1] = '\0'; + if (!array_list_add(out, copy)) { + free(copy); + operation_ok = false; + } else { + (*recorded)++; + } + } + } else { + local_survives = true; + } + } else { + bool found = keep_is_file(keep, child_rel); + if (found || !deletable) { + local_survives = true; + } else { + char* copy = str_dup(child_rel); + if (!copy || !array_list_add(out, copy)) { + free(copy); + operation_ok = false; + } else { + (*recorded)++; + } + } + } + free(child_rel); + } + closedir(dir); + *all_removed = !local_survives; + return operation_ok; +} + +bool delete_extras_list(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, + ArrayList* out, size_t* count_out) { + if (count_out) + *count_out = 0; + if (!manifest || !out) + return false; + PathIndex keep; + if (!build_keep_index(manifest, &keep)) + return false; + PathIndex dirs; + bool have_dirs = synced_dirs != NULL; + if (have_dirs && + !path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) { + path_index_free(&keep); + return false; + } + int rootfd; + int root_fd = utils_get_authorized_root_fd(); + if (root_fd >= 0) { + if (utils_get_authorized_root_path()) + rootfd = open_authorized_destination(dest_root); + else if (dest_root == NULL) + rootfd = dup(root_fd); + else + rootfd = -1; + } else { + rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + } + if (rootfd < 0) { + path_index_free(&keep); + if (have_dirs) + path_index_free(&dirs); + return false; + } + bool all_removed = false; + size_t recorded = 0; + bool ok = list_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, out, &recorded, skips, + skip_count, false, &all_removed); + if (close(rootfd) != 0) + ok = false; + path_index_free(&keep); + if (have_dirs) + path_index_free(&dirs); + if (count_out) + *count_out = recorded; + return ok; +} + DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest, const ArrayList* synced_dirs, size_t max_delete, const DeleteSkipEntry* skips, int skip_count, diff --git a/src/shared/utils.h b/src/shared/utils.h index 0cca144..67f8fe4 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -138,6 +138,14 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m const ArrayList* synced_dirs, size_t max_delete, const DeleteSkipEntry* skips, int skip_count, size_t* deleted_out, size_t* skipped_out); +/* Read-only companion to delete_extras_limited: walk the destination exactly as + the delete pass would and APPEND (strdup'd) destination-relative paths that + WOULD be removed, without touching disk. Used for -n/--dry-run --delete + would-delete reporting. Returns true on a clean walk; the caller owns the + strings appended to `out` and receives their count in *count_out. */ +bool delete_extras_list(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, + ArrayList* out, size_t* count_out); bool delete_extras(const char* dest_root, const ArrayList* manifest); bool utils_set_authorized_root(int fd, const char* canonical_path); /* The fd-only compatibility form is fail-closed for path-based operations;