Accept the full rsync 3.4.1 --info (backup, del, flist, mount, nonreg, progress, remove, symsafe) and --debug (acl, backup, bind, chdir, connect, cmd, del, deltasum, dup, exit, filter, flist, fuzzy, genr, hash, hlink, iconv, nstr, own, recv, send, time) vocabularies, plus the historical syms/hl/owner aliases, with level suffixes. Categories FastSync already emits (copy/name/misc/skip/stats and io/proto/pack/util) still set their log flags; the rest are accepted but silent. Unknown names remain rejected by name, matching rsync. Update the CLI unit tests and the rsync-parity integration tests (previously they required del/filter to be rejected).
1023 lines
47 KiB
Python
1023 lines
47 KiB
Python
"""Client-only rsync-parity quick wins.
|
|
|
|
Each test here pins behaviour that must match real ``rsync 3.4.1``. The
|
|
differential tests skip cleanly when rsync is not installed.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from common import (
|
|
CLIENT_CMD,
|
|
CountingProxy,
|
|
TEST_DATA_DIR,
|
|
ServerManager,
|
|
run_client,
|
|
clean_dir,
|
|
get_dest_received_dir,
|
|
)
|
|
|
|
RSYNC = shutil.which("rsync")
|
|
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
|
|
|
|
|
def _rel_files(root, skip=()):
|
|
"""Relative paths of regular files and symlinks below root, sorted."""
|
|
out = []
|
|
for dirpath, _dirs, files in os.walk(root):
|
|
for name in files:
|
|
if name in skip:
|
|
continue
|
|
out.append(os.path.relpath(os.path.join(dirpath, name), root))
|
|
return sorted(out)
|
|
|
|
|
|
def _rsync(args):
|
|
env = dict(os.environ, LC_ALL="C")
|
|
return subprocess.run(
|
|
[RSYNC] + args, capture_output=True, text=True, env=env, timeout=120
|
|
)
|
|
|
|
|
|
class TestCopyLinksReferentError:
|
|
"""#38/#39: a broken referent under -L/--copy-unsafe-links exits 23."""
|
|
|
|
def _make_broken_tree(self, root):
|
|
clean_dir(root)
|
|
with open(os.path.join(root, "ok.txt"), "wb") as fh:
|
|
fh.write(b"hello\n")
|
|
os.symlink("/nonexistent/target", os.path.join(root, "broken"))
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_copy_links_broken_referent_exit_23(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_cl_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_cl_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_cl_rdst")
|
|
self._make_broken_tree(source)
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
|
|
rsync_result = _rsync(["-aL", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 23, rsync_result.stderr
|
|
|
|
result, _ = run_client(source, dest, flags=["-L"], port=shared_server.port)
|
|
assert result.returncode == 23, (
|
|
f"-L broken referent must exit 23, got {result.returncode}: "
|
|
f"{result.stderr[:300]}"
|
|
)
|
|
received = get_dest_received_dir(dest, source)
|
|
assert os.path.exists(os.path.join(received, "ok.txt"))
|
|
|
|
@requires_rsync
|
|
def test_copy_links_broken_referent_multithreaded_exit_23(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_cl_mt_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_cl_mt_dst")
|
|
self._make_broken_tree(source)
|
|
clean_dir(dest)
|
|
result, _ = run_client(source, dest, flags=["-L", "--threads"], port=shared_server.port)
|
|
assert result.returncode == 23, (
|
|
f"-L broken referent must exit 23 under --threads, got {result.returncode}"
|
|
)
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_copy_unsafe_links_broken_referent_exit_23(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_cul_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_cul_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_cul_rdst")
|
|
clean_dir(source)
|
|
os.makedirs(os.path.join(source, "sub"))
|
|
with open(os.path.join(source, "ok.txt"), "wb") as fh:
|
|
fh.write(b"hello\n")
|
|
# Unsafe (escaping) target with no referent: dereferenced -> exit 23.
|
|
os.symlink("../../../nonexistent/target", os.path.join(source, "sub", "unsafe"))
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
|
|
rsync_result = _rsync(["-a", "--copy-unsafe-links", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 23, rsync_result.stderr
|
|
|
|
result, _ = run_client(source, dest, flags=["-l", "--copy-unsafe-links"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 23, (
|
|
f"--copy-unsafe-links broken referent must exit 23, got {result.returncode}"
|
|
)
|
|
|
|
@requires_rsync
|
|
def test_copy_unsafe_links_safe_broken_stays_symlink(self, shared_server):
|
|
"""A safe (non-escaping) broken symlink is NOT dereferenced: exit 0."""
|
|
source = os.path.join(TEST_DATA_DIR, "qw_cul_safe_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_cul_safe_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_cul_safe_rdst")
|
|
clean_dir(source)
|
|
os.makedirs(os.path.join(source, "sub"))
|
|
os.symlink("nonexistent-target", os.path.join(source, "sub", "safe"))
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
|
|
rsync_result = _rsync(["-a", "--copy-unsafe-links", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
|
|
result, _ = run_client(source, dest, flags=["-l", "--copy-unsafe-links"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
received = get_dest_received_dir(dest, source)
|
|
assert os.path.islink(os.path.join(received, "sub", "safe"))
|
|
|
|
|
|
class TestIgnoreMissingArgsParity:
|
|
"""#58: --files-from + --ignore-missing-args matches rsync, including the
|
|
empty-list case (rsync exits 0 transferring nothing)."""
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_missing_entry_skipped_like_rsync(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_ima_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_ima_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_ima_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
lst = os.path.join(TEST_DATA_DIR, "qw_ima_list")
|
|
with open(lst, "w") as fh:
|
|
fh.write("a.txt\nmissing.txt\n")
|
|
|
|
rsync_result = _rsync(["-a", "--files-from=" + lst, "--ignore-missing-args",
|
|
source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
result, _ = run_client(source, dest, flags=["--files-from", lst,
|
|
"--ignore-missing-args"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
received = get_dest_received_dir(dest, source)
|
|
assert os.path.exists(os.path.join(received, "a.txt"))
|
|
assert not os.path.exists(os.path.join(received, "missing.txt"))
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_empty_files_from_list_succeeds_like_rsync(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_ima_empty_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_ima_empty_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_ima_empty_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
lst = os.path.join(TEST_DATA_DIR, "qw_ima_empty_list")
|
|
with open(lst, "w") as fh:
|
|
fh.write("")
|
|
|
|
rsync_result = _rsync(["-a", "--files-from=" + lst, source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
result, _ = run_client(source, dest, flags=["--files-from", lst],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, (
|
|
f"empty --files-from must succeed like rsync, got {result.returncode}: "
|
|
f"{result.stderr[:300]}"
|
|
)
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_empty_files_from_with_delete_is_not_destructive(self, shared_server):
|
|
"""An empty --files-from list synchronizes nothing, so --delete must not
|
|
wipe the destination (rsync keeps the extra)."""
|
|
source = os.path.join(TEST_DATA_DIR, "qw_ima_edel_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_ima_edel_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_ima_edel_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "keep.txt"), "wb") as fh:
|
|
fh.write(b"keep\n")
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
with open(os.path.join(rdst, "extra.txt"), "wb") as fh:
|
|
fh.write(b"extra\n")
|
|
lst = os.path.join(TEST_DATA_DIR, "qw_ima_edel_list")
|
|
with open(lst, "w") as fh:
|
|
fh.write("")
|
|
rs = _rsync(["-a", "--delete", "--files-from=" + lst, source + "/", rdst + "/"])
|
|
assert rs.returncode == 0, rs.stderr
|
|
assert os.path.exists(os.path.join(rdst, "extra.txt"))
|
|
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
seed = get_dest_received_dir(dest, source)
|
|
os.makedirs(seed, exist_ok=True)
|
|
with open(os.path.join(seed, "extra.txt"), "wb") as fh:
|
|
fh.write(b"extra\n")
|
|
result, _ = run_client(source, dest,
|
|
flags=["--delete", "--files-from", lst],
|
|
port=server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
assert os.path.exists(os.path.join(seed, "extra.txt")), \
|
|
"empty --files-from + --delete must not delete the destination"
|
|
|
|
|
|
class TestPerDirFilterOrdering:
|
|
"""#10: -F .rsync-filter evaluation order matches rsync (a directory's own
|
|
rules before its ancestors'; anchored rules are relative to their owner)."""
|
|
|
|
def _run_pair(self, shared_server, root_rules, sub_rules, subsub_rules=None):
|
|
tag = "qw_f"
|
|
source = os.path.join(TEST_DATA_DIR, tag + "_src")
|
|
dest = os.path.join(TEST_DATA_DIR, tag + "_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, tag + "_rdst")
|
|
clean_dir(source)
|
|
os.makedirs(os.path.join(source, "sub"))
|
|
if subsub_rules is not None:
|
|
os.makedirs(os.path.join(source, "sub", "deep"))
|
|
with open(os.path.join(source, "bar"), "wb") as fh:
|
|
fh.write(b"bar\n")
|
|
with open(os.path.join(source, "sub", "foo"), "wb") as fh:
|
|
fh.write(b"foo\n")
|
|
if subsub_rules is not None:
|
|
with open(os.path.join(source, "sub", "deep", "foo"), "wb") as fh:
|
|
fh.write(b"deep foo\n")
|
|
with open(os.path.join(source, ".rsync-filter"), "w") as fh:
|
|
fh.write(root_rules)
|
|
with open(os.path.join(source, "sub", ".rsync-filter"), "w") as fh:
|
|
fh.write(sub_rules)
|
|
if subsub_rules is not None:
|
|
with open(os.path.join(source, "sub", "deep", ".rsync-filter"), "w") as fh:
|
|
fh.write(subsub_rules)
|
|
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
fs_result, _ = run_client(source, dest, flags=["--preserve", "-F"],
|
|
port=shared_server.port)
|
|
assert fs_result.returncode == 0, fs_result.stderr[:300]
|
|
received = get_dest_received_dir(dest, source)
|
|
fs_files = _rel_files(received, skip=(".rsync-filter",))
|
|
return fs_files, source, rdst
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_child_include_overrides_parent_exclude(self, shared_server):
|
|
fs_files, source, rdst = self._run_pair(shared_server, "- foo\n", "+ foo\n")
|
|
rsync_result = _rsync(["-aF", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
assert fs_files == _rel_files(rdst, skip=(".rsync-filter",))
|
|
assert "sub/foo" in fs_files
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_child_exclude_overrides_parent_include(self, shared_server):
|
|
fs_files, source, rdst = self._run_pair(shared_server, "+ foo\n", "- foo\n")
|
|
rsync_result = _rsync(["-aF", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
assert fs_files == _rel_files(rdst, skip=(".rsync-filter",))
|
|
assert "sub/foo" not in fs_files
|
|
|
|
@requires_rsync
|
|
def test_three_level_inheritance(self, shared_server):
|
|
fs_files, source, rdst = self._run_pair(
|
|
shared_server, "- foo\n", "+ foo\n", "- foo\n"
|
|
)
|
|
rsync_result = _rsync(["-aF", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
assert fs_files == _rel_files(rdst, skip=(".rsync-filter",))
|
|
|
|
|
|
class TestDeleteEdgeSemantics:
|
|
"""#20/#23/#24/#25: delete timing/policy edge cases match rsync."""
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_delete_before_removes_extras_like_rsync(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_db_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_db_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_db_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "keep.txt"), "wb") as fh:
|
|
fh.write(b"keep\n")
|
|
for d in (dest, rdst):
|
|
clean_dir(d)
|
|
with open(os.path.join(d, "extra.txt"), "wb") as fh:
|
|
fh.write(b"extra\n")
|
|
received_seed = get_dest_received_dir(dest, source)
|
|
os.makedirs(received_seed, exist_ok=True)
|
|
with open(os.path.join(received_seed, "extra.txt"), "wb") as fh:
|
|
fh.write(b"extra\n")
|
|
|
|
rsync_result = _rsync(["-a", "--delete-before", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
result, _ = run_client(source, dest, flags=["--delete-before"],
|
|
port=server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
received = get_dest_received_dir(dest, source)
|
|
assert os.path.exists(os.path.join(received, "keep.txt"))
|
|
assert not os.path.exists(os.path.join(received, "extra.txt")), \
|
|
"--delete-before must remove destination extras"
|
|
assert not os.path.exists(os.path.join(rdst, "extra.txt"))
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_delete_excluded_protects_then_deletes(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_de_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_de_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_de_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "keep.txt"), "wb") as fh:
|
|
fh.write(b"keep\n")
|
|
with open(os.path.join(source, "skip.log"), "wb") as fh:
|
|
fh.write(b"log\n")
|
|
|
|
# Default --delete protects the excluded mirror (rsync parity).
|
|
rdst_prot = os.path.join(TEST_DATA_DIR, "qw_de_rprot")
|
|
clean_dir(rdst_prot)
|
|
with open(os.path.join(rdst_prot, "skip.log"), "wb") as fh:
|
|
fh.write(b"stale\n")
|
|
rsync_result = _rsync(["-a", "--delete", "--exclude=*.log", source + "/",
|
|
rdst_prot + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
assert os.path.exists(os.path.join(rdst_prot, "skip.log"))
|
|
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
clean_dir(dest)
|
|
seed = get_dest_received_dir(dest, source)
|
|
os.makedirs(seed, exist_ok=True)
|
|
with open(os.path.join(seed, "skip.log"), "wb") as fh:
|
|
fh.write(b"stale\n")
|
|
result, _ = run_client(source, dest, flags=["--delete", "--exclude=*.log"],
|
|
port=server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
received = get_dest_received_dir(dest, source)
|
|
assert os.path.exists(os.path.join(received, "skip.log")), \
|
|
"--delete must protect the excluded destination mirror"
|
|
|
|
# --delete-excluded removes it.
|
|
clean_dir(rdst)
|
|
with open(os.path.join(rdst, "skip.log"), "wb") as fh:
|
|
fh.write(b"stale\n")
|
|
rsync_result = _rsync(["-a", "--delete", "--delete-excluded",
|
|
"--exclude=*.log", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
assert not os.path.exists(os.path.join(rdst, "skip.log"))
|
|
|
|
result, _ = run_client(
|
|
source, dest,
|
|
flags=["--delete", "--delete-excluded", "--exclude=*.log"],
|
|
port=server.port,
|
|
)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
assert not os.path.exists(os.path.join(received, "skip.log")), \
|
|
"--delete-excluded must remove the excluded mirror"
|
|
|
|
@requires_rsync
|
|
def test_force_replaces_nonempty_destination_dir(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_force_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_force_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_force_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "x"), "wb") as fh:
|
|
fh.write(b"file-content\n")
|
|
clean_dir(rdst)
|
|
os.makedirs(os.path.join(rdst, "x"))
|
|
with open(os.path.join(rdst, "x", "blocker"), "wb") as fh:
|
|
fh.write(b"blocker\n")
|
|
|
|
rsync_result = _rsync(["-a", "--force", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
assert os.path.isfile(os.path.join(rdst, "x"))
|
|
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
clean_dir(dest)
|
|
seed = get_dest_received_dir(dest, source)
|
|
os.makedirs(os.path.join(seed, "x"), exist_ok=True)
|
|
with open(os.path.join(seed, "x", "blocker"), "wb") as fh:
|
|
fh.write(b"blocker\n")
|
|
result, _ = run_client(source, dest, flags=["--force"],
|
|
port=server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
received = get_dest_received_dir(dest, source)
|
|
assert os.path.isfile(os.path.join(received, "x")), \
|
|
"--force must replace a non-empty destination directory"
|
|
|
|
@requires_rsync
|
|
def test_no_force_nonempty_dir_is_partial_error(self, shared_server):
|
|
"""Without --force a non-empty destination dir blocking a file is not
|
|
replaced; rsync exits 23, fastsync must not silently mangle it."""
|
|
source = os.path.join(TEST_DATA_DIR, "qw_noforce_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_noforce_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_noforce_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "x"), "wb") as fh:
|
|
fh.write(b"file-content\n")
|
|
clean_dir(rdst)
|
|
os.makedirs(os.path.join(rdst, "x"))
|
|
with open(os.path.join(rdst, "x", "blocker"), "wb") as fh:
|
|
fh.write(b"blocker\n")
|
|
rsync_result = _rsync(["-a", source + "/", rdst + "/"])
|
|
assert rsync_result.returncode == 23, rsync_result.stderr
|
|
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
clean_dir(dest)
|
|
seed = get_dest_received_dir(dest, source)
|
|
os.makedirs(os.path.join(seed, "x"), exist_ok=True)
|
|
with open(os.path.join(seed, "x", "blocker"), "wb") as fh:
|
|
fh.write(b"blocker\n")
|
|
result, _ = run_client(source, dest, port=server.port)
|
|
assert result.returncode != 0, "a blocked file install must not report success"
|
|
|
|
|
|
def _snapshot(root):
|
|
"""rel path -> (kind, payload) for every file/symlink below root."""
|
|
result = {}
|
|
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
for name in list(dirnames):
|
|
p = os.path.join(dirpath, name)
|
|
if os.path.islink(p):
|
|
result[os.path.relpath(p, root)] = ("link", os.readlink(p))
|
|
dirnames.remove(name)
|
|
for name in filenames:
|
|
p = os.path.join(dirpath, name)
|
|
if os.path.islink(p):
|
|
result[os.path.relpath(p, root)] = ("link", os.readlink(p))
|
|
else:
|
|
with open(p, "rb") as fh:
|
|
result[os.path.relpath(p, root)] = ("file", fh.read())
|
|
return result
|
|
|
|
|
|
def _assert_same_tree(rdst, received, msg=""):
|
|
rsync_tree = _snapshot(rdst)
|
|
fs_tree = _snapshot(received)
|
|
assert fs_tree == rsync_tree, (
|
|
f"tree mismatch {msg}\n----- rsync only/diff -----\n"
|
|
f"{ {k: v for k, v in rsync_tree.items() if fs_tree.get(k) != v} }\n"
|
|
f"----- fastsync only/diff -----\n"
|
|
f"{ {k: v for k, v in fs_tree.items() if rsync_tree.get(k) != v} }"
|
|
)
|
|
|
|
|
|
class TestVerifyAndFlip:
|
|
"""Item 8: confirm already-implemented client-side rows match rsync."""
|
|
|
|
def _src(self, tag):
|
|
source = os.path.join(TEST_DATA_DIR, f"vw_{tag}_src")
|
|
clean_dir(source)
|
|
return source
|
|
|
|
def _dst(self, tag):
|
|
d = os.path.join(TEST_DATA_DIR, f"vw_{tag}_dst")
|
|
clean_dir(d)
|
|
return d
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_links_verbatim_matches_rsync(self, shared_server):
|
|
source = self._src("links")
|
|
dest = self._dst("links")
|
|
rdst = self._dst("links_r")
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
os.symlink("a.txt", os.path.join(source, "rel"))
|
|
os.symlink("/etc/hostname", os.path.join(source, "abs"))
|
|
os.symlink("../../escape", os.path.join(source, "dd"))
|
|
assert _rsync(["-a", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(-l/--links)")
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_dry_run_does_not_write_like_rsync(self, shared_server):
|
|
source = self._src("dry")
|
|
dest = self._dst("dry")
|
|
rdst = self._dst("dry_r")
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
os.makedirs(os.path.join(source, "sub"))
|
|
with open(os.path.join(source, "sub", "b.txt"), "wb") as fh:
|
|
fh.write(b"b\n")
|
|
assert _rsync(["-a", "--dry-run", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["-a", "--dry-run"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--dry-run)")
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_ignore_existing_matches_rsync(self, shared_server):
|
|
source = self._src("ie")
|
|
dest = self._dst("ie")
|
|
rdst = self._dst("ie_r")
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"source\n")
|
|
with open(os.path.join(source, "new.txt"), "wb") as fh:
|
|
fh.write(b"new\n")
|
|
for root in (get_dest_received_dir(dest, source), rdst):
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(os.path.join(root, "a.txt"), "wb") as fh:
|
|
fh.write(b"destination-kept\n")
|
|
assert _rsync(["-a", "--ignore-existing", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--ignore-existing"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--ignore-existing)")
|
|
|
|
@requires_rsync
|
|
def test_append_matches_rsync(self, shared_server):
|
|
source = self._src("app")
|
|
dest = self._dst("app")
|
|
rdst = self._dst("app_r")
|
|
prefix = b"P" * (256 * 1024)
|
|
tail = b"T" * (16 * 1024)
|
|
with open(os.path.join(source, "grow"), "wb") as fh:
|
|
fh.write(prefix + tail)
|
|
for root in (get_dest_received_dir(dest, source), rdst):
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(os.path.join(root, "grow"), "wb") as fh:
|
|
fh.write(prefix)
|
|
assert _rsync(["-a", "--append", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--append"], port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--append)")
|
|
|
|
@requires_rsync
|
|
def test_append_verify_matches_rsync(self, shared_server):
|
|
source = self._src("appv")
|
|
dest = self._dst("appv")
|
|
rdst = self._dst("appv_r")
|
|
prefix = b"Q" * (128 * 1024)
|
|
tail = b"Z" * (8 * 1024)
|
|
with open(os.path.join(source, "grow"), "wb") as fh:
|
|
fh.write(prefix + tail)
|
|
for root in (get_dest_received_dir(dest, source), rdst):
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(os.path.join(root, "grow"), "wb") as fh:
|
|
fh.write(prefix)
|
|
assert _rsync(["-a", "--append-verify", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--append-verify"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--append-verify)")
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_delay_updates_matches_rsync(self, shared_server):
|
|
source = self._src("delay")
|
|
dest = self._dst("delay")
|
|
rdst = self._dst("delay_r")
|
|
for rel, content in {"a.txt": b"a\n", "sub/b.txt": b"b\n"}.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)
|
|
assert _rsync(["-a", "--delay-updates", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--delay-updates"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--delay-updates)")
|
|
|
|
@requires_rsync
|
|
def test_preallocate_matches_rsync(self, shared_server):
|
|
source = self._src("prealloc")
|
|
dest = self._dst("prealloc")
|
|
rdst = self._dst("prealloc_r")
|
|
with open(os.path.join(source, "f.bin"), "wb") as fh:
|
|
fh.write(b"x" * (512 * 1024))
|
|
assert _rsync(["-a", "--preallocate", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--preallocate"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--preallocate)")
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_sparse_preallocate_blocks_match_rsync(self, shared_server):
|
|
"""rsync lets --preallocate win over --sparse: the hole file gets its
|
|
full space reserved (st_blocks ~ size/512) even though the sparse writer
|
|
seeks over the zero run. FastSync must agree both on the block count and
|
|
with rsync's exact value for each flag combination."""
|
|
source = self._src("sparsepre")
|
|
dest = self._dst("sparsepre")
|
|
rdst = self._dst("sparsepre_r")
|
|
total = 1024 * 1024
|
|
# A zero run >= the 4 KiB sparse threshold in the middle of the image.
|
|
with open(os.path.join(source, "hole.bin"), "wb") as fh:
|
|
fh.write(os.urandom(64 * 1024))
|
|
fh.write(b"\x00" * (768 * 1024))
|
|
fh.write(os.urandom(total - 64 * 1024 - 768 * 1024))
|
|
|
|
def run_both(flags, tag):
|
|
rsync_dst = self._dst(f"sparsepre_{tag}_r")
|
|
fs_dst = self._dst(f"sparsepre_{tag}_f")
|
|
assert _rsync(["-a"] + flags + [source + "/", rsync_dst + "/"]).returncode == 0
|
|
result, _ = run_client(source, fs_dst, flags=["-a"] + flags,
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
rfile = os.path.join(rsync_dst, "hole.bin")
|
|
ffile = os.path.join(get_dest_received_dir(fs_dst, source), "hole.bin")
|
|
with open(rfile, "rb") as rfh, open(ffile, "rb") as ffh:
|
|
assert rfh.read() == ffh.read(), f"{tag}: content diverged"
|
|
return os.stat(rfile).st_blocks, os.stat(ffile).st_blocks
|
|
|
|
r_sparse, f_sparse = run_both(["--sparse"], "sparse")
|
|
r_both, f_both = run_both(["--sparse", "--preallocate"], "both")
|
|
|
|
assert f_sparse == r_sparse, (
|
|
f"--sparse st_blocks: fastsync={f_sparse} rsync={r_sparse}"
|
|
)
|
|
assert f_both == r_both, (
|
|
f"--sparse --preallocate st_blocks: fastsync={f_both} rsync={r_both}"
|
|
)
|
|
# Only meaningful where the filesystem actually reports holes; otherwise
|
|
# both sides simply allocate the full size and the equality above holds.
|
|
has_holes = r_sparse * 512 < total
|
|
if has_holes:
|
|
assert f_both > f_sparse, (
|
|
"preallocate must win over sparse: "
|
|
f"sparse={f_sparse} both={f_both} blocks"
|
|
)
|
|
assert r_both > r_sparse, (
|
|
f"rsync preallocate must win: sparse={r_sparse} both={r_both}"
|
|
)
|
|
|
|
@requires_rsync
|
|
def test_fuzzy_content_matches_rsync(self, shared_server):
|
|
source = self._src("fuzzy")
|
|
dest = self._dst("fuzzy")
|
|
rdst = self._dst("fuzzy_r")
|
|
payload = (b"the quick brown fox\n" * 4096)
|
|
with open(os.path.join(source, "renamed.txt"), "wb") as fh:
|
|
fh.write(payload)
|
|
for root in (get_dest_received_dir(dest, source), rdst):
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(os.path.join(root, "old_name.txt"), "wb") as fh:
|
|
fh.write(payload)
|
|
assert _rsync(["-a", "--fuzzy", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["-y"], port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(-y/--fuzzy)")
|
|
|
|
@requires_rsync
|
|
def test_skip_compress_content_matches_rsync(self, shared_server):
|
|
source = self._src("skipz")
|
|
dest = self._dst("skipz")
|
|
rdst = self._dst("skipz_r")
|
|
with open(os.path.join(source, "already.zip"), "wb") as fh:
|
|
fh.write(b"PK" + b"z" * 4096)
|
|
with open(os.path.join(source, "text.txt"), "wb") as fh:
|
|
fh.write(b"compress me\n" * 1024)
|
|
assert _rsync(["-az", "--skip-compress=gz/zip", source + "/",
|
|
rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["-z", "--skip-compress=gz/zip"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--skip-compress)")
|
|
|
|
@requires_rsync
|
|
def test_copy_dest_content_matches_rsync(self, shared_server):
|
|
source = self._src("copyd")
|
|
dest = self._dst("copyd")
|
|
with open(os.path.join(source, "f.txt"), "wb") as fh:
|
|
fh.write(b"copy-from-basis\n")
|
|
# fastsync basis DIR is relative to the receive root; the basis file is
|
|
# looked up at the same source-mirror relative path.
|
|
rel = os.path.abspath(source).lstrip(os.sep)
|
|
basis = os.path.join(dest, "basis", rel)
|
|
os.makedirs(basis, exist_ok=True)
|
|
with open(os.path.join(basis, "f.txt"), "wb") as fh:
|
|
fh.write(b"copy-from-basis\n")
|
|
received = get_dest_received_dir(dest, source)
|
|
result, _ = run_client(source, dest,
|
|
flags=["--copy-dest=basis", "--incremental"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
assert os.path.exists(os.path.join(received, "f.txt"))
|
|
with open(os.path.join(received, "f.txt"), "rb") as fh:
|
|
assert fh.read() == b"copy-from-basis\n"
|
|
|
|
@requires_rsync
|
|
def test_trust_sender_content_matches_rsync(self, shared_server):
|
|
source = self._src("trust")
|
|
dest = self._dst("trust")
|
|
rdst = self._dst("trust_r")
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
assert _rsync(["-a", "--trust-sender", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--trust-sender"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
_assert_same_tree(rdst, get_dest_received_dir(dest, source), "(--trust-sender)")
|
|
|
|
@requires_rsync
|
|
def test_compare_dest_content_matches_rsync(self, shared_server):
|
|
source = self._src("cmpd")
|
|
dest = self._dst("cmpd")
|
|
rdst = self._dst("cmpd_r")
|
|
with open(os.path.join(source, "f.txt"), "wb") as fh:
|
|
fh.write(b"basis-content\n")
|
|
# rsync resolves --compare-dest relative to the destination dir; its
|
|
# basis file sits at the transfer-relative path.
|
|
os.makedirs(os.path.join(rdst, "basis"), exist_ok=True)
|
|
with open(os.path.join(rdst, "basis", "f.txt"), "wb") as fh:
|
|
fh.write(b"basis-content\n")
|
|
rs = _rsync(["-a", "--compare-dest=basis", source + "/", rdst + "/"])
|
|
assert rs.returncode == 0, rs.stderr
|
|
assert not os.path.exists(os.path.join(rdst, "f.txt")), \
|
|
"rsync compare-dest must leave the destination sparse"
|
|
|
|
# fastsync resolves the basis DIR relative to the receive root, and the
|
|
# file's relative path there mirrors the source path.
|
|
rel = os.path.abspath(source).lstrip(os.sep)
|
|
basis = os.path.join(dest, "basis", rel)
|
|
os.makedirs(basis, exist_ok=True)
|
|
with open(os.path.join(basis, "f.txt"), "wb") as fh:
|
|
fh.write(b"basis-content\n")
|
|
received = get_dest_received_dir(dest, source)
|
|
result, _ = run_client(source, dest,
|
|
flags=["--compare-dest=basis", "--incremental"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
# rsync --compare-dest never copies: a basis match is simply not
|
|
# transferred, so the destination stays sparse (no f.txt).
|
|
assert not os.path.exists(os.path.join(received, "f.txt"))
|
|
|
|
@requires_rsync
|
|
def test_link_dest_hardlinks_matches_rsync(self, shared_server):
|
|
source = self._src("linkd")
|
|
dest = self._dst("linkd")
|
|
with open(os.path.join(source, "f.txt"), "wb") as fh:
|
|
fh.write(b"link-basis-content\n")
|
|
rel = os.path.abspath(source).lstrip(os.sep)
|
|
basis = os.path.join(dest, "basis", rel)
|
|
os.makedirs(basis, exist_ok=True)
|
|
basis_file = os.path.join(basis, "f.txt")
|
|
with open(basis_file, "wb") as fh:
|
|
fh.write(b"link-basis-content\n")
|
|
received = get_dest_received_dir(dest, source)
|
|
result, _ = run_client(source, dest,
|
|
flags=["--link-dest=basis", "--incremental"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
dest_file = os.path.join(received, "f.txt")
|
|
assert os.path.exists(dest_file)
|
|
assert os.stat(dest_file).st_ino == os.stat(basis_file).st_ino, \
|
|
"--link-dest must hard-link to the basis file"
|
|
|
|
|
|
class TestIgnoreExistingShortCircuit:
|
|
"""#9: --ignore-existing is decided by the receiver during the per-file
|
|
check, before the sender streams any payload. A large destination file that
|
|
is already present must therefore cost almost no wire bytes, not the full
|
|
file; rsync short-circuits the same way."""
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_skip_answered_before_payload(self):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_ie_wire_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_ie_wire_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_ie_wire_rdst")
|
|
clean_dir(source)
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
big = b"S" * (4 * 1024 * 1024)
|
|
with open(os.path.join(source, "big.bin"), "wb") as fh:
|
|
fh.write(big)
|
|
for root in (get_dest_received_dir(dest, source), rdst):
|
|
os.makedirs(root, exist_ok=True)
|
|
with open(os.path.join(root, "big.bin"), "wb") as fh:
|
|
fh.write(b"D" * len(big))
|
|
|
|
assert _rsync(["-a", "--ignore-existing", source + "/",
|
|
rdst + "/"]).returncode == 0
|
|
|
|
# A dedicated one-shot server keeps the proxy's connection teardown
|
|
# deterministic (the session-wide server can linger on an idle socket).
|
|
with ServerManager() as server:
|
|
cmd = CLIENT_CMD + [
|
|
"--source-dir", source, "--dest-dir", dest, "--save-to-disk",
|
|
"--server-port", str(server.port), "-a", "--ignore-existing",
|
|
]
|
|
proxy = CountingProxy(server.port)
|
|
# Only the client->server count matters here; it is final once the
|
|
# client exits, so do not wait for the server to close its idle
|
|
# socket (which can take the full default join timeout).
|
|
result = proxy.run(cmd, join_timeout=1.0)
|
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
|
# The receiver answered the skip before the sender transmitted the file:
|
|
# only config/path/check frames crossed, not the 4 MiB payload.
|
|
assert proxy.client_to_server < len(big) // 10, (
|
|
f"--ignore-existing transmitted {proxy.client_to_server} bytes for a "
|
|
f"skipped {len(big)}-byte file"
|
|
)
|
|
received = get_dest_received_dir(dest, source)
|
|
with open(os.path.join(received, "big.bin"), "rb") as fh:
|
|
assert fh.read() == b"D" * len(big), \
|
|
"--ignore-existing must preserve the destination content"
|
|
_assert_same_tree(rdst, received, "(--ignore-existing wire short-circuit)")
|
|
|
|
|
|
@pytest.mark.skipif(os.geteuid() != 0, reason="ownership mapping requires root")
|
|
class TestOwnershipMapping:
|
|
"""#33/#34/#35: --usermap/--groupmap/--chown match rsync's numeric result."""
|
|
|
|
def _prep(self, tag):
|
|
source = os.path.join(TEST_DATA_DIR, f"own_{tag}_src")
|
|
dest = os.path.join(TEST_DATA_DIR, f"own_{tag}_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, f"own_{tag}_rdst")
|
|
clean_dir(source)
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
return source, dest, rdst
|
|
|
|
@requires_rsync
|
|
def test_usermap_groupmap_matches_rsync(self, shared_server):
|
|
source, dest, rdst = self._prep("map")
|
|
assert _rsync(["-a", "--usermap=*:12345", "--groupmap=*:54321",
|
|
source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest,
|
|
flags=["--usermap=*:12345", "--groupmap=*:54321"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
rs = os.stat(os.path.join(rdst, "a.txt"))
|
|
fs = os.stat(os.path.join(get_dest_received_dir(dest, source), "a.txt"))
|
|
assert (fs.st_uid, fs.st_gid) == (rs.st_uid, rs.st_gid) == (12345, 54321)
|
|
|
|
@requires_rsync
|
|
def test_chown_matches_rsync(self, shared_server):
|
|
source, dest, rdst = self._prep("chown")
|
|
assert _rsync(["-a", "--chown=23456:65432", source + "/",
|
|
rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--chown=23456:65432"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
rs = os.stat(os.path.join(rdst, "a.txt"))
|
|
fs = os.stat(os.path.join(get_dest_received_dir(dest, source), "a.txt"))
|
|
assert (fs.st_uid, fs.st_gid) == (rs.st_uid, rs.st_gid) == (23456, 65432)
|
|
|
|
|
|
class TestFakeSuper:
|
|
"""#32: --fake-super stores privileged attrs via xattrs. FastSync uses its
|
|
own reserved key (documented divergence) but the file DATA must match rsync."""
|
|
|
|
@requires_rsync
|
|
def test_fake_super_data_matches_rsync(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "fs_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "fs_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "fs_rdst")
|
|
clean_dir(source)
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"fake-super-data\n")
|
|
assert _rsync(["-a", "--fake-super", source + "/", rdst + "/"]).returncode == 0
|
|
result, _ = run_client(source, dest, flags=["--fake-super"],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, result.stderr[:300]
|
|
with open(os.path.join(get_dest_received_dir(dest, source), "a.txt"), "rb") as fh:
|
|
assert fh.read() == b"fake-super-data\n"
|
|
with open(os.path.join(rdst, "a.txt"), "rb") as fh:
|
|
assert fh.read() == b"fake-super-data\n"
|
|
|
|
|
|
# rsync 3.4.1's full --info/--debug vocabularies (from `rsync --info=help` /
|
|
# `--debug=help`). FastSync must accept every one of them; only the categories
|
|
# with an existing FastSync counterpart emit output, the rest are accepted but
|
|
# currently silent.
|
|
RSYNC_INFO_CATEGORIES = (
|
|
"backup", "copy", "del", "flist", "misc", "mount", "name", "nonreg",
|
|
"progress", "remove", "skip", "stats", "symsafe", "all", "none",
|
|
)
|
|
RSYNC_DEBUG_CATEGORIES = (
|
|
"acl", "backup", "bind", "chdir", "connect", "cmd", "del", "deltasum",
|
|
"dup", "exit", "filter", "flist", "fuzzy", "genr", "hash", "hlink",
|
|
"iconv", "io", "nstr", "own", "proto", "recv", "send", "time", "all",
|
|
"none",
|
|
)
|
|
# Extra categories FastSync also accepts: rsync's own help spells these
|
|
# `symsafe`/`hlink`/`own`, but the historical aliases are kept working, and
|
|
# `pack`/`util` are FastSync-specific debug channels.
|
|
FASTSYNC_INFO_ALIASES = ("syms",)
|
|
FASTSYNC_DEBUG_ALIASES = ("hl", "owner", "pack", "util")
|
|
|
|
|
|
class TestInfoDebugFlagParity:
|
|
"""#01/#02: FastSync accepts rsync 3.4.1's full --info/--debug vocabulary
|
|
(with level suffixes) so a valid rsync invocation is never rejected up
|
|
front. Unknown names are still refused by name."""
|
|
|
|
def _tree(self):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_flags_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_flags_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_flags_rdst")
|
|
clean_dir(source)
|
|
clean_dir(dest)
|
|
clean_dir(rdst)
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
return source, dest, rdst
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_info_vocabulary_accepted_like_rsync(self, shared_server):
|
|
source, dest, rdst = self._tree()
|
|
for cat in RSYNC_INFO_CATEGORIES:
|
|
rs = _rsync(["-a", "--info=" + cat, source + "/", rdst + "/"])
|
|
assert rs.returncode == 0, f"rsync rejected --info={cat}: {rs.stderr}"
|
|
clean_dir(rdst)
|
|
result, _ = run_client(source, dest, flags=["--info=" + cat],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, (
|
|
f"--info={cat} must be accepted like rsync: {result.stderr[:200]}"
|
|
)
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_debug_vocabulary_accepted_like_rsync(self, shared_server):
|
|
source, dest, rdst = self._tree()
|
|
for cat in RSYNC_DEBUG_CATEGORIES:
|
|
rs = _rsync(["-a", "--debug=" + cat, source + "/", rdst + "/"])
|
|
assert rs.returncode == 0, f"rsync rejected --debug={cat}: {rs.stderr}"
|
|
clean_dir(rdst)
|
|
result, _ = run_client(source, dest, flags=["--debug=" + cat],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, (
|
|
f"--debug={cat} must be accepted like rsync: {result.stderr[:200]}"
|
|
)
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_level_suffixes_accepted_like_rsync(self, shared_server):
|
|
source, dest, rdst = self._tree()
|
|
for flag in ("--info=stats2", "--info=copy0", "--info=all0",
|
|
"--debug=io2", "--debug=proto0", "--debug=all4"):
|
|
rs = _rsync(["-a", flag, source + "/", rdst + "/"])
|
|
assert rs.returncode == 0, f"rsync rejected {flag}: {rs.stderr}"
|
|
clean_dir(rdst)
|
|
result, _ = run_client(source, dest, flags=[flag],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, (
|
|
f"{flag} must be accepted like rsync: {result.stderr[:200]}"
|
|
)
|
|
|
|
def test_fastsync_alias_categories_accepted(self, shared_server):
|
|
"""FastSync-specific/alias spellings: accepted (rsync spells them
|
|
symsafe/hlink/own) but not emitted."""
|
|
source, dest, _ = self._tree()
|
|
for flag in (["--info=" + c for c in FASTSYNC_INFO_ALIASES] +
|
|
["--debug=" + c for c in FASTSYNC_DEBUG_ALIASES]):
|
|
result, _ = run_client(source, dest, flags=[flag],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, (
|
|
f"{flag} must be accepted: {result.stderr[:200]}"
|
|
)
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_unknown_categories_rejected_by_name(self, shared_server):
|
|
"""Truly unknown names are refused by name, exactly like rsync."""
|
|
source, dest, rdst = self._tree()
|
|
for flag, name in (("--info=bogus", "bogus"),
|
|
("--debug=bogus", "bogus")):
|
|
rs = _rsync(["-a", flag, source + "/", rdst + "/"])
|
|
assert rs.returncode != 0, f"rsync unexpectedly accepted {flag}"
|
|
result, _ = run_client(source, dest, flags=[flag],
|
|
port=shared_server.port)
|
|
assert result.returncode != 0, f"{flag} must be rejected"
|
|
assert name in (result.stderr or ""), (
|
|
f"{flag} must be rejected by name, got: {result.stderr[:200]}"
|
|
)
|
|
|
|
|
|
class TestStopAtParity:
|
|
"""#62: --stop-at accepts rsync's full date/time form."""
|
|
|
|
@requires_rsync
|
|
@pytest.mark.ci
|
|
def test_stop_at_rsync_date_forms_accepted(self, shared_server):
|
|
source = os.path.join(TEST_DATA_DIR, "qw_stop_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "qw_stop_dst")
|
|
rdst = os.path.join(TEST_DATA_DIR, "qw_stop_rdst")
|
|
clean_dir(source)
|
|
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
|
fh.write(b"a\n")
|
|
# rsync's documented --stop-at forms, all in the future or resolvable.
|
|
forms = ["2030-12-31T23:59", "2030/12/31T23:59", "2030-12-31", ":59", "1-30", "1"]
|
|
for form in forms:
|
|
rs = _rsync(["-a", "--stop-at=" + form, source + "/", rdst + "/"])
|
|
assert rs.returncode == 0, f"rsync rejected {form}: {rs.stderr}"
|
|
clean_dir(rdst)
|
|
clean_dir(dest)
|
|
result, _ = run_client(source, dest, flags=["--stop-at=" + form],
|
|
port=shared_server.port)
|
|
assert result.returncode == 0, (
|
|
f"--stop-at={form} must be accepted like rsync: {result.stderr[:200]}"
|
|
)
|