fix(parity): protect -R prefix-relative excluded and size-skipped mirrors

A -R source prune (--exclude/--max-size) must record the destination wire
path below the reconstructed prefix so --delete protects it; the parallel
root scan and the sequential skip path used the source path instead.
This commit is contained in:
2026-09-16 23:21:58 +02:00
parent 5b2188d909
commit 695b5c8c25
2 changed files with 53 additions and 3 deletions
+23 -3
View File
@@ -1289,9 +1289,17 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
wire path, not its source path (which would not match the destination wire path, not its source path (which would not match the destination
layout and would leave the mirror deletable). */ layout and would leave the mirror deletable). */
if (inspected.excluded) { if (inspected.excluded) {
char* protected_path = scanner->relative_mode char* protected_path;
? child_rel_path(scanner->current_rel, entry->d_name) if (scanner->relative_mode) {
: path_cat(scanner->current_path, entry->d_name); protected_path = child_rel_path(scanner->current_rel, entry->d_name);
} else if (scanner->options.relative_prefix) {
char* relc = child_rel_path(scanner->current_rel, entry->d_name);
protected_path =
relc ? scanner_prefix_send_path(scanner->options.relative_prefix, relc) : NULL;
free(relc);
} else {
protected_path = path_cat(scanner->current_path, entry->d_name);
}
if (!protected_path) { if (!protected_path) {
scanner->failed = true; scanner->failed = true;
break; break;
@@ -1753,8 +1761,20 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel); bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel);
if (!files_from_prune && !use_rel && options->excluded_paths) { if (!files_from_prune && !use_rel && options->excluded_paths) {
const char* rel_path = *cur_path == '/' ? cur_path + 1 : cur_path; const char* rel_path = *cur_path == '/' ? cur_path + 1 : cur_path;
char* prefixed = NULL;
if (options->relative_prefix) {
prefixed = scanner_prefix_send_path(options->relative_prefix, entry->d_name);
if (!prefixed) {
free(rel);
free(cur_path);
ps->failed = true;
return;
}
rel_path = prefixed;
}
if (!excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path)) if (!excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path))
ps->failed = true; ps->failed = true;
free(prefixed);
} }
free(rel); free(rel);
free(cur_path); free(cur_path);
@@ -169,6 +169,36 @@ class TestDirsOneLevel:
== os.path.isfile(os.path.join(rdst, "unrelated", "keep.txt"))) == os.path.isfile(os.path.join(rdst, "unrelated", "keep.txt")))
assert _tree(dest) == _tree(rdst) assert _tree(dest) == _tree(rdst)
@requires_rsync
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_relative_delete_protects_excluded_mirror(self, mt):
"""-R --delete with --exclude must protect the destination mirror of an
excluded source path (recorded as a prefix-relative wire path)."""
source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_delexc_src"))
with open(os.path.join(source, "foo", "secret.tmp"), "wb") as fh:
fh.write(b"secret\n")
dest = os.path.join(TEST_DATA_DIR, "sel_delexc_dst")
rdst = os.path.join(TEST_DATA_DIR, "sel_delexc_rdst")
for root in (dest, rdst):
clean_dir(root)
os.makedirs(os.path.join(root, "foo"))
with open(os.path.join(root, "foo", "secret.tmp"), "wb") as fh:
fh.write(b"secret\n")
with open(os.path.join(root, "foo", "extra.txt"), "wb") as fh:
fh.write(b"extra\n")
spec = source + "/./foo"
with ServerManager() as server:
server.start(extra_args=["--allow-delete"])
r = _rsync(["-aR", "--delete", "--exclude=*.tmp", spec, rdst + "/"])
assert r.returncode == 0, r.stderr
flags = ["-a", "-R", "--delete", "--exclude=*.tmp"] + (["--threads"] if mt else [])
result, _ = run_client(spec, dest, flags=flags, port=server.port)
assert result.returncode == 0, result.stderr[:300]
assert _tree(dest) == _tree(rdst)
assert os.path.isfile(os.path.join(dest, "foo", "secret.tmp"))
assert not os.path.exists(os.path.join(dest, "foo", "extra.txt"))
class TestClientAliases: class TestClientAliases:
"""#5: safe rsync option aliases accepted client-side.""" """#5: safe rsync option aliases accepted client-side."""