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.
This commit is contained in:
2026-09-16 22:55:26 +02:00
parent 36d4d0e43e
commit ea28e25535
12 changed files with 493 additions and 31 deletions
+5
View File
@@ -300,6 +300,11 @@ char* change_render_format(const char* format, const Config* config, const Chang
ok = strbuf_append_char(&line, '%'); ok = strbuf_append_char(&line, '%');
break; break;
case 'i': { case 'i': {
if (event->deleted) {
/* rsync's ITEM_DELETED itemize code: `*deleting ` (11 chars). */
ok = strbuf_append(&line, "*deleting ");
break;
}
char code[12]; char code[12];
itemize_code(config, event, code); itemize_code(config, event, code);
ok = strbuf_append(&line, code); ok = strbuf_append(&line, code);
+1
View File
@@ -35,6 +35,7 @@ typedef struct {
bool is_symlink; bool is_symlink;
bool is_special; bool is_special;
bool is_hardlink; /* a hard-link sibling (linked, no data sent) */ 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* symlink_target;
const char* hardlink_target; const char* hardlink_target;
unsigned long long size; /* source file length in bytes */ unsigned long long size; /* source file length in bytes */
+15
View File
@@ -2366,6 +2366,21 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
* check. This is a wire field. */ * check. This is a wire field. */
config->report_dest_info = config->itemize_changes || config->out_format != NULL || config->report_dest_info = config->itemize_changes || config->out_format != NULL ||
(config->log_file != NULL && config->log_file_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; return 0;
} }
+179 -29
View File
@@ -85,20 +85,31 @@ static const char* stats_bytes(const Config* config, unsigned long long bytes, c
return buffer; return buffer;
} }
/* Print the rsync `--stats` block on stdout. FastSync is a push sender, so a /* Print the rsync `--stats` block on stdout. Byte totals use the process-wide
few receiver-only counters (matched data, file-list bytes, deletion count) wire counters and the receiver-only counters come from the STATUS_STATS frame;
are not observable and are reported as 0; the labels and layout match rsync the labels, layout and rate/speedup formulas match rsync 3.4.1. Shared by the
3.4.1. Shared by the single-threaded and multithreaded send paths. */ single-threaded and multithreaded send paths. */
static void report_transfer_stats(const Config* config, int total_files, 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) if (!config->stats || config->quiet)
return; 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 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 total_buffer[32];
char sent_buffer[32];
char recv_buffer[32];
char rate_buffer[32] = {0}; char rate_buffer[32] = {0};
char human_rate[32] = {0}; char human_rate[32] = {0};
const char* total = stats_bytes(config, total_bytes, total_buffer, sizeof(total_buffer)); 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; const char* rate_str = rate_buffer;
if (config->human_readable) { if (config->human_readable) {
if (!format_human_size_decimal((unsigned long long)rate, human_rate, sizeof(human_rate))) 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 { } else {
snprintf(rate_buffer, sizeof(rate_buffer), "%.2f", rate); snprintf(rate_buffer, sizeof(rate_buffer), "%.2f", rate);
} }
double speedup = (sent + received) > 0 ? (double)total_bytes / (double)(sent + received) : 0.0;
printf("\n"); printf("\n");
printf("Number of files: %d\n", total_files); printf("Number of files: %d\n", total_files);
printf("Number of created 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("Number of regular files transferred: %d\n", total_files);
printf("Total file size: %s bytes\n", total); printf("Total file size: %s bytes\n", total);
printf("Total transferred file size: %s bytes\n", total); printf("Total transferred file size: %s bytes\n", total);
printf("Literal data: %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 size: 0\n");
printf("File list generation time: 0.000 seconds\n"); printf("File list generation time: 0.000 seconds\n");
printf("File list transfer time: 0.000 seconds\n"); printf("File list transfer time: 0.000 seconds\n");
printf("Total bytes sent: %s\n", total); printf("Total bytes sent: %s\n", sent_s);
printf("Total bytes received: 0\n"); printf("Total bytes received: %s\n", recv_s);
printf("\n"); printf("\n");
printf("sent %s bytes received 0 bytes %s bytes/sec\n", total, rate_str); 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\n", total, 1.0); printf("total size is %s speedup is %.2f%s\n", total, speedup,
config->dry_run ? " (DRY RUN)" : "");
fflush(stdout); fflush(stdout);
} }
@@ -686,13 +699,59 @@ static void mark_sender_done(PipelineContextSender* context) {
mtx_unlock(&context->mutex_progress); 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. /* Send the final STATUS_FINISHED frame and await the receiver's verdict.
When --remove-source-files is active the receiver acknowledges each data When --remove-source-files is active the receiver acknowledges each data
file it processed, in send order: STATUS_NEXT means the file was written, 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 STATUS_OK means the file was skipped/unchanged. Skipped sources are marked
so the later removal pass keeps them. */ so the later removal pass keeps them. */
static bool finalize_transfer(Client* client, const Config* config, ArrayList* remove_sources, 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) if (delete_limit_out)
*delete_limit_out = false; *delete_limit_out = false;
if (!send_status(client->file_descriptor, STATUS_FINISHED)) 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; Status status;
if (!receive_status(client->file_descriptor, &status)) if (!receive_status(client->file_descriptor, &status))
return false; 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 /* A capped --max-delete commit is a successful transfer that the client must
report with rsync's exit code 25 (not an error). */ report with rsync's exit code 25 (not an error). */
if (status == STATUS_DELETE_LIMIT) { 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. */ dry-run reports the same clear diagnostic instead of aborting mid-stream. */
if (config_has_basis(config) && !basis_oversize_preflight(config)) if (config_has_basis(config) && !basis_oversize_preflight(config))
return 1; 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. */ /* A live session may follow, so arm graceful abort handling. */
client_set_abort_armed(true); client_set_abort_armed(true);
@@ -1461,6 +1521,8 @@ static int send_dry_run_remote(Config* config) {
PreparedScanner prepared; PreparedScanner prepared;
memset(&prepared, 0, sizeof(prepared)); memset(&prepared, 0, sizeof(prepared));
DirectoryScanner* scanner = NULL; DirectoryScanner* scanner = NULL;
ArrayList* dry_manifest = NULL;
ArrayList* dry_dirs = NULL;
if (!config_send(client->file_descriptor, config)) if (!config_send(client->file_descriptor, config))
goto dry_fail; goto dry_fail;
receive_daemon_motd(client, config); receive_daemon_motd(client, config);
@@ -1473,10 +1535,34 @@ static int send_dry_run_remote(Config* config) {
int file_count = 0; int file_count = 0;
unsigned long long total_bytes = 0; unsigned long long total_bytes = 0;
char size_buffer[32]; 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) if (!config->quiet)
printf("Dry run: files to be transferred\n"); printf("Dry run: files to be transferred\n");
Chunk* chunk; Chunk* chunk;
while ((chunk = directory_scanner_next(scanner)) != NULL) { 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++) { for (int i = 0; i < chunk->element_count; i++) {
File* f = chunk->items[i]; File* f = chunk->items[i];
if (!f) if (!f)
@@ -1538,12 +1624,69 @@ static int send_dry_run_remote(Config* config) {
goto dry_fail; goto dry_fail;
if (io_error) if (io_error)
log_message(LOG_LEVEL_WARNING, "source scan hit an unreadable directory"); log_message(LOG_LEVEL_WARNING, "source scan hit an unreadable directory");
/* Terminate the stream so the receiver emits its success frame; no data /* Send the keep-set manifest (no data frames) so the receiver can enumerate
frame and no delete manifest are ever sent in dry-run. */ 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)) if (!send_status(client->file_descriptor, STATUS_FINISHED))
goto dry_fail; goto dry_fail;
Status status; 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; goto dry_fail;
if (!config->quiet) { if (!config->quiet) {
if (config->human_readable) if (config->human_readable)
@@ -1555,6 +1698,10 @@ static int send_dry_run_remote(Config* config) {
ret = io_error ? 1 : 0; ret = io_error ? 1 : 0;
dry_fail: dry_fail:
if (dry_manifest)
array_list_delete(dry_manifest);
if (dry_dirs)
array_list_delete(dry_dirs);
if (scanner) if (scanner)
directory_scanner_destroy(scanner); directory_scanner_destroy(scanner);
prepared_scanner_destroy(&prepared); 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)) !send_dir_times(client, context->config, context->dir_entries))
goto send_fail; goto send_fail;
bool delete_limit = false; 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; context->delete_limit = delete_limit;
if (!ok && context->config->use_delete) if (!ok && context->config->use_delete)
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
@@ -2066,7 +2216,7 @@ static int send_chunks_multithreaded(void* pipeline_context) {
int total_files = context->total_files; int total_files = context->total_files;
unsigned long long total_bytes = context->total_bytes; unsigned long long total_bytes = context->total_bytes;
mtx_unlock(&context->mutex_progress); 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, log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files,
(double)total_bytes / (double)BYTES_PER_MIB); (double)total_bytes / (double)BYTES_PER_MIB);
disconnect_transfer_client(client); disconnect_transfer_client(client);
@@ -2670,15 +2820,15 @@ int send_files(Config* config) {
if (!send_dir_times(client, config, dir_entries)) if (!send_dir_times(client, config, dir_entries))
goto send_fail; goto send_fail;
bool delete_limit = false; 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) if (!ok && config->use_delete)
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
"server reported a deletion failure (--delete); see the server log for the reason"); "server reported a deletion failure (--delete); see the server log for the reason");
if (ok) if (ok)
remove_transferred_sources(config, remove_sources); remove_transferred_sources(config, remove_sources);
if (config->show_progress && !config->quiet) report_transfer_stats(config, total_files, total_bytes, start, &recv_stats);
print_transfer_progress(total_bytes, start, "Done.\n", config->human_readable);
report_transfer_stats(config, total_files, total_bytes, start);
log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files, log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files,
(double)total_bytes / (double)BYTES_PER_MIB); (double)total_bytes / (double)BYTES_PER_MIB);
/* A skipped source entry (--ignore-errors past an unreadable directory, or a /* A skipped source entry (--ignore-errors past an unreadable directory, or a
+42 -2
View File
@@ -11,6 +11,7 @@
#include "protocol.h" #include "protocol.h"
#include "utils.h" #include "utils.h"
#include <stdlib.h> #include <stdlib.h>
#include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <time.h> #include <time.h>
@@ -58,6 +59,28 @@ bool receiver_send_final_success(int fd, const Config* config, const ReceiverOut
return send_status(fd, final_status); 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) { static bool receiver_process_chunk(Chunk* chunk, const ReceiverSink* sink) {
if (!chunk || !sink || !sink->store_file) if (!chunk || !sink || !sink->store_file)
return false; return false;
@@ -337,7 +360,14 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
if (config->dry_run) { if (config->dry_run) {
/* Server-contacting --dry-run mutates nothing, so a keep-set manifest /* Server-contacting --dry-run mutates nothing, so a keep-set manifest
is consumed and discarded. The early-delete mode still needs its ACK 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); delete_manifest_free(manifest);
if (early_delete && !send_status(file_descriptor, STATUS_OK)) if (early_delete && !send_status(file_descriptor, STATUS_OK))
goto fail; goto fail;
@@ -465,6 +495,10 @@ typedef struct {
/* Set when a --max-delete commit was capped; the terminal frame then carries /* Set when a --max-delete commit was capped; the terminal frame then carries
STATUS_DELETE_LIMIT so the sender exits 25 like rsync. */ STATUS_DELETE_LIMIT so the sender exits 25 like rsync. */
bool delete_limit_reached; 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; } ReceiverSaveContext;
static bool receiver_save_file(File* file, void* context_pointer) { 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) { static bool receiver_send_success_frame(int fd, void* context_pointer) {
ReceiverSaveContext* context = context_pointer; ReceiverSaveContext* context = context_pointer;
Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK; 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 /* Server-contacting --dry-run: nothing was staged or written, so there is
nothing to publish and no directory times to stamp. */ nothing to publish and no directory times to stamp. */
if (context->config->dry_run) 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) { int receiver_receive_files(Config* config, int file_descriptor) {
ReceiverSaveContext context = {.config = config, .outcomes = {0}}; ReceiverSaveContext context = {.config = config, .outcomes = {0}};
dir_time_list_init(&context.dir_times); 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, 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); int ret = receiver_process(config, file_descriptor, &sink);
if (ret != 0 && config->delay_updates && config->delay_context) if (ret != 0 && config->delay_updates && config->delay_context)
delay_updates_cleanup(config->delay_context); delay_updates_cleanup(config->delay_context);
receiver_outcomes_destroy(&context.outcomes); receiver_outcomes_destroy(&context.outcomes);
dir_time_list_free(&context.dir_times); dir_time_list_free(&context.dir_times);
array_list_delete(context.would_delete);
return ret; return ret;
} }
+13
View File
@@ -39,15 +39,28 @@ typedef struct {
ReceiverSuccessFrame send_success_frame; ReceiverSuccessFrame send_success_frame;
/* Optional; may be NULL when the sink has no --max-delete handling. */ /* Optional; may be NULL when the sink has no --max-delete handling. */
ReceiverNoteDeleteLimit note_delete_limit; 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; } ReceiverSink;
bool receiver_outcomes_append(ReceiverOutcomes* outcomes, unsigned char code); bool receiver_outcomes_append(ReceiverOutcomes* outcomes, unsigned char code);
void receiver_outcomes_destroy(ReceiverOutcomes* outcomes); void receiver_outcomes_destroy(ReceiverOutcomes* outcomes);
/* Send the terminal success frame. `final_status` is usually STATUS_OK, or /* Send the terminal success frame. `final_status` is usually STATUS_OK, or
STATUS_DELETE_LIMIT when a --max-delete commit was capped. */ STATUS_DELETE_LIMIT when a --max-delete commit was capped. */
bool receiver_send_final_success(int fd, const Config* config, const ReceiverOutcomes* outcomes, bool receiver_send_final_success(int fd, const Config* config, const ReceiverOutcomes* outcomes,
Status final_status); 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); int receiver_process(Config* config, int file_descriptor, const ReceiverSink* sink);
/* receiver_process with an escape hatch for the commit-style (late) deletion: /* receiver_process with an escape hatch for the commit-style (late) deletion:
when `pending_manifest` is non-NULL the receiver does NOT delete at when `pending_manifest` is non-NULL the receiver does NOT delete at
+36
View File
@@ -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 /* Public wrappers used outside the commit path (and by unit tests): no
--max-delete budget. */ --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) { bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) {
DeleteBudgetState budget = { DeleteBudgetState budget = {
.max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false}; .max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false};
+8
View File
@@ -135,6 +135,14 @@ typedef enum {
stopped part of the work, or DELETE_COMMIT_ERROR on a genuine failure. */ stopped part of the work, or DELETE_COMMIT_ERROR on a genuine failure. */
DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* manifest); 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 /* Outcome of a single file_save_to_disk operation. The receiver needs to
distinguish "written" from "skipped" so --remove-source-files can be told distinguish "written" from "skipped" so --remove-source-files can be told
which sources were actually stored. */ which sources were actually stored. */
+27
View File
@@ -101,3 +101,30 @@ bool format_dest_state_receive(int fd, OutputDestState* state) {
state->gid = gid; state->gid = gid;
return true; 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;
}
+17
View File
@@ -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_send(int fd, const OutputDestState* state);
bool format_dest_state_receive(int fd, 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 #endif
+142
View File
@@ -725,6 +725,148 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k
return operation_ok; 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, DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
const ArrayList* synced_dirs, size_t max_delete, const ArrayList* synced_dirs, size_t max_delete,
const DeleteSkipEntry* skips, int skip_count, const DeleteSkipEntry* skips, int skip_count,
+8
View File
@@ -138,6 +138,14 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m
const ArrayList* synced_dirs, size_t max_delete, const ArrayList* synced_dirs, size_t max_delete,
const DeleteSkipEntry* skips, int skip_count, const DeleteSkipEntry* skips, int skip_count,
size_t* deleted_out, size_t* skipped_out); 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 delete_extras(const char* dest_root, const ArrayList* manifest);
bool utils_set_authorized_root(int fd, const char* canonical_path); bool utils_set_authorized_root(int fd, const char* canonical_path);
/* The fd-only compatibility form is fail-closed for path-based operations; /* The fd-only compatibility form is fail-closed for path-based operations;