Merge feat/p7-times: Phase 7 Wave D (real dir/symlink time preservation making -O/-J meaningful; secluded-args -> Impossible/Divergence; PROTOCOL 2.17.0)

This commit is contained in:
2026-09-12 11:22:23 +02:00
25 changed files with 1000 additions and 77 deletions
+194
View File
@@ -4904,3 +4904,197 @@ class TestConnectivityClientOptions:
f"--blocking-io -c failed: {(result.stderr or result.stdout)[:300]}"
mismatches, missing = verify_transfer(source, get_dest_received_dir(dest, source))
assert not mismatches and not missing
DISTINCT_MTIME = 1_000_000_000 # 2001-09-09T01:46:40Z; a whole second
class TestDirectoryAndSymlinkTimes:
"""P7 Wave D: -O/--omit-dir-times and -J/--omit-link-times are real.
FastSync now captures and applies directory mtimes (deferred to the end of
the transfer, after children) and symlink mtimes (immediate, via no-follow
primitives). -O/-J suppress exactly their own class of times.
"""
def _tree(self, name):
source = os.path.join(TEST_DATA_DIR, name + "_src")
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
clean_dir(source)
clean_dir(dest)
os.makedirs(os.path.join(source, "sub", "deep"), exist_ok=True)
with open(os.path.join(source, "sub", "file.txt"), "wb") as fh:
fh.write(b"content\n")
with open(os.path.join(source, "sub", "deep", "deep.txt"), "wb") as fh:
fh.write(b"deeper\n")
dirs = (source, os.path.join(source, "sub"), os.path.join(source, "sub", "deep"))
for d in dirs:
os.utime(d, (DISTINCT_MTIME, DISTINCT_MTIME))
if abs(os.stat(source).st_mtime - DISTINCT_MTIME) > 2:
pytest.skip("filesystem does not preserve directory mtimes")
return source, dest, ("", "sub", os.path.join("sub", "deep"))
def _link_tree(self, name):
source = os.path.join(TEST_DATA_DIR, name + "_src")
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
clean_dir(source)
clean_dir(dest)
os.makedirs(os.path.join(source, "sub"), exist_ok=True)
with open(os.path.join(source, "sub", "file.txt"), "wb") as fh:
fh.write(b"target\n")
link = os.path.join(source, "sub", "link")
# A same-directory relative target (no ".."): FastSync refuses an
# escaping/ambiguous symlink target, and ".." is a deliberate divergence.
os.symlink("file.txt", link)
os.utime(link, (DISTINCT_MTIME, DISTINCT_MTIME), follow_symlinks=False)
if abs(os.lstat(link).st_mtime - DISTINCT_MTIME) > 2:
pytest.skip("filesystem does not preserve symlink mtimes")
return source, dest, os.path.join("sub", "link")
def _run(self, source, dest, flags, shared_server):
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \
f"{flags} failed: {(result.stderr or result.stdout)[:400]}"
return get_dest_received_dir(dest, source)
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_directory_mtime_round_trip(self, shared_server, mt):
source, dest, rels = self._tree("dirtime")
flags = ["-a"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
for rel in rels:
src_m = os.stat(os.path.join(source, rel)).st_mtime
dst_m = os.stat(os.path.join(received, rel)).st_mtime
assert abs(dst_m - src_m) < 2, \
f"dir '{rel}': source={src_m} dest={dst_m} (flags={flags})"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_omit_dir_times_suppresses_only_dirs(self, shared_server, mt):
source, dest, rels = self._tree("omitdir")
flags = ["-a", "-O"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
for rel in rels:
dst_m = os.stat(os.path.join(received, rel)).st_mtime
assert abs(dst_m - DISTINCT_MTIME) > 5, \
f"-O must not apply directory times ('{rel}' got {dst_m})"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_symlink_mtime_round_trip(self, shared_server, mt):
source, dest, rel = self._link_tree("linktime")
flags = ["-a"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
src_link = os.path.join(source, rel)
dst_link = os.path.join(received, rel)
assert os.path.islink(dst_link), f"{dst_link} is not a symlink"
src_m = os.lstat(src_link).st_mtime
dst_m = os.lstat(dst_link).st_mtime
assert abs(dst_m - src_m) < 2, f"symlink times: source={src_m} dest={dst_m}"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_omit_link_times_suppresses_only_links(self, shared_server, mt):
source, dest, rel = self._link_tree("omitlink")
flags = ["-a", "-J"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
dst_link = os.path.join(received, rel)
assert os.path.islink(dst_link), f"{dst_link} is not a symlink"
dst_m = os.lstat(dst_link).st_mtime
assert abs(dst_m - DISTINCT_MTIME) > 5, \
f"-J must not apply symlink times (got {dst_m})"
@pytest.mark.ci
def test_omit_flags_are_independent(self, shared_server):
"""-O suppresses only directory times and -J only symlink times: with
-O the symlink time is still preserved, and with -J the dir times are."""
source, dest, rel = self._link_tree("omitindep")
# Add a subdirectory mtime to check alongside the symlink.
sub = os.path.join(source, "sub")
os.utime(sub, (DISTINCT_MTIME, DISTINCT_MTIME))
# -O => dir times omitted, symlink time preserved.
clean_dir(dest + "_o")
recv_o = self._run(source, dest + "_o", ["-a", "-O"], shared_server)
assert abs(os.lstat(os.path.join(recv_o, rel)).st_mtime - DISTINCT_MTIME) < 2, \
"-O must not suppress symlink times"
assert abs(os.stat(os.path.join(recv_o, "sub")).st_mtime - DISTINCT_MTIME) > 5, \
"-O must suppress directory times"
# -J => symlink times omitted, dir times preserved.
clean_dir(dest + "_j")
recv_j = self._run(source, dest + "_j", ["-a", "-J"], shared_server)
assert abs(os.lstat(os.path.join(recv_j, rel)).st_mtime - DISTINCT_MTIME) > 5, \
"-J must suppress symlink times"
assert abs(os.stat(os.path.join(recv_j, "sub")).st_mtime - DISTINCT_MTIME) < 2, \
"-J must not suppress directory times"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_preserve_does_not_create_empty_source_dir(self, shared_server, mt):
"""P7 Wave D #1: a captured-but-EMPTY source directory is never created
at the destination. The scanner records its time (it is transmitted via
STATUS_DIR_TIMES), but the receiver treats that entry as record-only, so
`-a` keeps the documented "empty dirs are never transferred" behavior."""
source = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_src")
dest = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "keep.txt"), "wb") as fh:
fh.write(b"regular file\n")
os.makedirs(os.path.join(source, "empty_sub"))
flags = ["-a"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
assert not os.path.lexists(os.path.join(received, "empty_sub")), \
f"-a created an empty source directory at {received}/empty_sub"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_prune_empty_dirs_still_does_not_create_empty_dir(self, shared_server, mt):
"""P7 Wave D #1: `-a -m` (--prune-empty-dirs) keeps its semantics -- a
captured empty directory is never created even though its time is
recorded."""
source = os.path.join(TEST_DATA_DIR, f"prune_empty_{'m' if mt else 's'}_src")
dest = os.path.join(TEST_DATA_DIR, f"prune_empty_{'m' if mt else 's'}_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "keep.txt"), "wb") as fh:
fh.write(b"regular file\n")
os.makedirs(os.path.join(source, "empty_sub"))
flags = ["-a", "-m"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
assert not os.path.lexists(os.path.join(received, "empty_sub")), \
f"-a -m created an empty source directory at {received}/empty_sub"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_collision_at_dir_time_path_does_not_abort(self, shared_server, mt):
"""P7 Wave D #1: a pre-existing regular file at a source-empty-dir's
mirror path must not abort the transfer (the old mkdir failed and failed
the run) and must not be clobbered."""
source = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_src")
dest = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "keep.txt"), "wb") as fh:
fh.write(b"regular file\n")
os.makedirs(os.path.join(source, "collide"))
# Plant a regular file at exactly the mirror path of source/collide.
received = get_dest_received_dir(dest, source)
os.makedirs(received, exist_ok=True)
blocker = os.path.join(received, "collide")
with open(blocker, "wb") as fh:
fh.write(b"pre-existing blocker\n")
flags = ["-a"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \
f"-a aborted on a pre-existing file at an empty-dir path: " \
f"{(result.stderr or result.stdout)[:400]}"
assert os.path.isfile(blocker) and not os.path.islink(blocker), \
"the pre-existing blocker was replaced by a directory"
with open(blocker, "rb") as fh:
assert fh.read() == b"pre-existing blocker\n", "the blocker file was clobbered"
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
+3 -3
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol:
@pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.16.0 (the current PROTOCOL_VERSION) is accepted and the
"""--protocol=2.17.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.16.0"],
result, _ = run_client(source, dest, flags=["--protocol=2.17.0"],
port=shared_server.port)
assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -118,7 +118,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
for bad in ("2.15.0", "2.17.0", "216", "31"):
for bad in ("2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"--protocol={bad} should be rejected"