diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index a42bf29..a444950 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -2107,6 +2107,345 @@ class TestDeleteTiming: assert os.path.exists(extra), "unauthorized delete removed an extra file" +def _seed_delete_tree(tag, entries, dest): + """Create a source tree and seed a full mirror at `dest`, returning + (source, received_mirror).""" + source = os.path.join(TEST_DATA_DIR, f"delpol_{tag}_src") + clean_dir(source) + for rel, content in entries.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) + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + return source, received + + +class TestDeletePolicy: + """Deletion-policy family: --delete-excluded, --max-delete, --force, + --ignore-errors and --prune-empty-dirs.""" + + def _write(self, path, content): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(content) + + @pytest.mark.parametrize("mt", [False, True]) + @pytest.mark.parametrize("timing", ["--delete", "--delete-before"]) + def test_delete_protects_excluded_by_default_and_delete_excluded_removes(self, mt, timing): + """rsync parity: with --delete a destination mirror path whose source was + excluded survives (protected by default); --delete-excluded opts back + into deleting it. Verified single-threaded, -m, and an early timing + run (--delete-before) where the manifest arrives before any data.""" + source = os.path.join(TEST_DATA_DIR, f"delexcl_{timing.strip('-')}_{mt}_src") + clean_dir(source) + entries = { + "keep.txt": b"kept\n", + "secret.log": b"secret\n", + "sub/nested.log": b"nested secret\n", + } + for rel, content in entries.items(): + self._write(os.path.join(source, rel), content) + dest = os.path.join(TEST_DATA_DIR, f"delexcl_{timing.strip('-')}_{mt}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + self._write(os.path.join(received, "extra.txt"), b"extra\n") + + # Default: the excluded mirrors survive --delete, genuine extras die. + flags = ["--exclude", "*.log", timing] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"default delete sync failed: {(result.stderr or result.stdout)[:300]}" + assert os.path.exists(os.path.join(received, "secret.log")), \ + "excluded dest file was deleted under plain --delete (rsync protects it)" + assert os.path.exists(os.path.join(received, "sub", "nested.log")), \ + "nested excluded dest file was deleted under plain --delete" + assert not os.path.exists(os.path.join(received, "extra.txt")), \ + "genuine extra was not deleted" + + # --delete-excluded: excluded mirrors are extras again and die. + self._write(os.path.join(received, "extra.txt"), b"extra\n") + flags = ["--exclude", "*.log", timing, "--delete-excluded"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}" + assert not os.path.exists(os.path.join(received, "secret.log")), \ + "--delete-excluded did not remove the excluded dest file" + assert not os.path.exists(os.path.join(received, "sub", "nested.log")), \ + "--delete-excluded did not remove the nested excluded dest file" + assert not os.path.exists(os.path.join(received, "extra.txt")), \ + "genuine extra survived --delete-excluded" + assert _read_file(os.path.join(received, "keep.txt")) == b"kept\n" + + @pytest.mark.parametrize("mt", [False, True]) + def test_delete_excluded_excluded_directory_subtree(self, mt): + """A whole source directory excluded by a filter rule protects its whole + destination mirror by default; --delete-excluded removes the subtree.""" + source = os.path.join(TEST_DATA_DIR, f"delexcldir_{mt}_src") + clean_dir(source) + self._write(os.path.join(source, "keep.txt"), b"kept\n") + self._write(os.path.join(source, "skipdir", "a.log"), b"a\n") + self._write(os.path.join(source, "skipdir", "deep", "b.log"), b"b\n") + dest = os.path.join(TEST_DATA_DIR, f"delexcldir_{mt}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + + flags = ["--filter=- skipdir/", "--delete"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"default delete sync failed: {(result.stderr or result.stdout)[:300]}" + assert os.path.exists(os.path.join(received, "skipdir", "a.log")), \ + "excluded dir subtree was deleted under plain --delete" + assert os.path.exists(os.path.join(received, "skipdir", "deep", "b.log")), \ + "nested excluded dir content was deleted under plain --delete" + + flags = ["--filter=- skipdir/", "--delete", "--delete-excluded"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}" + assert not os.path.exists(os.path.join(received, "skipdir")), \ + "--delete-excluded did not remove the excluded dir subtree" + + @pytest.mark.parametrize("mt", [False, True]) + @pytest.mark.parametrize("timing", ["--delete", "--delete-before"]) + def test_max_delete_exceeded_fails_without_deleting(self, mt, timing): + """A run that would exceed --max-delete deletes nothing and fails.""" + source = os.path.join(TEST_DATA_DIR, f"maxdel_{timing.strip('-')}_{mt}_src") + clean_dir(source) + self._write(os.path.join(source, "keep.txt"), b"kept\n") + dest = os.path.join(TEST_DATA_DIR, f"maxdel_{timing.strip('-')}_{mt}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + extras = [] + for i in range(4): + name = f"e{i}.txt" + self._write(os.path.join(received, name), b"extra\n") + extras.append(os.path.join(received, name)) + + flags = ["--max-delete=2", timing] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode != 0, \ + f"--max-delete=2 with 4 extras unexpectedly succeeded: {result.stderr[:300]}" + for path in extras: + assert os.path.exists(path), \ + "--max-delete overrun deleted files (must be all-or-nothing)" + + @pytest.mark.parametrize("mt", [False, True]) + def test_max_delete_not_exceeded_deletes_exactly(self, mt): + """When the extras are at or below --max-delete the run succeeds and + removes exactly the extras.""" + source = os.path.join(TEST_DATA_DIR, f"maxdelok_{mt}_src") + clean_dir(source) + self._write(os.path.join(source, "keep.txt"), b"kept\n") + dest = os.path.join(TEST_DATA_DIR, f"maxdelok_{mt}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + for i in range(3): + self._write(os.path.join(received, f"e{i}.txt"), b"extra\n") + flags = ["--max-delete=3", "--delete"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"--max-delete=3 with 3 extras failed: {(result.stderr or result.stdout)[:300]}" + for i in range(3): + assert not os.path.exists(os.path.join(received, f"e{i}.txt")), \ + f"extra e{i}.txt not deleted under --max-delete=3" + + @pytest.mark.parametrize("mt", [False, True]) + def test_force_replaces_nonempty_dir_with_file(self, mt): + """--force lets an incoming regular file replace a non-empty destination + directory; without it the write (and the run) fails.""" + source = os.path.join(TEST_DATA_DIR, f"force_{mt}_src") + clean_dir(source) + self._write(os.path.join(source, "sub", "old.txt"), b"old\n") + self._write(os.path.join(source, "keep.txt"), b"kept\n") + dest = os.path.join(TEST_DATA_DIR, f"force_{mt}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + + # The source path `sub` becomes a regular file (the dir is gone). + os.unlink(os.path.join(source, "sub", "old.txt")) + os.rmdir(os.path.join(source, "sub")) + self._write(os.path.join(source, "sub"), b"now a file\n") + + result, _ = run_client(source, dest, port=server.port) + assert result.returncode != 0, \ + "a file over a non-empty directory must fail without --force" + assert os.path.isdir(os.path.join(received, "sub")), \ + "directory was destroyed although the run failed without --force" + assert os.path.exists(os.path.join(received, "sub", "old.txt")), \ + "non-empty dir content was lost although the run failed without --force" + + flags = ["--force"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"--force run failed: {(result.stderr or result.stdout)[:300]}" + assert os.path.isfile(os.path.join(received, "sub")), \ + "--force did not replace the directory with the file" + assert _read_file(os.path.join(received, "sub")) == b"now a file\n" + assert not os.path.exists(os.path.join(received, "sub", "old.txt")), \ + "--force left the old directory content behind" + + @pytest.mark.parametrize("mt", [False, True]) + def test_prune_empty_dirs_dirs_mode(self, mt): + """--prune-empty-dirs omits an empty source directory's explicit entry in + --dirs mode (nothing is created, and an existing empty mirror is removed + by --delete). Recursive transfers never emit empty dirs, so the flag is + a no-op there (documented rsync -m parity).""" + source = os.path.join(TEST_DATA_DIR, f"prune_{mt}_src") + clean_dir(source) + os.makedirs(source, exist_ok=True) # physically empty source dir + + dest = os.path.join(TEST_DATA_DIR, f"prune_{mt}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, flags=["--dirs"], port=server.port) + assert result.returncode == 0, f"-d seed failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + assert os.path.isdir(received), "-d should create the empty mirror dir" + assert os.listdir(received) == [] + + # prune-empty-dirs: the empty mirror is pruned by --delete. + flags = ["--dirs", "--prune-empty-dirs", "--delete"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"--dirs --prune-empty-dirs --delete failed: {(result.stderr or result.stdout)[:300]}" + assert not os.path.exists(received), \ + "--prune-empty-dirs did not prune the empty dir (--delete left it)" + + # A fresh destination: prune-empty-dirs means the empty dir is never sent. + dest2 = os.path.join(TEST_DATA_DIR, f"prune2_{mt}_dst") + clean_dir(dest2) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + flags = ["--dirs", "--prune-empty-dirs", "-i"] + (["-m"] if mt else []) + result, _ = run_client(source, dest2, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"--dirs --prune-empty-dirs failed: {(result.stderr or result.stdout)[:300]}" + received2 = get_dest_received_dir(dest2, source) + assert not os.path.exists(received2), \ + "--prune-empty-dirs transferred the empty directory" + assert result.stdout == "", \ + f"--prune-empty-dirs leaked an itemize line: {result.stdout[:200]}" + + @pytest.mark.parametrize("mt", [False, True]) + def test_prune_empty_dirs_recursion_inherent(self, mt): + """In recursive mode FastSync never transfers empty directories (rsync + -m parity): a truly-empty destination directory chain is removed by + --delete whether or not --prune-empty-dirs is given (the flag has no + additional effect there), while directories holding kept files survive. + A filter-excluded file's mirror is protected, so a directory that still + holds one is left intact (rsync default delete-excluded semantics).""" + source = os.path.join(TEST_DATA_DIR, f"prunerec_{mt}_src") + clean_dir(source) + self._write(os.path.join(source, "keep.txt"), b"kept\n") + self._write(os.path.join(source, "a", "keep.log"), b"a log\n") + self._write(os.path.join(source, "b", "deep", "kept.txt"), b"deep kept\n") + dest = os.path.join(TEST_DATA_DIR, f"prunerec_{mt}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + # A stray empty chain (FastSync recursion never creates such dirs, so + # this models one left by an external tool / an earlier --dirs run). + os.makedirs(os.path.join(received, "empty", "chain")) + + for prune in ([], ["--prune-empty-dirs"]): + flags = prune + ["--delete"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}" + assert not os.path.exists(os.path.join(received, "empty")), \ + "truly-empty dir chain was not removed by --delete" + assert os.path.exists(os.path.join(received, "b", "deep", "kept.txt")), \ + "non-empty dir subtree was wrongly removed" + assert _read_file(os.path.join(received, "keep.txt")) == b"kept\n" + + # An excluded file's mirror is protected: the dir that holds it stays. + flags = ["--exclude", "*.log", "--delete", "--prune-empty-dirs"] + (["-m"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, \ + f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}" + assert os.path.exists(os.path.join(received, "a", "keep.log")), \ + "excluded file mirror was deleted under --delete (rsync protects it)" + + def test_ignore_errors_keeps_deletion_active_on_scan_error(self): + """A source I/O error (unreadable directory) aborts the run so no + deletion happens by default; --ignore-errors continues, still transfers + the readable tree and still deletes. Run as an unprivileged user so the + mode-000 directory is genuinely unreadable.""" + if os.geteuid() != 0 or shutil.which("setpriv") is None: + pytest.skip("requires root + setpriv to drop privileges for the client") + tag = f"ioerr_{os.getpid()}" + source = os.path.join(TEST_DATA_DIR, f"{tag}_src") + clean_dir(source) + self._write(os.path.join(source, "top.txt"), b"top\n") + self._write(os.path.join(source, "ok", "inside.txt"), b"inside\n") + self._write(os.path.join(source, "locked", "blocked.txt"), b"blocked\n") + dest = os.path.join(TEST_DATA_DIR, f"{tag}_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + # Seed as root (server is root too). + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + try: + os.chmod(os.path.join(source, "locked"), 0) + + # Default: scan error aborts the run; nothing is deleted. + self._write(os.path.join(received, "extra.txt"), b"extra\n") + cmd = CLIENT_CMD + ["--source-dir", source, "--dest-dir", dest, + "--save-to-disk", "--server-port", str(server.port), + "--delete"] + result = subprocess.run(["setpriv", "--reuid=65534", "--regid=65534", + "--clear-groups"] + cmd, text=True, capture_output=True) + assert result.returncode != 0, "unreadable source dir did not fail the run" + assert os.path.exists(os.path.join(received, "extra.txt")), \ + "default run deleted although the scan hit an I/O error" + + # --ignore-errors: the readable tree transfers, deletion still runs. + self._write(os.path.join(received, "extra.txt"), b"extra\n") + cmd = CLIENT_CMD + ["--source-dir", source, "--dest-dir", dest, + "--save-to-disk", "--server-port", str(server.port), + "--delete", "--ignore-errors"] + result = subprocess.run(["setpriv", "--reuid=65534", "--regid=65534", + "--clear-groups"] + cmd, text=True, capture_output=True) + assert not os.path.exists(os.path.join(received, "extra.txt")), \ + f"--ignore-errors did not keep deletion active: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(received, "locked")), \ + "mirror of the unreadable dir was not treated as an extra" + finally: + os.chmod(os.path.join(source, "locked"), 0o755) + + def _pin_mtime(path, ts): os.utime(path, (ts, ts)) diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 3d21fc6..1252463 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -1648,6 +1648,73 @@ static void test_parse_args_files_from() { remove(list_path); } +/* The deletion-policy family parses onto the config fields: --delete-excluded, + * --ignore-errors and --force are flags, --max-delete takes a non-negative + * number, and --prune-empty-dirs is the long-only spelling (FastSync's -m stays + * multithreading). None of them implies --delete by itself. */ +static void test_parse_args_delete_policy_flags() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", + "--delete", + "--delete-excluded", + "--max-delete=5", + "--ignore-errors", + "--force", + "--prune-empty-dirs", + "/src", + "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 9, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->use_delete); + EXPECT_TRUE(cfg->delete_excluded); + EXPECT_EQ_INT(cfg->max_delete, 5); + EXPECT_TRUE(cfg->ignore_errors); + EXPECT_TRUE(cfg->force_delete); + EXPECT_TRUE(cfg->prune_empty_dirs); + EXPECT_FALSE(cfg->delete_before); + config_delete(cfg); + + /* --max-delete accepts the separated-argument and zero forms. */ + cfg = config_create(); + char* argv2[] = {"fastsync", "--max-delete", "0", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->max_delete, 0); + config_delete(cfg); +} + +static void test_parse_args_delete_policy_invalid_values() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--max-delete=abc", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1); + config_delete(cfg); + + cfg = config_create(); + char* argv2[] = {"fastsync", "--max-delete=-3", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), -1); + config_delete(cfg); +} + +/* --max-delete without --delete is inert (it only bounds a --delete run); the + * config stays valid. */ +static void test_parse_args_max_delete_inert_without_delete() { + Config* cfg = config_create(); + cfg->send_directory = str_dup("/src"); + cfg->receive_root_directory = str_dup("/dst"); + char* argv[] = {"fastsync", "--max-delete=5", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_FALSE(cfg->use_delete); + EXPECT_EQ_INT(cfg->max_delete, 5); + EXPECT_TRUE(validate_config(cfg)); + config_delete(cfg); +} + void test_client_cli() { test_validate_config_required_paths(); test_validate_config_incompatible_options(); @@ -1740,4 +1807,7 @@ void test_client_cli() { test_parse_args_basis_dirs(); test_parse_args_basis_invalid_paths(); test_validate_config_basis_rejects_chunk_serialization(); + test_parse_args_delete_policy_flags(); + test_parse_args_delete_policy_invalid_values(); + test_parse_args_max_delete_inert_without_delete(); } diff --git a/tests/test_config.c b/tests/test_config.c index b0c8ceb..5003366 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -561,6 +561,61 @@ static void test_config_delete_timing_conflict_rejected() { config_delete(c); } +/* The deletion-policy fields that cross the wire survive a config round trip: + --force (force_delete), --delete-excluded, --prune-empty-dirs and the + --max-delete number (default -1 == no client limit). */ +static void test_config_delete_policy_wire_roundtrip() { + if (is_running_under_valgrind()) + return; + + struct { + bool force_delete, delete_excluded, prune_empty_dirs; + int max_delete; + } cases[] = { + {false, false, false, -1}, + {true, false, false, 0}, + {false, true, true, 7}, + }; + 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->force_delete == cases[i].force_delete && + recv->delete_excluded == cases[i].delete_excluded && + recv->prune_empty_dirs == cases[i].prune_empty_dirs && + recv->max_delete == cases[i].max_delete; + } + 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->force_delete = cases[i].force_delete; + send_cfg->delete_excluded = cases[i].delete_excluded; + send_cfg->prune_empty_dirs = cases[i].prune_empty_dirs; + send_cfg->max_delete = cases[i].max_delete; + 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() { @@ -712,6 +767,7 @@ void test_config() { test_config_delay_updates_reserved_backup_rejected(); test_config_delete_timing_wire_roundtrip(); test_config_delete_timing_conflict_rejected(); + test_config_delete_policy_wire_roundtrip(); test_config_basis_roundtrip(); test_config_basis_wire_rejects_escaping(); test_config_basis_normalization(); diff --git a/tests/test_scanner.c b/tests/test_scanner.c index 87fb497..9c390cc 100644 --- a/tests/test_scanner.c +++ b/tests/test_scanner.c @@ -397,9 +397,8 @@ static void test_parallel_scanner_root_chunks_without_workers() { create_test_file(file1, "a"); create_test_file(file2, "b"); - ScannerOptions options = {false, 1, NULL, 0, NULL, 0, 0, - 0, 0, 0, false, false, false, false, - false, false, NULL, NULL, false, false, false}; + ScannerOptions options = {0}; + options.chunk_size = 1; ParallelScanner* scanner = parallel_scanner_create_with_options(dir, &options, NULL); EXPECT_NOT_NULL(scanner); diff --git a/tests/test_server.c b/tests/test_server.c index 8fca4e5..df0ab31 100644 --- a/tests/test_server.c +++ b/tests/test_server.c @@ -476,7 +476,7 @@ static Config* make_late_delete_config(const char* root) { return cfg; } -static int run_pending_receiver(Config* cfg, int fd, ArrayList** pending) { +static int run_pending_receiver(Config* cfg, int fd, DeleteManifest** pending) { ReceiverSink sink = {0}; return receiver_process_pending(cfg, fd, &sink, pending); } @@ -492,9 +492,10 @@ static void test_late_manifest_abort_frees_keepset() { EXPECT_TRUE(send_status(p[1], STATUS_MANIFEST)); 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_status(p[1], STATUS_ABORT)); - ArrayList* pending = NULL; + DeleteManifest* pending = NULL; EXPECT_EQ_INT(run_pending_receiver(cfg, p[0], &pending), -1); EXPECT_NULL(pending); @@ -514,9 +515,10 @@ static void test_late_manifest_eof_frees_keepset() { EXPECT_TRUE(send_status(p[1], STATUS_MANIFEST)); 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 */ shutdown(p[1], SHUT_WR); - ArrayList* pending = NULL; + DeleteManifest* pending = NULL; EXPECT_EQ_INT(run_pending_receiver(cfg, p[0], &pending), -1); EXPECT_NULL(pending); @@ -536,11 +538,13 @@ static void test_late_second_manifest_frees_both() { EXPECT_TRUE(send_status(p[1], STATUS_MANIFEST)); 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_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 */ - ArrayList* pending = NULL; + DeleteManifest* pending = NULL; EXPECT_EQ_INT(run_pending_receiver(cfg, p[0], &pending), -1); EXPECT_NULL(pending); diff --git a/tests/test_shared_utils.c b/tests/test_shared_utils.c index b4c2d84..a7bdd40 100644 --- a/tests/test_shared_utils.c +++ b/tests/test_shared_utils.c @@ -2,9 +2,204 @@ #include "utils.h" #include "protocol.h" #include "test_utils.h" +#include +#include +#include +#include #include #include +#include #include +#include + +/* ---- delete-walker tests ---- */ + +static char* make_walk_root(const char* tag) { + char* path = malloc(256); + if (!path) + return NULL; + snprintf(path, 256, "/tmp/fastsync_walk_%s_%d", tag, (int)getpid()); + rmdir(path); + if (mkdir(path, 0755) != 0) { + free(path); + return NULL; + } + return path; +} + +static bool write_file_at(const char* dir, const char* name, const char* content) { + char* path = path_cat(dir, name); + if (!path) + return false; + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + bool ok = fd >= 0; + if (fd >= 0) { + if (content) { + const char* p = content; + size_t remaining = strlen(content); + while (remaining > 0) { + ssize_t n = write(fd, p, remaining); + if (n <= 0) { + ok = false; + break; + } + p += n; + remaining -= (size_t)n; + } + } + close(fd); + } + free(path); + return ok; +} + +static bool file_exists(const char* dir, const char* name) { + char* path = path_cat(dir, name); + bool exists = path && access(path, F_OK) == 0; + free(path); + return exists; +} + +static bool dir_exists(const char* dir, const char* name) { + char* path = path_cat(dir, name); + struct stat st; + bool exists = path && stat(path, &st) == 0 && S_ISDIR(st.st_mode); + free(path); + return exists; +} + +static int make_subdir(const char* root, const char* name) { + char* path = path_cat(root, name); + int rc = -1; + if (path) { + rc = mkdir(path, 0755); + free(path); + } + return rc; +} + +static void remove_walk_tree(const char* path) { + DIR* dir = opendir(path); + if (!dir) { + rmdir(path); + return; + } + const struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) + continue; + char* child = path_cat(path, entry->d_name); + if (child) { + struct stat st; + if (lstat(child, &st) == 0 && S_ISDIR(st.st_mode)) + remove_walk_tree(child); + else + unlink(child); + free(child); + } + } + closedir(dir); + rmdir(path); +} + +static ArrayList* make_manifest_strings(const char* const* entries, int count) { + ArrayList* manifest = array_list_create(free); + if (!manifest) + return NULL; + for (int i = 0; i < count; i++) { + char* dup = str_dup(entries[i]); + if (!dup || !array_list_add(manifest, dup)) { + free(dup); + array_list_delete(manifest); + return NULL; + } + } + return manifest; +} + +static void test_walker_removes_extras_keeps_manifest_and_protected() { + char* root = make_walk_root("basic"); + EXPECT_NOT_NULL(root); + EXPECT_TRUE(write_file_at(root, "a.txt", "extra")); + EXPECT_TRUE(write_file_at(root, "keep.txt", "kept")); + EXPECT_EQ_INT(make_subdir(root, "d"), 0); + EXPECT_TRUE(write_file_at(root, "d/e.txt", "extra")); + EXPECT_TRUE(write_file_at(root, "d/k.txt", "kept")); + EXPECT_EQ_INT(make_subdir(root, "prot"), 0); + EXPECT_TRUE(write_file_at(root, "prot/f.txt", "untouched")); + + const char* keeps[] = {"keep.txt", "d/k.txt"}; + ArrayList* manifest = make_manifest_strings(keeps, 2); + EXPECT_NOT_NULL(manifest); + DeleteSkipEntry skip = {"prot", false}; + size_t deleted = 0; + DeleteWalkResult result = delete_extras_limited(root, manifest, 100000, &skip, 1, &deleted); + EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK); + EXPECT_FALSE(file_exists(root, "a.txt")); + EXPECT_TRUE(file_exists(root, "keep.txt")); + EXPECT_FALSE(file_exists(root, "d/e.txt")); + EXPECT_TRUE(file_exists(root, "d/k.txt")); + EXPECT_TRUE(dir_exists(root, "d")); + EXPECT_TRUE(file_exists(root, "prot/f.txt")); + EXPECT_TRUE(deleted >= 2); + array_list_delete(manifest); + remove_walk_tree(root); +} + +static void test_walker_max_delete_exceeded_deletes_nothing() { + char* root = make_walk_root("maxdel"); + EXPECT_NOT_NULL(root); + EXPECT_TRUE(write_file_at(root, "a.txt", "extra")); + EXPECT_TRUE(write_file_at(root, "b.txt", "extra")); + EXPECT_TRUE(write_file_at(root, "c.txt", "extra")); + const char* keeps[1] = {NULL}; + ArrayList* manifest = make_manifest_strings(keeps, 0); + EXPECT_NOT_NULL(manifest); + size_t deleted = 999; + DeleteWalkResult result = delete_extras_limited(root, manifest, 2, NULL, 0, &deleted); + EXPECT_EQ_INT((int)result, (int)DELETE_WALK_LIMIT_EXCEEDED); + EXPECT_EQ_INT((int)deleted, 0); + EXPECT_TRUE(file_exists(root, "a.txt")); + EXPECT_TRUE(file_exists(root, "b.txt")); + EXPECT_TRUE(file_exists(root, "c.txt")); + array_list_delete(manifest); + remove_walk_tree(root); +} + +static void test_walker_max_delete_exact_bound_deletes() { + char* root = make_walk_root("maxdel2"); + EXPECT_NOT_NULL(root); + EXPECT_TRUE(write_file_at(root, "a.txt", "extra")); + EXPECT_TRUE(write_file_at(root, "b.txt", "extra")); + const char* keeps[1] = {NULL}; + ArrayList* manifest = make_manifest_strings(keeps, 0); + EXPECT_NOT_NULL(manifest); + size_t deleted = 0; + DeleteWalkResult result = delete_extras_limited(root, manifest, 2, NULL, 0, &deleted); + EXPECT_EQ_INT((int)result, (int)DELETE_WALK_OK); + EXPECT_EQ_INT((int)deleted, 2); + EXPECT_FALSE(file_exists(root, "a.txt")); + EXPECT_FALSE(file_exists(root, "b.txt")); + array_list_delete(manifest); + remove_walk_tree(root); +} + +static void test_walker_unlimited_deletes_all() { + char* root = make_walk_root("unlim"); + EXPECT_NOT_NULL(root); + EXPECT_TRUE(write_file_at(root, "a.txt", "extra")); + EXPECT_TRUE(write_file_at(root, "b.txt", "extra")); + EXPECT_EQ_INT(make_subdir(root, "emptydir"), 0); + const char* keeps[1] = {NULL}; + ArrayList* manifest = make_manifest_strings(keeps, 0); + EXPECT_NOT_NULL(manifest); + EXPECT_TRUE(delete_extras(root, manifest)); + EXPECT_FALSE(file_exists(root, "a.txt")); + EXPECT_FALSE(file_exists(root, "b.txt")); + EXPECT_FALSE(dir_exists(root, "emptydir")); + array_list_delete(manifest); + remove_walk_tree(root); +} typedef struct { bool eight_bit_output; @@ -24,6 +219,11 @@ static int escape_thread(void* arg) { } void test_shared_utils() { + test_walker_removes_extras_keeps_manifest_and_protected(); + test_walker_max_delete_exceeded_deletes_nothing(); + test_walker_max_delete_exact_bound_deletes(); + test_walker_unlimited_deletes_all(); + char formatted[32]; EXPECT_TRUE(format_human_bytes(0, formatted, sizeof(formatted))); EXPECT_EQ_STR(formatted, "0 B");