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.
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+170
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user