[object Object]

← back to Homesonspec

TK-10809: snapshot-verify catches DEST symlink false-green before gated delete

73a0e94fb27caf9fcd1309fc7e09a7c8b1a6148b · 2026-08-30 09:48:06 -0700 · Claude

FIX1: lstatSync existence check + distinct 'symlink' verdict; never hash-follow a
DEST symlink (rsync -a preserves links; a link back to SRC would sha-match and score
'ok' right before a gated delete wipes the real file). Wired through offload-snapshots
counters/summary/slice-print/clean-check so a symlink forces clean=false.
FIX2: +2 tests — absolute-under-SRC legacy-row shape classifies 'ok'; DEST symlink
back to SRC classifies 'symlink' (not 'ok') and proves non-destructive (SRC intact).
FIX3: explicit BLOCKING breakdown line (miss/mismatch/symlink/abs-not-under-src).
FIX4: runbook — step 5 is a prerequisite for step 4 PASSING when abs-not-under-src>0.

Files touched

Diff

commit 73a0e94fb27caf9fcd1309fc7e09a7c8b1a6148b
Author: Claude <steve@designerwallcoverings.com>
Date:   Sun Aug 30 09:48:06 2026 -0700

    TK-10809: snapshot-verify catches DEST symlink false-green before gated delete
    
    FIX1: lstatSync existence check + distinct 'symlink' verdict; never hash-follow a
    DEST symlink (rsync -a preserves links; a link back to SRC would sha-match and score
    'ok' right before a gated delete wipes the real file). Wired through offload-snapshots
    counters/summary/slice-print/clean-check so a symlink forces clean=false.
    FIX2: +2 tests — absolute-under-SRC legacy-row shape classifies 'ok'; DEST symlink
    back to SRC classifies 'symlink' (not 'ok') and proves non-destructive (SRC intact).
    FIX3: explicit BLOCKING breakdown line (miss/mismatch/symlink/abs-not-under-src).
    FIX4: runbook — step 5 is a prerequisite for step 4 PASSING when abs-not-under-src>0.
---
 ops/OFFLOAD-RUNBOOK.md           |  5 ++++-
 ops/lib/snapshot-verify.mjs      | 15 +++++++++++++--
 ops/lib/snapshot-verify.test.mjs | 37 +++++++++++++++++++++++++++++++++++++
 ops/offload-snapshots.mjs        | 16 ++++++++++++++++
 4 files changed, 70 insertions(+), 3 deletions(-)

