feat(editor): add --export-zone-checksum for SHA-256 integrity manifests

Emits a SHA-256 manifest of every source file in a zone in the
standard sha256sum format. Lets users verify zone integrity after
download/transfer using the standard system tool — no custom
verifier needed:

  wowee_editor --export-zone-checksum custom_zones/MyZone

  3298c35a...  Z_30_30.whm
  f81e3d37...  Z_30_30.wot
  6a49519f...  creatures.json
  4625e30b...  zone.json

  sha256sum -c custom_zones/MyZone/SHA256SUMS
  Z_30_30.whm: OK
  Z_30_30.wot: OK
  ...

Source-only by design — derived outputs (.glb/.obj/.stl/.html/.png/
ZONE.md/DEPS.md/quests.dot/SHA256SUMS itself/Makefile) are excluded
since they're regeneratable and would invalidate the checksum on
every rebuild.

Includes a self-contained 90-LoC SHA-256 (FIPS 180-4 / RFC 6234)
in an internal namespace — no OpenSSL/Crypto++ dependency added.
Streaming hash (16KB chunks) so it scales to giant terrain WHMs
without holding the whole file in memory.

Verified end-to-end: scaffolded zone with 1 creature → checksum
manifest of 4 source files (zone.json, creatures.json, .whm, .wot)
in standard format → sha256sum -c reports all 4 OK.
This commit is contained in:
Kelsi 2026-05-06 15:51:50 -07:00
parent 4668140eed
commit b628535a91

View file

