[object Object]

← 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

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 →