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
+45
View File
@@ -148,6 +148,50 @@ static void test_ancestor_and_descendant_queries() {
remove(path);
}
/* The delete-walker's synchronized-directory predicate: a directory is in scope
only when it is a listed directory or lies below one, NOT when it is merely an
implied parent of a listed file. */
static void test_dir_in_scope() {
char err[160];
/* NULL set / empty list semantics. */
EXPECT_TRUE(file_list_dir_in_scope(NULL, "anything"));
const char* path = "test_file_list_dirscope.txt";
write_list(path, "d1/leaf.txt\n");
FileListSet* set = file_list_load(path, false, err, sizeof(err));
EXPECT_NOT_NULL(set);
/* d1 is only an implied parent of a listed FILE: not synchronized. */
EXPECT_FALSE(file_list_dir_in_scope(set, "d1"));
EXPECT_FALSE(file_list_dir_in_scope(set, "d1/sub"));
EXPECT_FALSE(file_list_dir_in_scope(set, "other"));
file_list_destroy(set);
remove(path);
/* A listed DIRECTORY synchronizes itself and its whole subtree. */
write_list(path, "d1/\nother\n");
set = file_list_load(path, false, err, sizeof(err));
EXPECT_NOT_NULL(set);
EXPECT_TRUE(file_list_dir_in_scope(set, "d1"));
EXPECT_TRUE(file_list_dir_in_scope(set, "d1/sub/deep"));
EXPECT_TRUE(file_list_dir_in_scope(set, "other"));
EXPECT_TRUE(file_list_dir_in_scope(set, "other/x"));
EXPECT_FALSE(file_list_dir_in_scope(set, "d2"));
EXPECT_FALSE(file_list_dir_in_scope(set, "d1x")); /* component boundary */
EXPECT_FALSE(file_list_dir_in_scope(set, ""));
file_list_destroy(set);
remove(path);
/* "." lists the whole tree. */
write_list(path, ".\n");
set = file_list_load(path, false, err, sizeof(err));
EXPECT_NOT_NULL(set);
EXPECT_TRUE(file_list_dir_in_scope(set, ""));
EXPECT_TRUE(file_list_dir_in_scope(set, "anything/at/all"));
file_list_destroy(set);
remove(path);
}
/* Regression for the remote OOM: an adversarial --files-from entry made of a
very deep chain of repeated components must be indexed with memory
proportional to the entry count. The old implementation stored one copied
@@ -219,6 +263,7 @@ static void test_oversized_entry_rejected() {
void test_file_list() {
test_membership_matches_reference();
test_ancestor_and_descendant_queries();
test_dir_in_scope();
test_deep_paths_are_bounded();
test_oversized_entry_rejected();
}