* 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:
94
src/pristine-baseline.cts
Normal file
94
src/pristine-baseline.cts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user