fix(fs): recreate empty dirs, replace blocking file; -R --no-implied-dirs implied parents

- Recursive scans emit a directory entry for every traversed directory that
  produced no transferred child, so empty source dirs (and dirs emptied by
  filtering) are recreated like rsync; -m prunes them, --files-from/--list-only
  never emit implicit dirs.
- A directory entry now replaces a destination regular file (rsync removes the
  non-directory) instead of aborting; confined to the secure parent fd.
- -R --no-implied-dirs --files-from: stop refusing a listed file whose parent
  is not listed; create the implied parent with default attributes (no source
  metadata is captured for it), matching rsync 3.4.1.
- Differential gate: drop min_size/empty_dirs_recursive/dirs_plain allowlist
  entries (dirs_plain now hands FastSync the same trailing-slash source as
  rsync); add differential test for the files-from implied parent.
- Docs: -d row -> Parity, tally 108/24/24.

No wire change (PROTOCOL_VERSION stays 2.26.0).
This commit is contained in:
2026-09-18 20:25:42 +02:00
parent d162d93570
commit 13708352ec
12 changed files with 191 additions and 146 deletions
-34
View File
@@ -25,40 +25,6 @@ re-triaged when the row moves.
# case id -> {aspect: "reason (ref: RSYNC_COMPAT.md ...)"}
CAVEATS = {
# A source subtree whose only files are all filtered out (here, by
# --min-size) is left behind as an empty directory by rsync but not by
# FastSync: the recursive scanner keeps directory entries record-only, so
# `sub/deep` is only implicit through the (skipped) file. This is the
# documented recursive-empty-directory residual, not a payload/selection
# bug.
"min_size": {
"tree": "recursive transfer does not create a source directory that "
"becomes empty after --min-size filtering (FastSync directory "
"entries are record-only). ref: RSYNC_COMPAT.md `-d/--dirs` "
"row and completion-wave residual ('recursive transfers still "
"do not create empty directories').",
},
# FastSync's recursive scanner keeps directory entries record-only, so a
# plain `-d`/recursive source whose only role for a directory is that entry
# (empty dir, or a dir emptied by filtering) is not created on the
# destination. rsync creates it. `--dirs`/`--files-from`-listed
# directories DO cross as explicit entries (covered by the passing
# `empty_dirs_files_from` / `files_from` cases).
"empty_dirs_recursive": {
"tree": "recursive transfer does not create empty source directories. "
"ref: RSYNC_COMPAT.md `-d/--dirs` row and completion-wave "
"residual ('recursive transfers still do not create empty "
"directories').",
},
# A plain `-d` invocation: FastSync's `--source-dir` treats the argument as
# the directory entry itself (creates the empty source-root mirror), while
# rsync's `src/` trailing-slash form lists the immediate contents.
"dirs_plain": {
"tree": "plain -d semantics: FastSync creates the source-root directory "
"entry (its documented --dirs files-from behavior) instead of "
"rsync's one-level contents listing for a `src/` argument. "
"ref: RSYNC_COMPAT.md `-d/--dirs` row (⚠️).",
},
# --max-delete stops the extras walk part-way and exits 25 in both
# implementations; which of the remaining extras survives depends on
# deletion order, which neither tool specifies. The exit code and the
+6 -2
View File
@@ -89,6 +89,9 @@ class Case:
ignore_paths: Tuple[str, ...] = ()
extra_check: Optional[Callable] = None
files_from: Optional[Tuple[str, ...]] = None
# rsync receives ``src + "/"``; FastSync mirrors the path it is given, so a
# trailing-slash-sensitive case must hand FastSync the same form.
fs_src_suffix: str = ""
ci: bool = False
ref: str = ""
@@ -418,6 +421,7 @@ def run_differential( # noqa: PLR0913 (explicit scenario parameters)
ignore_paths: Tuple[str, ...] = (),
extra_check: Optional[Callable] = None,
files_from: Optional[Tuple[str, ...]] = None,
fs_src_suffix: str = "",
) -> Dict[str, object]:
"""Run one rsync/FastSync pair and return the diff aspects.
@@ -447,7 +451,7 @@ def run_differential( # noqa: PLR0913 (explicit scenario parameters)
fs_flags.append(f"--files-from={list_path}")
rs = run_rsync(src, rdst, rs_flags)
fs_result, _ = run_fastsync(src, fdst, fs_flags, server.port)
fs_result, _ = run_fastsync(src + fs_src_suffix, fdst, fs_flags, server.port)
class _View:
"""Adapter so tree_diff/extra_check keep the Case-shaped interface."""
@@ -486,7 +490,7 @@ def execute_case(case: Case, server) -> Dict[str, object]:
layout=case.layout, seed=case.seed, stdout=case.stdout,
compare_modes=case.compare_modes, compare_hardlinks=case.compare_hardlinks,
ignore_paths=case.ignore_paths, extra_check=case.extra_check,
files_from=case.files_from,
files_from=case.files_from, fs_src_suffix=case.fs_src_suffix,
)
@@ -218,7 +218,8 @@ _CASES = [
H.Case("files_from", "relative", ["--dirs", "-R"],
files_from=("dir1", "sub/x.txt"), layout=H.RELATIVE, ci=True,
ref="-d/--dirs + --files-from"),
H.Case("dirs_plain", "basic", ["-d"], ref="-d/--dirs (plain)"),
H.Case("dirs_plain", "basic", ["-d"], fs_src_suffix="/",
ref="-d/--dirs (plain)"),
H.Case("empty_dirs_recursive", "empty_dir", ["-a"],
ref="recursive empty-directory residual"),
H.Case("empty_dirs_files_from", "empty_dir", ["--dirs", "-R"],
+28 -26
View File
@@ -3523,23 +3523,25 @@ class TestMissingArgs:
class TestNoImpliedDirs:
"""--no-implied-dirs (only meaningful with -R + --files-from) refuses to
place a listed file whose parent directory is not itself listed."""
"""--no-implied-dirs (meaningful with -R) omits the source metadata of a
listed path's implied parent directories but still creates those parents
with default attributes, matching rsync 3.4.1."""
def _make(self):
return _make_relative_source("noimplied_src")
@pytest.mark.parametrize("mt", [False, True])
def test_implied_dir_only_fails_entry(self, shared_server, mt):
def test_implied_dir_created_with_default_attrs(self, shared_server, mt):
source = self._make()
dest = os.path.join(TEST_DATA_DIR, "noimplied_dst")
clean_dir(dest)
lst = _write_rel_list(b"a/b.txt\n") # "a" itself is not listed
flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode != 0, "implied parent directory was not rejected"
assert "--no-implied-dirs" in (result.stderr or result.stdout)
assert not os.path.exists(os.path.join(dest, "a", "b.txt"))
assert result.returncode == 0, \
f"implied parent directory was not created: {result.stderr[:200]}"
assert os.path.isdir(os.path.join(dest, "a")), "implied parent 'a' was not created"
assert _read_file(os.path.join(dest, "a", "b.txt")) == b"nested\n"
@pytest.mark.parametrize("mt", [False, True])
def test_listed_dir_allows_file(self, shared_server, mt):
@@ -3666,9 +3668,10 @@ class TestDirs:
files.extend(os.path.relpath(os.path.join(root, n), mirror) for n in names)
assert files == [], f"--dirs descended into contents: {files}"
def test_dirs_listed_dir_colliding_with_file_fails(self, shared_server):
"""A listed directory that already exists as a regular file at the
destination fails the transfer cleanly instead of clobbering the file."""
def test_dirs_listed_dir_replaces_blocking_file(self, shared_server):
"""rsync parity: a listed directory replaces a regular file already at
its destination path (rsync removes the non-directory and creates the
directory)."""
source = self._make()
dest = os.path.join(TEST_DATA_DIR, "dirs_coll_dst")
clean_dir(dest)
@@ -3678,8 +3681,10 @@ class TestDirs:
lst = _write_rel_list(b"dir1\n")
result, _ = run_client(source, dest, flags=["--files-from", lst, "--dirs", "-R"],
port=shared_server.port)
assert result.returncode != 0, "dir entry over an existing file did not fail"
assert os.path.isfile(blocker), "blocking regular file was clobbered"
assert result.returncode == 0, \
f"dir entry over an existing file failed: {(result.stderr or result.stdout)[:300]}"
assert os.path.isdir(blocker) and not os.path.islink(blocker), \
"blocking regular file was not replaced by the incoming directory"
class TestMkpath:
@@ -6690,11 +6695,10 @@ class TestDirectoryAndSymlinkTimes:
@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."""
def test_preserve_creates_empty_source_dir(self, shared_server, mt):
"""rsync parity: a recursive `-a` transfer recreates an empty source
directory at the destination (the scanner emits it as an explicit
directory entry)."""
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)
@@ -6705,8 +6709,8 @@ class TestDirectoryAndSymlinkTimes:
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"
assert os.path.isdir(os.path.join(received, "empty_sub")), \
f"-a did not recreate the empty source directory at {received}/empty_sub"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
@@ -6729,10 +6733,10 @@ class TestDirectoryAndSymlinkTimes:
@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."""
def test_collision_at_empty_dir_path_replaces_blocker(self, shared_server, mt):
"""rsync parity: a pre-existing regular file at a source empty-dir's
mirror path is replaced by the incoming directory (rsync removes the
non-directory and creates the directory); the run succeeds."""
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)
@@ -6751,10 +6755,8 @@ class TestDirectoryAndSymlinkTimes:
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.isdir(blocker) and not os.path.islink(blocker), \
"the pre-existing blocker was not replaced by the incoming directory"
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
@@ -111,6 +111,45 @@ class TestRelativeGeneral:
assert int(rs.st_mtime) == int(fs.st_mtime), \
f"mtime mismatch for {rel} with {extra}"
@requires_rsync
@pytest.mark.ci
def test_no_implied_dirs_files_from_matches_rsync(self, shared_server):
"""-R --no-implied-dirs --files-from: a listed file whose parent is not
itself listed still transfers; the implied parent is created with
default attributes (rsync 3.4.1 parity)."""
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_nidff_src"))
# Make the implied parent unmistakably non-default on the source so a
# wrongly-applied attribute would be observable.
os.chmod(os.path.join(source, "foo"), 0o700)
os.chmod(os.path.join(source, "foo", "bar"), 0o711)
os.utime(os.path.join(source, "foo"), (978307200, 978307200))
os.utime(os.path.join(source, "foo", "bar"), (978307200, 978307200))
lst = os.path.join(TEST_DATA_DIR, "sel_nidff_list")
with open(lst, "w") as fh:
fh.write("foo/bar/baz/f.txt\n")
dest = os.path.join(TEST_DATA_DIR, "sel_nidff_dst")
rdst = os.path.join(TEST_DATA_DIR, "sel_nidff_rdst")
clean_dir(dest)
clean_dir(rdst)
r = _rsync(["-rlpt", "-R", "--no-implied-dirs", "--files-from=" + lst,
source + "/", rdst + "/"])
assert r.returncode == 0, r.stderr
result, _ = run_client(source, dest, flags=[
"-rlpt", "-R", "--no-implied-dirs", "--files-from", lst],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert _tree(rdst) == _tree(dest), "implied-parent layout mismatch"
# The implied parents exist on both sides and carry the run-time default
# attributes, not the source's (non-default) ones.
for rel in ("foo", "foo/bar", "foo/bar/baz"):
rs = os.stat(os.path.join(rdst, rel))
fs = os.stat(os.path.join(dest, rel))
assert (rs.st_mode & 0o7777) == (fs.st_mode & 0o7777), \
f"mode mismatch for implied {rel}"
# The listed file is transferred with its content.
with open(os.path.join(dest, "foo", "bar", "baz", "f.txt"), "rb") as fh:
assert fh.read() == b"deep\n"
class TestDirsOneLevel:
"""#13: -d with a trailing slash (or '.') lists the source's immediate