From 5b2188d909f4cf0524eec54f113abd486bf84b56 Mon Sep 17 00:00:00 2001 From: TapTap Date: Wed, 16 Sep 2026 23:20:46 +0200 Subject: [PATCH] fix(parity): scope -R --delete to the transferred prefix subtree A general -R transfer places its files below the reconstructed prefix, so marking the whole receive root as the delete scope deleted unrelated sibling directories (data loss; rsync keeps them). Use the prefix itself as the root marker when it is non-empty, in both the single-threaded and multithreaded pipelines. --- src/client/client_send.c | 22 +++++++++++++++-- tests/integration/test_parity_selection.py | 28 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/client/client_send.c b/src/client/client_send.c index 839c64a..c38ec1e 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -335,6 +335,24 @@ static bool append_implied_dir_times(const Config* config, ArrayList* dir_entrie return ok; } +/* The delete-walk root scope for a full (non---files-from) transfer: rsync + * confines --delete to the directories it actually transferred. A plain + * recursive run mirrors the source under the receive root, so "." (the whole + * tree) is correct; an -R run transfers only the reconstructed prefix subtree, + * so the walk is scoped to that prefix instead. Returns a malloc'd wire path + * (or "."), or NULL on allocation failure. */ +static char* delete_scope_root_marker(const Config* config) { + if (config->relative && config->files_from_set == NULL && config->send_directory) { + char* prefix = scanner_relative_prefix(config->send_directory); + if (!prefix) + return NULL; + if (prefix[0] != '\0') + return prefix; + free(prefix); + } + return str_dup("."); +} + /* True when some --files-from entry is an ancestor-or-equal directory of * `rel` (an empty entry -- the whole tree "." -- counts as the root). */ static bool file_list_ancestor_listed(const FileListSet* set, const char* rel) { @@ -2561,7 +2579,7 @@ int send_files(Config* config) { receive root, so mark the root itself (the "." sentinel) and let the scanner record nothing extra. */ if (config->files_from_set == NULL) { - char* root_marker = str_dup("."); + char* root_marker = delete_scope_root_marker(config); if (!root_marker || !array_list_add(synced_dirs, root_marker)) { free(root_marker); goto send_fail; @@ -2918,7 +2936,7 @@ int send_files_multithreaded(Config** config_ptr) { return 1; } if (config->files_from_set == NULL) { - char* root_marker = str_dup("."); + char* root_marker = delete_scope_root_marker(config); if (!root_marker || !array_list_add(context->synced_dirs, root_marker)) { free(root_marker); pipeline_context_sender_destroy(context); diff --git a/tests/integration/test_parity_selection.py b/tests/integration/test_parity_selection.py index 3f2224f..04790e5 100644 --- a/tests/integration/test_parity_selection.py +++ b/tests/integration/test_parity_selection.py @@ -141,6 +141,34 @@ class TestDirsOneLevel: assert result.returncode == 0, result.stderr[:300] assert _tree(rdst) == _tree(dest) + @requires_rsync + @pytest.mark.ci + def test_relative_delete_scope_matches_rsync(self): + """-R --delete must be confined to the transferred prefix subtree so a + sibling destination directory survives (rsync parity).""" + source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_delscope_src")) + dest = os.path.join(TEST_DATA_DIR, "sel_delscope_dst") + rdst = os.path.join(TEST_DATA_DIR, "sel_delscope_rdst") + spec = source + "/./foo" + for root in (dest, rdst): + clean_dir(root) + os.makedirs(os.path.join(root, "foo")) + with open(os.path.join(root, "foo", "extra.txt"), "wb") as fh: + fh.write(b"extra\n") + os.makedirs(os.path.join(root, "unrelated")) + with open(os.path.join(root, "unrelated", "keep.txt"), "wb") as fh: + fh.write(b"keep\n") + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + r = _rsync(["-aR", "--delete", spec, rdst + "/"]) + assert r.returncode == 0, r.stderr + result, _ = run_client(spec, dest, flags=["-a", "-R", "--delete"], + port=server.port) + assert result.returncode == 0, result.stderr[:300] + assert (os.path.isfile(os.path.join(dest, "unrelated", "keep.txt")) + == os.path.isfile(os.path.join(rdst, "unrelated", "keep.txt"))) + assert _tree(dest) == _tree(rdst) + class TestClientAliases: """#5: safe rsync option aliases accepted client-side."""