From be752b9bfdc156212ef73c3c3424ca963495192d Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 7 Sep 2026 14:34:30 +0200 Subject: [PATCH] test: --ignore-missing-args / --delete-missing-args coverage Unit: CLI parse + imply relationships (delete-missing implies ignore, not delete; valid with --delete and delete timing); delete_missing_args config wire round-trip (ignore_missing_args confirmed client-only); three-section manifest round-trip and third-section traversal rejection; manifest_delete_missing_args exact-path semantics; commit-time parking. Existing two-section manifest frames updated to the three-section format. Integration: default missing entry is a hard pre-transfer error; --ignore-missing-args transfers the rest and succeeds (all-missing transfers nothing; empty list still errors); --delete-missing-args removes exactly the missing mirror and leaves unrelated extras unless --delete is also present; filter-exclusion protection never blocks the explicit deletion; --delete-before early timing composes; all parametrized single-threaded vs -m. --- tests/integration/test_features.py | 214 +++++++++++++++++++++++++++++ tests/test_client_cli.c | 67 +++++++++ tests/test_config.c | 52 +++++++ tests/test_server.c | 171 ++++++++++++++++++++++- 4 files changed, 503 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 422c21d..a3b1e28 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -1742,6 +1742,220 @@ class TestRelativeFilesFrom: "unlisted relative file was not deleted" +class TestMissingArgs: + """--ignore-missing-args / --delete-missing-args: a --files-from entry that + does not exist under the source is skipped instead of failing the run, and + (delete-missing) its destination mirror is removed receiver-side. Following + rsync, --delete-missing-args implies --ignore-missing-args but is + independent of --delete: unrelated extras stay unless --delete is also + given, and the missing-args deletion (an explicit user request) is never + blocked by filter-exclusion protection.""" + + def _make_source(self, name): + source = os.path.join(TEST_DATA_DIR, name) + clean_dir(source) + for rel, content in { + "a.txt": b"a\n", + "sub/b.txt": b"b\n", + "keep.txt": b"keep\n", + "prot/kept.txt": b"kept\n", + }.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) + return source + + @pytest.mark.parametrize("mt", [False, True]) + def test_missing_entry_is_hard_error_before_transfer(self, shared_server, mt): + source = self._make_source("mg_default_src") + dest = os.path.join(TEST_DATA_DIR, "mg_default_dst") + clean_dir(dest) + lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") + flags = ["--files-from", lst] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=shared_server.port) + assert result.returncode != 0, "a listed-but-missing entry did not fail the run" + assert "gone.txt" in (result.stderr or result.stdout) + received = get_dest_received_dir(dest, source) + assert not os.path.isfile(os.path.join(received, "a.txt")), \ + "the transfer started despite the missing-entry hard error" + + @pytest.mark.parametrize("mt", [False, True]) + def test_ignore_missing_args_transfers_the_rest(self, shared_server, mt): + source = self._make_source("mg_ignore_src") + dest = os.path.join(TEST_DATA_DIR, "mg_ignore_dst") + clean_dir(dest) + lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") + flags = ["--files-from", lst, "--ignore-missing-args"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=shared_server.port) + assert result.returncode == 0, f"ignore-missing-args sync failed: {result.stderr[:300]}" + received = get_dest_received_dir(dest, source) + assert _read_file(os.path.join(received, "a.txt")) == b"a\n" + assert _read_file(os.path.join(received, "sub", "b.txt")) == b"b\n" + assert not os.path.exists(os.path.join(received, "gone.txt")), \ + "nothing was transferred for the missing entry" + assert "--ignore-missing-args" in (result.stderr or result.stdout), \ + "the skipped entry must be observable (not a silent no-op)" + + @pytest.mark.parametrize("mt", [False, True]) + def test_all_missing_entries_succeed_transferring_nothing(self, shared_server, mt): + source = self._make_source("mg_all_missing_src") + dest = os.path.join(TEST_DATA_DIR, "mg_all_missing_dst") + clean_dir(dest) + lst = _write_rel_list(b"gone1.txt\ngone2.txt\n") + flags = ["--files-from", lst, "--ignore-missing-args"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=shared_server.port) + assert result.returncode == 0, \ + f"all-missing run should succeed (rsync parity): {result.stderr[:300]}" + received = get_dest_received_dir(dest, source) + assert not os.path.exists(os.path.join(received, "gone1.txt")) + + @pytest.mark.parametrize("mt", [False, True]) + def test_empty_list_stays_a_hard_error(self, shared_server, mt): + source = self._make_source("mg_empty_src") + dest = os.path.join(TEST_DATA_DIR, "mg_empty_dst") + clean_dir(dest) + lst = _write_rel_list(b"") + flags = ["--files-from", lst, "--ignore-missing-args"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=shared_server.port) + assert result.returncode != 0, "an empty --files-from list must stay a hard error" + assert "contains no entries" in (result.stderr or result.stdout) + + @pytest.mark.parametrize("mt", [False, True]) + def test_delete_missing_removes_mirror_not_unrelated(self, mt): + """-R layout: --delete-missing-args deletes exactly the missing entry's + destination mirror (bare relative path) and leaves unrelated extras + untouched; with --delete also present the unrelated extras go too.""" + source = self._make_source("mg_del_src") + dest = os.path.join(TEST_DATA_DIR, "mg_del_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + seed = _write_rel_list(b"a.txt\nsub/b.txt\n") + result, _ = run_client(source, dest, + flags=["--files-from", seed, "-R"] + (["-m"] if mt else []), + port=server.port) + assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}" + assert os.path.isfile(os.path.join(dest, "a.txt")) + assert os.path.isfile(os.path.join(dest, "sub", "b.txt")) + + # Plant the missing entry's destination mirror and an unrelated extra. + with open(os.path.join(dest, "gone.txt"), "w") as fh: + fh.write("stale mirror") + with open(os.path.join(dest, "unrelated.txt"), "w") as fh: + fh.write("unrelated") + + lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") + flags = ["--files-from", lst, "-R", "--delete-missing-args"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, f"delete-missing sync failed: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(dest, "gone.txt")), \ + "the missing entry's destination mirror was not deleted" + assert os.path.isfile(os.path.join(dest, "unrelated.txt")), \ + "--delete-missing-args removed an unrelated extra (only --delete may)" + assert os.path.isfile(os.path.join(dest, "a.txt")) + assert os.path.isfile(os.path.join(dest, "sub", "b.txt")) + + # Now with --delete the unrelated extra is an ordinary extra and must go. + lst2 = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") + flags2 = ["--files-from", lst2, "-R", "--delete-missing-args", "--delete"] + \ + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags2, port=server.port) + assert result.returncode == 0, f"delete-missing + delete sync failed: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(dest, "unrelated.txt")), \ + "--delete did not remove the unrelated extra" + assert not os.path.exists(os.path.join(dest, "gone.txt")) + assert os.path.isfile(os.path.join(dest, "a.txt")) + + @pytest.mark.parametrize("mt", [False, True]) + def test_delete_missing_mirror_outside_relative_layout(self, mt): + """Without -R the missing entry's mirror mirrors the full source path + below the destination root, exactly like a present sibling's.""" + source = self._make_source("mg_del_nor_src") + dest = os.path.join(TEST_DATA_DIR, "mg_del_nor_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + # Full-tree seed places every current source file in the mirrored layout. + result, _ = run_client(source, dest, flags=["--delete"], port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + assert os.path.isfile(os.path.join(received, "a.txt")) + + # Plant a stale mirror for an entry not (yet) on the source. + with open(os.path.join(received, "gone.txt"), "w") as fh: + fh.write("stale") + lst = _write_rel_list(b"a.txt\ngone.txt\n") + result, _ = run_client(source, dest, + flags=["--files-from", lst, "--delete-missing-args"], + port=server.port) + assert result.returncode == 0, f"delete-missing no-R sync failed: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(received, "gone.txt")), \ + "the full-source-mirror path of the missing entry was not deleted" + assert os.path.isfile(os.path.join(received, "a.txt")) + + @pytest.mark.parametrize("mt", [False, True]) + def test_delete_missing_args_not_blocked_by_exclude_protection(self, mt): + """A missing-arg mirror that sits under a filter-excluded directory is an + explicit user request, so --delete-missing-args removes it even though an + ordinary --delete honours the exclusion protection (rsync parity). Uses + the non-relative layout: exclusion protection is only recorded there.""" + source = self._make_source("mg_excl_src") + dest = os.path.join(TEST_DATA_DIR, "mg_excl_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + # Full-tree seed mirrors the whole source below the destination root. + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + assert os.path.isfile(os.path.join(received, "prot", "kept.txt")) + + # A stale mirror under the (now excluded) prot/ directory, plus an extra. + with open(os.path.join(received, "prot", "gone.txt"), "w") as fh: + fh.write("stale") + with open(os.path.join(received, "extra.txt"), "w") as fh: + fh.write("extra") + + lst = _write_rel_list(b"a.txt\nprot/gone.txt\n") + flags = ["--files-from", lst, "--filter=- prot/", "--delete-missing-args", + "--delete"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, f"delete-missing exclude sync failed: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(received, "prot", "gone.txt")), \ + "the explicit missing-arg deletion was blocked by exclusion protection" + assert os.path.isfile(os.path.join(received, "prot", "kept.txt")), \ + "the excluded-but-present destination file must stay (default protection)" + assert not os.path.exists(os.path.join(received, "extra.txt")), \ + "--delete did not remove the unrelated extra" + assert os.path.isfile(os.path.join(received, "a.txt")) + + @pytest.mark.parametrize("mt", [False, True]) + def test_delete_missing_args_with_delete_before(self, mt): + """--delete-before (early delete timing) composes with --delete-missing-args: + the exact-path deletions commit with the early manifest, before data, and + --delete-before implies --delete (so unrelated extras go too).""" + source = self._make_source("mg_early_src") + dest = os.path.join(TEST_DATA_DIR, "mg_early_dst") + clean_dir(dest) + with open(os.path.join(dest, "gone.txt"), "w") as fh: + fh.write("stale") + with open(os.path.join(dest, "extra.txt"), "w") as fh: + fh.write("extra") + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + lst = _write_rel_list(b"a.txt\ngone.txt\ngone2.txt\n") + flags = ["--files-from", lst, "-R", "--delete-missing-args", "--delete-before"] + \ + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, f"early delete-missing sync failed: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(dest, "gone.txt")), \ + "early timing did not remove the missing-arg mirror" + assert os.path.isfile(os.path.join(dest, "a.txt")), "a.txt was not transferred" + assert not os.path.exists(os.path.join(dest, "extra.txt")), \ + "--delete-before implies --delete: unrelated extras must go" + + class TestNoImpliedDirs: """--no-implied-dirs (only meaningful with -R + --files-from) refuses to place a listed file whose parent directory is not itself listed.""" diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 5882bae..d5d041d 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -1855,6 +1855,72 @@ static void test_parse_args_max_delete_inert_without_delete() { config_delete(cfg); } +/* --ignore-missing-args / --delete-missing-args parse onto their config fields. + * --delete-missing-args implies --ignore-missing-args (order-independent), + * does NOT imply --delete (rsync: independent of other delete processing), and + * the config stays valid in every combination. */ +static void test_parse_args_missing_args_flags() { + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + char* argv[] = {"fastsync", "--ignore-missing-args", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->ignore_missing_args); + EXPECT_FALSE(cfg->delete_missing_args); + EXPECT_FALSE(cfg->use_delete); + cfg->send_directory = str_dup("/src"); + cfg->receive_root_directory = str_dup("/dst"); + EXPECT_TRUE(validate_config(cfg)); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + char* argv2[] = {"fastsync", "--delete-missing-args", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->delete_missing_args); + EXPECT_TRUE(cfg->ignore_missing_args); + EXPECT_FALSE(cfg->use_delete); + cfg->send_directory = str_dup("/src"); + cfg->receive_root_directory = str_dup("/dst"); + EXPECT_TRUE(validate_config(cfg)); + config_delete(cfg); + + /* The implication is order-independent: even with the explicit flag first. */ + cfg = config_create(); + positional_count = 0; + char* argv3[] = {"fastsync", "--ignore-missing-args", "--delete-missing-args", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv3, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->ignore_missing_args); + EXPECT_TRUE(cfg->delete_missing_args); + config_delete(cfg); + + /* --delete-missing-args composes with --delete (both active) and with a + delete-timing flag (which implies --delete); timing stays valid. */ + cfg = config_create(); + positional_count = 0; + char* argv4[] = {"fastsync", "--delete", "--delete-missing-args", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->use_delete); + EXPECT_TRUE(cfg->delete_missing_args); + EXPECT_TRUE(cfg->ignore_missing_args); + cfg->send_directory = str_dup("/src"); + cfg->receive_root_directory = str_dup("/dst"); + EXPECT_TRUE(validate_config(cfg)); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + char* argv5[] = {"fastsync", "--delete-before", "--delete-missing-args", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv5, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->use_delete); + EXPECT_TRUE(cfg->delete_before); + EXPECT_TRUE(cfg->delete_missing_args); + cfg->send_directory = str_dup("/src"); + cfg->receive_root_directory = str_dup("/dst"); + EXPECT_TRUE(validate_config(cfg)); + config_delete(cfg); +} + void test_client_cli() { test_validate_config_required_paths(); test_validate_config_incompatible_options(); @@ -1956,4 +2022,5 @@ void test_client_cli() { test_parse_args_delete_policy_flags(); test_parse_args_delete_policy_invalid_values(); test_parse_args_max_delete_inert_without_delete(); + test_parse_args_missing_args_flags(); } diff --git a/tests/test_config.c b/tests/test_config.c index ced2318..542fe61 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -619,6 +619,57 @@ static void test_config_delete_policy_wire_roundtrip() { } } +/* --delete-missing-args crosses the wire (the receiver executes the exact-path + deletions) while --ignore-missing-args is client-only: the receiver must + observe delete_missing_args unchanged and ignore_missing_args always false. */ +static void test_config_delete_missing_args_wire_roundtrip() { + if (is_running_under_valgrind()) + return; + + struct { + bool delete_missing_args, ignore_missing_args; + } cases[] = { + {false, false}, + {true, false}, + {true, true}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + int p[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + Config* recv = config_receive(p[0]); + bool ok = recv != NULL; + if (ok) { + ok = recv->delete_missing_args == cases[i].delete_missing_args && + /* ignore_missing_args never crosses the wire. */ + recv->ignore_missing_args == false; + } + config_delete(recv); + close(p[0]); + _exit(ok ? 0 : 1); + } else { + close(p[0]); + io_set_fds(p[1], p[1]); + Config* send_cfg = config_create(); + EXPECT_NOT_NULL(send_cfg); + send_cfg->send_directory = str_dup("/src"); + send_cfg->receive_root_directory = str_dup("/dst"); + send_cfg->delete_missing_args = cases[i].delete_missing_args; + send_cfg->ignore_missing_args = cases[i].ignore_missing_args; + bool sent = config_send(p[1], send_cfg); + int status; + waitpid(pid, &status, 0); + close(p[1]); + config_delete(send_cfg); + EXPECT_TRUE(sent); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); + } + } +} + /* Basis-dir lists survive the config wire: each entry's type and path must round-trip unchanged. */ static void test_config_basis_roundtrip() { @@ -771,6 +822,7 @@ void test_config() { test_config_delete_timing_wire_roundtrip(); test_config_delete_timing_conflict_rejected(); test_config_delete_policy_wire_roundtrip(); + test_config_delete_missing_args_wire_roundtrip(); test_config_basis_roundtrip(); test_config_basis_wire_rejects_escaping(); test_config_basis_normalization(); diff --git a/tests/test_server.c b/tests/test_server.c index df0ab31..2db2647 100644 --- a/tests/test_server.c +++ b/tests/test_server.c @@ -231,7 +231,10 @@ static char* make_check_root(const char* tag) { } static void write_check_file(const char* dir, const char* name, const char* content) { - char path[1024]; + /* Sized so a caller that passes a PATH_MAX-bounded `dir` (e.g. one of the + test's own char[1024] stack buffers) still provably fits with the joined + name, keeping -Werror=format-truncation quiet. */ + char path[4096]; snprintf(path, sizeof(path), "%s/%s", dir, name); int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd >= 0) { @@ -493,6 +496,7 @@ static void test_late_manifest_abort_frees_keepset() { EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_str(p[1], "keep.txt")); EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ EXPECT_TRUE(send_status(p[1], STATUS_ABORT)); DeleteManifest* pending = NULL; @@ -516,6 +520,7 @@ static void test_late_manifest_eof_frees_keepset() { EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_str(p[1], "keep.txt")); EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ shutdown(p[1], SHUT_WR); DeleteManifest* pending = NULL; @@ -539,10 +544,12 @@ static void test_late_second_manifest_frees_both() { EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_str(p[1], "first.txt")); EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ EXPECT_TRUE(send_status(p[1], STATUS_MANIFEST)); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_str(p[1], "second.txt")); EXPECT_TRUE(send_int(p[1], 0)); /* protected-prefix section is empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* missing-args section is empty */ DeleteManifest* pending = NULL; EXPECT_EQ_INT(run_pending_receiver(cfg, p[0], &pending), -1); @@ -553,6 +560,165 @@ static void test_late_second_manifest_frees_both() { config_delete(cfg); } +/* A delete-manifest frame with a third (missing-args) section round-trips: the + receiver keeps all three sections and the missing paths are confined exactly + like the keep-set (a traversal entry in the missing section is rejected). + receive_manifest_entries() reads the counts directly (the leading + STATUS_MANIFEST code is consumed by the caller, so these frames do not send + it). */ +static void test_receive_manifest_three_sections() { + Config* cfg = config_create(); + EXPECT_NOT_NULL(cfg); + cfg->receive_root_directory = str_dup("/tmp/dst"); + int p[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + io_set_fds(p[0], p[1]); + + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_str(p[1], "keep.txt")); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_str(p[1], "protected.txt")); + EXPECT_TRUE(send_int(p[1], 2)); + EXPECT_TRUE(send_str(p[1], "gone.txt")); + EXPECT_TRUE(send_str(p[1], "dir/gone.bin")); + + DeleteManifest* manifest = receive_manifest_entries(p[0]); + EXPECT_NOT_NULL(manifest); + EXPECT_EQ_INT(manifest->keeps->size, 1); + EXPECT_EQ_STR((char*)manifest->keeps->items[0], "keep.txt"); + EXPECT_EQ_INT(manifest->protected->size, 1); + EXPECT_EQ_STR((char*)manifest->protected->items[0], "protected.txt"); + EXPECT_EQ_INT(manifest->missing->size, 2); + EXPECT_EQ_STR((char*)manifest->missing->items[0], "gone.txt"); + EXPECT_EQ_STR((char*)manifest->missing->items[1], "dir/gone.bin"); + delete_manifest_free(manifest); + + /* A traversal entry in the third section is rejected like every other. */ + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 0)); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_str(p[1], "../escape")); + EXPECT_NULL(receive_manifest_entries(p[0])); + Status status; + EXPECT_TRUE(receive_status(p[1], &status)); + EXPECT_EQ_INT(status, STATUS_ERROR); + + close(p[0]); + close(p[1]); + config_delete(cfg); +} + +/* --delete-missing-args exact-path deletions: regular files and empty + directories are removed, a non-empty directory survives without + --force/--delete and is recursively removed with --force or --delete, and a + missing mirror is a no-op. */ +static void test_manifest_delete_missing_args() { + char* root = make_check_root("qmissing"); + EXPECT_NOT_NULL(root); + write_check_file(root, "gone.txt", "stale"); + char empty_dir[1024], full_dir[1024], inner[1024]; + snprintf(empty_dir, sizeof(empty_dir), "%s/empty_dir", root); + snprintf(full_dir, sizeof(full_dir), "%s/full_dir", root); + snprintf(inner, sizeof(inner), "%s/full_dir/inner.txt", root); + EXPECT_EQ_INT(mkdir(empty_dir, 0755), 0); + EXPECT_EQ_INT(mkdir(full_dir, 0755), 0); + write_check_file(full_dir, "inner.txt", "content"); + + Config* cfg = config_create(); + EXPECT_NOT_NULL(cfg); + cfg->receive_root_directory = str_dup(root); + cfg->delete_missing_args = true; + + DeleteManifest* manifest = calloc(1, sizeof(DeleteManifest)); + EXPECT_NOT_NULL(manifest); + manifest->keeps = array_list_create(free); + manifest->protected = array_list_create(free); + manifest->missing = array_list_create(free); + EXPECT_TRUE(array_list_add(manifest->missing, str_dup("gone.txt"))); + EXPECT_TRUE(array_list_add(manifest->missing, str_dup("empty_dir"))); + EXPECT_TRUE(array_list_add(manifest->missing, str_dup("full_dir"))); + EXPECT_TRUE(array_list_add(manifest->missing, str_dup("never_here.txt"))); + + /* Without --delete/--force the non-empty directory survives (rsync parity). */ + EXPECT_TRUE(manifest_delete_missing_args(cfg, manifest)); + char path[1024]; + snprintf(path, sizeof(path), "%s/gone.txt", root); + EXPECT_EQ_INT(access(path, F_OK), -1); + snprintf(path, sizeof(path), "%s/empty_dir", root); + EXPECT_EQ_INT(access(path, F_OK), -1); + snprintf(path, sizeof(path), "%s/full_dir", root); + EXPECT_EQ_INT(access(path, F_OK), 0); + EXPECT_EQ_INT(access(inner, F_OK), 0); + + /* With --force the non-empty directory mirror is removed recursively. */ + cfg->force_delete = true; + EXPECT_TRUE(array_list_add(manifest->missing, str_dup("full_dir"))); + EXPECT_TRUE(manifest_delete_missing_args(cfg, manifest)); + EXPECT_EQ_INT(access(full_dir, F_OK), -1); + + delete_manifest_free(manifest); + config_delete(cfg); + remove(full_dir); + rmdir(empty_dir); + rmdir(root); + free(root); +} + +/* A delete-missing-args manifest parked by the commit path is committed after + STATUS_FINISHED: the mirror that exists is removed, a missing mirror is a + no-op, and unrelated destination content is untouched (no --delete). */ +static void test_receiver_pending_commits_missing_args() { + char* root = make_check_root("qmisscomm"); + EXPECT_NOT_NULL(root); + write_check_file(root, "gone.txt", "stale"); + write_check_file(root, "extra.txt", "unrelated"); + + Config* cfg = config_create(); + EXPECT_NOT_NULL(cfg); + cfg->send_directory = str_dup("/src"); + cfg->receive_root_directory = str_dup(root); + cfg->delete_missing_args = true; + + int p[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + io_set_fds(p[0], p[1]); + io_set_bwlimit(0); + + EXPECT_TRUE(send_status(p[1], STATUS_MANIFEST)); + EXPECT_TRUE(send_int(p[1], 0)); /* keep-set empty */ + EXPECT_TRUE(send_int(p[1], 0)); /* protected empty */ + EXPECT_TRUE(send_int(p[1], 2)); + EXPECT_TRUE(send_str(p[1], "gone.txt")); + EXPECT_TRUE(send_str(p[1], "never_here.txt")); + EXPECT_TRUE(send_status(p[1], STATUS_FINISHED)); + + /* NULL pending: the single-threaded commit path deletes at FINISHED. The + sink sends the terminal STATUS_OK success frame. */ + ReceiverSink sink = {.send_success = true}; + EXPECT_EQ_INT(receiver_process_pending(cfg, p[0], &sink, NULL), 0); + Status ack; + EXPECT_TRUE(receive_status(p[1], &ack)); + EXPECT_EQ_INT(ack, STATUS_OK); + + char path[1024]; + snprintf(path, sizeof(path), "%s/gone.txt", root); + EXPECT_EQ_INT(access(path, F_OK), -1); + snprintf(path, sizeof(path), "%s/extra.txt", root); + EXPECT_EQ_INT(access(path, F_OK), 0); + + close(p[0]); + close(p[1]); + config_delete(cfg); + { + /* remove fixtures */ + char pth[1024]; + snprintf(pth, sizeof(pth), "%s/extra.txt", root); + remove(pth); + rmdir(root); + } + free(root); +} + void test_server() { if (!is_running_under_valgrind()) { test_receive_files_finished(); @@ -566,5 +732,8 @@ void test_server() { test_late_manifest_abort_frees_keepset(); test_late_manifest_eof_frees_keepset(); test_late_second_manifest_frees_both(); + test_receive_manifest_three_sections(); + test_manifest_delete_missing_args(); + test_receiver_pending_commits_missing_args(); } }