Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions cli/__tests__/gitignore-cache.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
130 changes: 130 additions & 0 deletions cli/__tests__/review-runtime-fingerprint.test.mts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion cli/lib/install/gitignore-cache.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
108 changes: 108 additions & 0 deletions cli/lib/ship/review/runtime-fingerprint.mts
Original file line number Diff line number Diff line change
@@ -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<string>,
): 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<string>,
): 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 <expected> <path> [...]');
}
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 <path>');
process.stdout.write(reviewRuntimeFingerprint(target));
}

const invokedPath = process.argv[1];
if (invokedPath && realpathSync(invokedPath) === realpathSync(fileURLToPath(import.meta.url))) {
runCli(process.argv.slice(2));
}
Loading