209 lines
9.1 KiB
Python
209 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Repo-local evidence gate. Python 3.9+, Git, no dependencies."""
|
|
import argparse
|
|
import datetime as dt
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import tempfile
|
|
|
|
|
|
def git(root, *args):
|
|
return subprocess.check_output(['git', '-C', str(root), *args], stderr=subprocess.DEVNULL, timeout=30)
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def local(root, name):
|
|
p = Path(name)
|
|
if p.is_absolute() or '..' in p.parts or not p.parts or p == Path('.'):
|
|
raise ValueError('paths must be relative, nonempty, and contain no ..')
|
|
resolved = (root / p).resolve()
|
|
if not resolved.is_relative_to(root):
|
|
raise ValueError('path escapes repository')
|
|
return resolved
|
|
|
|
|
|
def changed(root, base, card):
|
|
tracked = git(root, 'diff', '--no-renames', '--name-only', '-z', base)
|
|
others = git(root, 'ls-files', '--others', '--exclude-standard', '-z')
|
|
return sorted(set(os.fsdecode(p) for p in (tracked + others).split(b'\0') if p) - {card})
|
|
|
|
|
|
def snapshot(root, base, card):
|
|
# Hash tracked and untracked contents; never persist command output or file contents.
|
|
h = hashlib.sha256(git(root, 'rev-parse', 'HEAD'))
|
|
for name in changed(root, base, card):
|
|
h.update(os.fsencode(name) + b'\0')
|
|
p = root / name
|
|
if p.is_symlink():
|
|
h.update(os.fsencode(os.readlink(p)))
|
|
elif p.is_file():
|
|
with p.open('rb') as f:
|
|
for block in iter(lambda: f.read(65536), b''):
|
|
h.update(block)
|
|
else:
|
|
h.update(b'<deleted>')
|
|
# Include staged content, file modes, deletions and the pinned task even if unchanged.
|
|
h.update(git(root, 'diff', '--binary', base, '--', '.', ':(exclude)' + card))
|
|
h.update(git(root, 'diff', '--cached', '--binary', base, '--', '.', ':(exclude)' + card))
|
|
return h.hexdigest()
|
|
|
|
|
|
def save(path, data):
|
|
if path.is_symlink():
|
|
raise ValueError('card must not be a symlink')
|
|
fd, name = tempfile.mkstemp(prefix='.finishline-', dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, 'w') as f:
|
|
json.dump(data, f, indent=2)
|
|
f.write('\n')
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(name, path)
|
|
finally:
|
|
if os.path.exists(name):
|
|
os.unlink(name)
|
|
|
|
|
|
def validate(root, data):
|
|
if data.get('version') != 1:
|
|
raise ValueError('unsupported card version')
|
|
local(root, data['task'])
|
|
if not re.fullmatch(r'[0-9a-f]{64}', data['task_sha256']):
|
|
raise ValueError('invalid task hash')
|
|
if not re.fullmatch(r'[0-9a-f]{40,64}', data['base']):
|
|
raise ValueError('invalid base commit')
|
|
if (not isinstance(data['allow'], list) or not isinstance(data['checks'], list)
|
|
or not 1 <= len(data['allow']) <= 100 or not 1 <= len(data['checks']) <= 20):
|
|
raise ValueError('require 1..100 scope paths and 1..20 acceptance checks')
|
|
for path in data['allow']:
|
|
local(root, path)
|
|
if not isinstance(data['timeout'], int) or not 1 <= data['timeout'] <= 300:
|
|
raise ValueError('timeout must be 1..300 seconds')
|
|
for argv in data['checks']:
|
|
if not isinstance(argv, list) or not argv or not all(isinstance(s, str) and s for s in argv):
|
|
raise ValueError('each check must be a nonempty JSON argv array')
|
|
|
|
|
|
def run_check(root, argv, timeout):
|
|
# No shell. Checks are still arbitrary local code, explicitly opted into by the user.
|
|
with subprocess.Popen(argv, cwd=root, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, start_new_session=True) as p:
|
|
try:
|
|
code = p.wait(timeout=timeout)
|
|
return {'argv': argv, 'exit': code}
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(p.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
p.wait()
|
|
return {'argv': argv, 'exit': None, 'error': 'timeout'}
|
|
|
|
|
|
def evaluate(root, data, card, execute):
|
|
validate(root, data)
|
|
task = local(root, data['task'])
|
|
content = task.read_bytes()
|
|
items = [(i, m.group(1).lower() == 'x') for i, line in enumerate(content.decode('utf-8').splitlines(), 1)
|
|
if (m := re.match(r'^\s*[-*] \[([ xX])\]\s+\S', line))]
|
|
paths = changed(root, data['base'], card)
|
|
outside = [p for p in paths if not any(p == a.rstrip('/') or p.startswith(a.rstrip('/') + '/') for a in data['allow'])]
|
|
report = {'at': dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
'head': git(root, 'rev-parse', 'HEAD').decode().strip(),
|
|
'task_sha256': digest(content), 'status': 'WAIT', 'reasons': [],
|
|
'evidence': [f"{data['task']}#L{i}" for i, _ in items],
|
|
'changed': paths, 'checks': []}
|
|
if digest(content) != data['task_sha256']:
|
|
# Checkbox progress is permitted; all other task edits require a new card.
|
|
normalized = re.sub(rb'(?m)^(\s*[-*] \[)[ xX](\]\s+)', rb'\1 \2', content)
|
|
if digest(normalized) != data['task_definition_sha256']:
|
|
report['reasons'].append('task definition changed; create a new reviewed card')
|
|
if outside:
|
|
report['reasons'].append('outside scope: ' + ', '.join(outside))
|
|
if report['reasons']:
|
|
report['status'] = 'ANDON'
|
|
return report
|
|
if not items:
|
|
report['reasons'].append('task has no checklist items')
|
|
elif not all(done for _, done in items):
|
|
report['reasons'].append('task checklist is incomplete')
|
|
if execute:
|
|
before = snapshot(root, data['base'], card)
|
|
for argv in data['checks']:
|
|
try:
|
|
result = run_check(root, argv, data['timeout'])
|
|
except OSError:
|
|
result = {'argv': argv, 'exit': None, 'error': 'could not start executable'}
|
|
report['checks'].append(result)
|
|
if result['exit'] != 0:
|
|
report['status'] = 'ANDON'
|
|
report['reasons'].append('acceptance check failed')
|
|
break
|
|
if snapshot(root, data['base'], card) != before or task.read_bytes() != content:
|
|
report['status'] = 'ANDON'
|
|
report['reasons'].append('acceptance checks changed repository evidence; rerun after review')
|
|
else:
|
|
report['reasons'].append('checks not executed; pass --run-checks after reviewing argv')
|
|
if not report['reasons']:
|
|
report['status'] = 'DONE'
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--card', default='finishline.json')
|
|
sub = parser.add_subparsers(dest='command', required=True)
|
|
init = sub.add_parser('init')
|
|
init.add_argument('--task', default='task.md')
|
|
init.add_argument('--allow', action='append', required=True)
|
|
init.add_argument('--check', action='append', required=True, help='JSON argv array; never shell text')
|
|
init.add_argument('--timeout', type=int, default=30)
|
|
check = sub.add_parser('check')
|
|
check.add_argument('--run-checks', action='store_true')
|
|
args = parser.parse_args()
|
|
try:
|
|
root = Path(git(Path.cwd(), 'rev-parse', '--show-toplevel').decode().strip()).resolve()
|
|
path = local(root, args.card)
|
|
if path.is_symlink():
|
|
raise ValueError('card must not be a symlink')
|
|
if args.command == 'init':
|
|
if path.exists():
|
|
raise ValueError('card already exists; use --card for a new reviewed contract')
|
|
task = local(root, args.task).read_bytes()
|
|
data = {'version': 1, 'task': local(root, args.task).relative_to(root).as_posix(), 'task_sha256': digest(task),
|
|
'task_definition_sha256': digest(re.sub(rb'(?m)^(\s*[-*] \[)[ xX](\]\s+)', rb'\1 \2', task)),
|
|
'base': git(root, 'rev-parse', 'HEAD').decode().strip(),
|
|
'allow': [Path(a).as_posix() for a in args.allow], 'checks': [json.loads(s) for s in args.check],
|
|
'timeout': args.timeout, 'runs': []}
|
|
validate(root, data)
|
|
save(path, data)
|
|
print('Created ' + args.card + '; review checks before --run-checks.')
|
|
return 0
|
|
data = json.loads(path.read_text())
|
|
original = path.read_bytes()
|
|
card_name = path.relative_to(root).as_posix()
|
|
report = evaluate(root, data, card_name, args.run_checks)
|
|
if not path.exists() or path.is_symlink() or path.read_bytes() != original:
|
|
report['status'] = 'ANDON'
|
|
report['reasons'].append('acceptance checks changed the card; review before rerunning')
|
|
data['runs'] = (data.get('runs', []) + [report])[-20:]
|
|
save(path, data)
|
|
print(json.dumps(report, indent=2))
|
|
return {'DONE': 0, 'WAIT': 1, 'ANDON': 2}[report['status']]
|
|
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as e:
|
|
# Do not echo exception contents: malformed cards/commands can contain secrets.
|
|
print(json.dumps({'status': 'ANDON', 'reasons': ['invalid or unavailable repository/card/task; review configuration'], 'error_type': type(e).__name__}))
|
|
return 2
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|