fix(delete): match rsync deletion semantics (#290)

- Scope the --delete extras walk to directories synchronized by the
  transfer: add a synchronized-directory section to the delete manifest
  (protocol 2.23.0) so --files-from subsets no longer delete untransmitted
  paths outside listed directory subtrees (data-loss fix).
- Separate --max-size/--min-size prune protection from --delete-excluded so
  size-pruned source mirrors survive (rsync parity).
- Unlink extraneous destination symlinks instead of skipping them.
- Make --max-delete partial (delete up to N, skip the rest) and exit 25;
  accept negative values as unlimited.
- Draw --delete-missing-args deletions from the shared --max-delete budget.
- Honor --force during --delay-updates publication.

Add unit and integration regression tests; update the pinned config wire
golden and version strings for the 2.23.0 manifest/status additions.
This commit is contained in:
2026-09-15 23:12:03 +02:00
parent 23552e823d
commit 82a1d5e240
28 changed files with 1159 additions and 438 deletions
+91 -59
View File
@@ -136,7 +136,8 @@ static void test_walker_removes_extras_keeps_manifest_and_protected() {
EXPECT_NOT_NULL(manifest);
DeleteSkipEntry skip = {"prot", false};
size_t deleted = 0;
DeleteWalkResult result = delete_extras_limited(root, manifest, 100000, &skip, 1, &deleted);
DeleteWalkResult result =
delete_extras_limited(root, manifest, NULL, 100000, &skip, 1, &deleted, NULL);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
EXPECT_FALSE(file_exists(root, "a.txt"));
EXPECT_TRUE(file_exists(root, "keep.txt"));
@@ -170,7 +171,8 @@ static void test_walker_keeps_nested_manifest_dirs() {
ArrayList* manifest = make_manifest_strings(keeps, 3);
EXPECT_NOT_NULL(manifest);
size_t deleted = 0;
DeleteWalkResult result = delete_extras_limited(root, manifest, 100000, NULL, 0, &deleted);
DeleteWalkResult result =
delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, &deleted, NULL);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
EXPECT_FALSE(file_exists(root, "extra.txt"));
EXPECT_TRUE(file_exists(root, "keepdir/deep/keep.txt"));
@@ -187,7 +189,9 @@ static void test_walker_keeps_nested_manifest_dirs() {
free(root);
}
static void test_walker_max_delete_exceeded_deletes_nothing() {
/* --max-delete is a partial cap (rsync parity): delete up to the limit, skip
the rest, and report DELETE_WALK_LIMIT_REACHED. */
static void test_walker_max_delete_partial_deletes_up_to_cap() {
char* root = make_walk_root("maxdel");
EXPECT_NOT_NULL(root);
EXPECT_TRUE(write_file_at(root, "a.txt", "extra"));
@@ -197,12 +201,15 @@ static void test_walker_max_delete_exceeded_deletes_nothing() {
ArrayList* manifest = make_manifest_strings(keeps, 0);
EXPECT_NOT_NULL(manifest);
size_t deleted = 999;
DeleteWalkResult result = delete_extras_limited(root, manifest, 2, NULL, 0, &deleted);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_LIMIT_EXCEEDED);
EXPECT_EQ_INT((int)deleted, 0);
EXPECT_TRUE(file_exists(root, "a.txt"));
EXPECT_TRUE(file_exists(root, "b.txt"));
EXPECT_TRUE(file_exists(root, "c.txt"));
size_t skipped = 0;
DeleteWalkResult result =
delete_extras_limited(root, manifest, NULL, 2, NULL, 0, &deleted, &skipped);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_LIMIT_REACHED);
EXPECT_EQ_INT((int)deleted, 2);
EXPECT_EQ_INT((int)skipped, 1);
int remaining = (file_exists(root, "a.txt") ? 1 : 0) + (file_exists(root, "b.txt") ? 1 : 0) +
(file_exists(root, "c.txt") ? 1 : 0);
EXPECT_EQ_INT(remaining, 1);
array_list_delete(manifest);
remove_walk_tree(root);
free(root);
@@ -217,7 +224,7 @@ static void test_walker_max_delete_exact_bound_deletes() {
ArrayList* manifest = make_manifest_strings(keeps, 0);
EXPECT_NOT_NULL(manifest);
size_t deleted = 0;
DeleteWalkResult result = delete_extras_limited(root, manifest, 2, NULL, 0, &deleted);
DeleteWalkResult result = delete_extras_limited(root, manifest, NULL, 2, NULL, 0, &deleted, NULL);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
EXPECT_EQ_INT((int)deleted, 2);
EXPECT_FALSE(file_exists(root, "a.txt"));
@@ -227,6 +234,77 @@ static void test_walker_max_delete_exact_bound_deletes() {
free(root);
}
/* Extraneous destination symlinks (including one pointing at a directory) must
be unlinked, never followed, so their targets survive. */
static void test_walker_removes_extraneous_symlinks() {
char* root = make_walk_root("symlink");
char* outside = make_walk_root("symlink_out");
EXPECT_NOT_NULL(root);
EXPECT_NOT_NULL(outside);
EXPECT_TRUE(write_file_at(outside, "secret.txt", "keep"));
EXPECT_TRUE(write_file_at(root, "keep.txt", "kept"));
char* link_file = path_cat(root, "link_file");
char* link_dir = path_cat(root, "link_dir");
char* link_broken = path_cat(root, "link_broken");
EXPECT_NOT_NULL(link_file);
EXPECT_NOT_NULL(link_dir);
EXPECT_NOT_NULL(link_broken);
EXPECT_EQ_INT(symlink("keep.txt", link_file), 0);
EXPECT_EQ_INT(symlink(outside, link_dir), 0);
EXPECT_EQ_INT(symlink("/nonexistent-target", link_broken), 0);
const char* keeps[] = {"keep.txt"};
ArrayList* manifest = make_manifest_strings(keeps, 1);
EXPECT_NOT_NULL(manifest);
size_t deleted = 0;
DeleteWalkResult result =
delete_extras_limited(root, manifest, NULL, 100000, NULL, 0, &deleted, NULL);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
EXPECT_FALSE(file_exists(root, "link_file"));
EXPECT_FALSE(file_exists(root, "link_dir"));
EXPECT_FALSE(file_exists(root, "link_broken"));
EXPECT_TRUE(file_exists(root, "keep.txt"));
EXPECT_TRUE(file_exists(outside, "secret.txt"));
free(link_file);
free(link_dir);
free(link_broken);
array_list_delete(manifest);
remove_walk_tree(root);
remove_walk_tree(outside);
free(root);
free(outside);
}
/* With a synchronized-dir set, extras outside it survive while extras directly
inside a listed directory are removed; the receive root is the "." sentinel. */
static void test_walker_confines_deletion_to_synced_dirs() {
char* root = make_walk_root("synced");
EXPECT_NOT_NULL(root);
EXPECT_TRUE(write_file_at(root, "rootextra.txt", "keep"));
EXPECT_EQ_INT(make_subdir(root, "inscope"), 0);
EXPECT_TRUE(write_file_at(root, "inscope/extra.txt", "delete"));
EXPECT_TRUE(write_file_at(root, "inscope/keep.txt", "kept"));
EXPECT_EQ_INT(make_subdir(root, "outscope"), 0);
EXPECT_TRUE(write_file_at(root, "outscope/extra.txt", "keep"));
const char* keeps[] = {"inscope/keep.txt"};
ArrayList* manifest = make_manifest_strings(keeps, 1);
ArrayList* dirs = array_list_create(free);
EXPECT_NOT_NULL(manifest);
EXPECT_NOT_NULL(dirs);
EXPECT_TRUE(array_list_add(dirs, str_dup("inscope")));
size_t deleted = 0;
DeleteWalkResult result =
delete_extras_limited(root, manifest, dirs, 100000, NULL, 0, &deleted, NULL);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK);
EXPECT_TRUE(file_exists(root, "rootextra.txt"));
EXPECT_FALSE(file_exists(root, "inscope/extra.txt"));
EXPECT_TRUE(file_exists(root, "inscope/keep.txt"));
EXPECT_TRUE(file_exists(root, "outscope/extra.txt"));
array_list_delete(manifest);
array_list_delete(dirs);
remove_walk_tree(root);
free(root);
}
static void test_walker_unlimited_deletes_all() {
char* root = make_walk_root("unlim");
EXPECT_NOT_NULL(root);
@@ -245,53 +323,6 @@ static void test_walker_unlimited_deletes_all() {
free(root);
}
/* The 100000-entry server hard bound (MAX_SERVER_DELETE_COUNT, which this test
exercises through a literal to avoid reaching into file_receive.c) is also
all-or-nothing: a destination holding more extras than the bound must be left
completely untouched. Skipped under valgrind: 100k file creations would be
far too slow under instrumentation. */
static void test_walker_hard_bound_all_or_nothing() {
if (is_running_under_valgrind())
return;
enum { HARD_BOUND = 100000 };
char* root = make_walk_root("hardbound");
EXPECT_NOT_NULL(root);
int rootfd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
EXPECT_TRUE(rootfd >= 0);
bool created = true;
for (int i = 0; created && i < HARD_BOUND + 1; i++) {
char name[32];
snprintf(name, sizeof(name), "f%d", i);
int fd = openat(rootfd, name, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0)
created = false;
else
close(fd);
}
EXPECT_TRUE(created);
const char* keeps[1] = {NULL};
ArrayList* manifest = make_manifest_strings(keeps, 0);
EXPECT_NOT_NULL(manifest);
size_t deleted = 999;
DeleteWalkResult result = delete_extras_limited(root, manifest, HARD_BOUND, NULL, 0, &deleted);
EXPECT_EQ_INT((int)result, (int)DELETE_WALK_LIMIT_EXCEEDED);
EXPECT_EQ_INT((int)deleted, 0);
EXPECT_TRUE(file_exists(root, "f0"));
EXPECT_TRUE(file_exists(root, "f100000"));
array_list_delete(manifest);
/* Fast cleanup: unlink every created name through the still-open root fd. */
if (rootfd >= 0) {
for (int i = 0; i < HARD_BOUND + 1; i++) {
char name[32];
snprintf(name, sizeof(name), "f%d", i);
(void)unlinkat(rootfd, name, 0);
}
close(rootfd);
}
rmdir(root);
free(root);
}
typedef struct {
bool eight_bit_output;
const char* expected;
@@ -553,10 +584,11 @@ void test_shared_utils() {
test_getdelim_bounded();
test_walker_removes_extras_keeps_manifest_and_protected();
test_walker_keeps_nested_manifest_dirs();
test_walker_max_delete_exceeded_deletes_nothing();
test_walker_max_delete_partial_deletes_up_to_cap();
test_walker_max_delete_exact_bound_deletes();
test_walker_removes_extraneous_symlinks();
test_walker_confines_deletion_to_synced_dirs();
test_walker_unlimited_deletes_all();
test_walker_hard_bound_all_or_nothing();
test_loopback_helpers();
test_fd_peer_ip();