#define _GNU_SOURCE #include "configcompare.h" #include #include #include #include #include #include /* 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/current` -> `/conf/`, * 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; }