fix(#4145): resolve gsd-pristine/ baselines by recorded hash, relocate orphans (#4364)

* test(#4145): regression rows for hash-matching prefix-less pristine baselines

RED skeleton: src/pristine-baseline.cts exports findPristineByHash as a
null-returning stub so the new rows fail behaviorally, not at require time.
Failing-first rows: verifier resolution (no_baseline must drop to 0 when an
exact-hash orphan exists), findPristineByHash unit row, and the two
saveLocalPatches relocation rows. Negative-space rows pin today's behavior:
missing baselines still report ok_no_baseline, mismatching orphans are never
adopted or deleted, canonical precedence and the #3657 drift posture are
untouched.

* fix(#4145): resolve gsd-pristine/ baselines by recorded hash, relocate orphans

Both pristine readers joined the manifest-keyed path strictly, so a snapshot
stored without the gsd-core/ prefix (an earlier release's writer) was reported
as ok_no_baseline by the verifier and pushed into regeneration by
saveLocalPatches — where incoming-release candidates can never satisfy the
recorded outgoing hash, leaving the correct baseline permanently unconsumed.

- src/pristine-baseline.cts (new, ADR-457): shared findPristineByHash —
  deterministic sorted scan of gsd-pristine/, exact sha-256 equality with the
  recorded pristine_hashes entry (the same authority the #3657 drift guard
  trusts), symlink-skipping, canonical path excluded via skipRel.
- verify-reapply-patches.cjs verifyFile(): on canonical miss with a recorded
  hash, adopt byte-identical content found anywhere under gsd-pristine/ before
  reporting OK_NO_BASELINE. Drift posture (#3657), canonical precedence, and
  the frozen REASON/report shapes are untouched; the verifier stays read-only.
- install.js saveLocalPatches(): preserve-check rescue — relocate a
  hash-matching orphan to the canonical path (copy, hash-verify, then remove
  the orphan) so the state self-heals on the next update instead of repeating
  forever. Honest accounting: new non-overlapping rescued counter.
- Workflow doc: one-sentence note on hash-based snapshot resolution.
- Derived ripples: INVENTORY-MANIFEST.json regen, eslint ignore + .gitignore
  entries for the compiled artifact, seedFixture mkdir fix in the new rows.

Emitted-Drift-Ack-Growth: reapply-patches.md — one-sentence note on hash-based pristine snapshot resolution (#4145)

* fix(#4145): review follow-up — orphan scan never consumes a canonical path

Adversarial review finding: with two modified files sharing byte-identical
outgoing content, recoverOrphanedPristine could adopt the OTHER file's
canonical pristine as its rescue source — relocating it (copy + delete at
its home path) and ping-ponging the single baseline between the two files
across updates. findPristineByHash's skip parameter now accepts a Set, and
saveLocalPatches passes the normalized manifest keys so every canonical
path is excluded; only genuine non-canonical orphans are eligible for
removal (no strict-join reader ever consults those). Adds the
canonical-theft regression row, a Set-skip unit assertion, and tightens the
workflow doc sentence the same pass flagged as overstated.

* fix(#4145): INVENTORY roster row + symlink-fixture correction

Two leftovers from the ab17b7a1e5 bench run, both root-caused:
- docs/INVENTORY.md roster row for cli_modules/pristine-baseline.cjs
  (#3762 gate: every manifest entry carries a row).
- The findPristineByHash symlink unit fixture placed its symlink target
  INSIDE the scanned root, so the walk legitimately matched the real target
  file. The implementation skips the symlink itself; the fixture now keeps
  the target outside the scanned tree so the assertion tests what it claims.

* changeset(#4145): fixed fragment for pristine baseline hash resolution

---------

Co-authored-by: gsd-agent <agent@gsd.local>
This commit is contained in:
Tom Boucher
2026-09-06 02:04:18 -04:00
committed by GitHub
parent d5a85da8ab
commit 6adf3098ac
11 changed files with 724 additions and 7 deletions

94
src/pristine-baseline.cts Normal file
View File

@@ -0,0 +1,94 @@
/**
* #4145: hash-first recovery for gsd-pristine/ baselines stored at an
* unexpected path.
*
* Some installs hold a pristine snapshot whose SHA-256 equals the hash recorded
* in backup-meta.json.pristine_hashes for a manifest-keyed file, but at a path
* that is not `path.join(pristineDir, relPath)` — e.g. stored without the
* `gsd-core/` top-level segment by an earlier release's writer. Both readers
* (verify-reapply-patches.cjs verifyFile and install.js saveLocalPatches)
* resolved strictly by that join, missed the snapshot, and reported
* ok_no_baseline / fell into regeneration that can never satisfy the recorded
* outgoing hash — a self-perpetuating gap.
*
* Hash equality with the recorded pristine_hashes entry is the same authority
* the #3657 drift guard already trusts, so a match cannot be the wrong
* baseline regardless of which release wrote it or where under gsd-pristine/
* it lives. This module owns the shared scan so the two readers cannot drift
* apart again (two private strict joins drifting is exactly the bug class).
*
* ADR-457: runtime module in src/*.cts, compiled to
* gsd-core/bin/lib/pristine-baseline.cjs.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
/**
* SHA-256 hex digest of a file's raw bytes. Byte-for-byte the same digest
* install.js fileHash() records into manifests and backup-meta.json.
*/
export function sha256File(absPath: string): string {
return crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex');
}
function walkSorted(dir: string, relPrefix: string, results: string[]): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // absent or unreadable — nothing to scan here
}
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of entries) {
// Never follow symlinks: gsd-pristine/ is installer-authored plain files;
// a link here is not a baseline and must not redirect the walk out of the
// tree (same posture as migration 004's walker).
if (entry.isSymbolicLink()) continue;
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walkSorted(path.join(dir, entry.name), rel, results);
} else if (entry.isFile()) {
results.push(rel);
}
}
}
/**
* Find the first file under `pristineDir` (deterministic sorted walk) whose
* SHA-256 equals `recordedHash`, as a pristineDir-relative POSIX path.
*
* - `skip` is never returned — a single POSIX relPath string or a Set of them.
* Callers pass the canonical path(s) they (or other files in the same run)
* already own, so a file sitting at a canonical path is never adopted
* through the scan. For the installer's relocation this is what prevents a
* byte-identical canonical belonging to ANOTHER modified file from being
* "rescued" away (relocated and deleted at its home path).
* - Multiple matches are byte-identical by sha-256 authority; sorted order
* makes the choice deterministic.
* - Returns null when pristineDir is absent/unreadable or nothing matches.
*/
export function findPristineByHash(
pristineDir: string,
recordedHash: string,
skip?: string | ReadonlySet<string>,
): string | null {
if (!pristineDir || typeof recordedHash !== 'string' || recordedHash.length === 0) {
return null;
}
const skipSet = skip instanceof Set ? skip : new Set(skip !== undefined ? [skip] : []);
const rels: string[] = [];
walkSorted(pristineDir, '', rels);
for (const rel of rels) {
if (skipSet.has(rel)) continue;
try {
if (sha256File(path.join(pristineDir, rel)) === recordedHash) {
return rel;
}
} catch {
// unreadable candidate — keep scanning
}
}
return null;
}