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:
2026-09-07 14:34:30 +02:00
parent 97f2dc468b
commit be752b9bfd
4 changed files with 503 additions and 1 deletions
+214
View File
@@ -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."""
+67
View File
@@ -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();
}
+52
View File
@@ -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
View File
@@ -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();
}
}