← 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
M ops/OFFLOAD-RUNBOOK.mdM ops/lib/snapshot-verify.mjsM ops/lib/snapshot-verify.test.mjsM ops/offload-snapshots.mjs
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 →