diff --git a/ops/OFFLOAD-RUNBOOK.md b/ops/OFFLOAD-RUNBOOK.md
index 7da47b71..394df71b 100644
--- a/ops/OFFLOAD-RUNBOOK.md
+++ b/ops/OFFLOAD-RUNBOOK.md
@@ -42,7 +42,10 @@ it NEVER unlinks, prunes, moves, or rewrites the source.
    ```
    - Require the banner **`VERIFY PASS (FULL)`** and **exit 0**.
    - **Do NOT proceed on exit 3** (SAMPLE-only — completeness unproven).
-   - **Do NOT proceed on exit 1** (dirty — misses / hash mismatches / invalid rows).
+   - **Do NOT proceed on exit 1** (dirty — misses / hash mismatches / symlinks / invalid rows).
+   - **If the `BLOCKING` line reports `abs-not-under-src > 0`, this step CANNOT reach
+     exit 0 until step 5 (legacy-absolute rewrite or symlink-prefix) is done** — step 5
+     is a prerequisite for step 4 *PASSING*, not merely for the later deletion.
    - Repeat until exit 0. Exit 0 + FULL is the ONLY proof the DEST is complete.
 
 5. **Rewrite legacy-ABSOLUTE `storagePath` rows (REQUIRED before any deletion).**
diff --git a/ops/lib/snapshot-verify.mjs b/ops/lib/snapshot-verify.mjs
index 982ef195..bf4d3e99 100644
--- a/ops/lib/snapshot-verify.mjs
+++ b/ops/lib/snapshot-verify.mjs
@@ -5,7 +5,7 @@
 //
 // 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 { readFileSync, lstatSync } from "node:fs";
 import { createHash } from "node:crypto";
 import { resolveAgainstDest } from "./snapshot-resolve.mjs";
 
@@ -13,6 +13,7 @@ import { resolveAgainstDest } from "./snapshot-resolve.mjs";
 //   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 "symlink"           : DEST entry is a symlink, NOT a real file — never hash-follow
 //   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) {
@@ -37,12 +38,22 @@ export function classifyForDest(row, src, dest) {
     return { verdict: "invalid", dest: null, reason: "absolute path is outside --src" };
   }
 
+  let entry;
   try {
-    statSync(path);
+    entry = lstatSync(path);
   } catch {
     return { verdict: "miss", dest: path };
   }
 
+  // rsync -a preserves symlinks (it copies the link, not the target). A DEST symlink
+  // pointing back at SRC (or anywhere with matching bytes) would hash-follow to "ok",
+  // falsely proving evidence lives in DEST — right before a gated delete wipes the real
+  // file and leaves a dangling link. A real snapshot store is plain files; never
+  // hash-follow a link, surface it as its own not-ok verdict for the operator to see.
+  if (entry.isSymbolicLink()) {
+    return { verdict: "symlink", 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
index b2a2073d..a1533ef8 100644
--- a/ops/lib/snapshot-verify.test.mjs
+++ b/ops/lib/snapshot-verify.test.mjs
@@ -7,6 +7,8 @@ import {
   readFileSync,
   cpSync,
   existsSync,
+  symlinkSync,
+  rmSync,
 } from "node:fs";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -68,6 +70,41 @@ test('"mismatch" for a file whose bytes do not match contentHash', () => {
   assert.equal(r.got, b.hash);
 });
 
+test('"ok" for an ABSOLUTE storagePath that IS under SRC (the prod legacy-row shape)', () => {
+  const { src, dest, a } = fixture();
+  // Legacy rows store an absolute path under SRC; resolveAgainstDest prefix-swaps
+  // SRC->DEST and the copy hash-matches. This is the real production row shape.
+  const r = classifyForDest(
+    { storagePath: join(src, a.rel), contentHash: a.hash },
+    src,
+    dest,
+  );
+  assert.equal(r.verdict, "ok");
+  assert.equal(r.dest, join(dest, a.rel));
+});
+
+test('"symlink" (NOT "ok") when the DEST path is a symlink back to SRC', () => {
+  const { src, dest, a } = fixture();
+  // Replace the real copied file at DEST with a symlink to the SRC original. The
+  // bytes behind the link hash-match, so a stat-follow would falsely score "ok" —
+  // FIX 1 must catch the link and refuse to hash-follow it.
+  const destPath = join(dest, a.rel);
+  rmSync(destPath);
+  symlinkSync(join(src, a.rel), destPath);
+
+  const r = classifyForDest({ storagePath: a.rel, contentHash: a.hash }, src, dest);
+  assert.equal(r.verdict, "symlink");
+  assert.equal(r.dest, destPath);
+
+  // Proves FIX 1 is non-destructive: the real SRC evidence is untouched.
+  assert.ok(existsSync(join(src, a.rel)), "SRC evidence must remain intact");
+  assert.deepEqual(
+    readFileSync(join(src, a.rel)),
+    Buffer.from("lennar raw evidence\n"),
+    "SRC bytes unchanged",
+  );
+});
+
 test('"abs-not-under-src" for an absolute storagePath outside SRC', () => {
   const { src, dest } = fixture();
   const r = classifyForDest(
diff --git a/ops/offload-snapshots.mjs b/ops/offload-snapshots.mjs
index 93d1e145..186a04cf 100644
--- a/ops/offload-snapshots.mjs
+++ b/ops/offload-snapshots.mjs
@@ -120,6 +120,7 @@ console.log(
 let ok = 0;
 const misses = [];
 const mismatches = [];
+const symlinks = [];
 const invalid = [];
 let absNotUnderSrc = 0;
 for (const line of rows) {
@@ -135,6 +136,9 @@ for (const line of rows) {
     case "mismatch":
       mismatches.push({ storagePath, dest: r.dest, expected: r.expected, got: r.got });
       break;
+    case "symlink":
+      symlinks.push({ storagePath, dest: r.dest });
+      break;
     case "abs-not-under-src":
       absNotUnderSrc++;
       invalid.push({ storagePath, reason: "absolute path is outside --src" });
@@ -149,6 +153,7 @@ const coverage = ((ok / total) * 100).toFixed(2);
 console.log(`\n  coverage: ${coverage}%  (ok ${ok} / ${rows.length})`);
 console.log(`  missing : ${misses.length}`);
 console.log(`  mismatch: ${mismatches.length}`);
+console.log(`  symlink : ${symlinks.length} (DEST entry is a link, not a real file — evidence not proven at DEST)`);
 console.log(`  invalid  : ${invalid.length}`);
 console.log(
   `  abs-not-under-src: ${absNotUnderSrc} (unverified: --src does not cover these legacy absolute paths)`,
@@ -158,12 +163,23 @@ if (misses.length > 20) console.log(`    … and ${misses.length - 20} more miss
 for (const m of mismatches.slice(0, 20))
   console.log(`    HASH ${m.storagePath}: expected ${m.expected.slice(0, 12)} got ${m.got.slice(0, 12)}`);
 if (mismatches.length > 20) console.log(`    … and ${mismatches.length - 20} more mismatches`);
+for (const m of symlinks.slice(0, 20)) console.log(`    SYMLINK ${m.storagePath} -> ${m.dest}`);
+if (symlinks.length > 20) console.log(`    … and ${symlinks.length - 20} more symlinks`);
 for (const m of invalid.slice(0, 20)) console.log(`    INVALID ${m.storagePath}: ${m.reason}`);
 
+// FIX 3 — spell out the blocking breakdown so an operator can't misread the coverage
+// line as near-done noise. abs-not-under-src sits in `invalid` but is its own blocker
+// class; every non-ok verdict here keeps the run from a clean PASS.
+console.log(
+  `\n  BLOCKING: miss=${misses.length} mismatch=${mismatches.length} symlink=${symlinks.length} abs-not-under-src=${absNotUnderSrc}` +
+    `  → run cannot PASS until these are 0`,
+);
+
 const rowCoverageComplete = rows.length === totalRows && totalRows > 0;
 const clean =
   misses.length === 0 &&
   mismatches.length === 0 &&
+  symlinks.length === 0 &&
   invalid.length === 0 &&
   (!FULL || rowCoverageComplete);
 

← 915bd9c2 TK-10809: add ops/OFFLOAD-RUNBOOK.md (SAFE prod offload orde  ·  back to Homesonspec  ·  Build and register Harris Doyle collector 473713d5 →