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:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user