feat(protocol): destination-state itemize for dirs/symlinks; --stats deleted breakdown

This commit is contained in:
2026-09-23 21:52:40 +02:00
parent cb2979fdf1
commit 29f8be161c
20 changed files with 761 additions and 99 deletions
+16 -3
View File
@@ -32,6 +32,17 @@ static bool keep_is_file(const PathIndex* index, const char* rel_path) {
return path_index_contains(index, rel_path);
}
/* Classify a removed entry from its st_mode for the per-type delete counters. */
DeleteEntryType delete_entry_type_of_mode(mode_t mode) {
if (S_ISDIR(mode))
return DELETE_ENTRY_DIR;
if (S_ISLNK(mode))
return DELETE_ENTRY_LINK;
if (S_ISREG(mode))
return DELETE_ENTRY_REG;
return DELETE_ENTRY_SPECIAL;
}
/* True when child_rel is, or lies below, a protected entry. A prefix "a"
therefore protects "a" and "a/b/c" but not "ab". Entries with top_level_only
set only protect DIRECT children of the receive root (at_root); nested
@@ -126,6 +137,7 @@ bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count,
break;
}
entries[used].is_dir = S_ISDIR(st.st_mode);
entries[used].mode = st.st_mode;
used++;
}
closedir(dir);
@@ -329,10 +341,10 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee
memcpy(with_slash, child_rel, len);
with_slash[len] = '/';
with_slash[len + 1] = '\0';
state->observer(state->observer_context, with_slash);
state->observer(state->observer_context, with_slash, DELETE_ENTRY_DIR);
free(with_slash);
} else {
state->observer(state->observer_context, child_rel);
state->observer(state->observer_context, child_rel, DELETE_ENTRY_DIR);
}
}
}
@@ -373,7 +385,8 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee
char* child_rel = path_cat((char*)rel_path, entries[i].name);
if (child_rel) {
if (state->observer)
state->observer(state->observer_context, child_rel);
state->observer(state->observer_context, child_rel,
delete_entry_type_of_mode(entries[i].mode));
char* escaped_path = output_escape(child_rel, log_get_8_bit_output());
fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
+21 -3
View File
@@ -5,6 +5,7 @@
#include "config.h"
#include <stdbool.h>
#include <stddef.h>
#include <sys/stat.h>
/* Delete engine.
*
@@ -66,6 +67,9 @@ bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSki
typedef struct {
char* name;
bool is_dir;
/* The entry's full st_mode from the AT_SYMLINK_NOFOLLOW stat, so a delete
observer can classify a removed non-directory as reg/link/special. */
mode_t mode;
} DeleteDirEntry;
/* Collect the entries of the directory open on `dirfd` (excluding "." and ".."),
stat'ing each with AT_SYMLINK_NOFOLLOW. On success *out is a malloc'd array of
@@ -99,10 +103,24 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m
const FilterRuleList* protect_rules, size_t* deleted_out,
size_t* skipped_out);
/* Entry kind of a removed path, reported to the delete observer so the receiver
can build rsync's `--stats` `Number of deleted files` per-type breakdown. The
four categories are a strict partition of every removed entry. */
typedef enum {
DELETE_ENTRY_REG = 0,
DELETE_ENTRY_DIR,
DELETE_ENTRY_LINK,
DELETE_ENTRY_SPECIAL
} DeleteEntryType;
/* Optional per-deletion observer: called for each destination-relative path
actually removed (a file, symlink, or directory), in removal order, so the
receiver can stream rsync's `--info=del`/`--info=remove` lines. */
typedef void (*DeletePathObserver)(void* context, const char* rel_path);
actually removed (a file, symlink, or directory) with its entry kind, in
removal order, so the receiver can stream rsync's `--info=del`/`--info=remove`
lines and tally the per-type `--stats` counters. */
typedef void (*DeletePathObserver)(void* context, const char* rel_path, DeleteEntryType type);
/* Classify a removed entry from its st_mode for the per-type delete counters. */
DeleteEntryType delete_entry_type_of_mode(mode_t mode);
/* `delete_extras_limited_observed` is delete_extras_limited with an optional
* observer; the observer is invoked only for entries truly removed. When
+3 -3
View File
@@ -191,13 +191,13 @@ typedef struct {
const char* prefix;
} PrefixedDeleteObserver;
static void prefixed_delete_observer(void* context, const char* rel) {
static void prefixed_delete_observer(void* context, const char* rel, DeleteEntryType type) {
PrefixedDeleteObserver* prefixed = context;
if (!prefixed->inner || !rel)
return;
char* joined = path_cat((char*)prefixed->prefix, rel);
if (joined) {
prefixed->inner(prefixed->inner_context, joined);
prefixed->inner(prefixed->inner_context, joined, type);
free(joined);
}
}
@@ -359,7 +359,7 @@ static bool delete_missing_args_budgeted_observed(const Config* config,
if (removed) {
budget->deleted++;
if (observer)
observer(observer_context, rel);
observer(observer_context, rel, delete_entry_type_of_mode(st.st_mode));
char* escaped = output_escape(rel, log_get_8_bit_output());
fprintf(stderr, " Deleted: %s\n", escaped ? escaped : "<allocation failed>");
free(escaped);
+10 -9
View File
@@ -478,9 +478,9 @@ struct DeletePlanSession {
};
/* Report one path the session truly removed (no-op without an observer). */
static void notify_deleted(DeletePlanSession* session, const char* rel) {
static void notify_deleted(DeletePlanSession* session, const char* rel, DeleteEntryType type) {
if (session && session->observer && rel)
session->observer(session->observer_context, rel);
session->observer(session->observer_context, rel, type);
}
/* A removed directory is reported with rsync's trailing slash (`deleting dir/`)
@@ -491,13 +491,13 @@ static void notify_deleted_dir(DeletePlanSession* session, const char* rel) {
size_t len = strlen(rel);
char* with_slash = malloc(len + 2);
if (!with_slash) {
session->observer(session->observer_context, rel);
session->observer(session->observer_context, rel, DELETE_ENTRY_DIR);
return;
}
memcpy(with_slash, rel, len);
with_slash[len] = '/';
with_slash[len + 1] = '\0';
session->observer(session->observer_context, with_slash);
session->observer(session->observer_context, with_slash, DELETE_ENTRY_DIR);
free(with_slash);
}
@@ -711,8 +711,8 @@ static bool process_extra_dir(int dirfd, const char* name, const char* child_rel
return errno == ENOTEMPTY || errno == EEXIST;
}
static bool process_extra_file(int dirfd, const char* name, const char* child_rel, bool force_now,
DeletePlanSession* session) {
static bool process_extra_file(int dirfd, const char* name, const char* child_rel, mode_t mode,
bool force_now, DeletePlanSession* session) {
if (session->defer && !force_now) {
return defer_add(session, child_rel);
}
@@ -724,7 +724,7 @@ static bool process_extra_file(int dirfd, const char* name, const char* child_re
session->deleted++;
session->planned++;
log_deleted(child_rel);
notify_deleted(session, child_rel);
notify_deleted(session, child_rel, delete_entry_type_of_mode(mode));
} else if (errno != ENOENT) {
return false;
}
@@ -828,7 +828,8 @@ static bool process_children(int dirfd, const char* dir_rel, const ArrayList* ke
operation_ok = false;
continue;
}
if (!process_extra_file(dirfd, entries[i].name, child_rel, force[i] || force_now, session))
if (!process_extra_file(dirfd, entries[i].name, child_rel, entries[i].mode,
force[i] || force_now, session))
operation_ok = false;
free(child_rel);
}
@@ -1032,7 +1033,7 @@ static bool apply_deferred_path(DeletePlanSession* session, const Config* config
session->deleted++;
session->planned++;
log_deleted(rel);
notify_deleted(session, rel);
notify_deleted(session, rel, delete_entry_type_of_mode(st.st_mode));
} else if (errno != ENOENT) {
close(parent_fd);
free(leaf);
+16 -6
View File
@@ -62,14 +62,16 @@ bool format_dest_state_send(int fd, const OutputDestState* state) {
if (!state)
return false;
int32_t has_old = state->existed ? 1 : 0;
int32_t target_matches = state->target_matches ? 1 : 0;
uint64_t size = (uint64_t)state->size;
int64_t mtime = (int64_t)state->mtime_sec;
int64_t mtime_nsec = state->mtime_nsec;
uint32_t mode = state->mode;
int32_t uid = state->uid;
int32_t gid = state->gid;
return send_n_data(fd, &has_old, sizeof(has_old)) && send_n_data(fd, &size, sizeof(size)) &&
send_n_data(fd, &mtime, sizeof(mtime)) &&
return send_n_data(fd, &has_old, sizeof(has_old)) &&
send_n_data(fd, &target_matches, sizeof(target_matches)) &&
send_n_data(fd, &size, sizeof(size)) && send_n_data(fd, &mtime, sizeof(mtime)) &&
send_n_data(fd, &mtime_nsec, sizeof(mtime_nsec)) && send_n_data(fd, &mode, sizeof(mode)) &&
send_n_data(fd, &uid, sizeof(uid)) && send_n_data(fd, &gid, sizeof(gid));
}
@@ -78,14 +80,16 @@ bool format_dest_state_receive(int fd, OutputDestState* state) {
if (!state)
return false;
int32_t has_old = 0;
int32_t target_matches = 0;
uint64_t size = 0;
int64_t mtime = 0;
int64_t mtime_nsec = 0;
uint32_t mode = 0;
int32_t uid = 0;
int32_t gid = 0;
if (!receive_n_data(fd, &has_old, sizeof(has_old)) || !receive_n_data(fd, &size, sizeof(size)) ||
!receive_n_data(fd, &mtime, sizeof(mtime)) ||
if (!receive_n_data(fd, &has_old, sizeof(has_old)) ||
!receive_n_data(fd, &target_matches, sizeof(target_matches)) ||
!receive_n_data(fd, &size, sizeof(size)) || !receive_n_data(fd, &mtime, sizeof(mtime)) ||
!receive_n_data(fd, &mtime_nsec, sizeof(mtime_nsec)) ||
!receive_n_data(fd, &mode, sizeof(mode)) || !receive_n_data(fd, &uid, sizeof(uid)) ||
!receive_n_data(fd, &gid, sizeof(gid)))
@@ -93,6 +97,7 @@ bool format_dest_state_receive(int fd, OutputDestState* state) {
memset(state, 0, sizeof(*state));
state->known = true;
state->existed = has_old != 0;
state->target_matches = target_matches != 0;
state->size = size;
state->mtime_sec = mtime;
state->mtime_nsec = mtime_nsec;
@@ -105,9 +110,10 @@ bool format_dest_state_receive(int fd, OutputDestState* state) {
bool format_stats_send(int fd, const ReceiverStats* stats) {
if (!stats)
return false;
unsigned long long fields[8] = {
unsigned long long fields[12] = {
stats->matched_data, stats->deleted_files, stats->would_delete_count, stats->literal_bytes,
stats->created_reg, stats->created_dir, stats->created_link, stats->created_special,
stats->deleted_reg, stats->deleted_dir, stats->deleted_link, stats->deleted_special,
};
return send_n_data(fd, fields, sizeof(fields));
}
@@ -115,7 +121,7 @@ bool format_stats_send(int fd, const ReceiverStats* stats) {
bool format_stats_receive(int fd, ReceiverStats* stats) {
if (!stats)
return false;
unsigned long long fields[8] = {0};
unsigned long long fields[12] = {0};
if (!receive_n_data(fd, fields, sizeof(fields)))
return false;
memset(stats, 0, sizeof(*stats));
@@ -127,5 +133,9 @@ bool format_stats_receive(int fd, ReceiverStats* stats) {
stats->created_dir = fields[5];
stats->created_link = fields[6];
stats->created_special = fields[7];
stats->deleted_reg = fields[8];
stats->deleted_dir = fields[9];
stats->deleted_link = fields[10];
stats->deleted_special = fields[11];
return true;
}
+25 -6
View File
@@ -17,10 +17,18 @@
/* Pre-transfer destination snapshot, reported by the receiver when the wire
* config carries report_dest_info. `known` distinguishes "no report was
* requested/received" from "the destination did not exist" (`existed == false`
* with `known == true`). */
* with `known == true`).
*
* `target_matches` is meaningful only for a symlink destination (protocol
* 2.30.0): the receiver compares its on-disk link target with the incoming
* target and reports whether they are equal, so the sender can render rsync's
* `cLc........` (target changed) versus `.L..t......` (attributes only) and
* suppress an unchanged symlink's line entirely. It is always false for every
* other entry kind. */
typedef struct {
bool known;
bool existed;
bool target_matches;
unsigned long long size;
long long mtime_sec;
long long mtime_nsec;
@@ -57,17 +65,24 @@ 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, extended in 2.28.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).
* 2.25.0, extended in 2.28.0 and 2.30.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).
*
* Protocol 2.28.0 adds the receiver-observed counters the sender cannot see:
* `literal_bytes` is the file data the receiver actually stored literally
* (whole files plus the literal fragments of a delta) and the four `created_*`
* counters split the destination entries the receiver newly created by type,
* reproducing rsync's `Number of created files` breakdown and an exact
* `Literal data` for a delta run. */
* `Literal data` for a delta run.
*
* Protocol 2.30.0 appends the four `deleted_*` counters: the same reg/dir/link/
* special split for the entries the receiver ACTUALLY removed, so `--stats` can
* render rsync's `Number of deleted files: X (reg: A, dir: B, link: C,
* special: D)` parenthetical. The scalar `deleted_files` stays the authoritative
* total (the breakdown is a strict partition of it). */
typedef struct {
unsigned long long matched_data;
unsigned long long deleted_files;
@@ -77,6 +92,10 @@ typedef struct {
unsigned long long created_dir;
unsigned long long created_link;
unsigned long long created_special;
unsigned long long deleted_reg;
unsigned long long deleted_dir;
unsigned long long deleted_link;
unsigned long long deleted_special;
} ReceiverStats;
/* Fixed-width STATUS_STATS counter record. The status frame and the optional
+27 -14
View File
@@ -129,8 +129,13 @@ enum NET_STATUS {
STATUS_KEEPALIVE,
STATUS_ABORT,
STATUS_CHECK_BATCH,
/* An explicit directory entry (--dirs): the sender transmits only the path;
* the receiver creates the directory below the receive root. */
/* An explicit directory entry (--dirs / an empty source directory): the sender
* transmits the path and, when metadata/xattrs are negotiated, their blocks;
* the receiver creates the directory below the receive root. Protocol 2.30.0
* inserts an int32 probe flag right after the status when report_dest_info is
* negotiated: probe=1 is a report-only frame (path only; the receiver answers
* STATUS_DEST_INFO and creates nothing), probe=0 is a real create that is
* answered with the directory's pre-transfer state before it is created. */
STATUS_MKDIR,
/* --append / --append-verify tail resume. STATUS_APPEND is sent by the
* receiver after a per-file STATUS_CHECK when the existing destination file
@@ -213,16 +218,22 @@ enum NET_STATUS {
* limit stopped deletions"). Appended after STATUS_DRY_RUN_TRANSFER so no
* existing status is renumbered. */
STATUS_DELETE_LIMIT,
/* Destination-state report for output parity (protocol 2.23.0). When the
* wire config carries report_dest_info=true, the receiver answers every
* per-file STATUS_CHECK request with STATUS_DEST_INFO FIRST, followed by a
* fixed record describing the pre-transfer destination entry
* (int32 has_old; uint64 size; int64 mtime; int64 mtime_nsec; uint32 mode;
* int32 uid; int32 gid). The ordinary STATUS_OK/STATUS_NEXT/... verdict
* follows, so the sender can render rsync-accurate -i/--out-format columns
* (new vs modified, and which of size/time/perms/owner/group differ) without
* changing the transfer decision itself. Appended after
* STATUS_DELETE_LIMIT so no existing status is renumbered. */
/* Destination-state report for output parity (protocol 2.23.0; extended to
* directories/symlinks in 2.30.0). When the wire config carries
* report_dest_info=true, the receiver answers every per-file STATUS_CHECK
* request with STATUS_DEST_INFO FIRST, followed by a fixed record describing
* the pre-transfer destination entry (int32 has_old; int32 target_matches;
* uint64 size; int64 mtime; int64 mtime_nsec; uint32 mode; int32 uid;
* int32 gid). The ordinary STATUS_OK/STATUS_NEXT/... verdict follows, so the
* sender can render rsync-accurate -i/--out-format columns (new vs modified,
* and which of size/time/perms/owner/group differ) without changing the
* transfer decision itself. Protocol 2.30.0 also uses this record for
* STATUS_MKDIR and STATUS_SYMLINK: the sender consumes it into the entry's
* dest_state before emitting its change line, and target_matches reports
* whether an existing symlink's on-disk target already equals the incoming
* one (so the sender can render `cLc........` vs `.L..t......` and suppress
* an unchanged symlink). Appended after STATUS_DELETE_LIMIT so no existing
* status is renumbered. */
STATUS_DEST_INFO,
/* Per-directory delete plan (protocol 2.24.0). The sender of a
* --delete-during/--delete-delay transfer streams one frame per source
@@ -244,8 +255,10 @@ enum NET_STATUS {
* config carries report_stats=true, the receiver sends this status once,
* immediately before its terminal success status, followed by a fixed stats
* record (see format_stats_send/receive in format.h) and, when the run is a
* --dry-run with --delete, the would-delete path list. Appended after
* STATUS_DELETE_PLAN so no existing status is renumbered. */
* --dry-run with --delete, the would-delete path list. Protocol 2.30.0
* appends the four deleted_reg/dir/link/special counters to that record, so
* --stats can render rsync's `Number of deleted files` per-type breakdown.
* Appended after STATUS_DELETE_PLAN so no existing status is renumbered. */
STATUS_STATS,
/* Client diagnostic channel (protocol 2.30.0, rsync's --stderr=client /
* --no-msgs2stderr). When the client's --stderr mode is `client`, the