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.
219 lines
7.8 KiB
C
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;
|
|
}
|