← back to Homesonspec
TK-10809: extract classifyForDest pure fn + hermetic verify test; offload VERIFY loop calls it (no behavior change)
66e0038ca3427a43b27021b09eb9de43469a46d0 · 2026-08-30 09:42:41 -0700 · steve
Files touched
A ops/lib/snapshot-verify.mjsA ops/lib/snapshot-verify.test.mjsM ops/offload-snapshots.mjs
Diff
commit 66e0038ca3427a43b27021b09eb9de43469a46d0
Author: steve <steve@designerwallcoverings.com>
Date: Sun Aug 30 09:42:41 2026 -0700
TK-10809: extract classifyForDest pure fn + hermetic verify test; offload VERIFY loop calls it (no behavior change)
---
ops/lib/snapshot-verify.mjs | 49 +++++++++++++++++
ops/lib/snapshot-verify.test.mjs | 116 +++++++++++++++++++++++++++++++++++++++
ops/offload-snapshots.mjs | 46 ++++++----------
3 files changed, 183 insertions(+), 28 deletions(-)
diff --git a/ops/lib/snapshot-verify.mjs b/ops/lib/snapshot-verify.mjs
new file mode 100644
index 00000000..982ef195
--- /dev/null
+++ b/ops/lib/snapshot-verify.mjs
@@ -0,0 +1,49 @@
+// Pure, hermetic verdict for a single RawSnapshot row resolved against the offload
+// DEST (TK-10809). Extracted from the offload-snapshots.mjs VERIFY loop so the
+// classify decision (ok / miss / mismatch / abs-not-under-src / invalid) is
+// unit-testable against real on-disk files without a DB, network, or the CLI.
+//
+// resolveAgainstDest is the single source of truth for the path mapping; this fn
+// layers the stat + sha256 comparison on top and NEVER writes/unlinks anything.
+import { readFileSync, statSync } from "node:fs";
+import { createHash } from "node:crypto";
+import { resolveAgainstDest } from "./snapshot-resolve.mjs";
+
+// classifyForDest(row, src, dest) -> { verdict, dest, reason?, expected?, got? }
+// verdict "ok" : file resolves under DEST and sha256 == contentHash
+// verdict "mismatch" : file exists but sha256 != contentHash
+// verdict "miss" : resolved DEST path does not exist on disk
+// verdict "abs-not-under-src" : absolute storagePath outside SRC (no DEST mapping)
+// verdict "invalid" : malformed row, or a traversal path the resolver rejects
+export function classifyForDest(row, src, dest) {
+ const storagePath = row?.storagePath;
+ const contentHash = row?.contentHash;
+ if (!storagePath || !contentHash) {
+ return { verdict: "invalid", dest: null, reason: "malformed row" };
+ }
+
+ let resolved;
+ try {
+ resolved = resolveAgainstDest(storagePath, src, dest);
+ } catch (error) {
+ return { verdict: "invalid", dest: null, reason: error.message };
+ }
+
+ const { path, absNotUnderSrc } = resolved;
+ if (absNotUnderSrc) {
+ return { verdict: "abs-not-under-src", dest: null };
+ }
+ if (!path) {
+ return { verdict: "invalid", dest: null, reason: "absolute path is outside --src" };
+ }
+
+ try {
+ statSync(path);
+ } catch {
+ return { verdict: "miss", dest: path };
+ }
+
+ const got = createHash("sha256").update(readFileSync(path)).digest("hex");
+ if (got === contentHash) return { verdict: "ok", dest: path };
+ return { verdict: "mismatch", dest: path, expected: contentHash, got };
+}
diff --git a/ops/lib/snapshot-verify.test.mjs b/ops/lib/snapshot-verify.test.mjs
new file mode 100644
index 00000000..b2a2073d
--- /dev/null
+++ b/ops/lib/snapshot-verify.test.mjs
@@ -0,0 +1,116 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ mkdtempSync,
+ mkdirSync,
+ writeFileSync,
+ readFileSync,
+ cpSync,
+ existsSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createHash } from "node:crypto";
+import { classifyForDest } from "./snapshot-verify.mjs";
+
+// Build a hermetic SRC tree (a couple of builder subdirs, real files with known
+// sha256), copy it to a DEST tree, and drive classifyForDest end-to-end against
+// real bytes on disk — no DB, no network, os.tmpdir() only.
+function fixture() {
+ const root = mkdtempSync(join(tmpdir(), "snapshot-verify-test-"));
+ const src = join(root, "src");
+ const dest = join(root, "dest");
+
+ const files = {};
+ const put = (builder, body) => {
+ const hash = createHash("sha256").update(Buffer.from(body)).digest("hex");
+ mkdirSync(join(src, builder), { recursive: true });
+ writeFileSync(join(src, builder, `${hash}.html`), body);
+ files[`${builder}/${hash}.html`] = hash;
+ return { rel: `${builder}/${hash}.html`, hash };
+ };
+
+ const a = put("lennar", "lennar raw evidence\n");
+ const b = put("dr-horton", "dr horton raw evidence\n");
+
+ // DEST is a real copy of SRC (mirrors the rsync copy-only step).
+ cpSync(src, dest, { recursive: true });
+
+ return { root, src, dest, a, b };
+}
+
+test('"ok" for a copied+matching file', () => {
+ const { src, dest, a } = fixture();
+ const r = classifyForDest({ storagePath: a.rel, contentHash: a.hash }, src, dest);
+ assert.equal(r.verdict, "ok");
+ assert.equal(r.dest, join(dest, a.rel));
+});
+
+test('"miss" for a row whose file is not at DEST', () => {
+ const { src, dest } = fixture();
+ const ghostHash = createHash("sha256").update("never written\n").digest("hex");
+ const r = classifyForDest(
+ { storagePath: `centex/${ghostHash}.html`, contentHash: ghostHash },
+ src,
+ dest,
+ );
+ assert.equal(r.verdict, "miss");
+ assert.equal(r.dest, join(dest, `centex/${ghostHash}.html`));
+});
+
+test('"mismatch" for a file whose bytes do not match contentHash', () => {
+ const { src, dest, b } = fixture();
+ // Real file at DEST exists (b.rel), but we claim a wrong contentHash.
+ const wrongHash = createHash("sha256").update("different bytes\n").digest("hex");
+ const r = classifyForDest({ storagePath: b.rel, contentHash: wrongHash }, src, dest);
+ assert.equal(r.verdict, "mismatch");
+ assert.equal(r.expected, wrongHash);
+ assert.equal(r.got, b.hash);
+});
+
+test('"abs-not-under-src" for an absolute storagePath outside SRC', () => {
+ const { src, dest } = fixture();
+ const r = classifyForDest(
+ { storagePath: "/some/other/root/lennar/deadbeef.html", contentHash: "deadbeef" },
+ src,
+ dest,
+ );
+ assert.equal(r.verdict, "abs-not-under-src");
+ assert.equal(r.dest, null);
+});
+
+test("traversal paths are rejected (never escape DEST)", () => {
+ const { src, dest } = fixture();
+ for (const evil of ["../../etc/passwd", "var/snapshots/../../x", "lennar/../../../../etc/passwd"]) {
+ const r = classifyForDest({ storagePath: evil, contentHash: "x" }, src, dest);
+ assert.equal(r.verdict, "invalid", `expected ${evil} to be rejected`);
+ // Rejected traversal yields no DEST path — it can never point outside DEST.
+ assert.equal(r.dest, null);
+ }
+});
+
+test("malformed rows are invalid, not thrown", () => {
+ const { src, dest } = fixture();
+ assert.equal(classifyForDest({ storagePath: "", contentHash: "h" }, src, dest).verdict, "invalid");
+ assert.equal(classifyForDest({ storagePath: "x/y.html", contentHash: "" }, src, dest).verdict, "invalid");
+ assert.equal(classifyForDest({}, src, dest).verdict, "invalid");
+});
+
+test("NON-DESTRUCTIVE: every SRC file still exists after classifying", () => {
+ const { src, dest, a, b } = fixture();
+ const before = {
+ a: readFileSync(join(src, a.rel)),
+ b: readFileSync(join(src, b.rel)),
+ };
+ // Run the full spread of classifications, including a mismatch + a miss + traversal.
+ classifyForDest({ storagePath: a.rel, contentHash: a.hash }, src, dest);
+ classifyForDest({ storagePath: b.rel, contentHash: "wrong" }, src, dest);
+ classifyForDest({ storagePath: "ghost/x.html", contentHash: "x" }, src, dest);
+ classifyForDest({ storagePath: "../../etc/passwd", contentHash: "x" }, src, dest);
+ classifyForDest({ storagePath: "/outside/x.html", contentHash: "x" }, src, dest);
+
+ assert.ok(existsSync(join(src, a.rel)), "SRC file a must still exist");
+ assert.ok(existsSync(join(src, b.rel)), "SRC file b must still exist");
+ assert.deepEqual(readFileSync(join(src, a.rel)), before.a, "SRC file a bytes unchanged");
+ assert.deepEqual(readFileSync(join(src, b.rel)), before.b, "SRC file b bytes unchanged");
+});
diff --git a/ops/offload-snapshots.mjs b/ops/offload-snapshots.mjs
index 3da3a3cb..93d1e145 100644
--- a/ops/offload-snapshots.mjs
+++ b/ops/offload-snapshots.mjs
@@ -13,10 +13,8 @@
// Usage:
// node ops/offload-snapshots.mjs --dest /mnt/vol/snapshots [--src DIR] [--apply] [--full]
import { execFileSync } from "node:child_process";
-import { readFileSync, statSync } from "node:fs";
-import { createHash } from "node:crypto";
import { isAbsolute, relative, resolve } from "node:path";
-import { resolveAgainstDest } from "./lib/snapshot-resolve.mjs";
+import { classifyForDest } from "./lib/snapshot-verify.mjs";
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(`--${name}`);
@@ -126,32 +124,24 @@ const invalid = [];
let absNotUnderSrc = 0;
for (const line of rows) {
const [storagePath, contentHash] = line.split("\t");
- if (!storagePath || !contentHash) {
- invalid.push({ storagePath: storagePath || "(empty)", reason: "malformed DB row" });
- continue;
+ const r = classifyForDest({ storagePath, contentHash }, SRC, DEST);
+ switch (r.verdict) {
+ case "ok":
+ ok++;
+ break;
+ case "miss":
+ misses.push({ storagePath, dest: r.dest });
+ break;
+ case "mismatch":
+ mismatches.push({ storagePath, dest: r.dest, expected: r.expected, got: r.got });
+ break;
+ case "abs-not-under-src":
+ absNotUnderSrc++;
+ invalid.push({ storagePath, reason: "absolute path is outside --src" });
+ break;
+ default:
+ invalid.push({ storagePath: storagePath || "(empty)", reason: r.reason });
}
- let resolved;
- try {
- resolved = resolveAgainstDest(storagePath, SRC, DEST);
- } catch (error) {
- invalid.push({ storagePath, reason: error.message });
- continue;
- }
- const { path: dest, absNotUnderSrc: notUnder } = resolved;
- if (notUnder) absNotUnderSrc++;
- if (!dest) {
- invalid.push({ storagePath, reason: "absolute path is outside --src" });
- continue;
- }
- try {
- statSync(dest);
- } catch {
- misses.push({ storagePath, dest });
- continue;
- }
- const digest = createHash("sha256").update(readFileSync(dest)).digest("hex");
- if (digest === contentHash) ok++;
- else mismatches.push({ storagePath, dest, expected: contentHash, got: digest });
}
const total = rows.length || 1;
← a7904b1e fix(homesonspec): correct fabricated CPG termsUrls in seed (
·
back to Homesonspec
·
TK-10809: add ops/OFFLOAD-RUNBOOK.md (SAFE prod offload orde 915bd9c2 →