diff --git a/agents/bradford/work/queue-327/BUILD.md b/agents/bradford/work/queue-327/BUILD.md new file mode 100644 index 000000000..de8d87597 --- /dev/null +++ b/agents/bradford/work/queue-327/BUILD.md @@ -0,0 +1,86 @@ +# Row 327 — reviewed binary push check (#1825) + +2026-10-24 Bradford, branch bradford/row327, base b617b80f6. + +## Plan and boundaries + +Implement the scratch check's ordered tokens in scripts/push-check.sh, using an +embedded Node program for byte-safe git paths and SHA-256 blob hashing. Bash is +the public entry point; Node is already a repository requirement. Git commands +use explicit resolved object IDs, disable external diff/textconv, and inspect +committed bytes. No queue writes, hook changes, binary evidence, or live state. +Fetch is opt-in. Read reviewed rows with the queue package rows() API rooted in +the caller's repository; this preserves canonical/witness/replay checks. + +Only the latest round counts; any changes receipt blocks that round. A matching +approval authorizes exactly the manifest path/hash, or a path changed from the +commit candidate's first parent with matching committed blob hash and mode. +Deleted binaries need no candidate. Multiple --row values form a union. + +Add scratch-only integration coverage in scripts/test-push-check.sh. Reuse queue +test fixture helpers to bootstrap real scratch queues, then use queue verbs to +open rounds and record receipts. All git writes, bare remotes and queues remain +under TMPDIR. Test fail-closed ordering and five required mutants, generated as +text copies in scratch. No old tests are modified. Add the TOOLS entry. + +Every run needs Sage CLEAR; run timeout -k 10. Record plain-text results here or +in this packet. After green checks, commit and push only bradford/row327 through +the repository helper, send Sage and Poe tip/base/path sha256 manifest, and move +327 in-review. Sage owns request posting and landing; Dewey reviews once there. + +## Recovery and choices + +Initial worktree queue next refused linked-worktree access as designed. Queue +claim is performed from the canonical checkout; production reads in the guard +retain the same refusal rather than invent a worktree override. + +Requested Sage ruling on AGENTS log appends outside the brief's owned paths. + +Sage sage-3989: lead handles BUILD-LOG and SESSIONS at landing. Session entry: +2026-10-24 Bradford — row 327 / #1825 — implementation and scratch verification +in this packet; outcome to be appended after acceptance. + +Implementation note: commit candidates use an explicit first-parent diff (empty +tree for a root commit), with rename detection disabled, like verify-commit. +The changes-veto case has both an approval and a changes receipt; a veto mutant +would otherwise still fail for lack of approval and provide false confidence. +A receipt for another candidate cannot be produced through queue verbs: the +suite corrupts only a scratch queue and expects the production reader to refuse. +Diff color, external diff and textconv are disabled so scans inspect plain git +output. Paths are read from NUL-delimited records (including spaces/tabs). + +## Verification (Sage CLEAR sage-3991) + +First new-suite run red: my whitespace-path fixture attempted a manifest path +that the queue intentionally forbids. Corrected the fixture to a commit +candidate, which can contain that git path. Production queue code and old tests +remain unchanged. Raw first-run output is test-run-1.txt. + +Second run green: PASS push-check 22 checks; KILLED skip-hash, +accept-changes, earlier-round, working-tree, no-row. test-run-2.txt holds the +plain-text receipt. bash -n both scripts and git diff --check passed. Runs used +TMPDIR recorded in tmpdir.txt and timeout -k 10 240; all generated binary bytes +were in disposable scratch repositories, never in this checkout or evidence. +The slot was used for only the authorized new suite and syntax/diff checks. +No claim of the lead's full scripts/test-*.sh landing gate is made here. + +Final inspection after the first green run found a path-decoding concern: lossy +UTF-8 decoding can conflate distinct git filenames. Hardened NUL-delimited path +decoding to reject invalid UTF-8, and quote newline-containing paths in refusal +output to preserve one STOP line. Added scratch cases for both. Requested a new +CLEAR (bradford-0005) because the previous slot had already been released. + +Final run (sage-3994 CLEAR) green on final code: PASS push-check 24 checks, +all five mutants KILLED again, bash -n both scripts and git diff --check passed. +Receipt: test-run-3.txt. The guard rejects non-UTF-8 git paths as environment +errors rather than comparing replacement characters; newline paths remain +checkable through commit candidates and refusals quote them on one line. + +Acceptance for the builder stage: ordered tokens and stop behavior, explicit +base/fetch, exact blob manifest/commit checks, latest-round/veto checks, deletion, +log/FF/secrets/attribution refusals, and all required mutants are covered. Queue +reads use rows() with production validation; tests use only scratch queues. +Candidate-mismatched receipts are impossible through the queue API and are +covered by a corrupt scratch queue refusing its read. The lead's full-suite gate, +Poe first review, Dewey landing review, and adoption for Sage's next push remain +landing stages. No hooks, role policy, credentials or other seats' paths changed. diff --git a/agents/bradford/work/queue-327/test-run-1.txt b/agents/bradford/work/queue-327/test-run-1.txt new file mode 100644 index 000000000..2450af596 --- /dev/null +++ b/agents/bradford/work/queue-327/test-run-1.txt @@ -0,0 +1,21 @@ +node:internal/modules/run_main:107 + triggerUncaughtException( + ^ + +AssertionError [ERR_ASSERTION]: queue: candidate manifest path must be a repo-relative path: "space tab\tasset.bin" + + +2 !== 1 + + at Object.round (file:///home/jwoltje/.t3/worktrees/mosaic-stack/bradford-row327/[eval1]:61:12) + at file:///home/jwoltje/.t3/worktrees/mosaic-stack/bradford-row327/[eval1]:116:7 + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 1, + operator: 'strictEqual', + diff: 'simple' +} + +Node.js v26.8.1 diff --git a/agents/bradford/work/queue-327/test-run-2.txt b/agents/bradford/work/queue-327/test-run-2.txt new file mode 100644 index 000000000..163e3a3c2 --- /dev/null +++ b/agents/bradford/work/queue-327/test-run-2.txt @@ -0,0 +1,6 @@ +PASS push-check 22 checks +KILLED skip-hash +KILLED accept-changes +KILLED earlier-round +KILLED working-tree +KILLED no-row diff --git a/agents/bradford/work/queue-327/test-run-3.txt b/agents/bradford/work/queue-327/test-run-3.txt new file mode 100644 index 000000000..336c7b113 --- /dev/null +++ b/agents/bradford/work/queue-327/test-run-3.txt @@ -0,0 +1,6 @@ +PASS push-check 24 checks +KILLED skip-hash +KILLED accept-changes +KILLED earlier-round +KILLED working-tree +KILLED no-row diff --git a/agents/bradford/work/queue-327/tmpdir.txt b/agents/bradford/work/queue-327/tmpdir.txt new file mode 100644 index 000000000..c42029d08 --- /dev/null +++ b/agents/bradford/work/queue-327/tmpdir.txt @@ -0,0 +1 @@ +/mnt/storage/scratch/tmp/YPrPwR diff --git a/docs/TOOLS.md b/docs/TOOLS.md index cc19e9b47..44d993f97 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1365,3 +1365,23 @@ public sign-in smoke and a forged-loopback 401 check; rollback drops flags before middleware. Same-uid callers can still read the file; separate service uid is Jason's question on #385. The Authentik allowed-uid file remains an identity input and is never reused for proof. + +## `scripts/push-check.sh` — verify a prospective push + +`scripts/push-check.sh SHA EXPECTED_LOG [--row N]... [--base-ref REF] [--fetch]` +checks the repository at the caller's cwd; it never pushes or writes the queue. +EXPECTED_LOG must contain exactly `git log --oneline BASE..SHA` (including its +newlines). BASE defaults to `refs/remotes/origin/refactor`; `--base-ref` overrides +it. Only `--fetch` fetches origin, before resolving BASE. + +Success prints `LOG_OK`, `FF_OK`, `NO_BINARY` or `BINARY_REVIEWED n`, +`SECRETS_CLEAN`, `NO_ATTRIBUTION`, then `PUSH_READY BASE..SHA` as the last line. +Each present binary needs its committed blob sha256 and exact path in an approved +latest-round candidate from a supplied row. A `changes` receipt blocks that round. +Commit candidates authorize only changed paths with matching bytes and mode. +Deleted binaries need no review. Queue reads retain canonical-checkout checks. + +Exit 0 means ready; 1 means refusal (`STOP reason`, always the last line); 2 means +usage or environment error. A refusal never prints later success tokens. +`timeout -k 10 240 bash scripts/test-push-check.sh` exercises isolated scratch +repositories and the five required mutants; set TMPDIR to a scratch filesystem. diff --git a/scripts/push-check.sh b/scripts/push-check.sh new file mode 100755 index 000000000..392c4fb0a --- /dev/null +++ b/scripts/push-check.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# Check an explicit committed push range. Fetch is opt-in; never pushes. +command -v node >/dev/null 2>&1 || { echo 'STOP node unavailable'; exit 2; } +exec node --input-type=module - "$@" <<'JS' +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const stop = (reason, code = 1) => { console.log(`STOP ${reason}`); process.exit(code); }; +const git = (...args) => { + const r = spawnSync('git', args, { maxBuffer: 256 * 1024 * 1024 }); + if (r.error || r.status !== 0) throw new Error('git'); + return r.stdout; +}; +// Git paths must round-trip as UTF-8 before comparing them with queue paths. +const pathsText = bytes => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); +const shownPath = path => /[\r\n]/.test(path) ? JSON.stringify(path) : path; +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const text = (...args) => git(...args).toString('utf8').trimEnd(); +const resolve = ref => text('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`); +const argv = process.argv.slice(2); +if (argv.length < 2) stop('usage: push-check.sh SHA EXPECTED_LOG [--row N]... [--base-ref REF] [--fetch]', 2); +const [ref, expectedFile] = argv.splice(0, 2); +let baseRef = 'refs/remotes/origin/refactor', fetch = false; +const rowIds = new Set(); +while (argv.length) { + const flag = argv.shift(); + if (flag === '--fetch' && !fetch) fetch = true; + else if (flag === '--row' && /^[1-9][0-9]*$/.test(argv[0] ?? '') && Number.isSafeInteger(Number(argv[0]))) rowIds.add(Number(argv.shift())); + else if (flag === '--base-ref' && argv[0] && !argv[0].startsWith('-')) baseRef = argv.shift(); + else stop('usage', 2); +} +let sha, base, expected; +try { sha = resolve(ref); expected = readFileSync(expectedFile); } +catch { stop('sha or expected log unavailable', 2); } +if (fetch) { try { git('fetch', '-q', 'origin'); } catch { stop('fetch', 2); } } +try { base = resolve(baseRef); } catch { stop('base ref unavailable', 2); } +try { + if (!git('log', '--oneline', `${base}..${sha}`).equals(expected)) stop('log'); + console.log('LOG_OK'); + try { git('merge-base', '--is-ancestor', base, sha); } catch { stop('ff'); } + console.log('FF_OK'); + + // --no-renames keeps -z numstat to exactly one path per record. Never parse + // quoted path output or consult working-tree bytes. + const binaries = pathsText(git('diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--numstat', '-z', base, sha)) + .split('\0').filter(Boolean).filter(s => s.startsWith('-\t-\t')).map(s => s.slice(4)); + const tree = commit => { + const entries = new Map(); + for (const entry of pathsText(git('ls-tree', '-r', '-z', commit)).split('\0').filter(Boolean)) { + const tab = entry.indexOf('\t'); + const [mode, type, oid] = entry.slice(0, tab).split(' '); + entries.set(entry.slice(tab + 1), { mode, type, oid }); + } + return entries; + }; + const have = tree(sha); + const present = binaries.filter(path => have.has(path)); + let approved = []; + if (present.length && rowIds.size) { + let rows; + try { + const root = text('rev-parse', '--show-toplevel'); + const { rows: readRows } = await import(pathToFileURL(join(root, 'packages/queue/src/store.mjs')).href); + const result = readRows({ cwd: process.cwd(), env: process.env }); + if (result.code !== 0) throw new Error('queue'); + rows = result.rows; + for (const id of rowIds) if (!rows.some(r => r.id === id)) throw new Error('row'); + } catch { stop('queue unavailable or invalid', 2); } + for (const row of rows.filter(r => rowIds.has(r.id))) { + const round = row.review?.rounds.at(-1); + if (!round || round.receipts?.some(r => r.verdict === 'changes')) continue; + if (!round.receipts?.some(r => r.verdict === 'approve' && r.candidate === round.candidate.digest)) continue; + approved.push(round.candidate); + } + } + const candidates = new Map(); + for (const c of approved) { + if (c.kind !== 'commit') continue; + const candidateTree = tree(c.digest); + let parent; + try { parent = text('rev-parse', '--verify', `${c.digest}^1`); } + catch { parent = text('hash-object', '-t', 'tree', '/dev/null'); } + const changed = new Set(pathsText(git('diff-tree', '-r', '--no-renames', '--name-only', '-z', parent, c.digest)).split('\0').filter(Boolean)); + candidates.set(c.digest, { candidateTree, changed }); + } + for (const path of present) { + const h = have.get(path); + const digest = hash(git('cat-file', 'blob', h.oid)); + const matches = approved.some(c => { + if (c.kind === 'manifest') return c.text.split('\n').includes(`${digest} ${path}`); + const { candidateTree, changed } = candidates.get(c.digest); + const entry = candidateTree.get(path); + return changed.has(path) && entry?.type === 'blob' && entry.mode === h.mode && hash(git('cat-file', 'blob', entry.oid)) === digest; + }); + if (!matches) stop(`binary ${shownPath(path)} ${digest} not in a reviewed candidate`); + } + console.log(present.length ? `BINARY_REVIEWED ${present.length}` : 'NO_BINARY'); + + const diff = git('diff', '--no-color', '--no-ext-diff', '--no-textconv', base, sha).toString('utf8'); + const secret = /BEGIN [A-Z ]*PRIVATE KEY|ghp_[A-Za-z0-9]{20}|sk-ant-[A-Za-z0-9-]{10}|xox[bp]-|AKIA[0-9A-Z]{16}|(token|secret|password)["' ]*[:=] *["'][A-Za-z0-9_\-]{24,}/i; + if (diff.split('\n').some(line => line.startsWith('+') && secret.test(line))) stop('secrets'); + console.log('SECRETS_CLEAN'); + if (/co-authored-by|generated with/i.test(text('log', '--format=%B', `${base}..${sha}`))) stop('attribution'); + console.log('NO_ATTRIBUTION'); + console.log(`PUSH_READY ${base}..${sha}`); +} catch { stop('git or environment error', 2); } +JS diff --git a/scripts/test-push-check.sh b/scripts/test-push-check.sh new file mode 100755 index 000000000..ba706569f --- /dev/null +++ b/scripts/test-push-check.sh @@ -0,0 +1,203 @@ +#!/bin/bash +# All repositories, queue writes, remotes and mutants are disposable scratch. +set -euo pipefail +ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd) +exec node --input-type=module - "$ROOT" "$@" <<'JS' +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createHash } from 'node:crypto'; +const root = process.argv[2]; +const { scratchRepo, genesisCommitted, cli } = await import(pathToFileURL(join(root, 'packages/queue/tests/helpers.mjs'))); +const target = process.argv[3] ?? join(root, 'scripts/push-check.sh'); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const binary = Buffer.from([0, 1, 2, 3]); +const digest = sha256(binary); +const cleanups = []; +let count = 0; +function fixture() { + const repo = scratchRepo({ after: fn => cleanups.push(fn) }); + genesisCommitted(repo); + const run = (args, by = 'darkwing', allow = false) => { + const r = cli(repo, args, { by }); + assert.ok(r.code === 0 || allow, r.err); + return r; + }; + run(['move', '6', 'blocked', '--reason', 'paused', '--op', 'block-six-0001']); + run(['move', '9', 'in-progress', '--op', 'start-nine-0001']); + run(['set', '9', 'reviewers', 'dewey,filbert', '--op', 'set-reviewers-0001'], 'sage'); + // A real local bare remote and clone, with no network or origin access. + const bare = join(repo.base, 'remote.git'); + repo.g('init', '--bare', '-q', bare); + repo.g('clone', '-q', bare, join(repo.base, 'clone')); + repo.g('remote', 'add', 'origin', bare); + const base = repo.g('rev-parse', 'HEAD').trim(); + repo.g('update-ref', 'refs/remotes/origin/refactor', base); + const commit = (path, bytes, message = 'content') => { + if (bytes === null) repo.g('rm', '--', path); + else { writeFileSync(join(repo.root, path), bytes); repo.g('add', '--', path); } + repo.g('commit', '-q', '-m', message); + return repo.g('rev-parse', 'HEAD').trim(); + }; + const check = (sha, extra = [], expectedCode = 0, reason = null, script = target) => { + const log = join(repo.base, 'expected.log'); + writeFileSync(log, repo.g('log', '--oneline', `${base}..${sha}`)); + const r = spawnSync('bash', [script, sha, log, ...extra], { cwd: repo.root, env: repo.env, encoding: 'utf8' }); + assert.equal(r.status, expectedCode, `${r.stdout}\n${r.stderr}`); + const lines = r.stdout.trim().split('\n'); + if (expectedCode === 0) assert.match(lines.at(-1), /^PUSH_READY /); + else { + assert.equal(lines.filter(s => s.startsWith('STOP ')).length, 1, r.stdout); + assert.match(lines.at(-1), reason ?? /^STOP /); + assert.ok(!r.stdout.includes('PUSH_READY'), r.stdout); + } + count++; + return r.stdout; + }; + let n = 0; + const round = (candidate, verdict = 'approve') => { + n++; + const r = run(['move', '9', 'in-review', '--candidate', candidate, '--op', `review-nine-000${n}`], 'darkwing', true); + // Missing dummy transport credentials fails the request, after opening a + // valid round. Recording a receipt is cooperative and needs no credential. + assert.equal(r.code, 1, r.out + r.err); + const row = JSON.parse(run(['show', '9']).out); + const c = row.review.rounds.at(-1).candidate.digest; + run(['review', 'record', '9', '--verdict', verdict, '--candidate', c, '--comment', String(n), '--op', `verdict-nine-00${n}`], 'filbert'); + }; + const manifest = (d = digest, path = 'asset.bin') => { + const file = join(repo.base, 'manifest.sha256'); + writeFileSync(file, `${d} ${path}\n`); + return file; + }; + return { repo, run, base, commit, check, round, manifest }; +} +try { + { + const f = fixture(); + const sha = f.commit('text.txt', 'hello\n'); + assert.match(f.check(sha), /NO_BINARY\nSECRETS_CLEAN\nNO_ATTRIBUTION\nPUSH_READY /); + f.check(sha, ['--base-ref', f.base]); + f.check(sha, ['--bad-option'], 2, /^STOP usage/); + // Explicit fetch goes only to the scratch remote. + f.check(sha, ['--fetch']); + const wrong = join(f.repo.base, 'wrong.log'); writeFileSync(wrong, 'wrong\n'); + const r = spawnSync('bash', [target, sha, wrong], { cwd: f.repo.root, env: f.repo.env, encoding: 'utf8' }); + assert.equal(r.status, 1); assert.equal(r.stdout, 'STOP log\n'); count++; + f.repo.g('update-ref', 'refs/remotes/origin/refactor', sha); + const log = join(f.repo.base, 'empty.log'); writeFileSync(log, ''); + const ff = spawnSync('bash', [target, f.base, log], { cwd: f.repo.root, env: f.repo.env, encoding: 'utf8' }); + assert.equal(ff.status, 1); assert.equal(ff.stdout, 'LOG_OK\nSTOP ff\n'); count++; + } + { + const f = fixture(), sha = f.commit('asset.bin', binary); + f.check(sha, [], 1, /^STOP binary asset.bin /); + f.round(f.manifest('0'.repeat(64))); + f.check(sha, ['--row', '9'], 1, /^STOP binary asset.bin /); + } + { + const f = fixture(), sha = f.commit('asset.bin', binary); + f.round(f.manifest()); + writeFileSync(join(f.repo.root, 'asset.bin'), 'working tree differs'); + assert.match(f.check(sha, ['--row', '9']), /BINARY_REVIEWED 1\n/); + f.check(sha, [], 1, /^STOP binary asset.bin /); + const other = f.commit('asset.bin', Buffer.from([0, 8, 9])); + // The working tree is the approved version; the pushed blob isn't. + writeFileSync(join(f.repo.root, 'asset.bin'), binary); + f.check(other, ['--row', '9'], 1, /^STOP binary asset.bin /); + } + { + const f = fixture(), sha = f.commit('asset.bin', binary); + f.round(sha); + assert.match(f.check(sha, ['--row', '9']), /BINARY_REVIEWED 1\n/); + const other = f.commit('asset.bin', Buffer.from([0, 7])); + f.check(other, ['--row', '9'], 1, /^STOP binary asset.bin /); + } + { + const f = fixture(), sha = f.commit('space tab\tasset.bin', binary); + f.round(sha); + assert.match(f.check(sha, ['--row', '9']), /BINARY_REVIEWED 1\n/); + } + { + const f = fixture(), sha = f.commit('line\nbreak.bin', binary); + f.check(sha, [], 1, /^STOP binary "line\\nbreak.bin" /); + writeFileSync(Buffer.concat([Buffer.from(f.repo.root + '/'), Buffer.from([255]), Buffer.from('.bin')]), binary); + f.repo.g('add', '-A'); f.repo.g('commit', '-q', '-m', 'invalid UTF-8 path'); + f.check(f.repo.g('rev-parse', 'HEAD').trim(), [], 2, /^STOP git or environment error$/); + } + { + const f = fixture(); + f.commit('asset.bin', binary); + const unrelated = f.commit('text.txt', 'unrelated\n'); + f.round(unrelated); + f.check(unrelated, ['--row', '9'], 1, /^STOP binary asset.bin /); + } + { + const f = fixture(), sha = f.commit('asset.bin', binary); + f.round(f.manifest(), 'changes'); + const row = JSON.parse(f.run(['show', '9']).out); + f.run(['review', 'record', '9', '--verdict', 'approve', '--candidate', row.review.rounds.at(-1).candidate.digest, '--comment', '2', '--op', 'approve-dewey-0001'], 'dewey'); + f.check(sha, ['--row', '9'], 1, /^STOP binary asset.bin /); + } + { + const f = fixture(), sha = f.commit('asset.bin', binary); + f.round(f.manifest()); + f.run(['move', '9', 'in-progress', '--op', 'revise-nine-0001']); + f.round(f.manifest('0'.repeat(64))); + f.check(sha, ['--row', '9'], 1, /^STOP binary asset.bin /); + } + { + const f = fixture(), sha = f.commit('asset.bin', binary); + f.round(f.manifest()); + // This shape cannot be recorded by the queue. Its read API must reject + // tampered receipts before the guard can treat them as authority. + const path = f.repo.queuePath; + const doc = JSON.parse(readFileSync(path)); + doc.rows.find(r => r.id === 9).review.rounds.at(-1).receipts[0].candidate = '0'.repeat(64); + writeFileSync(path, JSON.stringify(doc)); + f.check(sha, ['--row', '9'], 2, /^STOP queue unavailable or invalid$/); + } + { + const f = fixture(), added = f.commit('asset.bin', binary); + f.repo.g('update-ref', 'refs/remotes/origin/refactor', added); + const deleted = f.commit('asset.bin', null); + // Explicit base retains the check helper's original expected log range. + f.repo.g('update-ref', 'refs/remotes/origin/refactor', f.base); + assert.match(f.check(deleted, ['--base-ref', added], 1, /^STOP log$/), /STOP log/); + const log = join(f.repo.base, 'delete.log'); writeFileSync(log, f.repo.g('log', '--oneline', `${added}..${deleted}`)); + const r = spawnSync('bash', [target, deleted, log, '--base-ref', added], { cwd: f.repo.root, env: f.repo.env, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stdout); assert.match(r.stdout, /NO_BINARY\n/); assert.match(r.stdout.trim().split('\n').at(-1), /^PUSH_READY /); count++; + } + for (const [message, content, reason, prefix] of [ + ['content', 'password="' + 'a'.repeat(30) + '"\n', 'secrets', 'LOG_OK\nFF_OK\nNO_BINARY\n'], + ['Generated with example', 'hello\n', 'attribution', 'LOG_OK\nFF_OK\nNO_BINARY\nSECRETS_CLEAN\n'], + ]) { + const f = fixture(), sha = f.commit('text.txt', content, message); + assert.equal(f.check(sha, [], 1), `${prefix}STOP ${reason}\n`); + } + console.log(`PASS push-check ${count} checks`); + if (!process.argv[3]) { + const dir = mkdtempSync(join(tmpdir(), 'push-check-mutants-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const source = readFileSync(target, 'utf8'); + const mutants = [ + ['skip-hash', "c.text.split('\\n').includes(`${digest} ${path}`)", "c.text.split('\\n').some(line => line.endsWith(` ${path}`))"], + ['accept-changes', "round.receipts?.some(r => r.verdict === 'changes')", 'false'], + ['earlier-round', 'row.review?.rounds.at(-1)', 'row.review?.rounds.at(0)'], + ['working-tree', "hash(git('cat-file', 'blob', h.oid))", 'hash(readFileSync(path))'], + ['no-row', 'if (!matches) stop(', 'if (!matches && rowIds.size) stop('], + ]; + for (const [name, before, after] of mutants) { + assert.ok(source.includes(before), name); + const file = join(dir, `${name}.sh`); writeFileSync(file, source.replace(before, after)); + const r = spawnSync('timeout', ['-k', '10', '120', 'bash', join(root, 'scripts/test-push-check.sh'), file], { encoding: 'utf8', maxBuffer: 4 << 20 }); + assert.equal(r.status, 1, `${name} survived or infrastructure failed: ${r.status}\n${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /AssertionError/, r.stderr); + console.log(`KILLED ${name}`); + } + } +} finally { for (const fn of cleanups.reverse()) fn(); } +JS