myxce/src/controller/testsuite/test_configcompare.c
Rongsong Shen 1d09631df0 feat(config-upgrade-snapshot): add configcompare tool, extend snapverify (task 8.4)
Build configcompare_paths(), an offline sibling to snapdiff/walcompare that
recomputes each member's active-config sha256 fresh from its own on-disk
workdir/name/conf bytes and reports identical vs. divergent (Req 4.2).
Wire it as a cmd_configcompare CLI subcommand in test.c, matching its
siblings, after review caught it shipped as an unreachable library function
in round 1.

Extend snapverify to match the current on-disk checkpoint format: bump
SNAP_FORMAT_VERSION to 2, replace the stale raw queue-frame decode with the
capnp length-prefixed walk, add host-frame and config-bytes-section
structural walks, and decode (not just presence-check) the header's
cfgref tag/sha. Without this fix snapverify rejected every checkpoint
written by the current codebase.
2026-07-25 09:06:24 +00:00

217 lines
7.6 KiB
C

#define _GNU_SOURCE
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <unity/unity.h>
#include "configcompare.h"
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_configcompare_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
/* Creates <root>/<name>/conf/ (a member's `workdir/name` directory, mirroring
* conf_dir = "%s/%s", myconf->workdir, myconf->name in persist.c /
* config_repl.c) and writes the resulting path into out_path. Does NOT touch
* config_repl.c or persist.c -- these are hand-written fixture files so the
* test exercises configcompare.c's OWN resolve-then-read logic, not a
* different code path's. */
static void make_member_dir(const char* root, const char* name,
char out_path[512])
{
snprintf(out_path, 512, "%s/%s", root, name);
TEST_ASSERT_EQUAL_INT(0, mkdir(out_path, 0700));
char conf_dir[560];
snprintf(conf_dir, sizeof(conf_dir), "%s/conf", out_path);
TEST_ASSERT_EQUAL_INT(0, mkdir(conf_dir, 0700));
}
static void write_conf_current(const char* member_path, const char* hexsuffix)
{
char ptr_path[600];
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", member_path);
FILE* f = fopen(ptr_path, "w");
TEST_ASSERT_NOT_NULL(f);
fprintf(f, "cluster.bin.%s\n", hexsuffix);
fclose(f);
}
static void write_conf_blob(const char* member_path, const char* hexsuffix,
const uint8_t* bytes, size_t len)
{
char blob_path[600];
snprintf(blob_path, sizeof(blob_path), "%s/conf/cluster.bin.%s",
member_path, hexsuffix);
FILE* f = fopen(blob_path, "wb");
TEST_ASSERT_NOT_NULL(f);
if (len > 0)
TEST_ASSERT_EQUAL_INT((int)len, (int)fwrite(bytes, 1, len, f));
fclose(f);
}
/* Full happy-path member: dir + conf/current + the versioned blob it
* points at. */
static void setup_member_ok(const char* root, const char* name,
const char* hexsuffix, const uint8_t* bytes,
size_t len, char out_path[512])
{
make_member_dir(root, name, out_path);
write_conf_current(out_path, hexsuffix);
write_conf_blob(out_path, hexsuffix, bytes, len);
}
static void test_two_members_identical_config_reports_identical(void)
{
fresh_dir();
const uint8_t data[] = "identical config bytes for both members";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
setup_member_ok(g_dir, "m1", "bbbbbbbbbbbb", data, sizeof(data) - 1, m1);
const char* paths[2] = { m0, m1 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.all_present);
TEST_ASSERT_EQUAL_INT(1, rep.all_identical);
TEST_ASSERT_EQUAL_INT(-1, rep.first_divergent_index);
cleanup_dir();
}
static void test_two_members_differing_config_reports_divergent(void)
{
fresh_dir();
const uint8_t data0[] = "config bytes version A";
const uint8_t data1[] = "config bytes version B, quite different";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data0, sizeof(data0) - 1, m0);
setup_member_ok(g_dir, "m1", "bbbbbbbbbbbb", data1, sizeof(data1) - 1, m1);
const char* paths[2] = { m0, m1 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_EQUAL_INT(1, rep.all_present);
TEST_ASSERT_EQUAL_INT(0, rep.all_identical);
TEST_ASSERT_EQUAL_INT(1, rep.first_divergent_index);
cleanup_dir();
}
/* A member with no conf/current pointer file at all must be reported as
* unreadable (read_rc != 0), never silently folded into "identical" or
* "divergent" against the two members that DO agree. */
static void test_missing_conf_current_reported_unreadable_not_divergent(void)
{
fresh_dir();
const uint8_t data[] = "shared config bytes across the healthy members";
char m0[512], m1[512], m2[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
/* m1: conf/ dir exists but conf/current was never written. */
make_member_dir(g_dir, "m1", m1);
setup_member_ok(g_dir, "m2", "cccccccccccc", data, sizeof(data) - 1, m2);
const char* paths[3] = { m0, m1, m2 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 3, stdout, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, rep.read_rc[0]);
TEST_ASSERT_NOT_EQUAL(0, rep.read_rc[1]);
TEST_ASSERT_EQUAL_INT(0, rep.all_present);
cleanup_dir();
}
/* A member whose conf/current points at a versioned file that does not
* exist on disk gets the same "unreadable" treatment as a missing pointer
* file -- neither is evidence of matching or mismatching configuration. */
static void test_current_points_to_missing_versioned_file_reported_unreadable(void)
{
fresh_dir();
const uint8_t data[] = "shared config bytes, dangling pointer case";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
/* m1: conf/current points at cluster.bin.dddddddddddd, but that
* versioned file was never written. */
make_member_dir(g_dir, "m1", m1);
write_conf_current(m1, "dddddddddddd");
const char* paths[2] = { m0, m1 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 2, stdout, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, rep.read_rc[0]);
TEST_ASSERT_NOT_EQUAL(0, rep.read_rc[1]);
cleanup_dir();
}
/* Basic determinism sanity check: comparing the same two byte-identical
* files twice in a row yields the same hash both times. */
static void test_repeated_comparison_of_identical_files_is_deterministic(void)
{
fresh_dir();
const uint8_t data[] = "deterministic hash check bytes, fixed content";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
setup_member_ok(g_dir, "m1", "bbbbbbbbbbbb", data, sizeof(data) - 1, m1);
const char* paths[2] = { m0, m1 };
configcompare_report_t rep1, rep2;
int rc1 = configcompare_paths(paths, 2, NULL, &rep1);
int rc2 = configcompare_paths(paths, 2, NULL, &rep2);
TEST_ASSERT_EQUAL_INT(0, rc1);
TEST_ASSERT_EQUAL_INT(0, rc2);
TEST_ASSERT_EQUAL_INT(0, memcmp(rep1.sha256[0], rep2.sha256[0], 32));
TEST_ASSERT_EQUAL_INT(0, memcmp(rep1.sha256[1], rep2.sha256[1], 32));
cleanup_dir();
}
/* Fewer than two members means there is nothing to compare -- a negative
* errno, not a degenerate "identical" verdict. */
static void test_n_less_than_2_returns_negative_errno(void)
{
const char* one_path[1] = { "/tmp/xce_configcompare_irrelevant" };
configcompare_report_t rep;
int rc0 = configcompare_paths(NULL, 0, NULL, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc0);
int rc1 = configcompare_paths(one_path, 1, NULL, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc1);
}
void test_configcompare(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_two_members_identical_config_reports_identical);
RUN_TEST(test_two_members_differing_config_reports_divergent);
RUN_TEST(test_missing_conf_current_reported_unreadable_not_divergent);
RUN_TEST(test_current_points_to_missing_versioned_file_reported_unreadable);
RUN_TEST(test_repeated_comparison_of_identical_files_is_deterministic);
RUN_TEST(test_n_less_than_2_returns_negative_errno);
}