@ -37,6 +37,91 @@
// Both validators are called from the per-file CLI commands AND
// from --validate-all which walks a zone dir. Returning a vector
// of error strings (empty == passed) keeps callers simple.
// Minimal SHA-256 implementation (FIPS 180-4) used by --export-zone-checksum
// to produce hashes that interoperate with `sha256sum -c`. Not exposed beyond
// this file — about 90 LoC, no external deps. See RFC 6234 for the algorithm.
namespace wowee_sha256 {
struct State {
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
uint64_t totalBits = 0;
uint8_t buf[64] = {};
size_t bufLen = 0;
};
static inline uint32_t rotr(uint32_t x, uint32_t n) { return (x >> n) | (x << (32 - n)); }
static void compress(State& s, const uint8_t* block) {
static const uint32_t K[64] = {
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
};
uint32_t w[64];
for (int i = 0; i < 16; ++i) {
w[i] = (uint32_t(block[i*4]) << 24) | (uint32_t(block[i*4+1]) << 16) |
(uint32_t(block[i*4+2]) << 8) | uint32_t(block[i*4+3]);
}
for (int i = 16; i < 64; ++i) {
uint32_t s0 = rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3);
uint32_t s1 = rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10);
w[i] = w[i-16] + s0 + w[i-7] + s1;
}
uint32_t a = s.h[0], b = s.h[1], c = s.h[2], d = s.h[3];
uint32_t e = s.h[4], f = s.h[5], g = s.h[6], h = s.h[7];
for (int i = 0; i < 64; ++i) {
uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
uint32_t ch = (e & f) ^ (~e & g);
uint32_t t1 = h + S1 + ch + K[i] + w[i];
uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
uint32_t mj = (a & b) ^ (a & c) ^ (b & c);
uint32_t t2 = S0 + mj;
h = g; g = f; f = e; e = d + t1;
d = c; c = b; b = a; a = t1 + t2;
}
s.h[0] += a; s.h[1] += b; s.h[2] += c; s.h[3] += d;
s.h[4] += e; s.h[5] += f; s.h[6] += g; s.h[7] += h;
}
static void update(State& s, const uint8_t* data, size_t len) {
s.totalBits += len * 8;
while (len > 0) {
size_t take = std::min(len, sizeof(s.buf) - s.bufLen);
std::memcpy(s.buf + s.bufLen, data, take);
s.bufLen += take; data += take; len -= take;
if (s.bufLen == 64) { compress(s, s.buf); s.bufLen = 0; }
}
}
static std::string hexFinal(State& s) {
s.buf[s.bufLen++] = 0x80;
if (s.bufLen > 56) {
std::memset(s.buf + s.bufLen, 0, 64 - s.bufLen);
compress(s, s.buf); s.bufLen = 0;
}
std::memset(s.buf + s.bufLen, 0, 56 - s.bufLen);
for (int i = 7; i >= 0; --i) s.buf[56 + (7 - i)] = (s.totalBits >> (i * 8)) & 0xFF;
compress(s, s.buf);
char out[65] = {};
for (int i = 0; i < 8; ++i) {
std::snprintf(out + i * 8, 9, "%08x", s.h[i]);
}
return std::string(out);
}
static std::string fileHex(const std::string& path) {
std::ifstream in(path, std::ios::binary);
if (!in) return "";
State s;
char chunk[16384];
while (in.read(chunk, sizeof(chunk)) || in.gcount() > 0) {
update(s, reinterpret_cast<const uint8_t*>(chunk),
static_cast<size_t>(in.gcount()));
}
return hexFinal(s);
}
} // namespace wowee_sha256
static std::vector<std::string> validateWomErrors(
const wowee::pipeline::WoweeModel& wom) {
std::vector<std::string> errors;
@ -555,6 +640,8 @@ static void printUsage(const char* argv0) {
std::printf(" Render a markdown documentation page for a zone (manifest + content)\n");
std::printf(" --export-zone-csv <zoneDir> [outDir]\n");
std::printf(" Emit creatures.csv / objects.csv / quests.csv for spreadsheet workflows\n");
std::printf(" --export-zone-checksum <zoneDir> [out.sha256]\n");
std::printf(" Emit a SHA-256 manifest of every source file in a zone (for integrity checks)\n");
std::printf(" --export-zone-html <zoneDir> [out.html]\n");
std::printf(" Emit a single-file HTML viewer next to the zone .glb (model-viewer based)\n");
std::printf(" --export-project-html <projectDir> [out.html]\n");
@ -691,6 +778,7 @@ int main(int argc, char* argv[]) {
"--zone-summary", "--info-zone-tree", "--info-zone-bytes",
"--export-zone-summary-md", "--export-quest-graph",
"--export-zone-csv", "--export-zone-html", "--export-project-html",
"--export-zone-checksum",
"--scaffold-zone", "--add-tile", "--remove-tile", "--list-tiles",
"--for-each-zone", "--zone-stats", "--info-tilemap",
"--list-zone-deps", "--check-zone-refs", "--check-zone-content",
@ -4545,6 +4633,66 @@ int main(int argc, char* argv[]) {
}
std::printf("Exported %d CSV file(s) to %s\n", filesWritten, outDir.c_str());
return 0;
} else if (std::strcmp(argv[i], "--export-zone-checksum") == 0 && i + 1 < argc) {
// SHA-256 manifest of every source file in a zone, in the
// standard sha256sum format ('<hex> <relpath>'). Lets users
// verify zone integrity after a download or transfer with the
// standard system tool:
// wowee_editor --export-zone-checksum custom_zones/MyZone
// sha256sum -c custom_zones/MyZone/SHA256SUMS
std::string zoneDir = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
namespace fs = std::filesystem;
if (!fs::exists(zoneDir + "/zone.json")) {
std::fprintf(stderr,
"export-zone-checksum: %s has no zone.json\n", zoneDir.c_str());
return 1;
}
if (outPath.empty()) outPath = zoneDir + "/SHA256SUMS";
// Source files only — derived outputs (.glb/.obj/.stl/.html/
// ZONE.md/DEPS.md/quests.dot/SHA256SUMS itself) are excluded
// since they're regeneratable and would invalidate the
// checksum on every rebuild.
auto isDerived = [](const fs::path& p) {
std::string ext = p.extension().string();
std::string name = p.filename().string();
if (ext == ".glb" || ext == ".obj" || ext == ".stl" ||
ext == ".html" || ext == ".dot" || ext == ".csv") return true;
if (name == "ZONE.md" || name == "DEPS.md" ||
name == "SHA256SUMS" || name == "Makefile") return true;
if (ext == ".png") return true; // BLP→PNG renders at root
return false;
};
std::vector<std::pair<std::string, std::string>> entries;
std::error_code ec;
for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) {
if (!e.is_regular_file()) continue;
if (isDerived(e.path())) continue;
std::string hex = wowee_sha256::fileHex(e.path().string());
if (hex.empty()) continue;
std::string rel = fs::relative(e.path(), zoneDir, ec).string();
if (ec) rel = e.path().string();
entries.push_back({hex, rel});
}
std::sort(entries.begin(), entries.end(),
[](const auto& a, const auto& b) { return a.second < b.second; });
std::ofstream out(outPath);
if (!out) {
std::fprintf(stderr,
"export-zone-checksum: cannot write %s\n", outPath.c_str());
return 1;
}
for (const auto& [hash, path] : entries) {
// sha256sum format: 64-char hex, two spaces, path.
out << hash << " " << path << "\n";
}
out.close();
std::printf("Wrote %s\n", outPath.c_str());
std::printf(" %zu file(s) hashed (source only, derived excluded)\n",
entries.size());
std::printf(" verify with: sha256sum -c %s\n", outPath.c_str());
return 0;
} else if (std::strcmp(argv[i], "--export-zone-html") == 0 && i + 1 < argc) {
// Generate a single-file HTML viewer next to the zone .glb.
// Anyone with a modern browser can open it — no installs, no