Merge feat/p3-fuzzy: --fuzzy/-y/--no-fuzzy similar-file delta basis
This commit is contained in:
@@ -6,6 +6,7 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
@@ -53,6 +54,78 @@ class ServerManager:
|
||||
self.stop()
|
||||
|
||||
|
||||
class CountingProxy:
|
||||
"""One-shot TCP forwarder that counts the bytes flowing in each direction
|
||||
between one client and the real server.
|
||||
|
||||
Client output and --stats report SOURCE lengths, so a delta/fuzzy transfer
|
||||
that moves only a few percent of the file is invisible in normal output.
|
||||
Routing the client through this proxy makes the actual wire usage
|
||||
observable: client_to_server counts every byte the client sent (config,
|
||||
paths, and file/delta payloads), server_to_client counts the reply bytes
|
||||
(including the receiver's delta signatures).
|
||||
"""
|
||||
|
||||
def __init__(self, target_port):
|
||||
self.target_port = target_port
|
||||
self._listener = socket.socket()
|
||||
self._listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self._listener.bind(("127.0.0.1", 0))
|
||||
self._listener.listen(1)
|
||||
self._listener.settimeout(30)
|
||||
self.port = self._listener.getsockname()[1]
|
||||
self.client_to_server = 0
|
||||
self.server_to_client = 0
|
||||
|
||||
@staticmethod
|
||||
def _pump(src, dst, counter):
|
||||
while True:
|
||||
try:
|
||||
data = src.recv(65536)
|
||||
except OSError:
|
||||
return
|
||||
if not data:
|
||||
try:
|
||||
dst.shutdown(socket.SHUT_WR)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
dst.sendall(data)
|
||||
except OSError:
|
||||
return
|
||||
counter[0] += len(data)
|
||||
|
||||
def run(self, cmd):
|
||||
"""Forward one client run (the full command list) to the real server and
|
||||
return the CompletedProcess after the counts have settled."""
|
||||
|
||||
def serve():
|
||||
try:
|
||||
client_sock, _ = self._listener.accept()
|
||||
server_sock = socket.create_connection(("127.0.0.1", self.target_port),
|
||||
timeout=10)
|
||||
except OSError:
|
||||
self._listener.close()
|
||||
return
|
||||
c2s, s2c = [0], [0]
|
||||
a = threading.Thread(target=self._pump, args=(client_sock, server_sock, c2s))
|
||||
b = threading.Thread(target=self._pump, args=(server_sock, client_sock, s2c))
|
||||
a.start()
|
||||
b.start()
|
||||
a.join()
|
||||
b.join()
|
||||
self.client_to_server = c2s[0]
|
||||
self.server_to_client = s2c[0]
|
||||
self._listener.close()
|
||||
|
||||
thread = threading.Thread(target=serve)
|
||||
thread.start()
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
thread.join(20)
|
||||
return result
|
||||
|
||||
|
||||
def run_client(source_dir, dest_dir, flags=None, port=None, extra_args=None):
|
||||
"""Run the client and return (result, duration)."""
|
||||
cmd = CLIENT_CMD + ["--source-dir", source_dir, "--dest-dir", dest_dir, "--save-to-disk"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Feature tests: incremental sync, bandwidth limiting, dry run, metadata, filters."""
|
||||
import filecmp
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -10,7 +11,7 @@ import pytest
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR,
|
||||
run_client,
|
||||
run_client, CountingProxy,
|
||||
generate_test_files, verify_transfer, clean_dir, make_result,
|
||||
get_dest_received_dir, CLIENT_CMD, ServerManager,
|
||||
)
|
||||
@@ -2910,3 +2911,355 @@ class TestBasisDestDirs:
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert not os.path.exists(received), \
|
||||
"over-limit basis run transferred files before failing"
|
||||
|
||||
|
||||
def _random_payloads(size=2 * 1024 * 1024, changed=64 * 1024, seed=1234):
|
||||
"""Return (old, new) byte strings of equal length where `new` differs from
|
||||
`old` only in one contiguous `changed`-byte region. Incompressible (random)
|
||||
data keeps the whole-file wire cost near the file size, so a delta transfer
|
||||
is distinguishable from a whole-file one by its wire bytes."""
|
||||
r = random.Random(seed)
|
||||
data = bytearray(r.randbytes(size))
|
||||
old = bytes(data)
|
||||
off = size // 3
|
||||
for i in range(off, off + changed):
|
||||
data[i] = r.randrange(256)
|
||||
return old, bytes(data)
|
||||
|
||||
|
||||
class TestFuzzy:
|
||||
"""-y/--fuzzy similar-file delta basis.
|
||||
|
||||
Scenario modelled on rsync's --fuzzy: a file is recreated under a similar
|
||||
NEW basename in the same directory. The destination still holds the
|
||||
old-named file (nothing deleted it), but the new path has no content of its
|
||||
own at the destination, so without --fuzzy the receiver has no delta basis
|
||||
and the whole file is sent. With --fuzzy the receiver searches the
|
||||
destination directory, picks the similar-named sibling as the delta basis,
|
||||
sends its block signature, and the sender transmits only the differences.
|
||||
The reconstructed file must be byte-identical to the source in every mode;
|
||||
only the wire usage changes (observed through CountingProxy, because client
|
||||
--stats report source lengths, not wire bytes).
|
||||
"""
|
||||
|
||||
OLD_NAME = "report-2025.dat"
|
||||
NEW_NAME = "report-2026.dat"
|
||||
TS = 1577836800 # 2020-01-01, used to pin stale destination mtimes
|
||||
SIZE = 2 * 1024 * 1024
|
||||
|
||||
def _client_via_proxy(self, source, dest, flags, proxy):
|
||||
cmd = (CLIENT_CMD + ["--source-dir", source, "--dest-dir", dest,
|
||||
"--save-to-disk", "--server-port", str(proxy.port)] + flags)
|
||||
return proxy.run(cmd)
|
||||
|
||||
def _seed_dest(self, source, dest, files, port):
|
||||
"""Write `files` {rel: bytes} into source and mirror them to dest."""
|
||||
for rel, content in files.items():
|
||||
full = os.path.join(source, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(content)
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, port=port)
|
||||
assert result.returncode == 0, f"seed failed: {(result.stderr or result.stdout)[:300]}"
|
||||
|
||||
def _prepare(self, tag):
|
||||
source = os.path.join(TEST_DATA_DIR, f"fuzzy_{tag}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"fuzzy_{tag}_dst")
|
||||
clean_dir(source)
|
||||
return source, dest
|
||||
|
||||
def _run_measured(self, source, dest, flags, port):
|
||||
"""Run a transfer through a byte-counting proxy. Returns (result, proxy)."""
|
||||
proxy = CountingProxy(port)
|
||||
result = self._client_via_proxy(source, dest, flags, proxy)
|
||||
return result, proxy
|
||||
|
||||
def test_fuzzy_uses_similar_sibling_as_delta_basis(self, shared_server):
|
||||
source, dest = self._prepare("basis")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
# Recreate the file under a similar new name; the old sibling stays on
|
||||
# the destination (nothing deletes it).
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy rename transfer failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \
|
||||
"fuzzy reconstruction is not byte-exact"
|
||||
# The wire carried the delta, not the 2 MiB whole file.
|
||||
assert proxy.client_to_server < len(new_bytes) // 4, \
|
||||
f"fuzzy transfer sent {proxy.client_to_server} bytes; expected a delta"
|
||||
|
||||
def test_without_fuzzy_sends_the_whole_file(self, shared_server):
|
||||
source, dest = self._prepare("whole")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
|
||||
result, proxy = self._run_measured(source, dest, ["--incremental", "--delta"], shared_server.port)
|
||||
assert result.returncode == 0, f"no-fuzzy rename failed: {result.stderr[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
# No similar basis: the whole file goes over the wire.
|
||||
assert proxy.client_to_server > len(new_bytes) // 2, \
|
||||
f"expected a whole-file transfer, got {proxy.client_to_server} bytes"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_fuzzy_byte_exact_single_and_multithreaded(self, shared_server, mt):
|
||||
source, dest = self._prepare(f"mt{'1' if mt else '0'}")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
|
||||
flags = ["--fuzzy"] + (["-m"] if mt else [])
|
||||
result, proxy = self._run_measured(source, dest, flags, shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy {'-m ' if mt else ''}rename failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \
|
||||
f"fuzzy {'-m ' if mt else ''}reconstruction is not byte-exact"
|
||||
assert proxy.client_to_server < len(new_bytes) // 4
|
||||
|
||||
def test_no_candidate_falls_back_to_whole_file(self, shared_server):
|
||||
# A brand-new destination directory holds no sibling at all, so --fuzzy
|
||||
# finds nothing and the file is transferred whole (and correctly).
|
||||
source, dest = self._prepare("nocand")
|
||||
clean_dir(dest)
|
||||
os.makedirs(source, exist_ok=True)
|
||||
_, new_bytes = _random_payloads()
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy no-candidate fallback failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert proxy.client_to_server > len(new_bytes) // 2, \
|
||||
"no-candidate fuzzy run should have sent the whole file"
|
||||
|
||||
def test_dissimilar_sibling_is_not_used(self, shared_server):
|
||||
# The destination holds a large sibling whose basename is too different
|
||||
# from the incoming name; the name gate must reject it and fall back to
|
||||
# a whole-file transfer.
|
||||
source, dest = self._prepare("dissim")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {"totally-unrelated-notes.bin": old_bytes},
|
||||
shared_server.port)
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy dissimilar-sibling run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert proxy.client_to_server > len(new_bytes) // 2, \
|
||||
"a dissimilar-named sibling must not be used as a fuzzy basis"
|
||||
|
||||
def test_fuzzy_helps_when_dest_holds_an_unsuitable_file(self, shared_server):
|
||||
# The destination DOES hold the exact new name, but it is a tiny stale
|
||||
# file (below the delta engine's minimum, ratio far outside its window),
|
||||
# so it cannot serve as the basis. --fuzzy then falls back to the
|
||||
# similar-named sibling.
|
||||
source, dest = self._prepare("unsuitable")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest,
|
||||
{self.OLD_NAME: old_bytes, self.NEW_NAME: b"stale small file\n"},
|
||||
shared_server.port)
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy unsuitable-dest run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \
|
||||
"byte-exactness broken when the destination file was unsuitable"
|
||||
assert proxy.client_to_server < len(new_bytes) // 4, \
|
||||
"fuzzy should have reused the similar sibling as the basis"
|
||||
|
||||
def test_whole_file_makes_fuzzy_inert(self, shared_server):
|
||||
# -W/--whole-file switches the delta machinery off, so --fuzzy has
|
||||
# nothing to attach to and the file is transferred whole (rsync parity).
|
||||
source, dest = self._prepare("wholefile")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy", "-W"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy -W run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert proxy.client_to_server > len(new_bytes) // 2, \
|
||||
"--whole-file must disable the fuzzy delta basis"
|
||||
|
||||
def test_fuzzy_via_short_y_alias(self, shared_server):
|
||||
source, dest = self._prepare("shorty")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
result, proxy = self._run_measured(source, dest, ["-y"], shared_server.port)
|
||||
assert result.returncode == 0, f"-y rename failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert proxy.client_to_server < len(new_bytes) // 4, "-y did not enable fuzzy"
|
||||
|
||||
def test_fuzzy_with_delay_updates_publishes_cleanly(self, shared_server):
|
||||
# A fuzzy-reconstructed file goes through the normal store engine, so
|
||||
# --delay-updates must stage and publish it with no staging leftovers.
|
||||
source, dest = self._prepare("delay")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--fuzzy", "--delay-updates"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy --delay-updates failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert not os.path.isdir(os.path.join(dest, ".fastsync-stage")), \
|
||||
"delay-updates staging tree was not cleaned up"
|
||||
|
||||
def test_fuzzy_source_removed_after_transfer(self, shared_server):
|
||||
# A fuzzy transfer is a real transfer (not a skip), so
|
||||
# --remove-source-files must remove the renamed source file.
|
||||
source, dest = self._prepare("rm")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--fuzzy", "--remove-source-files"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy --remove-source-files failed: {(result.stderr or result.stdout)[:300]}"
|
||||
assert not os.path.exists(os.path.join(source, self.NEW_NAME)), \
|
||||
"a fuzzy-transferred source should have been removed"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert _read_file(os.path.join(received, self.OLD_NAME)) == old_bytes
|
||||
|
||||
@staticmethod
|
||||
def _rand_bytes(size, seed):
|
||||
return random.Random(seed).randbytes(size)
|
||||
|
||||
def _replace_source_file(self, source, old_name, new_name, new_bytes):
|
||||
"""Remove old_name from source and add new_name with new_bytes."""
|
||||
os.unlink(os.path.join(source, old_name))
|
||||
with open(os.path.join(source, new_name), "wb") as fh:
|
||||
fh.write(new_bytes)
|
||||
|
||||
def test_worthless_fuzzy_basis_falls_back_inside_handshake(self, shared_server):
|
||||
# The sibling passes the name AND size gates but shares no blocks with
|
||||
# the incoming file, so the sender's delta is not worthwhile: it replies
|
||||
# STATUS_NEXT and the receiver consumes the WHOLE file inside the delta
|
||||
# handshake. This proves a bad fuzzy basis cannot desync the protocol
|
||||
# or corrupt the result.
|
||||
source, dest = self._prepare("worthless")
|
||||
basis = self._rand_bytes(self.SIZE, 424242)
|
||||
target = self._rand_bytes(self.SIZE, 777777)
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: basis}, shared_server.port)
|
||||
self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, target)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy worthless-basis run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == target, \
|
||||
"whole-file fallback after a worthless fuzzy basis is not byte-exact"
|
||||
assert proxy.client_to_server > self.SIZE // 2, \
|
||||
"a worthless basis should have made the sender fall back to the whole file"
|
||||
|
||||
def test_existing_dest_file_preferred_over_fuzzy_sibling(self, shared_server):
|
||||
# Non-displacement: the destination holds a file at the exact path that
|
||||
# is inside the delta size bounds (same size, different content, older
|
||||
# mtime). FastSync must delta against THAT file -- even though it
|
||||
# shares nothing with the source -- and must NOT reuse a similar-named
|
||||
# sibling that is byte-identical to the source.
|
||||
source, dest = self._prepare("nondisp")
|
||||
sibling = self._rand_bytes(self.SIZE, 111) # will equal the incoming file
|
||||
stale = self._rand_bytes(self.SIZE, 333) # worthless exact-path file
|
||||
self._seed_dest(source, dest,
|
||||
{self.OLD_NAME: sibling, self.NEW_NAME: stale},
|
||||
shared_server.port)
|
||||
# Force the exact-path destination file's mtime into the past so the
|
||||
# quick check deterministically decides to transfer it.
|
||||
os.utime(os.path.join(get_dest_received_dir(dest, source), self.NEW_NAME),
|
||||
(self.TS, self.TS))
|
||||
os.unlink(os.path.join(source, self.OLD_NAME))
|
||||
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
|
||||
fh.write(sibling)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy non-displacement run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == sibling
|
||||
assert proxy.client_to_server > self.SIZE // 2, \
|
||||
"the exact-path destination file must be the delta basis, not the fuzzy sibling"
|
||||
|
||||
def test_fuzzy_basis_larger_than_source(self, shared_server):
|
||||
# The similar sibling is LARGER than the incoming file (within the delta
|
||||
# engine's 10x ratio); the new file is an exact prefix of the basis, so
|
||||
# every block matches and only a tiny delta travels.
|
||||
source, dest = self._prepare("largerbasis")
|
||||
big = self._rand_bytes(1536 * 1024, 1)
|
||||
prefix = big[:1024 * 1024]
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: big}, shared_server.port)
|
||||
self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, prefix)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy larger-basis run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == prefix, \
|
||||
"shrunken file reconstructed from a larger fuzzy basis is not byte-exact"
|
||||
assert proxy.client_to_server < len(prefix) // 4, \
|
||||
"larger fuzzy basis should have carried most of the file as block matches"
|
||||
|
||||
def test_fuzzy_basis_smaller_than_source(self, shared_server):
|
||||
# The similar sibling is SMALLER than the incoming file; the new file
|
||||
# appends data past the basis, so the appended tail travels as literals
|
||||
# while the shared prefix is block-matched.
|
||||
source, dest = self._prepare("smallerbasis")
|
||||
base = self._rand_bytes(self.SIZE, 2)
|
||||
tail = self._rand_bytes(64 * 1024, 3)
|
||||
new_bytes = base + tail
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: base}, shared_server.port)
|
||||
self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, new_bytes)
|
||||
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fuzzy smaller-basis run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \
|
||||
"grown file reconstructed from a smaller fuzzy basis is not byte-exact"
|
||||
assert proxy.client_to_server < len(new_bytes) // 4, \
|
||||
"smaller fuzzy basis should have block-matched the shared prefix"
|
||||
|
||||
def test_no_fuzzy_end_to_end_equals_no_flag(self, shared_server):
|
||||
# --no-fuzzy must not enable anything: a run with it behaves exactly
|
||||
# like a run without it (whole-file transfer, byte-exact output).
|
||||
source, dest = self._prepare("nofuzzye2e")
|
||||
old_bytes, new_bytes = _random_payloads()
|
||||
self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port)
|
||||
self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, new_bytes)
|
||||
result, proxy = self._run_measured(source, dest, ["--no-fuzzy"], shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--no-fuzzy run failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
|
||||
assert proxy.client_to_server > len(new_bytes) // 2, \
|
||||
"--no-fuzzy should leave the default whole-file behavior intact"
|
||||
|
||||
|
||||
@@ -1169,6 +1169,146 @@ static void test_parse_args_whole_file() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -y/--fuzzy reuses a similar destination file as a delta basis, so it implies
|
||||
* the receiver-driven delta path (--incremental + --delta): FastSync's delta
|
||||
* machinery is OFF by default, so without the implication a bare --fuzzy would
|
||||
* be a silent no-op. Both spellings behave identically. */
|
||||
static void test_parse_args_fuzzy_implies_delta() {
|
||||
static const char* const spellings[] = {"--fuzzy", "-y"};
|
||||
for (size_t i = 0; i < sizeof(spellings) / sizeof(spellings[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", (char*)spellings[i], "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->fuzzy);
|
||||
EXPECT_TRUE(cfg->use_incremental);
|
||||
EXPECT_TRUE(cfg->use_delta);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --no-fuzzy turns the flag back off; the incremental/delta implication must
|
||||
* only fire when the FINAL value of the flag is true (order-independent). */
|
||||
static void test_parse_args_fuzzy_negation() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--fuzzy", "--no-fuzzy", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->fuzzy);
|
||||
EXPECT_FALSE(cfg->use_delta);
|
||||
EXPECT_FALSE(cfg->use_incremental);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* reordered[] = {"fastsync", "--no-fuzzy", "--fuzzy", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, reordered, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->fuzzy);
|
||||
EXPECT_TRUE(cfg->use_incremental);
|
||||
EXPECT_TRUE(cfg->use_delta);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -W/--whole-file switches the delta machinery off, so --fuzzy is inert (the
|
||||
* per-file quick check still needs --incremental, which stays implied). */
|
||||
static void test_parse_args_fuzzy_with_whole_file() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--fuzzy", "-W", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->fuzzy);
|
||||
EXPECT_TRUE(cfg->whole_file);
|
||||
EXPECT_TRUE(cfg->use_incremental);
|
||||
EXPECT_FALSE(cfg->use_delta);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* An explicit --no-delta is respected by the --fuzzy implication in either
|
||||
* argument order (a user who switched delta off does not want it forced on). */
|
||||
static void test_parse_args_fuzzy_respects_no_delta() {
|
||||
static const char* const combos[][2] = {
|
||||
{"--fuzzy", "--no-delta"},
|
||||
{"--no-delta", "--fuzzy"},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(combos) / sizeof(combos[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", (char*)combos[i][0], (char*)combos[i][1], "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->fuzzy);
|
||||
EXPECT_FALSE(cfg->use_delta);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* An explicit --no-incremental is respected by the --fuzzy implication in
|
||||
* either argument order (unlike the basis-dir options, --fuzzy does not force
|
||||
* the incremental handshake back on). Because delta needs the handshake, the
|
||||
* delta implication is suppressed too, so the run is a plain (default-mode)
|
||||
* transfer rather than an invalid "--delta requires --incremental" config. */
|
||||
static void test_parse_args_fuzzy_respects_no_incremental() {
|
||||
static const char* const combos[][2] = {
|
||||
{"--fuzzy", "--no-incremental"},
|
||||
{"--no-incremental", "--fuzzy"},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(combos) / sizeof(combos[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", (char*)combos[i][0], (char*)combos[i][1], "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->fuzzy);
|
||||
EXPECT_FALSE(cfg->use_incremental);
|
||||
EXPECT_FALSE(cfg->use_delta);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup("/dst");
|
||||
EXPECT_TRUE(validate_config(cfg));
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --fuzzy requires the delta machinery, which the chunk-serialization (-s) and
|
||||
* sendfile (-f) modes reject -- mirroring the --delta constraint checks. */
|
||||
static void test_validate_config_fuzzy_incompatible_modes() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--fuzzy", "-s", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup("/dst");
|
||||
EXPECT_TRUE(cfg->use_incremental);
|
||||
EXPECT_FALSE(validate_config(cfg));
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* sendfile_argv[] = {"fastsync", "--fuzzy", "-f", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, sendfile_argv, positional_args, &positional_count), 0);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup("/dst");
|
||||
EXPECT_TRUE(cfg->use_delta);
|
||||
EXPECT_FALSE(validate_config(cfg));
|
||||
config_delete(cfg);
|
||||
|
||||
/* A plain --fuzzy run is a valid configuration. */
|
||||
cfg = config_create();
|
||||
char* ok_argv[] = {"fastsync", "--fuzzy", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, ok_argv, positional_args, &positional_count), 0);
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup("/dst");
|
||||
EXPECT_TRUE(cfg->use_delta);
|
||||
EXPECT_TRUE(cfg->use_incremental);
|
||||
EXPECT_TRUE(validate_config(cfg));
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -x and --one-file-system enable client-side single-filesystem scanning. */
|
||||
static void test_parse_args_one_file_system() {
|
||||
Config* cfg = config_create();
|
||||
@@ -1780,6 +1920,12 @@ void test_client_cli() {
|
||||
test_parse_args_secluded_args();
|
||||
test_parse_args_short_s_remains_chunk_serialization();
|
||||
test_parse_args_whole_file();
|
||||
test_parse_args_fuzzy_implies_delta();
|
||||
test_parse_args_fuzzy_negation();
|
||||
test_parse_args_fuzzy_with_whole_file();
|
||||
test_parse_args_fuzzy_respects_no_delta();
|
||||
test_parse_args_fuzzy_respects_no_incremental();
|
||||
test_validate_config_fuzzy_incompatible_modes();
|
||||
test_parse_args_one_file_system();
|
||||
test_parse_args_compression_aliases();
|
||||
test_parse_args_compression_equals_and_none();
|
||||
|
||||
@@ -128,6 +128,7 @@ static void test_config_send_receive() {
|
||||
send_cfg->use_executability = true;
|
||||
send_cfg->use_delta = true;
|
||||
send_cfg->whole_file = true;
|
||||
send_cfg->fuzzy = true;
|
||||
send_cfg->ignore_times = true;
|
||||
send_cfg->size_only = true;
|
||||
send_cfg->compression_level = 5;
|
||||
@@ -188,6 +189,8 @@ static void test_config_send_receive() {
|
||||
ok = false;
|
||||
if (recv_cfg->use_delta)
|
||||
ok = false;
|
||||
if (!recv_cfg->fuzzy)
|
||||
ok = false;
|
||||
if (recv_cfg->modify_window != 4)
|
||||
ok = false;
|
||||
if (!recv_cfg->existing)
|
||||
|
||||
Reference in New Issue
Block a user