myxce/src/controller/testsuite/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

219 lines
7.8 KiB
C

#define _GNU_SOURCE
#include "configcompare.h"
#include <errno.h>
#include <inttypes.h>
#include <openssl/sha.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
/* Bound on a member's config bytes this tool will read -- mirrors
* persist.c's PERSIST_CONFIG_BYTES_MAX_SIZE / config_repl.h's CONF_MAX_SIZE
* (1 MiB) exactly. Duplicated as its own local constant rather than
* including either header, for the same reason the resolve-then-read logic
* below is duplicated -- see read_member_config_sha256()'s doc comment. */
#define CONFIGCOMPARE_CONFIG_MAX_SIZE (1u * 1024u * 1024u)
/* Resolve `<conf_dir>/conf/current` -> `<conf_dir>/conf/<versioned file>`,
* read its raw bytes, and hash them into `out_sha`.
*
* DELIBERATE DUPLICATION -- this is the FOURTH independent copy of this
* exact resolve-then-read-then-hash logic in this codebase:
* 1. config_repl.c's conf_read_current_filename() + conf_read_raw()
* (the production implementation -- config replication).
* 2. config_repl.c's local_config_sha256() (activation-fence hash check;
* an inline copy for the same "no cross-file call needed here"
* reason as #4 below).
* 3. persist.c's read_active_config_bytes() (checkpoint config-bytes
* embedding; see its own doc comment for the full "why duplicate,
* not call" rationale -- avoiding a persist.c -> config_repl.c
* layering dependency).
* 4. This function. configcompare.c is compiled into the standalone
* xce_test CLI (meson.build's xce_test_src), which links ONLY
* libxce_dep / librtti_dep / struct2json_dep / readline_dep /
* history_dep / ncurses_dep / tinylog_dep / zlib_dep /
* c_capnproto_dep -- deliberately NOT config_repl.c, persist.c,
* cluster.c, or any other heavy controller object file. This is a
* lightweight, dependency-light offline CLI tool family
* (walcompare/snapdiff/snapverify all share the same constraint; no
* live RPC to any cluster member, consistent with every other tool
* in this family -- design.md "Regression Tooling"). Adding a
* config_repl.c dependency to link in one function would violate
* that boundary, so the ~25-line resolve+read is duplicated inline
* here instead, exactly as persist.c's read_active_config_bytes()
* already chose to do for the identical reason.
*
* Returns 0 with `out_sha` filled on success. Returns a negative errno on
* any failure to resolve or read: -ENOENT (no conf/current pointer, or it
* names a versioned file that is not present -- benign "not provisioned
* yet" as well as genuine absence, both surfaced identically to the
* caller as "unreadable"), -EINVAL (malformed pointer file: empty,
* embedded '/', wrong prefix, wrong hex length), -EIO (short
* read/zero-byte versioned file), -EFBIG (versioned file exceeds
* CONFIGCOMPARE_CONFIG_MAX_SIZE), or -ENOMEM. This is the caller's
* "unreadable/absent" signal -- distinct from and never conflated with a
* mismatched hash. */
static int read_member_config_sha256(const char* conf_dir, uint8_t out_sha[32])
{
if (!conf_dir || !*conf_dir)
return -EINVAL;
char head_path[4160];
snprintf(head_path, sizeof(head_path), "%s/conf/current", conf_dir);
FILE* hf = fopen(head_path, "r");
if (hf == NULL)
return -ENOENT; /* no config pushed to this member yet */
struct stat hst;
if (fstat(fileno(hf), &hst) == 0 && hst.st_size == 0) {
fclose(hf);
return -ENOENT; /* interrupted-write pointer file -- treat as absent */
}
char fname[256];
if (fgets(fname, (int)sizeof(fname), hf) == NULL) {
fclose(hf);
return -EIO;
}
fclose(hf);
fname[strcspn(fname, "\n")] = '\0';
if (strchr(fname, '/') != NULL)
return -EINVAL; /* corrupted pointer file -- embedded path separator */
if (strncmp(fname, "cluster.bin.", 12) != 0)
return -EINVAL;
const char* hex = fname + 12;
if (strlen(hex) != 12 || strspn(hex, "0123456789abcdef") != 12)
return -EINVAL;
char cfg_path[4416];
snprintf(cfg_path, sizeof(cfg_path), "%s/conf/%s", conf_dir, fname);
FILE* cf = fopen(cfg_path, "rb");
if (cf == NULL)
return -ENOENT; /* pointer references a file that is not there */
if (fseek(cf, 0, SEEK_END) != 0) { fclose(cf); return -EIO; }
long sz = ftell(cf);
if (sz < 0) { fclose(cf); return -EIO; }
if (fseek(cf, 0, SEEK_SET) != 0) { fclose(cf); return -EIO; }
if (sz == 0) {
fclose(cf);
return -EIO; /* zero-byte versioned config file: genuine corruption */
}
if ((size_t)sz > CONFIGCOMPARE_CONFIG_MAX_SIZE) {
fclose(cf);
return -EFBIG;
}
uint8_t* buf = (uint8_t*)malloc((size_t)sz);
if (buf == NULL) {
fclose(cf);
return -ENOMEM;
}
if (fread(buf, 1, (size_t)sz, cf) != (size_t)sz) {
free(buf);
fclose(cf);
return -EIO;
}
fclose(cf);
SHA256(buf, (size_t)sz, out_sha);
free(buf);
return 0;
}
static const char* sha256_hex(const uint8_t sha[32], char out[65])
{
static const char digits[] = "0123456789abcdef";
for (int i = 0; i < 32; i++) {
out[i * 2] = digits[(sha[i] >> 4) & 0xF];
out[i * 2 + 1] = digits[sha[i] & 0xF];
}
out[64] = '\0';
return out;
}
int configcompare_paths(const char* const* conf_paths, int n, FILE* out,
configcompare_report_t* rep)
{
configcompare_report_t local;
memset(&local, 0, sizeof(local));
local.first_divergent_index = -1;
if (!conf_paths || n < 2) {
if (rep)
*rep = local;
if (out)
fprintf(out,
"configcompare: nothing to compare (need >= 2 members, "
"got %d)\n", n);
return -EINVAL;
}
if (n > CONFIGCOMPARE_MAX_MEMBERS) {
if (rep)
*rep = local;
if (out)
fprintf(out, "configcompare: too many members (%d > max %d)\n", n,
CONFIGCOMPARE_MAX_MEMBERS);
return -EINVAL;
}
local.n = n;
int first_ok = -1;
char hexbuf[65];
for (int i = 0; i < n; i++) {
int rc = read_member_config_sha256(conf_paths[i], local.sha256[i]);
local.read_rc[i] = rc;
if (rc != 0) {
if (out)
fprintf(out,
"configcompare: member %d (%s): UNREADABLE: %s\n", i,
conf_paths[i] ? conf_paths[i] : "(null)",
strerror(-rc));
if (rep)
*rep = local;
/* Short-circuit on the first unreadable member, mirroring
* snapdiff_paths's early-return-on-open-failure style: a
* member we could not read is not evidence of matching OR
* mismatching configuration, so there is no point continuing
* to accumulate a verdict that will be discarded anyway. */
return rc;
}
if (out)
fprintf(out, "configcompare: member %d (%s): sha256=%s\n", i,
conf_paths[i], sha256_hex(local.sha256[i], hexbuf));
if (first_ok < 0) {
first_ok = i;
} else if (local.first_divergent_index < 0 &&
memcmp(local.sha256[i], local.sha256[first_ok], 32) != 0) {
local.first_divergent_index = i;
}
}
local.all_present = 1;
local.all_identical = (local.first_divergent_index < 0);
if (rep)
*rep = local;
if (!local.all_identical) {
if (out)
fprintf(out,
"configcompare: DIVERGENT -- member %d differs from "
"member %d\n",
local.first_divergent_index, first_ok);
return 1;
}
if (out)
fprintf(out, "configcompare: IDENTICAL across %d members\n", n);
return 0;
}