diff --git a/src/client/scanner.c b/src/client/scanner.c index 0721fa5..8770757 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -1289,9 +1289,17 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { wire path, not its source path (which would not match the destination layout and would leave the mirror deletable). */ if (inspected.excluded) { - char* protected_path = scanner->relative_mode - ? child_rel_path(scanner->current_rel, entry->d_name) - : path_cat(scanner->current_path, entry->d_name); + char* protected_path; + if (scanner->relative_mode) { + 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) { scanner->failed = true; 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); if (!files_from_prune && !use_rel && options->excluded_paths) { 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)) ps->failed = true; + free(prefixed); } free(rel); free(cur_path); diff --git a/tests/integration/test_parity_selection.py b/tests/integration/test_parity_selection.py index 04790e5..68e68b7 100644 --- a/tests/integration/test_parity_selection.py +++ b/tests/integration/test_parity_selection.py @@ -169,6 +169,36 @@ class TestDirsOneLevel: == os.path.isfile(os.path.join(rdst, "unrelated", "keep.txt"))) 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: """#5: safe rsync option aliases accepted client-side."""