// 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 `` exit ; Verification /; review at . // Undone by: `git revert -m 1 ` (two-way). | (one-way: ). | (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/ // gives "no run evidence": git looks a name up as `$GIT_DIR/`, // `refs/` and `refs/tags/` before `refs/heads/`, 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 `/`, `/`, `/refs/` or // `/refs/tags/`, or when a `packed-refs` line (not a `^` peeled // line) names exactly `refs/` or `refs/tags/`. 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 `'; // `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 ` ` (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/ * (see the header): an entry of any kind at one of the four paths, or a * packed `refs/` or `refs/tags/`. 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/`: 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; } }