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
+1 -1
View File
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
verify_transfer,
)
PROTOCOL_VERSION = b"2.22.0"
PROTOCOL_VERSION = b"2.23.0"
STATUS_MANIFEST = 5
STATUS_OK = 0
+286 -36
View File
@@ -2635,8 +2635,9 @@ class TestRelativeFilesFrom:
"bare relative layout must not appear without -R"
def test_relative_delete_manifest_stays_consistent(self):
"""--delete derives from the sent (-R) relative paths, so a later
subset run removes unlisted relative entries but keeps listed ones."""
"""--delete with --files-from is confined to the synchronized directories
(rsync parity): listing a FILE does not make its parent a delete scope,
but listing the DIRECTORY does."""
source = _make_relative_source("rel_del_src")
dest = os.path.join(TEST_DATA_DIR, "rel_del_dst")
clean_dir(dest)
@@ -2648,14 +2649,27 @@ class TestRelativeFilesFrom:
assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}"
assert os.path.isfile(os.path.join(dest, "sub", "y.txt"))
# A file-only listing leaves sub/ unsynchronized: y.txt survives.
subset = _write_rel_list(b"sub/x.txt\n")
result, _ = run_client(source, dest,
flags=["--files-from", subset, "-R", "--delete"],
port=server.port)
assert result.returncode == 0, f"-R delete sync failed: {result.stderr[:200]}"
assert os.path.isfile(os.path.join(dest, "sub", "x.txt")), "listed file was deleted"
assert os.path.exists(os.path.join(dest, "sub", "y.txt")), \
"file-only --files-from made the parent a delete scope (rsync keeps it)"
# Listing the directory synchronizes it: a source-removed y.txt is now
# an in-scope extra and is deleted.
os.unlink(os.path.join(source, "sub", "y.txt"))
listed_dir = _write_rel_list(b"sub/\n")
result, _ = run_client(source, dest,
flags=["--files-from", listed_dir, "-R", "--delete"],
port=server.port)
assert result.returncode == 0, f"-R dir delete sync failed: {result.stderr[:200]}"
assert os.path.isfile(os.path.join(dest, "sub", "x.txt"))
assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \
"unlisted relative file was not deleted"
"directory-listed --delete did not remove the in-scope extra"
class TestMissingArgs:
@@ -2772,14 +2786,15 @@ class TestMissingArgs:
assert os.path.isfile(os.path.join(dest, "a.txt"))
assert os.path.isfile(os.path.join(dest, "sub", "b.txt"))
# Now with --delete the unrelated extra is an ordinary extra and must go.
# --delete is confined to synchronized directories: no listed
# directory, so the root-level unrelated extra survives (rsync parity).
lst2 = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n")
flags2 = ["--files-from", lst2, "-R", "--delete-missing-args", "--delete"] + \
(["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags2, port=server.port)
assert result.returncode == 0, f"delete-missing + delete sync failed: {result.stderr[:300]}"
assert not os.path.exists(os.path.join(dest, "unrelated.txt")), \
"--delete did not remove the unrelated extra"
assert os.path.isfile(os.path.join(dest, "unrelated.txt")), \
"--delete under --files-from removed an extra outside a listed directory"
assert not os.path.exists(os.path.join(dest, "gone.txt"))
assert os.path.isfile(os.path.join(dest, "a.txt"))
@@ -2810,6 +2825,34 @@ class TestMissingArgs:
"the full-source-mirror path of the missing entry was not deleted"
assert os.path.isfile(os.path.join(received, "a.txt"))
@pytest.mark.parametrize("mt", [False, True])
def test_delete_missing_args_respects_max_delete_budget(self, mt):
"""#290 (5): --delete-missing-args deletions draw from the same
--max-delete budget as the ordinary extras walk: only the first N happen
and the run exits 25 like rsync."""
source = self._make_source("mg_budget_src")
dest = os.path.join(TEST_DATA_DIR, "mg_budget_dst")
clean_dir(dest)
with ServerManager() as server:
server.start(extra_args=["--allow-delete"])
result, _ = run_client(source, dest, port=server.port)
assert result.returncode == 0, f"seed failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
for name in ("gone1.txt", "gone2.txt", "gone3.txt"):
with open(os.path.join(received, name), "w") as fh:
fh.write("stale")
lst = _write_rel_list(b"a.txt\ngone1.txt\ngone2.txt\ngone3.txt\n")
flags = ["--files-from", lst, "--delete-missing-args", "--max-delete=2"] + \
(["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 25, \
f"--delete-missing-args --max-delete=2 should exit 25: {result.stderr[:300]}"
remaining = [n for n in ("gone1.txt", "gone2.txt", "gone3.txt")
if os.path.exists(os.path.join(received, n))]
assert len(remaining) == 1, \
f"missing-args deletions ignored the --max-delete budget: {remaining}"
assert os.path.isfile(os.path.join(received, "a.txt"))
@pytest.mark.parametrize("mt", [False, True])
def test_delete_missing_args_not_blocked_by_exclude_protection(self, mt):
"""A missing-arg mirror that sits under a filter-excluded directory is an
@@ -2842,8 +2885,10 @@ class TestMissingArgs:
"the explicit missing-arg deletion was blocked by exclusion protection"
assert os.path.isfile(os.path.join(received, "prot", "kept.txt")), \
"the excluded-but-present destination file must stay (default protection)"
assert not os.path.exists(os.path.join(received, "extra.txt")), \
"--delete did not remove the unrelated extra"
# A file-only --files-from listing synchronizes no directory, so the
# root-level extra is outside the delete scope (rsync parity).
assert os.path.isfile(os.path.join(received, "extra.txt")), \
"--delete under --files-from removed an extra outside a listed directory"
assert os.path.isfile(os.path.join(received, "a.txt"))
@pytest.mark.parametrize("mt", [False, True])
@@ -2868,8 +2913,10 @@ class TestMissingArgs:
assert not os.path.exists(os.path.join(dest, "gone.txt")), \
"early timing did not remove the missing-arg mirror"
assert os.path.isfile(os.path.join(dest, "a.txt")), "a.txt was not transferred"
assert not os.path.exists(os.path.join(dest, "extra.txt")), \
"--delete-before implies --delete: unrelated extras must go"
# --delete-before implies --delete, but the extras walk is still
# confined to synchronized directories: no listed directory here.
assert os.path.isfile(os.path.join(dest, "extra.txt")), \
"--delete-before under --files-from removed an extra outside a listed directory"
@pytest.mark.parametrize("mt", [False, True])
@pytest.mark.parametrize("relative", [False, True])
@@ -2879,6 +2926,12 @@ class TestMissingArgs:
exact-path deletions must not abort the --delete extras walk. Covers
the -R bare-relative layout and the full source-mirror layout."""
source = self._make_source("mg_deep_src")
# A listed directory gives the extras walk a synchronized scope to work
# in, so the test can prove the absent-parent missing entry did not abort
# it.
os.makedirs(os.path.join(source, "scope"))
with open(os.path.join(source, "scope", "keep.txt"), "w") as fh:
fh.write("kept\n")
dest = os.path.join(TEST_DATA_DIR, "mg_deep_dst")
clean_dir(dest)
rel_flags = ["-R"] if relative else []
@@ -2898,16 +2951,22 @@ class TestMissingArgs:
assert os.path.isfile(os.path.join(target_root, "a.txt"))
with open(os.path.join(target_root, "extra.txt"), "w") as fh:
fh.write("extra")
os.makedirs(os.path.join(target_root, "scope"), exist_ok=True)
with open(os.path.join(target_root, "scope", "extra.txt"), "w") as fh:
fh.write("extra")
lst = _write_rel_list(b"a.txt\nsub/gone.txt\n")
lst = _write_rel_list(b"a.txt\nscope/\nsub/gone.txt\n")
flags = ["--files-from", lst, "--delete-missing-args", "--delete"] + rel_flags + \
(["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \
f"deep missing-entry sync failed: {result.stderr[:300]}"
assert _read_file(os.path.join(target_root, "a.txt")) == b"a\n"
assert not os.path.exists(os.path.join(target_root, "extra.txt")), \
assert _read_file(os.path.join(target_root, "scope", "keep.txt")) == b"kept\n"
assert not os.path.exists(os.path.join(target_root, "scope", "extra.txt")), \
"--delete extras walk was aborted by the absent-parent missing entry"
# The root-level extra is outside every listed directory: it survives.
assert os.path.exists(os.path.join(target_root, "extra.txt"))
assert not os.path.exists(os.path.join(target_root, "sub")), \
"the absent parent directory of the missing entry was created"
@@ -3316,6 +3375,154 @@ def _seed_delete_tree(tag, entries, dest):
return source, received
class TestDeleteScope:
"""#290 (1): --delete with --files-from is confined to the directories the
transfer synchronized (rsync parity), so untransmitted paths outside a
listed directory subtree are never deleted. Data-loss capable."""
def _write(self, path, content):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(content)
def _seed(self, tag):
source = os.path.join(TEST_DATA_DIR, f"dscope_{tag}_src")
clean_dir(source)
for rel, content in {
"listed.txt": b"listed\n",
"unlisted.txt": b"unlisted\n",
"other/c.txt": b"c\n",
"sub/x.txt": b"x\n",
"sub/y.txt": b"y\n",
}.items():
self._write(os.path.join(source, rel), content)
dest = os.path.join(TEST_DATA_DIR, f"dscope_{tag}_dst")
clean_dir(dest)
server = ServerManager()
server.start(extra_args=["--allow-delete"])
result, _ = run_client(source, dest, port=server.port)
assert result.returncode == 0, f"seed failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
self._write(os.path.join(received, "sub", "extra.txt"), b"in-scope extra\n")
self._write(os.path.join(received, "rootextra.txt"), b"root extra\n")
self._write(os.path.join(received, "other", "extra.txt"), b"other extra\n")
return source, dest, received, server
@pytest.mark.parametrize("mt", [False, True])
@pytest.mark.ci
def test_files_from_delete_confined_to_listed_dirs(self, mt):
source, dest, received, server = self._seed(f"dir_{mt}")
try:
listed = _write_rel_list(b"listed.txt\nsub/\n")
flags = ["--files-from", listed, "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"delete failed: {result.stderr[:300]}"
assert not os.path.exists(os.path.join(received, "sub", "extra.txt")), \
"in-scope extra under a listed directory was not deleted"
assert os.path.isfile(os.path.join(received, "sub", "x.txt"))
assert os.path.exists(os.path.join(received, "unlisted.txt")), \
"unlisted path outside a listed directory was deleted (data loss)"
assert os.path.exists(os.path.join(received, "other", "c.txt")), \
"unlisted sibling directory was deleted (data loss)"
assert os.path.exists(os.path.join(received, "rootextra.txt")), \
"receive-root extra outside a listed directory was deleted (data loss)"
finally:
server.stop()
@pytest.mark.parametrize("mt", [False, True])
@pytest.mark.ci
def test_files_from_delete_file_listing_keeps_parent_extras(self, mt):
source, dest, received, server = self._seed(f"file_{mt}")
try:
# Listing a FILE does not synchronize its parent directory, so the
# parent's extras survive exactly like rsync.
listed = _write_rel_list(b"listed.txt\nsub/x.txt\n")
flags = ["--files-from", listed, "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"delete failed: {result.stderr[:300]}"
assert os.path.exists(os.path.join(received, "sub", "extra.txt")), \
"a file-only --files-from made its parent a delete scope"
assert os.path.exists(os.path.join(received, "rootextra.txt"))
assert os.path.exists(os.path.join(received, "unlisted.txt"))
finally:
server.stop()
class TestDeleteExtraneousSymlinks:
"""#290 (3): --delete unlinks extraneous destination symlinks (never follows
them), matching rsync, and leaves their targets intact."""
@pytest.mark.parametrize("mt", [False, True])
@pytest.mark.ci
def test_delete_unlinks_extraneous_symlinks(self, mt):
source = os.path.join(TEST_DATA_DIR, f"dsym_{mt}_src")
clean_dir(source)
with open(os.path.join(source, "keep.txt"), "wb") as fh:
fh.write(b"kept\n")
dest = os.path.join(TEST_DATA_DIR, f"dsym_{mt}_dst")
clean_dir(dest)
with ServerManager() as server:
server.start(extra_args=["--allow-delete"])
result, _ = run_client(source, dest, port=server.port)
assert result.returncode == 0, f"seed failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
outside = os.path.join(TEST_DATA_DIR, f"dsym_{mt}_outside")
clean_dir(outside)
with open(os.path.join(outside, "secret.txt"), "wb") as fh:
fh.write(b"secret\n")
os.symlink("keep.txt", os.path.join(received, "link_file"))
os.symlink(outside, os.path.join(received, "link_dir"))
os.symlink("/nonexistent-target", os.path.join(received, "link_broken"))
os.makedirs(os.path.join(received, "realdir"), exist_ok=True)
os.symlink("../realdir", os.path.join(received, "realdir", "self"))
flags = ["--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"delete failed: {result.stderr[:300]}"
assert not os.path.lexists(os.path.join(received, "link_file")), \
"extraneous symlink to a file was not unlinked"
assert not os.path.lexists(os.path.join(received, "link_dir")), \
"extraneous symlink to a directory was not unlinked"
assert not os.path.lexists(os.path.join(received, "link_broken")), \
"extraneous dangling symlink was not unlinked"
assert not os.path.lexists(os.path.join(received, "realdir", "self")), \
"extraneous self-referential symlink was not unlinked"
assert os.path.isfile(os.path.join(received, "keep.txt"))
assert os.path.isfile(os.path.join(outside, "secret.txt")), \
"an extraneous symlink was followed and its target deleted"
class TestSizePruneProtection:
"""#290 (2): --max-size/--min-size pruned source mirrors survive --delete
even with --delete-excluded (rsync keeps them)."""
@pytest.mark.parametrize("mt", [False, True])
@pytest.mark.parametrize("flag", ["--max-size=1000", "--min-size=1000"])
@pytest.mark.ci
def test_size_pruned_mirror_survives_delete_excluded(self, mt, flag):
source = os.path.join(TEST_DATA_DIR, f"dsize_{mt}_{flag.strip('-=')}_src")
clean_dir(source)
with open(os.path.join(source, "small.txt"), "wb") as fh:
fh.write(b"small\n")
with open(os.path.join(source, "big.bin"), "wb") as fh:
fh.write(b"0" * 5000)
dest = os.path.join(TEST_DATA_DIR, f"dsize_{mt}_{flag.strip('-=')}_dst")
clean_dir(dest)
with ServerManager() as server:
server.start(extra_args=["--allow-delete"])
result, _ = run_client(source, dest, port=server.port)
assert result.returncode == 0, f"seed failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
flags = [flag, "--delete", "--delete-excluded"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"size delete failed: {result.stderr[:300]}"
assert os.path.isfile(os.path.join(received, "small.txt")), \
"size-pruned small mirror was deleted under --delete-excluded"
assert os.path.isfile(os.path.join(received, "big.bin")), \
"size-pruned big mirror was deleted under --delete-excluded"
class TestDeletePolicy:
"""Deletion-policy family: --delete-excluded, --max-delete, --force,
--ignore-errors and --prune-empty-dirs."""
@@ -3412,8 +3619,9 @@ class TestDeletePolicy:
@pytest.mark.parametrize("mt", [False, True])
@pytest.mark.parametrize("timing", ["--delete", "--delete-before"])
def test_max_delete_exceeded_fails_without_deleting(self, mt, timing):
"""A run that would exceed --max-delete deletes nothing and fails."""
def test_max_delete_exceeded_deletes_up_to_cap_and_exits_25(self, mt, timing):
"""rsync parity: --max-delete=N deletes up to N extras, skips the rest and
still succeeds as a transfer, exiting 25 with a diagnostic."""
source = os.path.join(TEST_DATA_DIR, f"maxdel_{timing.strip('-')}_{mt}_src")
clean_dir(source)
self._write(os.path.join(source, "keep.txt"), b"kept\n")
@@ -3424,19 +3632,51 @@ class TestDeletePolicy:
result, _ = run_client(source, dest, port=server.port)
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
extras = []
for i in range(4):
name = f"e{i}.txt"
self._write(os.path.join(received, name), b"extra\n")
extras.append(os.path.join(received, name))
self._write(os.path.join(received, f"e{i}.txt"), b"extra\n")
flags = ["--max-delete=2", timing] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode != 0, \
f"--max-delete=2 with 4 extras unexpectedly succeeded: {result.stderr[:300]}"
for path in extras:
assert os.path.exists(path), \
"--max-delete overrun deleted files (must be all-or-nothing)"
assert result.returncode == 25, \
f"--max-delete=2 with 4 extras should exit 25: {result.stderr[:300]}"
remaining = [i for i in range(4)
if os.path.exists(os.path.join(received, f"e{i}.txt"))]
assert len(remaining) == 2, \
f"--max-delete=2 deleted {4 - len(remaining)} extras, expected 2"
assert os.path.isfile(os.path.join(received, "keep.txt"))
assert "--max-delete" in (result.stderr or result.stdout)
@pytest.mark.parametrize("mt", [False, True])
def test_max_delete_zero_and_negative(self, mt):
"""--max-delete=0 warns about every extra without deleting (exit 25);
a negative value is rsync's deprecated unlimited spelling (exit 0)."""
source = os.path.join(TEST_DATA_DIR, f"maxdelzn_{mt}_src")
clean_dir(source)
self._write(os.path.join(source, "keep.txt"), b"kept\n")
dest = os.path.join(TEST_DATA_DIR, f"maxdelzn_{mt}_dst")
clean_dir(dest)
with ServerManager() as server:
server.start(extra_args=["--allow-delete"])
result, _ = run_client(source, dest, port=server.port)
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
for i in range(3):
self._write(os.path.join(received, f"e{i}.txt"), b"extra\n")
flags = ["--max-delete=0", "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 25, f"--max-delete=0 should exit 25: {result.stderr[:300]}"
for i in range(3):
assert os.path.exists(os.path.join(received, f"e{i}.txt")), \
"--max-delete=0 deleted an extra"
# -1 (and any negative) means no client limit: every extra goes.
flags = ["--max-delete=-1", "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \
f"--max-delete=-1 should be unlimited: {result.stderr[:300]}"
for i in range(3):
assert not os.path.exists(os.path.join(received, f"e{i}.txt")), \
"--max-delete=-1 did not remove every extra"
@pytest.mark.parametrize("mt", [False, True])
def test_max_delete_not_exceeded_deletes_exactly(self, mt):
@@ -3499,16 +3739,16 @@ class TestDeletePolicy:
"--force did not replace the directory with the file"
assert _read_file(os.path.join(received, "sub")) == b"now a file\n"
def test_force_inert_under_delay_updates(self):
"""Documented divergence: --force acts on the immediate-install path; a
--delay-updates run stages into its own tree and its publication renames
over regular files only, so a blocking directory is not cleared and the
run fails."""
source = os.path.join(TEST_DATA_DIR, "force_delay_src")
@pytest.mark.parametrize("mt", [False, True])
def test_force_replaces_dir_under_delay_updates(self, mt):
"""rsync parity: --force also acts during a --delay-updates publication,
clearing a non-empty destination directory that blocks an incoming file
(without --force the run fails and the directory survives)."""
source = os.path.join(TEST_DATA_DIR, f"force_delay_{mt}_src")
clean_dir(source)
self._write(os.path.join(source, "sub", "old.txt"), b"old\n")
self._write(os.path.join(source, "keep.txt"), b"kept\n")
dest = os.path.join(TEST_DATA_DIR, "force_delay_dst")
dest = os.path.join(TEST_DATA_DIR, f"force_delay_{mt}_dst")
clean_dir(dest)
with ServerManager() as server:
server.start(extra_args=["--allow-delete"])
@@ -3518,14 +3758,24 @@ class TestDeletePolicy:
os.unlink(os.path.join(source, "sub", "old.txt"))
os.rmdir(os.path.join(source, "sub"))
self._write(os.path.join(source, "sub"), b"now a file\n")
result, _ = run_client(source, dest, flags=["--force", "--delay-updates"],
# Without --force the blocking directory is untouched and the run fails.
result, _ = run_client(source, dest, flags=["--delay-updates"] + (["--threads"] if mt else []),
port=server.port)
assert result.returncode != 0, \
"--force --delay-updates unexpectedly replaced the blocking directory"
assert os.path.isdir(os.path.join(received, "sub")), \
"blocking directory was cleared although --delay-updates should keep --force inert"
assert os.path.exists(os.path.join(received, "sub", "old.txt")), \
"blocking directory content was lost"
"--delay-updates replaced a non-empty directory without --force"
assert os.path.isdir(os.path.join(received, "sub"))
assert os.path.exists(os.path.join(received, "sub", "old.txt"))
# With --force the publication clears it and installs the file.
result, _ = run_client(source, dest,
flags=["--force", "--delay-updates"] + (["--threads"] if mt else []),
port=server.port)
assert result.returncode == 0, \
f"--force --delay-updates failed: {result.stderr[:300]}"
assert os.path.isfile(os.path.join(received, "sub")), \
"--force under --delay-updates did not replace the blocking directory"
assert _read_file(os.path.join(received, "sub")) == b"now a file\n"
@pytest.mark.parametrize("mt", [False, True])
def test_prune_empty_dirs_dirs_mode(self, mt):
+2 -2
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol:
@pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.22.0 (the current PROTOCOL_VERSION) is accepted and the
"""--protocol=2.23.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.22.0"],
result, _ = run_client(source, dest, flags=["--protocol=2.23.0"],
port=shared_server.port)
assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
+6 -3
View File
@@ -317,7 +317,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.22.0"};
"--dest-dir", "/dst", "--protocol=2.23.0"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -327,7 +327,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.22.0"};
"/dst", "--protocol", "2.23.0"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -2480,10 +2480,13 @@ static void test_parse_args_delete_policy_invalid_values() {
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1);
config_delete(cfg);
/* A negative --max-delete is rsync's deprecated "no client limit" spelling:
parse succeeds and every negative value clamps to -1. */
cfg = config_create();
char* argv2[] = {"fastsync", "--max-delete=-3", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), -1);
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->max_delete, -1);
config_delete(cfg);
}
+7 -7
View File
@@ -2665,14 +2665,14 @@ static void golden_config_populate(Config* c) {
c->copy_as_gid = 222;
}
/* The pinned golden frame (protocol 2.22.0). The values below are the only
/* The pinned golden frame (protocol 2.23.0). The values below are the only
* thing that ties the generated table to the historical wire format; update
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.22.0
* preserve-attribute split appends four serialized bools
* (preserve_perms/times/owner/group) to CONFIG_WIRE_METADATA_TIMES_FIELDS after
* omit_link_times. */
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.23.0
* delete-semantics wave keeps the config-frame LAYOUT unchanged, but the
* embedded version string moves to "2.23.0", so the byte-exact hash changes
* while the length stays 653. */
#define GOLDEN_WIRE_LEN 653
#define GOLDEN_WIRE_HASH 95530566005420798ULL
#define GOLDEN_WIRE_HASH 3267254725292157519ULL
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
unsigned long long h = 1469598103934665603ULL;
@@ -2754,7 +2754,7 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
return h;
}
/* Byte-for-byte wire compatibility guard (protocol 2.22.0). The expected hash
/* Byte-for-byte wire compatibility guard (protocol 2.23.0). The expected hash
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
static void test_config_wire_golden() {
if (is_running_under_valgrind())
+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();
}
+12 -4
View File
@@ -645,9 +645,10 @@ static void test_late_second_manifest_frees_both() {
config_delete(cfg);
}
/* A delete-manifest frame with a third (missing-args) section round-trips: the
receiver keeps all three sections and the missing paths are confined exactly
like the keep-set (a traversal entry in the missing section is rejected).
/* A delete-manifest frame with all four sections round-trips: the receiver
keeps the keep-set, protected prefixes, missing-args paths and synchronized
directories, and every section is confined exactly like the keep-set (a
traversal entry in the missing section is rejected).
receive_manifest_entries() reads the counts directly (the leading
STATUS_MANIFEST code is consumed by the caller, so these frames do not send
it). */
@@ -666,6 +667,9 @@ static void test_receive_manifest_three_sections() {
EXPECT_TRUE(send_int(p[1], 2));
EXPECT_TRUE(send_str(p[1], "gone.txt"));
EXPECT_TRUE(send_str(p[1], "dir/gone.bin"));
EXPECT_TRUE(send_int(p[1], 2));
EXPECT_TRUE(send_str(p[1], "."));
EXPECT_TRUE(send_str(p[1], "dir"));
DeleteManifest* manifest = receive_manifest_entries(p[0]);
EXPECT_NOT_NULL(manifest);
@@ -676,9 +680,12 @@ static void test_receive_manifest_three_sections() {
EXPECT_EQ_INT(manifest->missing->size, 2);
EXPECT_EQ_STR((char*)manifest->missing->items[0], "gone.txt");
EXPECT_EQ_STR((char*)manifest->missing->items[1], "dir/gone.bin");
EXPECT_EQ_INT(manifest->dirs->size, 2);
EXPECT_EQ_STR((char*)manifest->dirs->items[0], ".");
EXPECT_EQ_STR((char*)manifest->dirs->items[1], "dir");
delete_manifest_free(manifest);
/* A traversal entry in the third section is rejected like every other. */
/* A traversal entry in the missing section is rejected like every other. */
EXPECT_TRUE(send_int(p[1], 0));
EXPECT_TRUE(send_int(p[1], 0));
EXPECT_TRUE(send_int(p[1], 1));
@@ -780,6 +787,7 @@ static void test_receiver_pending_commits_missing_args() {
EXPECT_TRUE(send_int(p[1], 2));
EXPECT_TRUE(send_str(p[1], "gone.txt"));
EXPECT_TRUE(send_str(p[1], "never_here.txt"));
EXPECT_TRUE(send_int(p[1], 0)); /* no synchronized directories */
EXPECT_TRUE(send_status(p[1], STATUS_FINISHED));
/* NULL pending: the single-threaded commit path deletes at FINISHED. The
+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();