voyage/hooks/lib/merge-evidence.mjs
2026-11-04 10:38:39 +01:00

242 lines
10 KiB
JavaScript

// hooks/lib/merge-evidence.mjs
// The merge question's run evidence (fix round 4.5b2 part B, Step 11). Before
// it merges a run's branch into main, /trekexecute Phase 8 writes an evidence
// file (lib/review/merge-evidence.mjs write) under the git common dir, named
// by the branch's head sha. The Bash guard (hooks/scripts/pre-bash-executor.mjs)
// reads it here into two lines of the question it already asks:
// Tested by: End-to-end `<cmd>` exit <n>; Verification <passed>/<total>; review <verdict> at <sha8>.
// Undone by: `git revert -m 1 <merge>` (two-way). | (one-way: <text>). | (door not stated).
//
// Fail-safe by construction: the guard's `ask` decision is made before this
// runs, and this only returns text. It reads the filesystem only (no process
// is spawned, no module outside hooks/ is imported), never follows a symlink,
// reads at most 64 KiB of a regular file, and never throws: any error, a
// missing or foreign file, a head sha that does not match, or a field of the
// wrong type reads "no run evidence" or "not measured", never a green line.
// Text taken from the file prints with every non-printable or non-ASCII
// character as `?`.
//
// A merged name git would read as something other than refs/heads/<name>
// gives "no run evidence": git looks a name up as `$GIT_DIR/<name>`,
// `refs/<name>` and `refs/tags/<name>` before `refs/heads/<name>`, so the
// merge would not take the branch head the evidence was written for. The name
// is ambiguous when an entry of any kind (file, directory, link; lstat only)
// sits at `<git dir>/<name>`, `<common>/<name>`, `<common>/refs/<name>` or
// `<common>/refs/tags/<name>`, or when a `packed-refs` line (not a `^` peeled
// line) names exactly `refs/<name>` or `refs/tags/<name>`. A `packed-refs`
// that is there but cannot be read within its cap counts as ambiguous (cannot
// tell), never as "no tag". The writer (lib/review/merge-evidence.mjs)
// refuses the same names.
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'node:path';
import { commonDirOf } from './git-approval.mjs';
export const MAX_EVIDENCE_BYTES = 64 * 1024;
const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.';
const UNREADABLE = 'Tested by: not measured (the run evidence file could not be read).\nUndone by: not measured.';
const NOT = 'not measured';
const UNDO = 'Undone by: `git revert -m 1 <merge>`';
// `git merge` options that take a separate value: their value is not the branch.
const MERGE_VALUED = ['-m', '-F', '-s', '-X', '--message', '--file', '--strategy', '--strategy-option', '--into-name', '--cleanup'];
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const VERDICTS = ['ALLOW', 'WARN', 'BLOCK'];
/** Printable ASCII only; anything else prints as `?`. Cut to `max` characters. */
function clean(s, max = 200) {
const out = String(s).replace(/[^\x20-\x7e]/g, '?');
return out.length > max ? `${out.slice(0, max)}...` : out;
}
/** The merged branch: the one positional after the valued options; else null. */
export function mergedBranch(mergeArgs) {
if (!Array.isArray(mergeArgs)) return null;
const pos = [];
for (let i = 0; i < mergeArgs.length; i += 1) {
const a = mergeArgs[i];
if (typeof a !== 'string') return null;
if (MERGE_VALUED.includes(a)) { i += 1; continue; }
if (!a.startsWith('-')) pos.push(a);
}
if (pos.length !== 1) return null;
const name = pos[0];
// A plain branch name only: no `..`, no control character, no leading `-`.
if (name === '' || name.includes('..') || /[\x00-\x1f\x7f]/.test(name) || name.startsWith('-')) return null;
return name;
}
/** The git dir of the repo whose top is `root` (a `.git` dir, or a `.git` file naming one). */
// Same reading as gitDirOf in hooks/lib/git-approval.mjs (not exported there).
function gitDirAt(root) {
const dotGit = join(root, '.git');
let st;
try { st = lstatSync(dotGit); } catch { return null; }
if (st.isDirectory()) return dotGit;
if (!st.isFile()) return null;
const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
if (!m) return null;
return isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
}
/** A small regular file's text, or null (missing, not regular, too big). */
function smallRegular(path, max) {
let st;
try { st = lstatSync(path); } catch { return null; }
if (!st.isFile() || st.size > max) return null;
return readFileSync(path, 'utf8');
}
const PACKED_MAX = 16 * 1024 * 1024;
/**
* `packed-refs` of `common`: '' when there is none, its text, or null when it
* is there but not a regular file of at most PACKED_MAX bytes, or unreadable.
*/
function packedRefs(common) {
const path = join(common, 'packed-refs');
let st;
try {
st = lstatSync(path);
} catch (e) {
return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR') ? '' : null;
}
if (!st.isFile() || st.size > PACKED_MAX) return null;
try { return readFileSync(path, 'utf8'); } catch { return null; }
}
/** The ref name of each `packed-refs` line `<sha> <ref>` (a `^` peeled line is none). */
function packedLines(packed) {
const out = [];
for (const line of packed.split('\n')) {
const m = line.match(/^([0-9a-f]{40}|[0-9a-f]{64}) (.+)$/);
if (m) out.push([m[1], m[2]]);
}
return out;
}
/**
* True when git would read `name` as something other than refs/heads/<name>
* (see the header): an entry of any kind at one of the four paths, or a
* packed `refs/<name>` or `refs/tags/<name>`. Cannot tell counts as true.
*/
function ambiguousName(gitDir, common, name, packed) {
const parts = name.split('/');
for (const path of [join(gitDir, ...parts), join(common, ...parts), join(common, 'refs', ...parts), join(common, 'refs', 'tags', ...parts)]) {
try { lstatSync(path); return true; } catch (e) {
// No entry is ENOENT/ENOTDIR; any other error cannot tell.
if (!e || (e.code !== 'ENOENT' && e.code !== 'ENOTDIR')) return true;
}
}
if (packed === null) return true;
return packedLines(packed).some(([, ref]) => ref === `refs/${name}` || ref === `refs/tags/${name}`);
}
/**
* The head sha of `refs/heads/<branch>`: the loose ref, else `packed-refs`;
* null when there is none or the name is ambiguous for git.
*/
function headSha(gitDir, common, branch) {
const packed = packedRefs(common);
if (ambiguousName(gitDir, common, branch, packed)) return null;
const loose = smallRegular(join(common, 'refs', 'heads', ...branch.split('/')), 4096);
if (loose !== null) {
const sha = loose.trim();
if (SHA_RE.test(sha)) return sha;
}
if (!packed) return null;
for (const [sha, ref] of packedLines(packed)) {
if (ref === `refs/heads/${branch}`) return sha;
}
return null;
}
/**
* The evidence file's text: 'missing', null (there, but not a regular file of
* at most 64 KiB), or the text. Never follows a symlink and never opens a FIFO
* for a blocking read.
*/
function readEvidence(path) {
let st;
try {
st = lstatSync(path);
} catch (e) {
return e && e.code === 'ENOENT' ? 'missing' : null;
}
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_EVIDENCE_BYTES) return null;
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
let fd;
try {
fd = openSync(path, flags);
const fst = fstatSync(fd);
if (!fst.isFile() || fst.size > MAX_EVIDENCE_BYTES) return null;
const buf = Buffer.alloc(MAX_EVIDENCE_BYTES + 1);
let n = 0;
for (;;) {
const got = readSync(fd, buf, n, buf.length - n, null);
if (got === 0) break;
n += got;
if (n > MAX_EVIDENCE_BYTES) return null;
}
return buf.subarray(0, n).toString('utf8');
} catch {
return null;
} finally {
if (fd !== undefined) { try { closeSync(fd); } catch { /* closed */ } }
}
}
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const isCount = (n) => Number.isInteger(n) && n >= 0;
function testedLine(ev) {
const e2e = isObj(ev.end_to_end) ? ev.end_to_end : {};
const cmd = typeof e2e.command === 'string' && e2e.command.trim() !== '' ? `\`${clean(e2e.command)}\`` : `command ${NOT}`;
const exit = Number.isInteger(e2e.exit) ? String(e2e.exit) : NOT;
const ver = isObj(ev.verification) ? ev.verification : {};
const count = isCount(ver.passed) && isCount(ver.total) && ver.passed <= ver.total ? `${ver.passed}/${ver.total}` : NOT;
const rev = isObj(ev.review) ? ev.review : {};
const verdict = VERDICTS.includes(rev.verdict) ? rev.verdict : NOT;
const sha = typeof rev.sha === 'string' && SHA_RE.test(rev.sha) ? rev.sha.slice(0, 8) : NOT;
return `Tested by: End-to-end ${cmd} exit ${exit}; Verification ${count}; review ${verdict} at ${sha}.`;
}
function undoneLine(ev) {
const door = isObj(ev.door) ? ev.door : {};
if (door.type === 'two-way') return `${UNDO} (two-way).`;
if (door.type === 'one-way' && typeof door.text === 'string' && door.text.trim() !== '') {
return `${UNDO} (one-way: ${clean(door.text.trim())}).`;
}
return `${UNDO} (door not stated).`;
}
/**
* The two evidence lines for a merge, for the guard's question.
* @param {string} repoRoot top of the repo the merge runs in
* @param {string[]} mergeArgs the tokens after `merge`
* @returns {string} always two lines; never throws
*/
export function evidenceLines(repoRoot, mergeArgs) {
try {
const branch = mergedBranch(mergeArgs);
if (!branch || typeof repoRoot !== 'string' || repoRoot === '') return NO_EVIDENCE;
const gitDir = gitDirAt(repoRoot);
if (!gitDir) return NO_EVIDENCE;
const common = commonDirOf(gitDir);
const sha = headSha(gitDir, common, branch);
if (!sha) return NO_EVIDENCE;
const text = readEvidence(join(common, 'voyage', 'merge-evidence', `${sha}.json`));
if (text === 'missing') return NO_EVIDENCE;
if (text === null) return UNREADABLE;
let ev;
try { ev = JSON.parse(text); } catch { return UNREADABLE; }
if (!isObj(ev)) return UNREADABLE;
// A file of another format, another branch or another head is not this run's evidence.
if (ev.v !== 1 || ev.branch !== branch || ev.branch_head_sha !== sha) return NO_EVIDENCE;
return `${testedLine(ev)}\n${undoneLine(ev)}`;
} catch {
return UNREADABLE;
}
}