From 4e918a1b69caa4fb5ee9643bef423c1bc7d03b55 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:28:31 +0200 Subject: [PATCH] test(config): pin wire bytes and round-trip every field test_config_wire_golden() serializes a fully-populated Config through config_send_wire_block() and pins the exact frame to len=633 and FNV-1a hash 6163263374908258816, captured from the pre-X-macro implementation. Any field reorder, resize or codec change fails the test. test_config_wire_roundtrip_all_fields() serializes/deserializes a defaults Config and a fully-populated Config over a socketpair and compares every serialized field. The comparison is itself generated from CONFIG_WIRE_FIELDS (one CONFIG_CMP_ per table entry), so a new table entry automatically extends coverage; it cannot fall out of sync. It normalizes the receiver's NULL/"" canonicalization, the max_alloc server clamp and the derived use_delta/use_xattrs bits. --- tests/test_config.c | 304 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) diff --git a/tests/test_config.c b/tests/test_config.c index 7689cf5..41d6a30 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1,5 +1,6 @@ #include "test_config.h" #include "config.h" +#include "delta.h" #include "identity.h" #include "multiprocessing.h" #include "protocol.h" @@ -2194,6 +2195,307 @@ static void test_config_receive_rejects_unified_invariants() { } } +/* --------------------------------------------------------------------------- + * Wire round-trip equivalence. + * + * config_wire_equal() is generated from the SAME CONFIG_WIRE_FIELDS table as + * the serializer, so it can never miss a serialized field: adding a table + * entry automatically extends this comparison. Each KIND maps to a comparison + * macro; STR_OPT/STR_KEEP normalize the NULL-vs-"" canonicalization the + * receiver performs, RAW_MAXALLOC models the server-side clamp, and + * DERIVED_DELTA compares the effective (whole_file-suppressed) bit. + * ------------------------------------------------------------------------- */ +static void golden_config_populate(Config* c); + +static bool str_opt_equal(const char* a, const char* b) { + if (a == NULL || a[0] == '\0') + return b == NULL || b[0] == '\0'; + return b != NULL && strcmp(a, b) == 0; +} + +static bool idmap_equal(const IdentityMap* a, int ac, const IdentityMap* b, int bc) { + if (ac != bc) + return false; + for (int i = 0; i < ac; i++) { + if (a[i].from != b[i].from || a[i].to != b[i].to) + return false; + } + return true; +} + +static bool skip_suffixes_equal(const Config* a, const Config* b) { + if (a->skip_compress_count != b->skip_compress_count) + return false; + for (int i = 0; i < a->skip_compress_count; i++) { + if (!str_opt_equal(a->skip_compress_suffixes[i], b->skip_compress_suffixes[i])) + return false; + } + return true; +} + +static bool basis_equal(const Config* a, const Config* b) { + if (a->basis_count != b->basis_count) + return false; + for (int i = 0; i < a->basis_count; i++) { + if (a->basis_dirs[i].type != b->basis_dirs[i].type || + !str_opt_equal(a->basis_dirs[i].path, b->basis_dirs[i].path)) + return false; + } + return true; +} + +#define CONFIG_CMP_BOOL(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_RAW(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_BOOL_8BIT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_RAW_MAXALLOC(a, b, name) \ + ((b)->name == ((a)->name > MAX_SERVER_ALLOC ? MAX_SERVER_ALLOC : (a)->name)) +#define CONFIG_CMP_DERIVED_DELTA(a, b, name) ((b)->name == ((a)->name && !(a)->whole_file)) +#define CONFIG_CMP_STR(a, b, name) \ + ((a)->name != NULL && (b)->name != NULL && strcmp((a)->name, (b)->name) == 0) +#define CONFIG_CMP_STR_OPT(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_STR_KEEP(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_STR_MODULE(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_STR_REDACTED_AUTH(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_INT_CHECKSUM_ALGO(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_SUPERMODE(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_IDENTITY(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_SKIPCOUNT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_BASISCOUNT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_IDMAPCOUNT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_BOOL_XATTR_DERIVE(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_COPY_AS_PRESENCE(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_COPY_AS_ID(a, b, name) (!(a)->copy_as_set || (a)->name == (b)->name) +#define CONFIG_CMP_BLOCK_SKIP_SUFFIXES(a, b, name) skip_suffixes_equal((a), (b)) +#define CONFIG_CMP_BLOCK_BASIS(a, b, name) basis_equal((a), (b)) +#define CONFIG_CMP_BLOCK_IDMAP(a, b, name) \ + idmap_equal((a)->name, (a)->name##_count, (b)->name, (b)->name##_count) + +#define WIRE_CMP(name, ctype, def, kind) \ + &&(CONFIG_CMP_##kind(a, b, name) \ + ? true \ + : (fprintf(stderr, " mismatched field: %s\n", #name), false)) + +static bool config_wire_equal(const Config* a, const Config* b) { + return true CONFIG_WIRE_FIELDS(WIRE_CMP); +} + +static bool roundtrip_and_compare(const Config* send_cfg) { + int p[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0) + return false; + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + io_set_bwlimit(0); + Config* recv = config_receive(p[0]); + bool equal = recv != NULL && config_wire_equal(send_cfg, recv); + config_delete(recv); + close(p[0]); + _exit(equal ? 0 : 1); + } + close(p[0]); + io_set_fds(p[1], p[1]); + io_set_bwlimit(0); + bool sent = config_send(p[1], send_cfg); + int status; + waitpid(pid, &status, 0); + close(p[1]); + return sent && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} + +/* Every serialized field must survive a frame round-trip, for a defaults config + * and for a fully-populated config. */ +static void test_config_wire_roundtrip_all_fields() { + if (is_running_under_valgrind()) + return; + + Config* defaults = config_create(); + EXPECT_NOT_NULL(defaults); + defaults->send_directory = str_dup("/src"); + defaults->receive_root_directory = str_dup("/dst"); + EXPECT_TRUE(roundtrip_and_compare(defaults)); + config_delete(defaults); + + Config* populated = config_create(); + EXPECT_NOT_NULL(populated); + golden_config_populate(populated); + /* Keep the populated config within the server-side validation bounds. */ + populated->delta_max_file_size = DELTA_MAX_FILE_SIZE; + populated->whole_file = false; + /* "X" is not part of FastSync's chmod grammar (see parse_clause), so use a + * spec the receiver-side validator accepts. */ + free(populated->chmod_spec); + populated->chmod_spec = str_dup("u=rw,go=r"); + EXPECT_TRUE(roundtrip_and_compare(populated)); + config_delete(populated); +} + +/* Populate every serialized field with a non-default value so the wire frame + * exercises each table entry. The values are deterministic. */ +static void golden_config_populate(Config* c) { + c->eight_bit_output = true; + c->max_alloc = 123456789ULL; + c->send_directory = str_dup("/golden/src"); + c->receive_root_directory = str_dup("/golden/dst"); + c->save_to_disk = true; + c->use_multithreading = true; + c->use_chunk_serialization = false; + c->use_compression = false; + c->use_metadata = true; + c->use_executability = true; + c->compression_level = 7; + c->chunk_size = 65536; + c->use_sendfile = false; + c->use_delete = true; + c->use_incremental = true; + c->size_only = true; + c->ignore_times = true; + c->use_delta = true; + c->whole_file = false; + c->delta_block_size = 4096; + c->delta_max_file_size = 987654321ULL; + c->backup = true; + c->backup_dir = str_dup("/golden/backup"); + c->remove_source_files = true; + c->follow_symlinks = true; + c->copy_links = true; + c->safe_links = true; + c->copy_unsafe_links = true; + c->preserve_hard_links = true; + c->preserve_acls = true; + c->preserve_xattrs = true; + c->preserve_devices = true; + c->preserve_sparse = true; + c->preserve_specials = true; + c->copy_devices = true; + c->write_devices = true; + c->ignore_existing = true; + c->existing = true; + c->update = true; + c->inplace = false; + c->delay_updates = false; + c->append = false; + c->use_fsync = true; + c->append_verify = false; + c->delete_excluded = true; + c->force_delete = true; + c->delete_missing_args = true; + c->delete_after = true; + c->preallocate = true; + c->max_delete = 42; + c->relative = true; + c->prune_empty_dirs = true; + c->mkpath = true; + c->delete_during = false; + c->delete_delay = false; + c->temp_dir = str_dup("/golden/tmp"); + c->partial = true; + c->partial_dir = str_dup("/golden/partial"); + c->suffix = str_dup(".golden"); + c->delete_before = false; + c->checksum = true; + c->modify_window = 3; + c->compress_choice = str_dup("zstd"); + c->chmod_spec = str_dup("u=rwX,go=rX"); + c->skip_compress_set = true; + c->skip_compress_count = 2; + c->skip_compress_suffixes = calloc(2, sizeof(char*)); + c->skip_compress_suffixes[0] = str_dup(".gz"); + c->skip_compress_suffixes[1] = str_dup(".xz"); + EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_COMPARE, "compare"), 0); + EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "link"), 0); + c->fuzzy = true; + c->checksum_algo = CHECKSUM_ALGO_MD5; + c->checksum_seed = 0x1122334455667788ULL; + c->numeric_ids = true; + c->chown_uid_set = true; + c->chown_uid = 1234; + c->chown_gid_set = true; + c->chown_gid = 5678; + c->usermap_count = 2; + c->usermap = calloc(2, sizeof(IdentityMap)); + c->usermap[0].from = IDENTITY_MATCH_ANY; + c->usermap[0].to = 1000; + c->usermap[1].from = 5; + c->usermap[1].to = 6; + c->groupmap_count = 1; + c->groupmap = calloc(1, sizeof(IdentityMap)); + c->groupmap[0].from = 7; + c->groupmap[0].to = 8; + c->preserve_atimes = true; + c->preserve_crtimes = true; + c->omit_dir_times = true; + c->omit_link_times = true; + c->munge_links = true; + c->keep_dirlinks = true; + c->fake_super = true; + c->module = str_dup("goldenmod"); + c->auth_user = str_dup("goldenuser"); + c->auth_password = str_dup("golden-pw"); + c->iconv_spec = str_dup("UTF-8,UTF-8"); + c->super_mode = SUPER_MODE_ON; + c->copy_as_set = true; + c->copy_as_uid = 111; + c->copy_as_gid = 222; +} + +/* FNV-1a 64 over the exact config-frame bytes emitted by + * config_send_wire_block(). This pins field order and width: any reorder or + * resize changes the hash. */ +static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len) { + int p[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0) + return 0; + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + io_set_bwlimit(0); + bool ok = config_send_wire_block(p[0], cfg); + close(p[0]); + _exit(ok ? 0 : 1); + } + close(p[0]); + unsigned long long h = 1469598103934665603ULL; + unsigned char buf[4096]; + ssize_t n; + size_t total = 0; + while ((n = read(p[1], buf, sizeof(buf))) > 0) { + for (ssize_t i = 0; i < n; i++) { + h ^= (unsigned long long)buf[i]; + h *= 1099511628211ULL; + } + total += (size_t)n; + } + close(p[1]); + int status = 0; + waitpid(pid, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + return 0; + *out_len = total; + return h; +} + +/* Byte-for-byte wire compatibility guard (protocol 2.20.0). The expected hash + * was captured from the pre-X-macro implementation; the refactor MUST NOT + * change it. */ +static void test_config_wire_golden() { + if (is_running_under_valgrind()) + return; + Config* c = config_create(); + EXPECT_NOT_NULL(c); + golden_config_populate(c); + size_t len = 0; + unsigned long long h = capture_wire_hash(c, &len); + printf(" wire golden: len=%zu hash=%llu\n", len, h); + /* Captured from the pre-X-macro (protocol 2.20.0) implementation. */ + EXPECT_TRUE(len == 633); + EXPECT_TRUE(h == 6163263374908258816ULL); + config_delete(c); +} + void test_config() { test_config_lifecycle(); test_config_ssh_dest(); @@ -2249,6 +2551,8 @@ void test_config() { test_config_receive_with_validate_rejects(); test_config_invariants_error_all_combinations(); test_config_receive_rejects_unified_invariants(); + test_config_wire_golden(); + test_config_wire_roundtrip_all_fields(); } test_identity_copy_as_refused(); test_identity_ownership_requested();