fix(parity): actual-removal delete-delay counts, rsync-accurate stats/progress/%C

- --delete-delay: count/track only entries actually removed; a directory
  refilled before commit (ENOTEMPTY) no longer inflates Number of deleted
  files or the --max-delete budget (unit + integration + rsync differential).
- --stats: per-type Number of files breakdown; only stored regular files
  count as transferred; transferred/literal byte totals and Total file size
  (symlink target lengths) now match rsync for whole-file transfers.
- --progress: print the leading ./ root line and include the root entry in
  the to-chk denominator (single-file output byte-identical to rsync).
- --out-format %C: use the selected transfer checksum and render every
  algorithm exactly like rsync; checksum_digest_file gains md4/sha1/none.
  Reclassify --out-format to Divergent (protocol-specific %b/delta-%c).
- Docs: RSYNC_COMPAT tally 107/25/24, HANDOFF update. No wire change.
This commit is contained in:
2026-09-18 20:13:29 +02:00
parent cee281ff55
commit 9fa1696eff
17 changed files with 659 additions and 93 deletions
+27 -2
View File
@@ -223,17 +223,25 @@ bool checksum_digest_file(ChecksumAlgo algo, uint64_t seed, const char* path, ui
if (fd < 0)
return false;
if (algo == CHECKSUM_ALGO_NONE) {
/* No checksum requested: nothing to read; an empty digest succeeds. */
close(fd);
*out_len = 0;
return true;
}
uint8_t buffer[64 * 1024];
bool ok = false;
if (algo == CHECKSUM_ALGO_MD5) {
if (algo == CHECKSUM_ALGO_MD5 || algo == CHECKSUM_ALGO_SHA1) {
const EVP_MD* md = algo == CHECKSUM_ALGO_MD5 ? EVP_md5() : EVP_sha1();
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
if (!ctx) {
close(fd);
return false;
}
unsigned int digest_len = 0;
if (EVP_DigestInit_ex(ctx, EVP_md5(), NULL) == 1) {
if (EVP_DigestInit_ex(ctx, md, NULL) == 1) {
ok = true;
ssize_t got;
while ((got = read(fd, buffer, sizeof(buffer))) > 0) {
@@ -254,6 +262,23 @@ bool checksum_digest_file(ChecksumAlgo algo, uint64_t seed, const char* path, ui
return ok;
}
if (algo == CHECKSUM_ALGO_MD4) {
Md4Ctx ctx;
md4_init(&ctx);
ok = true;
ssize_t got;
while ((got = read(fd, buffer, sizeof(buffer))) > 0)
md4_update(&ctx, buffer, (size_t)got);
if (got < 0)
ok = false;
if (ok) {
md4_final(&ctx, out);
*out_len = 16;
}
close(fd);
return ok;
}
XXH64_state_t xxh64;
XXH3_state_t* xxh3 = NULL;
if (algo == CHECKSUM_ALGO_XXH64) {
+17 -6
View File
@@ -412,6 +412,11 @@ struct DeletePlanSession {
bool dry_run;
size_t max_delete;
size_t deleted;
/* Removals charged against --max-delete. In --delete-delay mode a path is
planned (and the budget consumed) while scanning, but `deleted` advances
only when the commit actually unlinks it, so an entry that survives the
commit (a directory refilled mid-transfer -> ENOTEMPTY) is not reported. */
size_t planned;
size_t skipped;
bool limit_hit;
bool limit_logged;
@@ -565,7 +570,7 @@ static bool build_plan_skips(const Config* config, const DeletePlanSession* sess
}
static bool budget_available(const DeletePlanSession* session) {
return session->deleted < session->max_delete;
return session->planned < session->max_delete;
}
static void note_skipped(DeletePlanSession* session) {
@@ -579,7 +584,9 @@ static void log_deleted(const char* rel) {
free(escaped);
}
/* Append a snapshot path for --delete-delay. */
/* Append a snapshot path for --delete-delay. The budget is charged here, but
* `deleted` is not: the path counts only once apply_deferred_path truly
* unlinks it. */
static bool defer_add(DeletePlanSession* session, const char* rel) {
char* copy = str_dup(rel);
if (!copy)
@@ -588,7 +595,7 @@ static bool defer_add(DeletePlanSession* session, const char* rel) {
free(copy);
return false;
}
session->deleted++;
session->planned++;
return true;
}
@@ -633,6 +640,7 @@ static bool process_extra_dir(int dirfd, const char* name, const char* child_rel
}
if (unlinkat(dirfd, name, AT_REMOVEDIR) == 0) {
session->deleted++;
session->planned++;
log_deleted(child_rel);
*removed = true;
return true;
@@ -657,6 +665,7 @@ static bool process_extra_file(int dirfd, const char* name, const char* child_re
}
if (unlinkat(dirfd, name, 0) == 0) {
session->deleted++;
session->planned++;
log_deleted(child_rel);
} else if (errno != ENOENT) {
return false;
@@ -768,13 +777,14 @@ static bool apply_missing(DeletePlanSession* session, const Config* config) {
return true;
DeleteManifest manifest = {
.keeps = NULL, .protected = NULL, .missing = session->missing, .dirs = NULL};
size_t remaining = budget_available(session) ? session->max_delete - session->deleted : 0;
size_t remaining = budget_available(session) ? session->max_delete - session->planned : 0;
size_t deleted = 0;
size_t skipped = 0;
bool limit = false;
bool ok = manifest_delete_missing_args_limited(config, &manifest, remaining, &deleted, &skipped,
&limit);
session->deleted += deleted;
session->planned += deleted;
session->skipped += skipped;
if (limit)
session->limit_hit = true;
@@ -839,7 +849,6 @@ int delete_plan_session_receive(DeletePlanSession* session, const Config* config
/* Apply one snapshotted --delete-delay path (post-order: children precede their
* parent directory). */
static bool apply_deferred_path(DeletePlanSession* session, const Config* config, const char* rel) {
(void)session;
char* full = path_cat(config->receive_root_directory, rel);
if (!full)
return false;
@@ -863,8 +872,10 @@ static bool apply_deferred_path(DeletePlanSession* session, const Config* config
else
rc = unlinkat(parent_fd, leaf, 0);
bool ok = rc == 0 || errno == ENOENT || errno == ENOTEMPTY || errno == EEXIST;
if (rc == 0)
if (rc == 0) {
session->deleted++;
log_deleted(rel);
}
close(parent_fd);
free(leaf);
return ok;
+18
View File
@@ -73,4 +73,22 @@ typedef struct {
bool format_stats_send(int fd, const ReceiverStats* stats);
bool format_stats_receive(int fd, ReceiverStats* stats);
/* Sender-side file-list accounting for rsync's `--stats` block. Filled while
* the scan/send loops walk each entry: the flist counters describe every
* scanned source entry (transferred or skipped), while the transferred/literal
* counters describe only the regular files the receiver actually stored. The
* type split lets the client print rsync's `Number of files` breakdown; the
* receiver-only counters (matched data, deleted, created) come from
* STATUS_STATS. */
typedef struct {
unsigned long long flist_reg;
unsigned long long flist_dir;
unsigned long long flist_link;
unsigned long long flist_special;
unsigned long long total_file_size; /* sum of entry sizes (link target len) */
unsigned long long transferred_regular; /* regular files actually stored */
unsigned long long transferred_file_size; /* source size of those files */
unsigned long long literal_data; /* literal bytes sent for them */
} TransferStats;
#endif
+1
View File
@@ -42,6 +42,7 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que
context->total_files = 0;
context->progress_bytes = 0;
context->total_bytes = 0;
memset(&context->stats, 0, sizeof(context->stats));
context->sender_done = false;
atomic_init(&context->cancelled, false);
protocol_session_init(&context->allocation_session, -1, -1);
+4
View File
@@ -9,6 +9,7 @@
#include "config.h"
#include "delete_plan.h"
#include "file.h"
#include "format.h"
#include "protocol.h"
#include "queue.h"
#include "stop_condition.h"
@@ -86,6 +87,9 @@ typedef struct {
int total_files;
unsigned long long progress_bytes;
unsigned long long total_bytes;
/* Per-type flist / transferred accounting for the rsync --stats breakdown and
the progress `to-chk` denominator. Guarded by mutex_progress. */
TransferStats stats;
bool sender_done;
atomic_bool cancelled;
ProtocolSession allocation_session;