fix(parity): receiver temp-dir confinement, server I/O floor, delete budget
Address review findings on feat/rsync-parity: - confine --temp-dir below the receive root (reject absolute/.. like backup-dir/partial-dir); keep EXDEV non-atomic fallback - floor server session I/O deadlines at SERVER_IO_TIMEOUT_SEC (60s) and install it on the socket layer at startup (slow-loris) - charge each --delete-missing-args directory removal once and clamp the extras-walk remaining budget so it can never underflow past --max-delete - normalize --compress-choice=auto to zstd client-side and accept it on receive so auto transfers no longer fail - map received --max-alloc=0 to MAX_SERVER_ALLOC (receive path only) - zero File.dest_state; include log-file-format in report_dest_info; add STATUS_DELETE_LIMIT name; recognize --skip-compress as a separate-value option; OOM-guard send_list_only root entry; drop the dead -M= branch; record the bare relative protected prefix for -R size-prunes in both scanners; refresh delete-manifest comment - pin the rsync tarball sha256 and bump integrator image to v11 Tests: temp-dir rejection/relative/cross-device, server timeout floor, delete-missing dir budget regression, compress-choice=auto e2e, max-alloc=0 receive mapping, dest_state, report_dest_info modes, skip-compress dash value, -M short forms, -R root size-prune mirror protection (rsync 3.4.1 confirmed).
This commit is contained in:
@@ -254,6 +254,13 @@ def _wait_for_port(port, timeout=5):
|
||||
|
||||
|
||||
def _wait_proc(proc, timeout=5):
|
||||
"""Stop a long-lived subprocess promptly. The server installs a SIGTERM
|
||||
handler, so signal first and only escalate to SIGKILL if it does not exit;
|
||||
waiting without signalling would burn the full timeout on every stop."""
|
||||
if proc.poll() is not None:
|
||||
proc.wait()
|
||||
return
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
@@ -1379,6 +1379,20 @@ class TestChecksumChoice:
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, f"{bad} must be rejected"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_compress_choice_auto_transfers(self, shared_server):
|
||||
"""--compress-choice=auto is normalized to zstd client-side, so the
|
||||
receiver never rejects the transfer (#4)."""
|
||||
clean_dir(DEST_DIR)
|
||||
flags = ["-z", "--compress-choice=auto"]
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--compress-choice=auto sync failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
@pytest.mark.parametrize("algo", ["xxh64", "xxh3", "xxh128", "md5"])
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt):
|
||||
@@ -2215,17 +2229,10 @@ class TestTempDir:
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "a missing relative --temp-dir must fail"
|
||||
|
||||
missing_abs = os.path.join(TEST_DATA_DIR, "no_such_abs_scratch")
|
||||
assert not os.path.lexists(missing_abs)
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", missing_abs],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "a missing absolute --temp-dir must fail"
|
||||
|
||||
def test_temp_dir_absolute_outside_root_is_used(self, shared_server):
|
||||
"""rsync accepts any temp dir, including one outside the destination
|
||||
tree; the completed files are still installed below the root and no
|
||||
temp files remain in the scratch dir."""
|
||||
def test_temp_dir_absolute_rejected(self, shared_server):
|
||||
"""The receiver confines --temp-dir to the destination root: an absolute
|
||||
(or `..`-escaping) value is rejected before any write, so a client can
|
||||
never make the receiver create scratch files in an arbitrary directory."""
|
||||
source = self._make_source("tempdir_abs_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst")
|
||||
clean_dir(dest)
|
||||
@@ -2235,13 +2242,14 @@ class TestTempDir:
|
||||
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"absolute temp-dir sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
mismatches, missing = verify_transfer(source, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
self._assert_clean_scratch(scratch)
|
||||
assert result.returncode != 0, "an absolute --temp-dir must be rejected"
|
||||
assert os.listdir(scratch) == [], "receiver wrote into an unconfined temp dir"
|
||||
# A relative traversal is rejected for the same reason.
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir=../escape_scratch"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "a `..` --temp-dir must be rejected"
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
shutil.rmtree(os.path.join(TEST_DATA_DIR, "escape_scratch"), ignore_errors=True)
|
||||
|
||||
|
||||
class TestTimeoutAndAllocLimits:
|
||||
@@ -2285,7 +2293,8 @@ class TestTimeoutAndAllocLimits:
|
||||
assert not missing and not mismatches
|
||||
|
||||
def test_temp_dir_cross_filesystem_fallback(self, shared_server):
|
||||
"""A --temp-dir on another filesystem must fall back to a non-atomic
|
||||
"""A confined relative --temp-dir that resolves (via a symlink under the
|
||||
destination root) to another filesystem must fall back to a non-atomic
|
||||
copy instead of aborting (rsync parity). Skipped when no second
|
||||
filesystem is available."""
|
||||
shm = "/dev/shm"
|
||||
@@ -2298,14 +2307,18 @@ class TestTimeoutAndAllocLimits:
|
||||
os.makedirs(scratch)
|
||||
try:
|
||||
source, dest = self._seed("tempdir_xdev_src")
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
|
||||
# The receiver resolves a relative temp dir under the destination
|
||||
# root; a symlink there points the scratch at the second filesystem.
|
||||
link = os.path.join(dest, "xdev_scratch")
|
||||
os.symlink(scratch, link)
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", "xdev_scratch"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"cross-fs temp-dir failed: {result.stderr[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
mismatches, missing = verify_transfer(source, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
assert os.listdir(scratch) == [], "temp files left behind"
|
||||
assert os.listdir(scratch) == [], "temp files left behind in the cross-fs scratch"
|
||||
finally:
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
@@ -2910,6 +2923,43 @@ class TestRelativeFilesFrom:
|
||||
assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \
|
||||
"directory-listed --delete did not remove the in-scope extra"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_relative_root_size_prune_protects_mirror_from_delete(self, mt):
|
||||
"""#12: a root-level --max-size prune under -R + --files-from must record
|
||||
the bare relative wire path as its delete-protected prefix, so the
|
||||
size-pruned entry's destination mirror survives --delete (rsync parity)."""
|
||||
source = _make_relative_source("rel_rootsize_src")
|
||||
# Big enough that a 100-byte cap prunes only this entry.
|
||||
with open(os.path.join(source, "big.txt"), "wb") as fh:
|
||||
fh.write(b"b" * 1000)
|
||||
dest = os.path.join(TEST_DATA_DIR, "rel_rootsize_dst")
|
||||
clean_dir(dest)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
# "." lists the whole tree, so the receive root is a delete scope
|
||||
# (a file-only list would leave the root out of scope, masking the
|
||||
# protected-prefix mismatch this test targets).
|
||||
lst = _write_rel_list(b".\n")
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--files-from", lst, "-R"] + (["--threads"] if mt else []),
|
||||
port=server.port)
|
||||
assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}"
|
||||
assert os.path.isfile(os.path.join(dest, "big.txt"))
|
||||
with open(os.path.join(dest, "unrelated.txt"), "w") as fh:
|
||||
fh.write("x")
|
||||
|
||||
# --max-size=100 prunes only big.txt; its dest mirror is always protected.
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--files-from", lst, "-R", "--delete", "--max-size=100"] +
|
||||
(["--threads"] if mt else []),
|
||||
port=server.port)
|
||||
assert result.returncode == 0, f"-R size+delete sync failed: {result.stderr[:300]}"
|
||||
assert not os.path.exists(os.path.join(dest, "unrelated.txt")), "delete not active"
|
||||
assert os.path.isfile(os.path.join(dest, "big.txt")), \
|
||||
"the size-pruned entry's mirror was wrongly deleted (protected prefix mismatch)"
|
||||
assert os.path.isfile(os.path.join(dest, "sub", "x.txt"))
|
||||
assert os.path.isfile(os.path.join(dest, "top.txt"))
|
||||
|
||||
|
||||
class TestMissingArgs:
|
||||
"""--ignore-missing-args / --delete-missing-args: a --files-from entry that
|
||||
|
||||
Reference in New Issue
Block a user