From b2c77e5d27e62735b18f5526d4fffc90ddc83430 Mon Sep 17 00:00:00 2001 From: norvalbv Date: Sat, 18 Jul 2026 11:24:15 +0100 Subject: [PATCH] refactor: add review runtime fingerprints Stacked on #114. Adds dormant foundations for freezing trusted review inputs before the public command is exposed: - manages the durable `.devkit/review-runs/` output ignore through init/clean - fingerprints file content and Git-relevant executable identity - fingerprints directories deterministically while dereferencing symlinks - rejects cyclic, dangling, and unsupported runtime paths - verifies multiple expected/absent paths through a symlink-safe CLI entrypoint Verification: - focused cache/fingerprint tests (15/15) - `bun run typecheck` - `bun run lint` (two inherited warnings outside this diff) - `bun run build` - `node cli/index.mts doctor` - Fallow introduced-debt scan clean - dedicated correctness review PASS after fixing its symlink-entrypoint blocker --- .gitignore | 3 +- cli/__tests__/gitignore-cache.test.mts | 5 + .../review-runtime-fingerprint.test.mts | 130 ++++++++++++++++++ cli/lib/install/gitignore-cache.mts | 3 +- cli/lib/ship/review/runtime-fingerprint.mts | 108 +++++++++++++++ 5 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 cli/__tests__/review-runtime-fingerprint.test.mts create mode 100644 cli/lib/ship/review/runtime-fingerprint.mts diff --git a/.gitignore b/.gitignore index 66c1a5f2..fd12477b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,12 @@ node_modules .search-code/ # qavis pass-receipt: content-addressed cache written by `qavis qa` (specific file — .qavis/recipe.json stays tracked) .qavis/receipt.json -# ship/gate caches under .devkit/ (regenerated, per-branch/-tree keyed; tracked manifests stay tracked) +# ship/review/gate caches under .devkit/ (regenerated, per-run/-branch/-tree keyed; tracked manifests stay tracked) .devkit/prefix-cache.json .devkit/decisions-verdict-cache.json .devkit/review-cache.json .devkit/review-progress-*.json +.devkit/review-runs/ .devkit/last-ship-gates-*.log .devkit/reconcile-manifest.json # decision-log embedding cache (query); rebuildable via `guard-decisions reindex`. docs/decisions/ IS committed. diff --git a/cli/__tests__/gitignore-cache.test.mts b/cli/__tests__/gitignore-cache.test.mts index 3435a343..e4ad7b65 100644 --- a/cli/__tests__/gitignore-cache.test.mts +++ b/cli/__tests__/gitignore-cache.test.mts @@ -19,6 +19,11 @@ afterEach(() => { }); describe('ensureDevkitCacheGitignore', () => { + it('manages the review run directory without ignoring tracked devkit state', () => { + expect(DEVKIT_CACHE_IGNORES).toContain('.devkit/review-runs/'); + expect(DEVKIT_CACHE_IGNORES).not.toContain('.devkit/'); + }); + it('appends every cache pattern when .gitignore is absent', () => { const d = tmp(); ensureDevkitCacheGitignore(d, false); diff --git a/cli/__tests__/review-runtime-fingerprint.test.mts b/cli/__tests__/review-runtime-fingerprint.test.mts new file mode 100644 index 00000000..35f47907 --- /dev/null +++ b/cli/__tests__/review-runtime-fingerprint.test.mts @@ -0,0 +1,130 @@ +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + reviewRuntimeFileFingerprint, + reviewRuntimeFingerprint, +} from '../lib/ship/review/runtime-fingerprint.mts'; +import { rootRegistry } from './_helpers.mts'; + +const FINGERPRINT_CLI = join( + dirname(fileURLToPath(import.meta.url)), + '../lib/ship/review/runtime-fingerprint.mts', +); +const { mkTmp, cleanup } = rootRegistry(); + +afterEach(cleanup); + +function runFingerprint(...args: string[]) { + return spawnSync(process.execPath, [FINGERPRINT_CLI, ...args], { encoding: 'utf8' }); +} + +describe('review runtime fingerprints', () => { + it('tracks binary content and executable mode, but not unrelated permission bits', () => { + const root = mkTmp('devkit-review-fingerprint-file-'); + const file = join(root, 'runtime.bin'); + const initial = Buffer.from([0, 1, 2, 0, 255]); + writeFileSync(file, initial); + chmodSync(file, 0o644); + const regular = reviewRuntimeFingerprint(file); + + expect(regular).toBe(reviewRuntimeFileFingerprint(initial, 0o644)); + chmodSync(file, 0o600); + expect(reviewRuntimeFingerprint(file)).toBe(regular); + chmodSync(file, 0o755); + expect(reviewRuntimeFingerprint(file)).not.toBe(regular); + writeFileSync(file, Buffer.from([0, 1, 2, 0, 254])); + expect(reviewRuntimeFingerprint(file)).not.toBe(regular); + }); + + it('uses deterministic relative-path order for directory trees', () => { + const first = mkTmp('devkit-review-fingerprint-order-a-'); + const second = mkTmp('devkit-review-fingerprint-order-b-'); + mkdirSync(join(first, 'nested')); + mkdirSync(join(second, 'nested')); + writeFileSync(join(first, 'z.txt'), 'last\n'); + writeFileSync(join(first, 'nested/a.txt'), 'first\n'); + writeFileSync(join(second, 'nested/a.txt'), 'first\n'); + writeFileSync(join(second, 'z.txt'), 'last\n'); + + expect(reviewRuntimeFingerprint(first)).toBe(reviewRuntimeFingerprint(second)); + writeFileSync(join(second, 'nested/a.txt'), 'changed\n'); + expect(reviewRuntimeFingerprint(first)).not.toBe(reviewRuntimeFingerprint(second)); + }); + + it('dereferences file and directory symlinks', () => { + const root = mkTmp('devkit-review-fingerprint-links-'); + const file = join(root, 'file.txt'); + const directory = join(root, 'directory'); + writeFileSync(file, 'runtime\n'); + mkdirSync(directory); + writeFileSync(join(directory, 'nested.txt'), 'nested\n'); + symlinkSync(file, join(root, 'file-link')); + symlinkSync(directory, join(root, 'directory-link')); + + expect(reviewRuntimeFingerprint(join(root, 'file-link'))).toBe(reviewRuntimeFingerprint(file)); + expect(reviewRuntimeFingerprint(join(root, 'directory-link'))).toBe( + reviewRuntimeFingerprint(directory), + ); + }); + + it('rejects cyclic directory projections', () => { + const root = mkTmp('devkit-review-fingerprint-cycle-'); + mkdirSync(join(root, 'nested')); + symlinkSync(root, join(root, 'nested/back')); + + expect(() => reviewRuntimeFingerprint(root)).toThrow(/cyclic review runtime path/); + }); + + it.runIf(process.platform !== 'win32')('rejects unsupported filesystem entry types', () => { + const root = mkTmp('devkit-review-fingerprint-fifo-'); + const fifo = join(root, 'runtime.fifo'); + const created = spawnSync('mkfifo', [fifo], { encoding: 'utf8' }); + expect(created.status, created.stderr).toBe(0); + + expect(() => reviewRuntimeFingerprint(fifo)).toThrow(/unsupported review runtime path type/); + }); + + it('verifies fingerprints and true absence in one invocation', () => { + const root = mkTmp('devkit-review-fingerprint-verify-'); + const file = join(root, 'runtime.txt'); + const absent = join(root, 'absent'); + writeFileSync(file, 'runtime\n'); + const expected = reviewRuntimeFingerprint(file); + + const unchanged = runFingerprint('--verify', expected, file, 'absent', absent); + expect(unchanged.status, unchanged.stderr).toBe(0); + + writeFileSync(file, 'changed\n'); + const changed = runFingerprint('--verify', expected, file, 'absent', absent); + expect(changed.status).toBe(1); + expect(changed.stderr).toContain(file); + }); + + it('does not treat a dangling symlink as absent', () => { + const root = mkTmp('devkit-review-fingerprint-dangling-'); + const link = join(root, 'runtime-link'); + symlinkSync(join(root, 'missing'), link); + + const result = runFingerprint('--verify', 'absent', link); + + expect(result.status).not.toBe(0); + }); + + it('runs verification when the CLI entrypoint is reached through a package symlink', () => { + const root = mkTmp('devkit-review-fingerprint-cli-link-'); + const cliLink = join(root, 'runtime-fingerprint.mts'); + const file = join(root, 'runtime.txt'); + symlinkSync(FINGERPRINT_CLI, cliLink); + writeFileSync(file, 'runtime\n'); + + const result = spawnSync(process.execPath, [cliLink, '--verify', 'wrong', file], { + encoding: 'utf8', + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(file); + }); +}); diff --git a/cli/lib/install/gitignore-cache.mts b/cli/lib/install/gitignore-cache.mts index 93f506af..a58ecbb4 100644 --- a/cli/lib/install/gitignore-cache.mts +++ b/cli/lib/install/gitignore-cache.mts @@ -16,12 +16,13 @@ import { join } from 'node:path'; // Each entry matches its writer verbatim: prefix-cache.mjs STORE_FILE, decisions/verdict-cache.mjs // STORE_FILE, review/cache.mjs CACHE_FILE, review/run-review.mjs progress (DEVKIT_REVIEW_PROGRESS), -// commit-with-gate-capture.sh's log, reconcile-manifest-write. +// review-target.sh's per-run output, commit-with-gate-capture.sh's log, reconcile-manifest-write. export const DEVKIT_CACHE_IGNORES = [ '.devkit/prefix-cache.json', '.devkit/decisions-verdict-cache.json', '.devkit/review-cache.json', '.devkit/review-progress-*.json', + '.devkit/review-runs/', '.devkit/last-ship-gates-*.log', '.devkit/reconcile-manifest.json', ]; diff --git a/cli/lib/ship/review/runtime-fingerprint.mts b/cli/lib/ship/review/runtime-fingerprint.mts new file mode 100644 index 00000000..b705e19f --- /dev/null +++ b/cli/lib/ship/review/runtime-fingerprint.mts @@ -0,0 +1,108 @@ +/** Stable content + executable-mode fingerprints for paths frozen into a review runtime. */ +import { createHash, type Hash } from 'node:crypto'; +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ABSENT_FINGERPRINT = 'absent'; + +function updateField(hash: Hash, value: string | Uint8Array): void { + const size = typeof value === 'string' ? Buffer.byteLength(value) : value.byteLength; + hash.update(`${size}:`); + hash.update(value); +} + +function executableMode(mode: number): string { + return (mode & 0o111) === 0 ? 'regular' : 'executable'; +} + +function updateFile(hash: Hash, relativePath: string, content: Uint8Array, mode: number): void { + updateField(hash, 'file'); + updateField(hash, relativePath); + updateField(hash, executableMode(mode)); + updateField(hash, content); +} + +function visitDirectory( + absolutePath: string, + relativePath: string, + hash: Hash, + ancestors: ReadonlySet, +): void { + const realPath = realpathSync(absolutePath); + if (ancestors.has(realPath)) throw new Error(`cyclic review runtime path: ${absolutePath}`); + + updateField(hash, 'directory'); + updateField(hash, relativePath); + const descendants = new Set(ancestors).add(realPath); + for (const entry of readdirSync(absolutePath).sort()) { + const childRelativePath = relativePath === '.' ? entry : `${relativePath}/${entry}`; + visitPath(join(absolutePath, entry), childRelativePath, hash, descendants); + } +} + +function visitPath( + absolutePath: string, + relativePath: string, + hash: Hash, + ancestors: ReadonlySet, +): void { + const stat = statSync(absolutePath); + if (stat.isFile()) { + updateFile(hash, relativePath, readFileSync(absolutePath), stat.mode); + return; + } + if (stat.isDirectory()) { + visitDirectory(absolutePath, relativePath, hash, ancestors); + return; + } + throw new Error(`unsupported review runtime path type: ${absolutePath}`); +} + +/** Fingerprint already-read file bytes with the executable mode used by the runtime. */ +export function reviewRuntimeFileFingerprint(content: Uint8Array, mode: number): string { + const hash = createHash('sha256'); + updateFile(hash, '.', content, mode); + return hash.digest('hex'); +} + +/** Fingerprint a file or directory, dereferencing symlinks and sorting directory entries. */ +export function reviewRuntimeFingerprint(path: string): string { + const hash = createHash('sha256'); + visitPath(path, '.', hash, new Set()); + return hash.digest('hex'); +} + +function runtimeFingerprintState(path: string): string { + const entry = lstatSync(path, { throwIfNoEntry: false }); + return entry === undefined ? ABSENT_FINGERPRINT : reviewRuntimeFingerprint(path); +} + +function verifyPairs(pairs: string[]): void { + if (pairs.length === 0 || pairs.length % 2 !== 0) { + throw new Error('usage: runtime-fingerprint --verify [...]'); + } + for (let index = 0; index < pairs.length; index += 2) { + const expected = pairs[index] as string; + const target = pairs[index + 1] as string; + if (runtimeFingerprintState(target) !== expected) { + console.error(target); + process.exitCode = 1; + } + } +} + +function runCli(args: string[]): void { + if (args[0] === '--verify') { + verifyPairs(args.slice(1)); + return; + } + const target = args[0]; + if (!target || args.length !== 1) throw new Error('usage: runtime-fingerprint '); + process.stdout.write(reviewRuntimeFingerprint(target)); +} + +const invokedPath = process.argv[1]; +if (invokedPath && realpathSync(invokedPath) === realpathSync(fileURLToPath(import.meta.url))) { + runCli(process.argv.slice(2)); +}