269 lines
11 KiB
JavaScript
269 lines
11 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;
|
|
}
|
|
}
|
|
|
|
/** The text for a command with several merges into main (N = the count). */
|
|
const MANY_MERGES = (n) =>
|
|
`Tested by: not shown (this command merges ${n} branches into main; merge one at a time to see each run's evidence).\nUndone by: not shown.`;
|
|
|
|
/**
|
|
* The evidence text for every merge approval of one command, for the guard's
|
|
* question: zero merges give '', one gives its two evidence lines (exactly
|
|
* evidenceLines), two or more give MANY_MERGES with the count: one question
|
|
* cannot show each run's evidence, so none is shown.
|
|
* @param {Array<{subject: string, merge: {args: string[]}}>} merges
|
|
* @returns {string} never throws
|
|
*/
|
|
export function evidenceForMerges(merges) {
|
|
try {
|
|
if (!Array.isArray(merges)) return UNREADABLE;
|
|
if (merges.length === 0) return '';
|
|
if (merges.length === 1) {
|
|
const m = merges[0];
|
|
if (!isObj(m)) return UNREADABLE;
|
|
return evidenceLines(m.subject, m.merge && m.merge.args);
|
|
}
|
|
return MANY_MERGES(merges.length);
|
|
} catch {
|
|
return UNREADABLE;
|
|
}
|
|
}
|