360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
import { existsSync } from 'node:fs';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { access, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm } from 'node:fs/promises';
|
|
import { spawnSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
|
|
function git(root, args, { allowFailure = false } = {}) {
|
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
if (result.status !== 0 && !allowFailure) {
|
|
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function listProspectiveCommits(root, activationCommit, head = 'HEAD') {
|
|
const result = git(root, [
|
|
'rev-list',
|
|
'--first-parent',
|
|
'--reverse',
|
|
`${activationCommit}..${head}`,
|
|
]);
|
|
return result.stdout.trim() ? result.stdout.trim().split('\n') : [];
|
|
}
|
|
|
|
export async function readManifestAtCommit(root, commit) {
|
|
const result = git(root, ['show', `${commit}:gates/gates.manifest.json`]);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
async function snapshotAuthoritativeTree(root) {
|
|
const snapshot = new Map();
|
|
async function walk(current) {
|
|
for (const child of await readdir(current, { withFileTypes: true })) {
|
|
if (['.git', '.home', 'node_modules'].includes(child.name)) continue;
|
|
const absolute = path.join(current, child.name);
|
|
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
const stats = await lstat(absolute);
|
|
if (stats.isDirectory()) {
|
|
await walk(absolute);
|
|
} else if (stats.isSymbolicLink()) {
|
|
snapshot.set(relative, `symlink:${stats.mode}:${await readlink(absolute)}`);
|
|
} else if (stats.isFile()) {
|
|
const digest = createHash('sha256')
|
|
.update(await readFile(absolute))
|
|
.digest('hex');
|
|
snapshot.set(relative, `file:${stats.mode}:${digest}`);
|
|
}
|
|
}
|
|
}
|
|
await walk(root);
|
|
return snapshot;
|
|
}
|
|
|
|
async function authoritativeTreeChanges(root, snapshot) {
|
|
const changes = [];
|
|
for (const [relative, expected] of snapshot) {
|
|
const absolute = path.join(root, relative);
|
|
let actual;
|
|
try {
|
|
const stats = await lstat(absolute);
|
|
if (stats.isSymbolicLink()) {
|
|
actual = `symlink:${stats.mode}:${await readlink(absolute)}`;
|
|
} else if (stats.isFile()) {
|
|
const digest = createHash('sha256')
|
|
.update(await readFile(absolute))
|
|
.digest('hex');
|
|
actual = `file:${stats.mode}:${digest}`;
|
|
} else {
|
|
actual = `other:${stats.mode}`;
|
|
}
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
actual = 'missing';
|
|
}
|
|
if (actual !== expected) changes.push(relative);
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
function bubblewrap(root, command, args, { storePath, timeout = 300_000 } = {}) {
|
|
const sandboxArgs = [
|
|
'--unshare-net',
|
|
'--unshare-pid',
|
|
'--unshare-ipc',
|
|
'--unshare-uts',
|
|
'--die-with-parent',
|
|
'--new-session',
|
|
'--clearenv',
|
|
];
|
|
for (const systemPath of ['/usr', '/bin', '/lib', '/lib64', '/etc']) {
|
|
if (existsSync(systemPath)) sandboxArgs.push('--ro-bind', systemPath, systemPath);
|
|
}
|
|
sandboxArgs.push('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--bind', root, '/work');
|
|
if (storePath) sandboxArgs.push('--ro-bind', storePath, '/pnpm-store');
|
|
const corepackHome = path.join(process.env.HOME ?? '', '.cache', 'node', 'corepack');
|
|
if (existsSync(corepackHome)) sandboxArgs.push('--ro-bind', corepackHome, '/corepack');
|
|
sandboxArgs.push(
|
|
'--chdir',
|
|
'/work',
|
|
'--setenv',
|
|
'HOME',
|
|
'/work/.home',
|
|
'--setenv',
|
|
'PATH',
|
|
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
'--setenv',
|
|
'LANG',
|
|
'C.UTF-8',
|
|
'--setenv',
|
|
'CI',
|
|
'true',
|
|
);
|
|
if (storePath) sandboxArgs.push('--setenv', 'NPM_CONFIG_STORE_DIR', '/pnpm-store');
|
|
if (existsSync(corepackHome)) sandboxArgs.push('--setenv', 'COREPACK_HOME', '/corepack');
|
|
const enteredMarker = `__MOSAIC_BWRAP_ENTERED_${randomUUID()}__`;
|
|
sandboxArgs.push(
|
|
'/bin/sh',
|
|
'-c',
|
|
'printf "%s\\n" "$1"; shift; exec "$@"',
|
|
'mosaic-bwrap-entry',
|
|
enteredMarker,
|
|
command,
|
|
...args,
|
|
);
|
|
const result = spawnSync('bwrap', sandboxArgs, { encoding: 'utf8', timeout });
|
|
const sandboxEntered = result.stdout?.includes(enteredMarker) === true;
|
|
return {
|
|
...result,
|
|
stdout: (result.stdout ?? '').replace(`${enteredMarker}\n`, ''),
|
|
sandboxLauncher: 'bwrap',
|
|
sandboxEntered,
|
|
};
|
|
}
|
|
|
|
export async function replayCommit(root, commit) {
|
|
const replayRoot = await mkdtemp(
|
|
path.join(path.dirname(root), `.gate-history-${commit.slice(0, 12)}-`),
|
|
);
|
|
const archive = `${replayRoot}.tar`;
|
|
try {
|
|
git(root, ['archive', '--format=tar', `--output=${archive}`, commit]);
|
|
const extract = spawnSync('tar', ['-xf', archive, '-C', replayRoot], { encoding: 'utf8' });
|
|
if (extract.status !== 0) {
|
|
return { status: extract.status, stdout: extract.stdout, stderr: extract.stderr };
|
|
}
|
|
const authoritativeSnapshot = await snapshotAuthoritativeTree(replayRoot);
|
|
await mkdir(path.join(replayRoot, '.home'), { recursive: true });
|
|
let storePath;
|
|
try {
|
|
await access(path.join(replayRoot, 'package.json'));
|
|
const init = spawnSync('git', ['init', '--quiet', replayRoot], { encoding: 'utf8' });
|
|
if (init.status !== 0) return init;
|
|
const store = spawnSync('pnpm', ['store', 'path'], { encoding: 'utf8' });
|
|
if (store.status !== 0) return store;
|
|
storePath = store.stdout.trim();
|
|
const install = bubblewrap(
|
|
replayRoot,
|
|
'pnpm',
|
|
['install', '--frozen-lockfile', '--offline'],
|
|
{ storePath, timeout: 600_000 },
|
|
);
|
|
if (install.status !== 0 || install.error || install.signal) {
|
|
return {
|
|
...install,
|
|
stderr: `historical frozen dependency install failed: ${install.error?.message || install.stderr || install.stdout || ''}`,
|
|
};
|
|
}
|
|
const authoritativeChanges = await authoritativeTreeChanges(
|
|
replayRoot,
|
|
authoritativeSnapshot,
|
|
);
|
|
if (authoritativeChanges.length > 0) {
|
|
return {
|
|
status: 1,
|
|
stdout: '',
|
|
stderr: `authoritative archived file changed during historical install: ${authoritativeChanges.join(', ')}`,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
return bubblewrap(
|
|
replayRoot,
|
|
process.execPath,
|
|
[
|
|
'/work/scripts/gate-verify.mjs',
|
|
'--root',
|
|
'/work',
|
|
'--manifest',
|
|
'gates/gates.manifest.json',
|
|
'--skip-history',
|
|
],
|
|
{ storePath },
|
|
);
|
|
} finally {
|
|
await rm(archive, { force: true });
|
|
await rm(replayRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export function assessProviderEvidence(commit, pipelines) {
|
|
const matches = pipelines.filter((candidate) => candidate.commit === commit);
|
|
if (matches.length === 0) {
|
|
return {
|
|
state: 'absent',
|
|
detail:
|
|
'no retained provider record was supplied; retention expiry and never-ran are not inferred',
|
|
};
|
|
}
|
|
const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']);
|
|
const stepStates = new Set([
|
|
'success',
|
|
'failure',
|
|
'error',
|
|
'pending',
|
|
'running',
|
|
'queued',
|
|
'skipped',
|
|
]);
|
|
const numbers = matches.map((candidate) => candidate.number);
|
|
const malformed = matches.some(
|
|
(candidate) =>
|
|
typeof candidate.commit !== 'string' ||
|
|
candidate.commit.length === 0 ||
|
|
!Number.isInteger(candidate.number) ||
|
|
!pipelineStates.has(candidate.status) ||
|
|
!Array.isArray(candidate.steps) ||
|
|
candidate.steps.some(
|
|
(step) =>
|
|
typeof step?.name !== 'string' ||
|
|
typeof step?.status !== 'string' ||
|
|
!stepStates.has(step.status),
|
|
),
|
|
);
|
|
if (malformed || new Set(numbers).size !== numbers.length) {
|
|
return {
|
|
state: 'terminal-failure',
|
|
detail: 'provider records are malformed or have ambiguous pipeline numbers',
|
|
};
|
|
}
|
|
const pipeline = [...matches].sort((left, right) => right.number - left.number)[0];
|
|
const gateSteps = (pipeline.steps ?? []).filter((step) => step.name === 'gate-verify');
|
|
if (gateSteps.length !== 1) {
|
|
return {
|
|
state: 'terminal-failure',
|
|
detail: `provider record has ambiguous gate-verify step count ${gateSteps.length}`,
|
|
};
|
|
}
|
|
const [gateStep] = gateSteps;
|
|
if (pipeline.status === 'success' && gateStep.status === 'success') {
|
|
return { state: 'terminal-success', detail: 'pipeline and gate-verify step succeeded' };
|
|
}
|
|
if (['pending', 'running', 'queued'].includes(pipeline.status)) {
|
|
return { state: 'current-running', detail: `pipeline is ${pipeline.status}` };
|
|
}
|
|
return {
|
|
state: 'terminal-failure',
|
|
detail: `pipeline=${pipeline.status ?? 'unknown'}, gate-verify=${gateStep?.status ?? 'absent'}`,
|
|
};
|
|
}
|
|
|
|
async function loadProviderEvidence() {
|
|
const evidenceFile = process.env.GATE_PROVIDER_EVIDENCE_FILE;
|
|
if (!evidenceFile) return [];
|
|
const parsed = JSON.parse(await readFile(evidenceFile, 'utf8'));
|
|
if (!Array.isArray(parsed)) throw new Error('provider evidence file must contain a JSON array');
|
|
return parsed;
|
|
}
|
|
|
|
function isMainCommit(root, head) {
|
|
if (process.env.CI_COMMIT_BRANCH === 'main') return true;
|
|
const result = git(root, ['merge-base', '--is-ancestor', head, 'refs/remotes/origin/main'], {
|
|
allowFailure: true,
|
|
});
|
|
return result.status === 0;
|
|
}
|
|
|
|
export async function verifyHistory({ root, manifest }) {
|
|
const failures = [];
|
|
const observations = [];
|
|
const head = git(root, ['rev-parse', 'HEAD']).stdout.trim();
|
|
if (!manifest.activationCommit) {
|
|
failures.push('history activationCommit is missing');
|
|
return { failures, observations };
|
|
}
|
|
const activationCheck = git(
|
|
root,
|
|
['merge-base', '--is-ancestor', manifest.activationCommit, head],
|
|
{ allowFailure: true },
|
|
);
|
|
if (activationCheck.status !== 0) {
|
|
failures.push(
|
|
`history activation commit ${manifest.activationCommit} is not an ancestor of ${head}`,
|
|
);
|
|
return { failures, observations };
|
|
}
|
|
const onMain = isMainCommit(root, head);
|
|
// RM-02 execution boundary (RM-60, cross-reference RM-59), kept adjacent in both directions:
|
|
// DOES: run every registered current-tree gate and declared inerting mutation on PR CI,
|
|
// unprivileged and fail-closed.
|
|
// DOES NOT: execute a commit's own verifier in an isolated PR replay. PR-controlled code would
|
|
// otherwise need the namespace capability intended to contain that same code. That external
|
|
// trust boundary must be runner/provider-owned before any PR executable or config is evaluated.
|
|
observations.push(
|
|
`RM-02 EXECUTION BOUNDARY ${head}: DOES: verify the current tree and declared inerting mutations on every PR, unprivileged and fail-closed; DOES NOT: execute isolated per-commit verifier replay in repository-controlled CI; owner RM-60, cross-reference RM-59`,
|
|
);
|
|
if (!onMain) {
|
|
observations.push(
|
|
`PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; retained provider evidence starts after merge and no replay success is inferred`,
|
|
);
|
|
}
|
|
|
|
const pipelines = onMain ? await loadProviderEvidence() : [];
|
|
const commits = await listProspectiveCommits(root, manifest.activationCommit, head);
|
|
for (const commit of commits) {
|
|
let commitManifest;
|
|
try {
|
|
commitManifest = await readManifestAtCommit(root, commit);
|
|
} catch (error) {
|
|
failures.push(`${commit}: own-tree registry cannot be read: ${error.message}`);
|
|
continue;
|
|
}
|
|
if (commitManifest.schemaVersion !== manifest.schemaVersion) {
|
|
failures.push(`${commit}: own-tree registry schema is not supported`);
|
|
continue;
|
|
}
|
|
const evidence = assessProviderEvidence(commit, pipelines);
|
|
if (commit === head) {
|
|
observations.push(
|
|
`CURRENT TREE EVALUATED ${commit}: all registered cases ran from this checkout; provider evidence=${evidence.state} (${evidence.detail})`,
|
|
);
|
|
continue;
|
|
}
|
|
observations.push(
|
|
`INTERMEDIATE REPLAY DEFERRED ${commit}: isolated own-tree execution is not performed by repository-controlled CI; owner RM-60, cross-reference RM-59; no success is inferred`,
|
|
);
|
|
if (!onMain) {
|
|
observations.push(
|
|
`PROVIDER EVIDENCE ${commit}: DEFERRED until the commit is on main; no success is inferred`,
|
|
);
|
|
continue;
|
|
}
|
|
observations.push(
|
|
`POST-MERGE DETECTION BOUNDARY ${commit}: protected isolated replay awaits RM-60; when available, a failure requires quarantine/revert and is detection, not pre-merge prevention`,
|
|
);
|
|
if (evidence.state === 'terminal-failure') {
|
|
failures.push(
|
|
`${commit}: retained provider evidence is not terminal-success (${evidence.detail})`,
|
|
);
|
|
} else if (evidence.state === 'terminal-success') {
|
|
observations.push(`PROVIDER EVIDENCE ${commit}: terminal-success (${evidence.detail})`);
|
|
} else {
|
|
observations.push(
|
|
`PROVIDER EVIDENCE ${commit}: ${evidence.state.toUpperCase()} (${evidence.detail}); no merge-time success is inferred`,
|
|
);
|
|
}
|
|
}
|
|
return { failures, observations };
|
|
}
|