diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 422c21d..6474c6b 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -550,6 +550,147 @@ class TestIncremental: assert not mismatches, f"Mismatch: {mismatches}" +class TestChecksumChoice: + """--checksum-choice/--cc and --checksum-seed: the whole-file digest used by + the --incremental/--checksum handshake is selectable and seedable. The + receiver hashes the on-disk old file with the SAME algorithm+seed, so an + unchanged file is skipped and a changed file (even with identical size and + mtime) is transferred -- and the transfer always lands byte-exact. + FastSync accepts xxh64 (default, seed-aware) and md5; names it does not + implement are rejected, never silently ignored.""" + + def test_unsupported_algorithm_is_rejected(self, shared_server): + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["--checksum", "--checksum-choice=sha256"], + port=shared_server.port, + ) + assert result.returncode != 0, "sha256 must be rejected, not silently ignored" + + @pytest.mark.parametrize("algo", ["xxh64", "md5"]) + @pytest.mark.parametrize("mt", [False, True]) + def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + + flags = (["-M", "--incremental", "--checksum", f"--checksum-choice={algo}"] + + (["-m"] if mt else [])) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert result.returncode == 0, f"checksum {algo} run failed: {result.stderr[:200]}" + + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + mismatches, missing = verify_transfer(SOURCE_DIR, received) + assert not missing, f"Missing: {missing}" + assert not mismatches, f"Mismatch: {mismatches}" + + # A changed source file with the SAME size and mtime must still be + # detected (and re-transferred byte-exactly) because the whole-file digest + # differs -- the explicit reason --checksum exists. This exercises the + # sender/receiver digest agreement for a non-default algorithm. + @pytest.mark.parametrize("algo", ["xxh64", "md5"]) + @pytest.mark.parametrize("mt", [False, True]) + def test_changed_same_size_mtime_redetected(self, shared_server, algo, mt): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) + assert result.returncode == 0 + + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + source_file = os.path.join(SOURCE_DIR, "small.txt") # "hello world\n" (12 bytes) + received_file = os.path.join(received, "small.txt") + source_stat = os.stat(source_file) + with open(received_file, "wb") as f: + f.write(b"DDDDDDDDDDDD") # same size, different content + os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) + + flags = (["-M", "--incremental", "--checksum", f"--checksum-choice={algo}"] + + (["-m"] if mt else [])) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert result.returncode == 0, f"checksum {algo} redetect failed: {result.stderr[:200]}" + with open(received_file, "rb") as f: + assert f.read() == b"hello world\n" + + @pytest.mark.parametrize("algo", ["xxh64", "md5"]) + def test_unchanged_run_transfers_almost_no_data(self, shared_server, algo): + # A fully-unchanged --checksum run skips every file: only the config + a + # small handshake travels, not the payloads. Proxy byte counts are not + # available for -m (multithreaded connections), so single-thread only. + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) + assert result.returncode == 0 + + flags = ["-M", "--incremental", "--checksum", f"--checksum-choice={algo}"] + proxy = CountingProxy(shared_server.port) + cmd = (CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", DEST_DIR, + "--save-to-disk", "--server-port", str(proxy.port)] + flags) + result = proxy.run(cmd) + assert result.returncode == 0, f"checksum {algo} skip run failed: {result.stderr[:200]}" + assert proxy.client_to_server < 100000, \ + f"unchanged --checksum run sent {proxy.client_to_server} bytes; expected a skip" + + @pytest.mark.parametrize("mt", [False, True]) + def test_seed_is_deterministic_and_preserves_content(self, shared_server, mt): + clean_dir(DEST_DIR) + flags = ["-M", "--incremental", "--checksum", + "--checksum-choice=xxh64", "--checksum-seed=987654"] + (["-m"] if mt else []) + first, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert first.returncode == 0, f"seeded run failed: {first.stderr[:200]}" + + # A second run with the SAME seed and unchanged content skips everything + # deterministically (same digests both sides). + second, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert second.returncode == 0, f"deterministic rerun failed: {second.stderr[:200]}" + + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + mismatches, missing = verify_transfer(SOURCE_DIR, received) + assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}" + + # A changed file with the same size and mtime is still caught and fixed + # (a non-zero seed does not weaken the comparison). + source_file = os.path.join(SOURCE_DIR, "medium.txt") + received_file = os.path.join(received, "medium.txt") + source_stat = os.stat(source_file) + with open(received_file, "wb") as f: + f.write(b"z" * os.path.getsize(source_file)) + os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) + third, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert third.returncode == 0, f"seeded redetect failed: {third.stderr[:200]}" + with open(received_file, "rb") as f: + assert f.read() == open(source_file, "rb").read() + + # --checksum-seed also feeds the delta path's per-block strong checksum on + # both ends (receiver signature and sender window hash use the same seed), + # so a seeded delta transfer still lands byte-exact. + @pytest.mark.parametrize("mt", [False, True]) + def test_seed_delta_block_hash_transfers_byte_exact(self, shared_server, mt): + source = os.path.join(TEST_DATA_DIR, f"ccseed_{'m' if mt else 's'}_src") + dest = os.path.join(TEST_DATA_DIR, f"ccseed_{'m' if mt else 's'}_dst") + clean_dir(source) + clean_dir(dest) + big = os.path.join(source, "big.bin") + with open(big, "wb") as f: + f.write(bytes(range(256)) * 200) # 51200 bytes > delta 16K floor + result, _ = run_client(source, dest, flags=["-M"], port=shared_server.port) + assert result.returncode == 0, f"seed delta seed failed: {result.stderr[:200]}" + + # Edit a region so the receiver must match a changed block with the seed. + with open(big, "r+b") as f: + f.seek(1000) + f.write(b"\x00" * 64) + # Force an mtime mismatch: the incremental quick-check skips files whose + # stored mtime second equals the source's, which can collide when the + # edit and the prior sync share a second. Setting an old dest mtime + # guarantees the delta path is exercised deterministically. + os.utime(os.path.join(get_dest_received_dir(dest, source), "big.bin"), (0, 0)) + flags = (["-M", "--incremental", "--delta", "--checksum-seed=314159"] + + (["-m"] if mt else [])) + result, _ = run_client(source, dest, flags=flags, port=shared_server.port) + assert result.returncode == 0, f"seed delta run failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + assert _read_file(os.path.join(received, "big.bin")) == _read_file(big), \ + "seeded delta transfer is not byte-exact" + + class TestUpdate: def test_update_skips_older_destination_and_allows_equal_or_newer_source(self, shared_server): clean_dir(DEST_DIR) diff --git a/tests/runner.c b/tests/runner.c index c74c6de..7232426 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -1,6 +1,7 @@ #include "test_array_list.h" #include "test_chunk.h" #include "test_change_list.h" +#include "test_checksum.h" #include "test_client_cli.h" #include "test_compression.h" #include "test_config.h" @@ -46,6 +47,7 @@ int main() { RUN_TEST(test_config); RUN_TEST(test_compression); RUN_TEST(test_scanner); + RUN_TEST(test_checksum); RUN_TEST(test_delta); RUN_TEST(test_data); RUN_TEST(test_protocol); diff --git a/tests/test_checksum.c b/tests/test_checksum.c new file mode 100644 index 0000000..05a0a95 --- /dev/null +++ b/tests/test_checksum.c @@ -0,0 +1,146 @@ +#include "test_checksum.h" +#include "checksum.h" +#include "test_utils.h" +#include + +/* Known xxHash64 vector (seed 0) for the empty string and a literal. + * The md5 vectors are the standard NIST/RFC1321 test strings. These pin the + * digest selection to genuinely distinct algorithm outputs so a --checksum- + * choice change is observable, not a silent no-op. */ + +static void test_checksum_xxh64_seed0() { + uint8_t out[CHECKSUM_MAX_DIGEST_LEN]; + size_t len = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "hello", 5, out, sizeof(out), &len)); + EXPECT_TRUE(len == (size_t)8); + /* Hard-coded: XXH64("hello", 5, 0). */ + uint8_t expect[8] = {0xa3, 0x6d, 0x9f, 0x88, 0x7d, 0x82, 0xc7, 0x26}; + for (int i = 0; i < 8; i++) + EXPECT_EQ_INT(out[i], expect[i]); +} + +static void test_checksum_xxh64_empty() { + uint8_t out[CHECKSUM_MAX_DIGEST_LEN]; + size_t len = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "", 0, out, sizeof(out), &len)); + EXPECT_TRUE(len == (size_t)8); + /* XXH64("", 0, 0). */ + uint8_t expect[8] = {0x99, 0xe9, 0xd8, 0x51, 0x37, 0xdb, 0x46, 0xef}; + for (int i = 0; i < 8; i++) + EXPECT_EQ_INT(out[i], expect[i]); +} + +/* A nonzero seed must change the xxh64 digest: the algorithm is genuinely + * seed-aware, deterministic, and distinct from seed 0. */ +static void test_checksum_xxh64_seed_changes_digest() { + uint8_t a[CHECKSUM_MAX_DIGEST_LEN], b[CHECKSUM_MAX_DIGEST_LEN]; + size_t alen = 0, blen = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 7, "payload", 7, a, sizeof(a), &alen)); + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "payload", 7, b, sizeof(b), &blen)); + EXPECT_TRUE(alen == blen); + EXPECT_TRUE(memcmp(a, b, alen) != 0); +} + +static void test_checksum_xxh64_seed_deterministic() { + uint8_t a[CHECKSUM_MAX_DIGEST_LEN], b[CHECKSUM_MAX_DIGEST_LEN]; + size_t alen = 0, blen = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 12345, "same", 4, a, sizeof(a), &alen)); + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 12345, "same", 4, b, sizeof(b), &blen)); + EXPECT_TRUE(alen == blen); + EXPECT_TRUE(memcmp(a, b, alen) == 0); +} + +static void test_checksum_md5_vectors() { + uint8_t out[CHECKSUM_MAX_DIGEST_LEN]; + size_t len = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD5, 0, "", 0, out, sizeof(out), &len)); + EXPECT_TRUE(len == (size_t)16); + uint8_t expect_empty[16] = {0xd4, 0x1d, 0x8c, 0xd9, 0x8f, 0x00, 0xb2, 0x04, + 0xe9, 0x80, 0x09, 0x98, 0xec, 0xf8, 0x42, 0x7e}; + EXPECT_TRUE(memcmp(out, expect_empty, 16) == 0); + + /* MD5("abc") */ + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD5, 0, "abc", 3, out, sizeof(out), &len)); + uint8_t expect_abc[16] = {0x90, 0x01, 0x50, 0x98, 0x3c, 0xd2, 0x4f, 0xb0, + 0xd6, 0x96, 0x3f, 0x7d, 0x28, 0xe1, 0x7f, 0x72}; + EXPECT_TRUE(memcmp(out, expect_abc, 16) == 0); +} + +/* md5 is 16 bytes and differs from the 8-byte xxh64 for the same input, so the + * choice is observably different both in length and in content. */ +static void test_checksum_algo_lengths_distinct() { + EXPECT_EQ_INT((int)checksum_digest_len(CHECKSUM_ALGO_XXH64), 8); + EXPECT_EQ_INT((int)checksum_digest_len(CHECKSUM_ALGO_MD5), 16); + + uint8_t x[CHECKSUM_MAX_DIGEST_LEN], m[CHECKSUM_MAX_DIGEST_LEN]; + size_t xl = 0, ml = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "same content", 12, x, sizeof(x), &xl)); + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD5, 0, "same content", 12, m, sizeof(m), &ml)); + EXPECT_TRUE(xl == (size_t)8); + EXPECT_TRUE(ml == (size_t)16); + EXPECT_TRUE(memcmp(x, m, 8) != 0); +} + +/* md5 has no seed: two distinct seeds give the same md5 digest (documented); + * the seed is only honored by xxh64 and the delta block hash (low 32 bits). */ +static void test_checksum_md5_seed_ignored() { + uint8_t a[CHECKSUM_MAX_DIGEST_LEN], b[CHECKSUM_MAX_DIGEST_LEN]; + size_t alen = 0, blen = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD5, 0, "data", 4, a, sizeof(a), &alen)); + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_MD5, 99, "data", 4, b, sizeof(b), &blen)); + EXPECT_TRUE(memcmp(a, b, alen) == 0); +} + +static void test_checksum_algo_name_mapping() { + EXPECT_EQ_INT(checksum_algo_from_name("xxh64"), (int)CHECKSUM_ALGO_XXH64); + EXPECT_EQ_INT(checksum_algo_from_name("XXH64"), (int)CHECKSUM_ALGO_XXH64); + EXPECT_EQ_INT(checksum_algo_from_name("xxhash"), (int)CHECKSUM_ALGO_XXH64); + EXPECT_EQ_INT(checksum_algo_from_name("XXHASH"), (int)CHECKSUM_ALGO_XXH64); + EXPECT_EQ_INT(checksum_algo_from_name("md5"), (int)CHECKSUM_ALGO_MD5); + EXPECT_EQ_INT(checksum_algo_from_name("MD5"), (int)CHECKSUM_ALGO_MD5); + EXPECT_TRUE(checksum_algo_from_name("sha256") < 0); + EXPECT_TRUE(checksum_algo_from_name("crc32") < 0); + EXPECT_TRUE(checksum_algo_from_name("none") < 0); + EXPECT_TRUE(checksum_algo_from_name("") < 0); + EXPECT_TRUE(checksum_algo_from_name(NULL) < 0); + + EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH64)); + EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_MD5)); + EXPECT_FALSE(checksum_algo_valid(99)); + EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH64), "xxh64"); + EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_MD5), "md5"); +} + +static void test_checksum_truncated_buffer_rejected() { + uint8_t small[4]; + size_t len = 0; + /* The digest cannot fit in a 4-byte buffer. */ + EXPECT_FALSE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "x", 1, small, sizeof(small), &len)); + EXPECT_FALSE(checksum_digest(CHECKSUM_ALGO_MD5, 0, "x", 1, small, sizeof(small), &len)); + EXPECT_FALSE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, NULL, 5, small, sizeof(small), &len)); + EXPECT_FALSE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "x", 1, NULL, 0, &len)); + EXPECT_FALSE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "x", 1, small, sizeof(small), NULL)); +} + +/* A NULL data pointer with size 0 is the empty input, not an error. */ +static void test_checksum_null_empty_digest() { + uint8_t a[CHECKSUM_MAX_DIGEST_LEN], b[CHECKSUM_MAX_DIGEST_LEN]; + size_t alen = 0, blen = 0; + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, NULL, 0, a, sizeof(a), &alen)); + EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH64, 0, "", 0, b, sizeof(b), &blen)); + EXPECT_TRUE(alen == blen); + EXPECT_TRUE(memcmp(a, b, alen) == 0); +} + +void test_checksum(void) { + test_checksum_xxh64_seed0(); + test_checksum_xxh64_empty(); + test_checksum_xxh64_seed_changes_digest(); + test_checksum_xxh64_seed_deterministic(); + test_checksum_md5_vectors(); + test_checksum_algo_lengths_distinct(); + test_checksum_md5_seed_ignored(); + test_checksum_algo_name_mapping(); + test_checksum_truncated_buffer_rejected(); + test_checksum_null_empty_digest(); +} \ No newline at end of file diff --git a/tests/test_checksum.h b/tests/test_checksum.h new file mode 100644 index 0000000..8849100 --- /dev/null +++ b/tests/test_checksum.h @@ -0,0 +1,6 @@ +#ifndef TEST_CHECKSUM_H +#define TEST_CHECKSUM_H + +void test_checksum(void); + +#endif \ No newline at end of file diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 5882bae..f0b70ae 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -1,4 +1,5 @@ #include "test_client_cli.h" +#include "checksum.h" #include "client_validation.h" #include "chmod.h" #include "config.h" @@ -787,8 +788,7 @@ static void test_parse_args_rejects_unimplemented_options() { "--ipv4", "--daemon", "--config", - "--server", - "--checksum-choice"}; + "--server"}; for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { Config* cfg = config_create(); @@ -953,7 +953,8 @@ static void test_parse_args_no_preserve_blocks_implicit_metadata() { } } -/* Checksum-choice spellings are recognized and rejected until algorithms are implemented. */ +/* --checksum-choice and its --cc alias select the whole-file digest algorithm + (default xxh64; both "xxh64" and the rsync "xxhash" spelling accepted). */ static void test_parse_args_checksum_choice_aliases() { static const char* const options[] = {"--checksum-choice", "--cc"}; @@ -962,12 +963,88 @@ static void test_parse_args_checksum_choice_aliases() { char* argv[] = {"fastsync", (char*)options[i], "xxh64", "/src", "/dst"}; int positional_args[2]; int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH64); + config_delete(cfg); + } +} +/* Both the "--checksum-choice=ALG" and "--cc=ALG" inline forms parse. */ +static void test_parse_args_checksum_choice_equals_forms() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--checksum-choice=md5", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_MD5); + config_delete(cfg); + + cfg = config_create(); + char* argv2[] = {"fastsync", "--cc=xxhash", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH64); + config_delete(cfg); +} + +/* An algorithm FastSync does not support must be rejected, never a silent + no-op. */ +static void test_parse_args_checksum_choice_rejects_unsupported() { + static const char* const bad[] = {"md4", "sha256", "crc32", "none", "bogus"}; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--checksum-choice", (char*)bad[i], "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1); config_delete(cfg); } } +/* --checksum-seed parses as a 64-bit non-negative integer (space and = forms); + invalid values are rejected. */ +static void test_parse_args_checksum_seed() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--checksum-seed=42", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->checksum_seed == 42ULL); + config_delete(cfg); + + cfg = config_create(); + char* argv2[] = {"fastsync", "--checksum-seed", "12345", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->checksum_seed == 12345ULL); + config_delete(cfg); + + /* 0 is a valid (and default) seed. */ + cfg = config_create(); + char* argv3[] = {"fastsync", "--checksum-seed=0", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->checksum_seed == 0ULL); + config_delete(cfg); + + /* Non-numeric and negative seeds are rejected. */ + static const char* const bad[] = {"abc", "-5", "1.5", ""}; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + cfg = config_create(); + char* argv4[] = {"fastsync", "--checksum-seed", (char*)bad[i], "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), -1); + config_delete(cfg); + } + + /* Missing value is rejected. */ + cfg = config_create(); + char* argv5[] = {"fastsync", "--checksum-seed"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 2, argv5, positional_args, &positional_count), -1); + config_delete(cfg); +} + static void test_parse_args_rejects_unsafe_negation() { static const char* const options[] = {"--no-archive", "--no-timeout", "--no-unknown"}; for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { @@ -1943,6 +2020,9 @@ void test_client_cli() { test_parse_args_log_file_format(); test_parse_args_checksum_choice_aliases(); test_parse_args_checksum_choice_requires_value(); + test_parse_args_checksum_choice_equals_forms(); + test_parse_args_checksum_choice_rejects_unsupported(); + test_parse_args_checksum_seed(); test_parse_args_temp_dir(); test_parse_args_delay_updates(); test_validate_config_delay_updates_rejects_inplace(); diff --git a/tests/test_config.c b/tests/test_config.c index ced2318..10b6211 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -754,6 +754,68 @@ static void test_config_is_remote_dest() { EXPECT_TRUE(config_is_remote_dest("user@host:")); } +/* --checksum-choice/--cc and --checksum-seed cross the wire intact so the + receiver hashes the on-disk old file with the same algorithm and seed. */ +static void test_config_checksum_options_wire_roundtrip() { + if (is_running_under_valgrind()) + return; + + struct { + int algo; + unsigned long long seed; + } cases[] = { + {CHECKSUM_ALGO_XXH64, 0}, + {CHECKSUM_ALGO_XXH64, 42}, + {CHECKSUM_ALGO_MD5, 7}, + {CHECKSUM_ALGO_MD5, 0}, + }; + 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 && recv->checksum_algo == cases[i].algo && + recv->checksum_seed == cases[i].seed; + 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->checksum_algo = cases[i].algo; + send_cfg->checksum_seed = cases[i].seed; + 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); + } + } +} + +/* An out-of-range algorithm id on the wire must be rejected on receive, never + accepted as-is (prevents mixing unsupported digests on a path). */ +static void test_config_receive_rejects_invalid_checksum_algo() { + if (is_running_under_valgrind()) + return; + Config* c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->checksum_algo = 99; + EXPECT_FALSE(roundtrip_config_ok(c)); + config_delete(c); +} + void test_config() { test_config_lifecycle(); test_config_ssh_dest(); @@ -774,6 +836,8 @@ void test_config() { test_config_basis_roundtrip(); test_config_basis_wire_rejects_escaping(); test_config_basis_normalization(); + test_config_checksum_options_wire_roundtrip(); + test_config_receive_rejects_invalid_checksum_algo(); } test_config_delete_timing_early_helper(); test_config_is_remote_dest(); diff --git a/tests/test_delta.c b/tests/test_delta.c index ac522f8..cfc8846 100644 --- a/tests/test_delta.c +++ b/tests/test_delta.c @@ -634,12 +634,65 @@ static void test_delta_hash_index_large_mostly_matching() { free(new_data); } +/* --checksum-seed: the delta strong (block) hash is genuinely seed-aware. A + * nonzero seed changes the per-block xxHash32, and a signature + delta computed + * with the same seed still reconstruct the file exactly (symmetric), while a + * mismatched seed produces a delta that does not match the signature blocks. */ +static void test_delta_xxhash32_seeded() { + const char* data = "seedme"; + uint32_t a = delta_xxhash32(data, 6); + uint32_t b = delta_xxhash32_seeded(data, 6, 42); + uint32_t c = delta_xxhash32_seeded(data, 6, 42); + EXPECT_TRUE(a != b); + EXPECT_EQ_INT((int)b, (int)c); + /* Unseeded == seeded with 0 (default reproduces today's behavior). */ + EXPECT_EQ_INT((int)delta_xxhash32(data, 6), (int)delta_xxhash32_seeded(data, 6, 0)); +} + +static void test_delta_seeded_signature_compute_matches() { + uint32_t block_size = 1024; + /* Identical old/new data with a non-zero seed: the receiver builds a seeded + signature and the sender computes a seeded delta over the same bytes, so + every block matches and applying the delta rebuilds the file exactly. */ + char data[4096]; + for (int i = 0; i < 4096; i++) + data[i] = (char)(i % 256); + + DeltaSignature* sig = delta_signature_create_seeded(data, 4096, block_size, 99); + EXPECT_NOT_NULL(sig); + Delta* delta = delta_compute_seeded(data, 4096, sig, block_size, 99); + EXPECT_NOT_NULL(delta); + void* rebuilt = delta_apply(data, 4096, delta, block_size); + EXPECT_NOT_NULL(rebuilt); + EXPECT_TRUE(memcmp(rebuilt, data, 4096) == 0); + free(rebuilt); + delta_destroy(delta); + delta_signature_destroy(sig); + + /* A MISMATCHED seed means the sender's window xxHash32 never equals the + receiver's signature-block xxHash32: no block can match, so the delta is + not worthwhile / has no block matches. This proves the seed really gates + the block comparison rather than being an inert parameter. */ + sig = delta_signature_create_seeded(data, 4096, block_size, 99); + EXPECT_NOT_NULL(sig); + delta = delta_compute_seeded(data, 4096, sig, block_size, 7); + EXPECT_NOT_NULL(delta); + bool any_match = false; + for (uint32_t i = 0; i < delta->instruction_count; i++) + if (delta->instructions[i].type == DELTA_INSTR_BLOCK_MATCH) + any_match = true; + EXPECT_FALSE(any_match); + delta_destroy(delta); + delta_signature_destroy(sig); +} + void test_delta() { test_adler32_basic(); test_adler32_different_data(); test_xxhash32_basic(); test_xxhash32_different_data(); test_xxhash64_different_data(); + test_delta_xxhash32_seeded(); test_signature_roundtrip(); test_delta_identical_files(); test_delta_small_edit(); @@ -653,4 +706,5 @@ void test_delta() { test_delta_apply_rejects_output_overflow(); test_delta_hash_index_matches_linear_reference(); test_delta_hash_index_large_mostly_matching(); + test_delta_seeded_signature_compute_matches(); }