fix: filter precedence, files-from errors, NUL/CRLF and filter-rule rejections

Address an independent c-review of the files-from/filter feature:

- .rsync-filter precedence now matches rsync: evaluate the innermost
  (current) directory's rules first, then ancestors, then the command-line
  base (--filter/-C), so a deeper file's '+' can re-include what a shallower
  '-' excluded (regression tests in both scan modes; single-thread and -m).
- --files-from: a listed entry missing on disk and an empty list are now hard
  errors surfaced pre-transfer in send_files, send_files_multithreaded,
  dry-run and --list-only; '.' (whole tree) and empty listed dirs stay valid.
- scanner_path_relative now handles a transfer root of / (previously the
  scanner aborted on children of /).
- Reject unsupported rsync filter syntax explicitly (no silent no-ops):
  +/- modifiers other than '/' (! C s r p x) and rules beginning with ':'/'.'
  /'!' (merge/dir-merge/list-clear shorthands). Docs updated.
- -0/--from0 NUL mode preserves entry bytes (no CR/LF trimming); only newline
  mode trims. Absolute-entry error message no longer includes the newline.
- --no-from0/--no-cvs-exclude registered as negatable booleans.
- RSYNC_COMPAT rows updated for the precedence, rejection list, NUL-mode
  detail and the documented O(entries x files) scalability bound of the
  allow-set (Summary unchanged: 62/3/5/1/76 = 147).
This commit is contained in:
2026-09-06 14:16:12 +02:00
parent f4c15a7b78
commit 7f2180ef90
11 changed files with 339 additions and 29 deletions
+67
View File
@@ -1489,6 +1489,43 @@ class TestFilesFrom:
assert result.returncode != 0, "missing --files-from file must be rejected"
assert "--files-from" in result.stderr
def test_files_from_missing_entry_rejected(self):
source = self._make_source("ff_missing_src")
dest = os.path.join(TEST_DATA_DIR, "ff_missing_dst")
clean_dir(dest)
lst = self._write_list(b"top.txt\nno_such_file.txt\n")
result, _ = run_client(source, dest, flags=["--files-from", lst])
assert result.returncode != 0, "a listed-but-missing file must be a hard error"
assert "not found" in result.stderr
def test_files_from_missing_entry_rejected_dry_run(self):
source = self._make_source("ff_missing_dry_src")
dest = os.path.join(TEST_DATA_DIR, "ff_missing_dry_dst")
clean_dir(dest)
lst = self._write_list(b"gone.bin\n")
result, _ = run_client(source, dest, flags=["--files-from", lst, "--dry-run"])
assert result.returncode != 0, "dry-run must also reject a listed-but-missing file"
assert "not found" in result.stderr
def test_files_from_empty_list_rejected(self):
source = self._make_source("ff_empty_src")
dest = os.path.join(TEST_DATA_DIR, "ff_empty_dst")
clean_dir(dest)
lst = self._write_list(b"")
result, _ = run_client(source, dest, flags=["--files-from", lst])
assert result.returncode != 0, "an empty --files-from list must be rejected"
assert "no entries" in result.stderr
def test_files_from_empty_directory_listed_is_not_an_error(self, shared_server):
source = self._make_source("ff_emptydir_src")
os.makedirs(os.path.join(source, "emptydir"), exist_ok=True)
dest = os.path.join(TEST_DATA_DIR, "ff_emptydir_dst")
clean_dir(dest)
lst = self._write_list(b"emptydir\n")
result, _ = run_client(source, dest, flags=["--files-from", lst],
port=shared_server.port)
assert result.returncode == 0, f"empty listed directory errored: {result.stderr[:200]}"
def test_files_from_delete_deletes_unlisted(self):
source = self._make_source("ff_delete_src")
dest = os.path.join(TEST_DATA_DIR, "ff_delete_dst")
@@ -1620,6 +1657,36 @@ class TestFilters:
assert not os.path.exists(os.path.join(received, ".rsync-filter")), \
".rsync-filter must not be transferred"
@pytest.mark.parametrize("mt", [False, True])
def test_per_dir_filter_deeper_file_overrides_outer(self, shared_server, mt):
source = os.path.join(TEST_DATA_DIR, "filter_ovr_src")
clean_dir(source)
entries = {
"top.tmp": b"outer excludes me\n",
"keep.txt": b"kept\n",
"sub/inside.tmp": b"inner re-includes me\n",
".rsync-filter": b"- *.tmp\n",
"sub/.rsync-filter": b"+ *.tmp\n",
}
for rel, content in entries.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)
dest = os.path.join(TEST_DATA_DIR, "filter_ovr_dst")
clean_dir(dest)
flags = ["-F"] + (["-m"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"-F override sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
assert os.path.isfile(os.path.join(received, "keep.txt"))
assert os.path.isfile(os.path.join(received, "sub", "inside.tmp")), \
"inner + *.tmp must re-include what the root - *.tmp excluded"
assert not os.path.exists(os.path.join(received, "top.tmp")), \
"outer - *.tmp still excludes root-level tmp files"
assert not os.path.exists(os.path.join(received, ".rsync-filter"))
assert not os.path.exists(os.path.join(received, "sub", ".rsync-filter"))
def test_filter_leaves_default_behavior_unchanged(self, shared_server):
source = self._make_tree("filter_default_src")
dest = os.path.join(TEST_DATA_DIR, "filter_default_dst")