864 lines
44 KiB
Python
864 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded code-review publication using the caller's authenticated adapter."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
# Reuse public JSON/page/cache and canonical spec codecs, not the spec-only publisher.
|
|
_SPEC_PATH = (Path(__file__).resolve().parents[4] /
|
|
'spec/skills/gitea-implementation-spec-review/scripts/review_support.py')
|
|
_spec = importlib.util.spec_from_file_location('gitea_native_spec_support', _SPEC_PATH)
|
|
_support = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_support)
|
|
_LOCK_PATH = Path(__file__).resolve().parents[5] / 'lib/managed_lock.py'
|
|
_lock_spec = importlib.util.spec_from_file_location('gitea_managed_lock', _LOCK_PATH)
|
|
_locks = importlib.util.module_from_spec(_lock_spec)
|
|
_lock_spec.loader.exec_module(_locks)
|
|
_BROKER_PATH = Path(__file__).resolve().parents[5] / 'lib/managed_lock_broker.py'
|
|
_broker_spec = importlib.util.spec_from_file_location('gitea_managed_lock_broker', _BROKER_PATH)
|
|
_broker = importlib.util.module_from_spec(_broker_spec)
|
|
_broker_spec.loader.exec_module(_broker)
|
|
ValidationError = _support.ValidationError
|
|
loads_json = _support.loads_json
|
|
collect_pages = _support.collect_pages
|
|
ORIGIN = 'https://gitea.jsfamily.quest'
|
|
SCHEMA = 'gitea-agent.code-review-publication/v1'
|
|
|
|
|
|
def fail(message):
|
|
raise ValidationError(message)
|
|
|
|
|
|
def require_adoption(declaration, revision):
|
|
if declaration is None:
|
|
fail('delegation adoption declaration required')
|
|
try:
|
|
repo_root = Path(__file__).resolve().parents[6]
|
|
import sys
|
|
if str(repo_root) not in sys.path:
|
|
sys.path.insert(0, str(repo_root))
|
|
from harness.lib.delegation_adoption import AdoptionError, require_adoption as adopt
|
|
except Exception:
|
|
fail('delegation adoption unavailable')
|
|
try:
|
|
return adopt(declaration, expected_source={'kind': 'git_tree', 'revision': revision}, expected_stage='code-review')
|
|
except AdoptionError as exc:
|
|
fail(str(exc))
|
|
except Exception:
|
|
fail('delegation adoption unavailable')
|
|
|
|
|
|
def positive_id(value):
|
|
if type(value) is not int or value < 1:
|
|
fail('invalid positive identity')
|
|
return value
|
|
|
|
|
|
def digest(value, length):
|
|
if type(value) is not str or not re.fullmatch('[0-9a-f]{' + str(length) + '}', value):
|
|
fail('invalid digest')
|
|
return value
|
|
|
|
|
|
def marker(plan):
|
|
t = plan['target']
|
|
return (f'<!-- gitea-agent:code-review/v1 owner={t["owner"]} repository={t["repository"]} '
|
|
f'issue={t["issue"]} pr={t["pull"]} head={t["head"]} '
|
|
f'contract-sha256={plan["contract_sha256"]} -->')
|
|
|
|
|
|
def validate_plan(plan):
|
|
keys = {'schema', 'target', 'contract_sha256', 'reviewer', 'spec_reviewer',
|
|
'spec_review_id', 'spec_paths', 'verdict', 'body', 'provider_contract'}
|
|
if type(plan) is not dict or set(plan) != keys or plan['schema'] != SCHEMA:
|
|
fail('invalid publication plan')
|
|
t = plan['target']
|
|
if type(t) is not dict or set(t) != {'owner', 'repository', 'issue', 'pull', 'head', 'base'}:
|
|
fail('invalid target schema')
|
|
for k in ['owner', 'repository']:
|
|
value = t[k]
|
|
if type(value) is not str or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}', value):
|
|
fail('invalid target identifier')
|
|
for k in ['issue', 'pull']:
|
|
positive_id(t[k])
|
|
for k in ['head', 'base']:
|
|
digest(t[k], 40)
|
|
digest(plan['contract_sha256'], 64)
|
|
for k, login in [('reviewer', 'gitea-review-agent'), ('spec_reviewer', 'gitea-spec-agent')]:
|
|
who = plan[k]
|
|
if type(who) is not dict or set(who) != {'id', 'login'} or who['login'] != login:
|
|
fail('invalid role identity')
|
|
positive_id(who['id'])
|
|
if plan['reviewer']['id'] == plan['spec_reviewer']['id']:
|
|
fail('reviewer independence violation')
|
|
positive_id(plan['spec_review_id'])
|
|
paths = plan['spec_paths']
|
|
if type(paths) is not list or any(type(x) is not str or not x or x.startswith('/')
|
|
or any(p in ['', '.', '..'] for p in x.split('/'))
|
|
or any(ord(c) < 32 for c in x) for x in paths) or len(set(paths)) != len(paths):
|
|
fail('invalid declared spec path scope')
|
|
if type(plan['verdict']) is not str or plan['verdict'] not in ['approve', 'request_changes']:
|
|
fail('unsupported publication verdict')
|
|
body = plan['body']
|
|
if type(body) is not str or not 1 <= len(body) <= 250000:
|
|
fail('invalid body')
|
|
try:
|
|
body.encode('utf-8')
|
|
except UnicodeError:
|
|
fail('invalid body encoding')
|
|
if (not body.startswith(marker(plan) + '\n') or body.count('<!-- gitea-agent:code-review/v1 ') != 1
|
|
or [x for x in body.splitlines() if x.startswith('- Verdict:')] != [f'- Verdict: `{plan["verdict"]}`']):
|
|
fail('body binding mismatch')
|
|
provider = plan['provider_contract']
|
|
fields = {'supersede_same_author', 'retouch_dismissed', 'unordered_commit_files', 'evidence'}
|
|
if type(provider) is not dict or set(provider) != fields:
|
|
fail('invalid provider contract')
|
|
flags = [provider[k] for k in fields - {'evidence'}]
|
|
if any(type(x) is not bool for x in flags) or type(provider['evidence']) is not list:
|
|
fail('invalid provider projection flags')
|
|
if any(type(x) is not str or not x.strip() for x in provider['evidence']):
|
|
fail('invalid provider evidence')
|
|
if any(flags) and not provider['evidence']:
|
|
fail('provider projection requires evidence')
|
|
return plan
|
|
|
|
|
|
def build_payload(plan):
|
|
validate_plan(plan)
|
|
return {'commit_id': plan['target']['head'], 'body': plan['body'],
|
|
'event': {'approve': 'APPROVED', 'request_changes': 'REQUEST_CHANGES'}[plan['verdict']],
|
|
'comments': []}
|
|
|
|
|
|
def canonical(value):
|
|
# Reuse duplicate/nonfinite validation before fingerprinting or comparison.
|
|
try:
|
|
text = json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(',', ':'))
|
|
loads_json(text)
|
|
return text.encode('utf-8')
|
|
except (TypeError, ValueError, UnicodeError):
|
|
fail('invalid JSON artifact')
|
|
|
|
|
|
def indexed(items, key='id'):
|
|
if type(items) is not list:
|
|
fail('invalid collection')
|
|
out = {}
|
|
for item in items:
|
|
if type(item) is not dict or key not in item:
|
|
fail('invalid collection item')
|
|
identity = item[key]
|
|
if key == 'id':
|
|
positive_id(identity)
|
|
elif key == 'sha':
|
|
digest(identity, 40)
|
|
elif (type(identity) is not str or not identity or identity.startswith('/')
|
|
or '\\' in identity or any(p in ['', '.', '..'] for p in identity.split('/'))):
|
|
fail('invalid filename')
|
|
if identity in out:
|
|
fail('duplicate collection identity')
|
|
out[identity] = item
|
|
return out
|
|
|
|
|
|
def check_pages(items, pages):
|
|
if type(pages) is not list or not pages:
|
|
fail('missing complete pagination evidence')
|
|
total = None; counted = 0
|
|
for number, page in enumerate(pages, 1):
|
|
if type(page) is not dict or set(page) != {'page', 'count', 'declared_total', 'link'}:
|
|
fail('invalid pagination record')
|
|
if type(page['page']) is not int or page['page'] != number:
|
|
fail('invalid page sequence')
|
|
count = page['count']; declared = page['declared_total']; link = page['link']
|
|
if type(count) is not int or not 0 <= count <= 50:
|
|
fail('invalid page size')
|
|
if declared is not None:
|
|
if type(declared) is not int or declared < 0 or (total is not None and declared != total):
|
|
fail('conflicting declared total')
|
|
total = declared
|
|
if link is not None and type(link) is not str:
|
|
fail('invalid Link header')
|
|
if number < len(pages) and count != 50:
|
|
fail('invalid pagination continuation')
|
|
counted += count
|
|
last = pages[-1]
|
|
if counted != len(items) or (total is not None and total != counted):
|
|
fail('collection cardinality mismatch')
|
|
if (last['link'] and re.search(r'rel=["\']?next', last['link'])) or (total is None and last['count'] == 50):
|
|
fail('truncated pagination')
|
|
|
|
|
|
def role_identity(value, expected):
|
|
if type(value) is not dict or type(value.get('id')) is not int or value.get('id') != expected['id'] or value.get('login') != expected['login']:
|
|
fail('role identity mismatch')
|
|
if value.get('origin', ORIGIN + '/api/v1') != ORIGIN + '/api/v1':
|
|
fail('origin mismatch')
|
|
|
|
|
|
def validate_snapshot(plan, snapshot):
|
|
validate_plan(plan)
|
|
required = {'identity', 'spec_user', 'repository', 'issue', 'pr', 'files', 'commits',
|
|
'reviews', 'review_comments', 'issue_comments', 'pr_comments', 'statuses', 'pagination'}
|
|
if type(snapshot) is not dict or not required <= set(snapshot):
|
|
fail('incomplete snapshot')
|
|
canonical(snapshot)
|
|
t = plan['target']; full = t['owner'] + '/' + t['repository']
|
|
role_identity(snapshot['identity'], plan['reviewer'])
|
|
role_identity(snapshot['spec_user'], plan['spec_reviewer'])
|
|
repo = snapshot['repository']; issue = snapshot['issue']; pr = snapshot['pr']
|
|
if any(type(x) is not dict for x in [repo, issue, pr, snapshot['pagination']]):
|
|
fail('invalid target snapshot shape')
|
|
positive_id(issue.get('number')); positive_id(pr.get('number'))
|
|
if 'repository' in issue and (type(issue['repository']) is not dict or issue['repository'].get('full_name') != full):
|
|
fail('source Issue repository mismatch')
|
|
for side in ['base', 'head']:
|
|
if (type(pr.get(side)) is not dict or type(pr[side].get('repo')) is not dict
|
|
or type(pr[side]['repo'].get('full_name')) is not str):
|
|
fail('invalid PR repository binding')
|
|
if (repo.get('full_name') != full or issue.get('number') != t['issue']
|
|
or pr.get('number') != t['pull'] or pr.get('html_url') != f'{ORIGIN}/{full}/pulls/{t["pull"]}'
|
|
or issue.get('state') != 'open' or pr.get('state') != 'open' or pr.get('merged') is not False):
|
|
fail('target or lifecycle mismatch')
|
|
if type(issue.get('body')) is not str or hashlib.sha256(issue['body'].encode('utf-8')).hexdigest() != plan['contract_sha256']:
|
|
fail('contract drift')
|
|
if snapshot.get('contract_sha256', plan['contract_sha256']) != plan['contract_sha256']:
|
|
fail('contract digest mismatch')
|
|
if (pr['base']['sha'] != t['base'] or pr['head']['sha'] != t['head']
|
|
or pr['base']['repo']['full_name'] != full or pr['base']['ref'] != repo['default_branch']):
|
|
fail('head/base/default branch drift')
|
|
author = positive_id(pr['user']['id'])
|
|
if author in [plan['reviewer']['id'], plan['spec_reviewer']['id']]:
|
|
fail('reviewer independence violation')
|
|
links = re.findall(r'(?im)^(?:closes|fixes|resolves)[ \t]+[^\r\n]+$', pr.get('body', ''))
|
|
if links != ['Closes #' + str(t['issue'])]:
|
|
fail('source issue linkage mismatch')
|
|
for name, key in [('files', 'filename'), ('commits', 'sha'), ('reviews', 'id'),
|
|
('issue_comments', 'id'), ('pr_comments', 'id'), ('statuses', 'id')]:
|
|
indexed(snapshot[name], key)
|
|
check_pages(snapshot[name], snapshot['pagination'].get(name))
|
|
review_map = indexed(snapshot['reviews'])
|
|
comments = snapshot['review_comments']
|
|
if type(comments) is not dict or set(comments) != {str(rid) for rid in review_map}:
|
|
fail('incomplete review comment collection')
|
|
for rid, review in review_map.items():
|
|
if type(review.get('user')) is not dict:
|
|
fail('invalid historical reviewer identity')
|
|
positive_id(review['user'].get('id'))
|
|
digest(review.get('commit_id'), 40)
|
|
if (type(review.get('body')) is not str or review.get('state') not in ['COMMENT', 'PENDING', 'APPROVED', 'REQUEST_CHANGES']
|
|
or any(type(review.get(k)) is not bool for k in ['official', 'dismissed', 'stale'])):
|
|
fail('invalid native review shape')
|
|
indexed(comments[str(rid)])
|
|
check_pages(comments[str(rid)], snapshot['pagination'].get('review_comments_' + str(rid)))
|
|
if type(review.get('comments_count')) is not int or review['comments_count'] != len(comments[str(rid)]):
|
|
fail('review comment cardinality mismatch')
|
|
approved = [r for r in snapshot['reviews'] if r.get('user', {}).get('id') == plan['spec_reviewer']['id']
|
|
and r.get('commit_id') == t['head'] and r.get('state') == 'APPROVED'
|
|
and r.get('stale') is False and r.get('dismissed') is False]
|
|
if len(approved) != 1 or approved[0]['id'] != plan['spec_review_id']:
|
|
fail('missing or ambiguous current-head spec approval')
|
|
role_identity(approved[0]['user'], plan['spec_reviewer'])
|
|
parsed = _support.parse_review(approved[0]['body'], set(plan['spec_paths']))
|
|
if (parsed['verdict'] != 'approve' or parsed['contract_sha256'] != plan['contract_sha256']
|
|
or parsed['target'] != {k: t[k] for k in ['owner', 'repository', 'issue', 'pull', 'head']}):
|
|
fail('spec approval binding mismatch')
|
|
return snapshot
|
|
|
|
|
|
def normalized_commits(commits, enabled):
|
|
import copy
|
|
out = copy.deepcopy(commits)
|
|
for row in out:
|
|
files = row.get('files', [])
|
|
indexed(files, 'filename')
|
|
if enabled and 'files' in row:
|
|
row['files'] = sorted(files, key=lambda f: f['filename'])
|
|
return out
|
|
|
|
|
|
def same_source(plan, before, after, new_review=None):
|
|
import copy
|
|
validate_snapshot(plan, before); validate_snapshot(plan, after)
|
|
a = copy.deepcopy(before); b = copy.deepcopy(after)
|
|
mutable = {'reviews', 'review_comments', 'pagination'}
|
|
# Provider-derived changes are tied to this exact review, never globally ignored.
|
|
if new_review is not None:
|
|
times = {new_review['submitted_at'], new_review['updated_at']}
|
|
for section in ['issue', 'pr']:
|
|
if a[section].get('updated_at') != b[section].get('updated_at'):
|
|
if b[section].get('updated_at') not in times:
|
|
fail('unrelated timestamp mutation')
|
|
b[section]['updated_at'] = a[section].get('updated_at')
|
|
if 'review_comments' in a['pr']:
|
|
old = a['pr']['review_comments']; new = b['pr'].get('review_comments')
|
|
if type(old) is not int or type(new) is not int or new != old + 1:
|
|
fail('PR review count projection mismatch')
|
|
b['pr']['review_comments'] = old
|
|
requested_before = indexed(a['pr'].get('requested_reviewers', []))
|
|
requested_after = indexed(b['pr'].get('requested_reviewers', []))
|
|
extra = set(requested_after) - set(requested_before)
|
|
if set(requested_before) - set(requested_after) or extra - {plan['reviewer']['id']}:
|
|
fail('unrelated requested reviewer mutation')
|
|
if any(canonical(row) != canonical(requested_after[rid]) for rid, row in requested_before.items()):
|
|
fail('requested reviewer content changed')
|
|
if extra:
|
|
role_identity(requested_after[plan['reviewer']['id']], plan['reviewer'])
|
|
b['pr']['requested_reviewers'] = a['pr'].get('requested_reviewers', [])
|
|
for data in [a, b]:
|
|
data['files'] = sorted(data['files'], key=lambda f: f['filename'])
|
|
data['commits'] = normalized_commits(data['commits'], plan['provider_contract']['unordered_commit_files'])
|
|
if canonical({k: v for k, v in a.items() if k not in mutable}) != canonical({k: v for k, v in b.items() if k not in mutable}):
|
|
fail('source snapshot drift')
|
|
|
|
|
|
def exact_binding(plan, snapshot, exact):
|
|
payload = build_payload(plan)
|
|
if type(exact) is not dict:
|
|
fail('invalid exact review')
|
|
rid = positive_id(exact.get('id'))
|
|
role_identity(exact.get('user'), plan['reviewer'])
|
|
if (exact.get('body') != payload['body'] or exact.get('commit_id') != payload['commit_id']
|
|
or exact.get('state') != payload['event'] or exact.get('stale') is not False
|
|
or exact.get('dismissed') is not False or type(exact.get('official')) is not bool
|
|
or type(exact.get('comments_count')) is not int or exact['comments_count'] != 0
|
|
or exact.get('pull_request_url') != snapshot['pr']['html_url']):
|
|
fail('exact review binding mismatch')
|
|
if snapshot['review_comments'].get(str(rid)) != []:
|
|
fail('inline comments are unsupported')
|
|
if canonical(indexed(snapshot['reviews']).get(rid)) != canonical(exact):
|
|
fail('exact review differs from complete collection')
|
|
for key in ['submitted_at', 'updated_at']:
|
|
from datetime import datetime
|
|
try:
|
|
stamp = datetime.fromisoformat(exact[key].replace('Z', '+00:00'))
|
|
if stamp.utcoffset() is None:
|
|
fail('unbound review timestamp')
|
|
except (KeyError, ValueError, TypeError, AttributeError):
|
|
fail('invalid review timestamp')
|
|
return rid
|
|
|
|
|
|
def verify_publication(plan, before, after, exact):
|
|
rid = exact_binding(plan, validate_snapshot(plan, after), exact)
|
|
same_source(plan, before, after, exact)
|
|
old = indexed(before['reviews']); new = indexed(after['reviews'])
|
|
if set(new) - set(old) != {rid} or set(old) - set(new):
|
|
fail('review cardinality mismatch')
|
|
current = [r for r in after['reviews'] if r['user']['id'] == plan['reviewer']['id']
|
|
and r['commit_id'] == plan['target']['head'] and r['state'] in ['PENDING', 'APPROVED', 'REQUEST_CHANGES']
|
|
and not r['dismissed']]
|
|
if len(current) != 1 or current[0]['id'] != rid:
|
|
fail('competing current-head review')
|
|
projected = []
|
|
for old_id, review in old.items():
|
|
fresh = new[old_id]
|
|
if before['review_comments'][str(old_id)] != after['review_comments'][str(old_id)]:
|
|
fail('historical review comments changed')
|
|
if canonical(review) == canonical(fresh):
|
|
continue
|
|
permitted = {'official', 'dismissed', 'updated_at'}
|
|
if (review['user']['id'] != plan['reviewer']['id'] or review['state'] not in ['APPROVED', 'REQUEST_CHANGES']
|
|
or set(review) != set(fresh)
|
|
or canonical({k: v for k, v in review.items() if k not in permitted}) != canonical({k: v for k, v in fresh.items() if k not in permitted})):
|
|
fail('historical review immutable content changed')
|
|
provider = plan['provider_contract']
|
|
superseded = provider['supersede_same_author'] and review.get('official') is True and review.get('dismissed') is False
|
|
retouched = provider['retouch_dismissed'] and review.get('official') is False and review.get('dismissed') is True
|
|
if (not (superseded or retouched) or fresh.get('official') is not False or fresh.get('dismissed') is not True
|
|
or fresh.get('updated_at') not in {exact['submitted_at'], exact['updated_at']}):
|
|
fail('unauthorized historical review projection')
|
|
projected.append(old_id)
|
|
return {'verified': True, 'review_id': rid, 'state': exact['state'], 'head': plan['target']['head'],
|
|
'projected_review_ids': projected, 'review_records_added': 1,
|
|
'commit_file_order_normalized': before['commits'] != after['commits'], 'read_only': True}
|
|
|
|
|
|
class UncertainPublication(ValidationError):
|
|
"""A durable attempt exists; a second create is prohibited."""
|
|
|
|
|
|
class Journal:
|
|
def __init__(self, directory):
|
|
import os, stat
|
|
self.path = Path(directory)
|
|
if not self.path.is_absolute():
|
|
fail('journal must be an absolute trusted path')
|
|
for p in [self.path, *self.path.parents]:
|
|
if p.is_symlink():
|
|
fail('symlinked journal path')
|
|
self.path.mkdir(mode=0o700, exist_ok=True)
|
|
st = self.path.lstat()
|
|
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o700:
|
|
fail('unsafe journal directory')
|
|
self.fd = None
|
|
|
|
def __enter__(self):
|
|
import fcntl, os, stat
|
|
self.fd = os.open(self.path / 'publication.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
st = os.fstat(self.fd)
|
|
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o600 or st.st_nlink != 1:
|
|
fail('unsafe journal lock')
|
|
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BaseException:
|
|
os.close(self.fd); self.fd = None
|
|
raise
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
import os
|
|
os.close(self.fd)
|
|
|
|
def read(self, name):
|
|
import os, stat
|
|
p = self.path / name
|
|
try:
|
|
fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
except FileNotFoundError:
|
|
return None
|
|
except OSError:
|
|
raise ValidationError('journal artifact unavailable') from None
|
|
with os.fdopen(fd, 'r', encoding='utf-8') as artifact:
|
|
st = os.fstat(artifact.fileno())
|
|
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o600 or st.st_nlink != 1:
|
|
fail('unsafe journal artifact')
|
|
return loads_json(artifact.read())
|
|
|
|
def write(self, name, value):
|
|
import os, tempfile
|
|
self.read(name) # Reject existing symlink/hardlink/foreign artifacts.
|
|
fd, tmp = tempfile.mkstemp(dir=self.path, prefix='.native-review-')
|
|
try:
|
|
with os.fdopen(fd, 'wb') as out:
|
|
out.write(canonical(value)); out.flush(); os.fsync(out.fileno())
|
|
os.replace(tmp, self.path / name)
|
|
directory = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
finally:
|
|
if os.path.exists(tmp):
|
|
os.unlink(tmp)
|
|
|
|
|
|
def candidates(plan, snapshot):
|
|
own = [r for r in snapshot['reviews'] if r['user']['id'] == plan['reviewer']['id']
|
|
and r['commit_id'] == plan['target']['head'] and r['state'] in ['PENDING', 'APPROVED', 'REQUEST_CHANGES']]
|
|
matching = [r for r in own if marker(plan) in r.get('body', '')]
|
|
if len(matching) > 1 or any(r not in matching for r in own):
|
|
fail('duplicate or conflicting current-head review')
|
|
if matching and (matching[0]['body'] != plan['body'] or matching[0]['state'] != build_payload(plan)['event']):
|
|
fail('conflicting canonical review')
|
|
return matching
|
|
|
|
|
|
def read_api(api, method, *args):
|
|
try:
|
|
return getattr(api, method)(*args)
|
|
except Exception:
|
|
raise ValidationError('authoritative API read unavailable; do not retry POST') from None
|
|
|
|
|
|
def check_lease(lease, plan):
|
|
try:
|
|
result = lease.check(plan)
|
|
except Exception:
|
|
raise ValidationError('held Issue lease invalid or unavailable') from None
|
|
if result is not None:
|
|
fail('unsupported lease check result')
|
|
|
|
|
|
def publish(plan, baseline, api, lease, journal_directory, *, declaration=None):
|
|
"""Use one trusted role transport and an already-held Issue lease; never retry POST."""
|
|
import copy
|
|
validate_snapshot(plan, baseline)
|
|
require_adoption(declaration, plan['target']['head'])
|
|
payload = build_payload(plan)
|
|
if any(not callable(getattr(api, k, None)) for k in ['identity', 'snapshot', 'create_review', 'read_review']):
|
|
fail('unsupported adapter capabilities')
|
|
if not callable(getattr(lease, 'check', None)):
|
|
fail('missing held Issue lease')
|
|
fingerprint = hashlib.sha256(canonical(plan)).hexdigest()
|
|
check_lease(lease, plan)
|
|
with Journal(journal_directory) as journal:
|
|
intent = journal.read('intent.json')
|
|
before = baseline
|
|
if intent is not None:
|
|
if type(intent) is not dict or intent.get('fingerprint') != fingerprint:
|
|
fail('journal belongs to a different publication')
|
|
before = journal.read('before.json')
|
|
if before is None or hashlib.sha256(canonical(before)).hexdigest() != intent.get('before_sha256'):
|
|
fail('journal baseline integrity mismatch')
|
|
validate_snapshot(plan, before)
|
|
expected_intent = {'schema': 'gitea-agent.native-review-intent/v1', 'fingerprint': fingerprint,
|
|
'before_sha256': hashlib.sha256(canonical(before)).hexdigest(),
|
|
'head': plan['target']['head'], 'event': payload['event'],
|
|
'body_sha256': hashlib.sha256(payload['body'].encode()).hexdigest()}
|
|
if canonical(intent) != canonical(expected_intent):
|
|
fail('journal intent schema or binding mismatch')
|
|
check_lease(lease, plan)
|
|
role_identity(read_api(api, 'identity'), plan['reviewer'])
|
|
fresh = validate_snapshot(plan, read_api(api, 'snapshot', plan['target']))
|
|
matching = candidates(plan, fresh)
|
|
if matching:
|
|
exact = read_api(api, 'read_review', plan['target'], matching[0]['id'])
|
|
if exact['id'] in indexed(before['reviews']):
|
|
same_source(plan, before, fresh)
|
|
if canonical(before['reviews']) != canonical(fresh['reviews']) or before['review_comments'] != fresh['review_comments']:
|
|
fail('no-op baseline drift')
|
|
rid = exact_binding(plan, fresh, exact)
|
|
result = {'verified': True, 'review_id': rid, 'state': exact['state'], 'head': plan['target']['head'], 'review_records_added': 0}
|
|
else:
|
|
result = verify_publication(plan, before, fresh, exact)
|
|
check_lease(lease, plan)
|
|
result.update(mode='no_op', read_only=False, wrote=False)
|
|
journal.write('verified.json', result)
|
|
return result
|
|
if intent is not None:
|
|
raise UncertainPublication('durable attempt has no authoritative match; do not retry POST')
|
|
same_source(plan, baseline, fresh)
|
|
if canonical(baseline['reviews']) != canonical(fresh['reviews']) or baseline['review_comments'] != fresh['review_comments']:
|
|
fail('review baseline drift')
|
|
check_lease(lease, plan)
|
|
# A second source read immediately precedes intent/create; do not mutate a stale baseline.
|
|
role_identity(read_api(api, 'identity'), plan['reviewer'])
|
|
latest = validate_snapshot(plan, read_api(api, 'snapshot', plan['target']))
|
|
same_source(plan, fresh, latest)
|
|
if canonical(fresh['reviews']) != canonical(latest['reviews']) or fresh['review_comments'] != latest['review_comments']:
|
|
fail('review baseline drift')
|
|
before = copy.deepcopy(latest)
|
|
journal.write('before.json', before)
|
|
journal.write('intent.json', {'schema': 'gitea-agent.native-review-intent/v1', 'fingerprint': fingerprint,
|
|
'before_sha256': hashlib.sha256(canonical(before)).hexdigest(),
|
|
'head': plan['target']['head'], 'event': payload['event'],
|
|
'body_sha256': hashlib.sha256(payload['body'].encode()).hexdigest()})
|
|
check_lease(lease, plan)
|
|
require_adoption(declaration, plan['target']['head'])
|
|
uncertain = False
|
|
response_id = None
|
|
try:
|
|
created = api.create_review(plan['target'], payload)
|
|
if type(created) is dict:
|
|
response_id = positive_id(created.get('id'))
|
|
else:
|
|
uncertain = True
|
|
except Exception:
|
|
# Never expose raw transport exception text (which may contain credentials).
|
|
uncertain = True
|
|
check_lease(lease, plan)
|
|
try:
|
|
role_identity(read_api(api, 'identity'), plan['reviewer'])
|
|
after = validate_snapshot(plan, read_api(api, 'snapshot', plan['target']))
|
|
matching = candidates(plan, after)
|
|
if len(matching) != 1:
|
|
raise UncertainPublication('create outcome not uniquely found; do not retry POST')
|
|
if response_id is not None and response_id != matching[0]['id']:
|
|
fail('create response and collection mismatch')
|
|
exact = read_api(api, 'read_review', plan['target'], matching[0]['id'])
|
|
result = verify_publication(plan, before, after, exact)
|
|
except ValidationError:
|
|
raise
|
|
except Exception:
|
|
raise UncertainPublication('authoritative readback unavailable; retained attempt; do not retry POST') from None
|
|
check_lease(lease, plan)
|
|
result.update(mode='reconciled' if uncertain else 'created', read_only=False, wrote=True)
|
|
journal.write('verified.json', result)
|
|
return result
|
|
|
|
|
|
class ApiAdapter:
|
|
"""REST bridge: request(path, payload=None) -> (JSON, headers, status).
|
|
|
|
The caller supplies one already-authenticated, no-redirect, TLS-verifying,
|
|
role-local transport. This class never loads/falls back to credentials.
|
|
"""
|
|
def __init__(self, request, *, origin, credential_source):
|
|
if not callable(request) or origin != ORIGIN + '/api/v1':
|
|
fail('invalid authenticated transport origin')
|
|
if credential_source not in ['$HERMES_HOME/.env:GITEA_TOKEN', 'role-scoped-external-secret:GITEA_TOKEN']:
|
|
fail('unverified role credential source')
|
|
self.request = request
|
|
self.origin = origin
|
|
self.credential_source = credential_source
|
|
|
|
def call(self, path, payload=None):
|
|
try:
|
|
data, headers, status = self.request(path, payload)
|
|
except Exception:
|
|
raise ValidationError('authenticated API request failed') from None
|
|
expected = [200, 201] if payload is not None else [200]
|
|
if type(status) is not int or status not in expected or not hasattr(headers, 'items'):
|
|
fail('authenticated API status/shape mismatch')
|
|
return data, {str(k).lower(): v for k, v in headers.items()}
|
|
|
|
def identity(self):
|
|
data, _ = self.call('/user')
|
|
if type(data) is not dict or data.get('login') != 'gitea-review-agent':
|
|
fail('unexpected authenticated role')
|
|
positive_id(data.get('id'))
|
|
return {'id': data['id'], 'login': data['login'], 'origin': self.origin,
|
|
'credential_source': self.credential_source}
|
|
|
|
def prefix(self, target):
|
|
for key in ['owner', 'repository']:
|
|
if type(target.get(key)) is not str or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}', target[key]):
|
|
fail('invalid request target')
|
|
positive_id(target['issue']); positive_id(target['pull'])
|
|
return '/repos/' + target['owner'] + '/' + target['repository']
|
|
|
|
def collection(self, endpoint, key):
|
|
records = []
|
|
def fetch(page):
|
|
data, headers = self.call(endpoint + '?limit=50&page=' + str(page))
|
|
raw_total = headers.get('x-total-count')
|
|
if raw_total is not None and (type(raw_total) is not str or not re.fullmatch(r'\d+', raw_total)):
|
|
fail('invalid declared collection total')
|
|
total = int(raw_total) if raw_total is not None else None
|
|
if data is None and total == 0:
|
|
data = []
|
|
if type(data) is not list:
|
|
fail('invalid API collection')
|
|
link = headers.get('link')
|
|
if link is not None and type(link) is not str:
|
|
fail('invalid collection Link header')
|
|
more = True if link and re.search(r'rel=["\']?next', link) else None
|
|
if more is None and total is not None:
|
|
more = page * 50 < total
|
|
records.append({'page': page, 'count': len(data), 'declared_total': total, 'link': link})
|
|
envelope = {'items': data, 'has_more': more}
|
|
if total is not None:
|
|
envelope['total_count'] = total
|
|
return envelope
|
|
items = collect_pages(fetch, key, page_size=50, max_pages=1000)
|
|
check_pages(items, records)
|
|
return items, records
|
|
|
|
def snapshot(self, target):
|
|
identity = self.identity() # Always first authoritative read.
|
|
prefix = self.prefix(target)
|
|
repo, _ = self.call(prefix)
|
|
issue, _ = self.call(prefix + '/issues/' + str(target['issue']))
|
|
pr, _ = self.call(prefix + '/pulls/' + str(target['pull']))
|
|
spec_user, _ = self.call('/users/gitea-spec-agent')
|
|
if type(spec_user) is not dict or spec_user.get('login') != 'gitea-spec-agent':
|
|
fail('invalid spec identity')
|
|
out = {'identity': identity, 'spec_user': {'id': positive_id(spec_user.get('id')), 'login': spec_user['login']},
|
|
'repository': repo, 'issue': issue, 'pr': pr, 'pagination': {}, 'review_comments': {}}
|
|
pull = prefix + '/pulls/' + str(target['pull'])
|
|
endpoints = [('files', pull + '/files', 'filename'), ('commits', pull + '/commits', 'sha'),
|
|
('reviews', pull + '/reviews', 'id'),
|
|
('issue_comments', prefix + '/issues/' + str(target['issue']) + '/comments', 'id'),
|
|
('pr_comments', prefix + '/issues/' + str(target['pull']) + '/comments', 'id'),
|
|
('statuses', prefix + '/statuses/' + digest(target['head'], 40), 'id')]
|
|
for name, endpoint, key in endpoints:
|
|
out[name], out['pagination'][name] = self.collection(endpoint, key)
|
|
for review in out['reviews']:
|
|
rid = str(positive_id(review['id']))
|
|
out['review_comments'][rid], out['pagination']['review_comments_' + rid] = self.collection(pull + '/reviews/' + rid + '/comments', 'id')
|
|
return out
|
|
|
|
def create_review(self, target, payload):
|
|
if type(payload) is not dict or set(payload) != {'commit_id', 'body', 'event', 'comments'} or payload['comments'] != []:
|
|
fail('invalid create payload')
|
|
data, _ = self.call(self.prefix(target) + '/pulls/' + str(target['pull']) + '/reviews', payload)
|
|
return data
|
|
|
|
def read_review(self, target, review_id):
|
|
positive_id(review_id)
|
|
data, _ = self.call(self.prefix(target) + '/pulls/' + str(target['pull']) + '/reviews/' + str(review_id))
|
|
return data
|
|
|
|
|
|
def _validate_registered_source(plan, policy_path, deadline_monotonic):
|
|
import math, os, stat, subprocess, time
|
|
if type(deadline_monotonic) not in [int, float] or not math.isfinite(deadline_monotonic):
|
|
fail('invalid or expired Issue lease')
|
|
t = plan['target']
|
|
info = _support.validate_cache_paths(policy_path, t['owner'], t['repository'], t['issue'])
|
|
wt = Path(info['repository']) / 'worktrees' / f'issue-{t["issue"]}' / 'checkout'
|
|
opened = []
|
|
def close_opened():
|
|
while opened:
|
|
try: os.close(opened.pop())
|
|
except OSError: pass
|
|
def reject(message):
|
|
close_opened()
|
|
fail(message)
|
|
def open_dir(path):
|
|
parts = path.parts
|
|
fd = os.open(parts[0], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
opened.append(fd)
|
|
try:
|
|
for part in parts[1:]:
|
|
nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
dir_fd=fd)
|
|
opened.append(nxt); os.close(fd); opened.remove(fd); fd = nxt
|
|
return fd
|
|
except BaseException:
|
|
close_opened(); raise
|
|
try:
|
|
worktrees_fd = open_dir(wt.parent.parent)
|
|
issue_fd = os.open(wt.parent.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
dir_fd=worktrees_fd)
|
|
opened.append(issue_fd)
|
|
checkout_fd = os.open(wt.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
dir_fd=issue_fd)
|
|
opened.append(checkout_fd)
|
|
for directory_fd in [worktrees_fd, issue_fd, checkout_fd]:
|
|
st = os.fstat(directory_fd)
|
|
if (not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid()
|
|
or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
|
|
reject('unsafe existing worktree path')
|
|
state_fd = os.open('state.json', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=issue_fd)
|
|
opened.append(state_fd)
|
|
git_pointer_fd = os.open('.git', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=checkout_fd)
|
|
opened.append(git_pointer_fd)
|
|
state_st = os.fstat(state_fd); git_st = os.fstat(git_pointer_fd)
|
|
if not stat.S_ISREG(state_st.st_mode) or state_st.st_uid != os.getuid() or stat.S_IMODE(state_st.st_mode) != 0o600:
|
|
reject('unsafe worktree state')
|
|
if not stat.S_ISREG(git_st.st_mode) or git_st.st_uid != os.getuid():
|
|
reject('invalid worktree Git pointer')
|
|
chunks = []
|
|
while chunk := os.read(state_fd, 65536):
|
|
chunks.append(chunk)
|
|
try:
|
|
state = loads_json(b''.join(chunks).decode('utf-8'))
|
|
except Exception:
|
|
close_opened()
|
|
raise
|
|
os.lseek(git_pointer_fd, 0, os.SEEK_SET)
|
|
pointer = os.read(git_pointer_fd, 4096).decode('utf-8').strip()
|
|
if not pointer.startswith('gitdir: '):
|
|
reject('invalid worktree Git pointer')
|
|
worktree_git_fd = open_dir(Path(pointer[8:]))
|
|
except OSError:
|
|
close_opened()
|
|
raise ValidationError('unsafe existing worktree path') from None
|
|
|
|
if (type(state) is not dict or state.get('schema') != 'gitea-agent.worktree/v1' or state.get('owner') != t['owner']
|
|
or state.get('repository') != t['repository'] or type(state.get('issue')) is not int
|
|
or state['issue'] != t['issue'] or state.get('created_by_role') != 'gitea-impl-agent'
|
|
or state.get('mode') != 'detached'):
|
|
reject('worktree registration identity mismatch')
|
|
|
|
git_binary = '/usr/bin/git'; binary_stat = Path(git_binary).lstat()
|
|
if not stat.S_ISREG(binary_stat.st_mode) or binary_stat.st_uid != 0 or binary_stat.st_mode & 0o022:
|
|
reject('untrusted system Git binary')
|
|
env = {'PATH': '/usr/bin:/bin', 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_OPTIONAL_LOCKS': '0'}
|
|
git_dir_fd = open_dir(Path(info['repository_git']))
|
|
def git(*args):
|
|
try:
|
|
process = subprocess.run([git_binary, '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null',
|
|
'-c', 'safe.directory=/proc/self/fd/' + str(checkout_fd),
|
|
'--git-dir=/proc/self/fd/' + str(worktree_git_fd),
|
|
'--work-tree=/proc/self/fd/' + str(checkout_fd), *args], capture_output=True, text=True, env=env, timeout=15,
|
|
pass_fds=(checkout_fd, worktree_git_fd, git_dir_fd))
|
|
except Exception:
|
|
close_opened()
|
|
raise ValidationError('read-only Git check unavailable') from None
|
|
if process.returncode or len(process.stdout) > 1000000:
|
|
reject('read-only Git check failed')
|
|
return process.stdout
|
|
if git('rev-parse', 'HEAD').strip() != t['head']:
|
|
reject('worktree HEAD mismatch')
|
|
if git('status', '--porcelain=v1', '--untracked-files=all').strip():
|
|
reject('existing worktree is not clean')
|
|
if git('rev-parse', '--path-format=absolute', '--git-common-dir').strip() != info['repository_git']:
|
|
reject('worktree common Git directory mismatch')
|
|
records = git('--git-dir=/proc/self/fd/' + str(git_dir_fd), 'worktree', 'list', '--porcelain', '-z').split('\0\0')
|
|
matches = [r.split('\0') for r in records if r.startswith('worktree ' + str(wt) + '\0')]
|
|
if len(matches) != 1 or 'detached' not in matches[0] or not any(x == 'locked' or x.startswith('locked ') for x in matches[0]):
|
|
reject('worktree detached/locked registration mismatch')
|
|
close_opened()
|
|
if time.monotonic() >= deadline_monotonic:
|
|
fail('Issue lease expired during source checks')
|
|
|
|
|
|
class HeldIssueLease:
|
|
"""Validate a caller-owned Issue lock FD; never close/release the caller's FD."""
|
|
def __init__(self, issue_fd, policy_path, deadline_monotonic):
|
|
self.issue_fd = issue_fd; self.policy_path = policy_path; self.deadline_monotonic = deadline_monotonic
|
|
|
|
def check(self, plan):
|
|
import math, time
|
|
validate_plan(plan)
|
|
if (type(self.issue_fd) is not int or type(self.deadline_monotonic) not in [int, float]
|
|
or not math.isfinite(self.deadline_monotonic) or time.monotonic() >= self.deadline_monotonic):
|
|
fail('invalid or expired Issue lease')
|
|
t = plan['target']; info = _support.validate_cache_paths(self.policy_path, t['owner'], t['repository'], t['issue'])
|
|
lock = Path(info['locks']) / f'issue-{t["issue"]}.lock'
|
|
try:
|
|
if not _locks.validate_held_flock(self.issue_fd, lock):
|
|
fail('Issue lock is not already held by this process')
|
|
except (OSError, _locks.LockValidationError):
|
|
raise ValidationError('Issue lock unavailable') from None
|
|
_validate_registered_source(plan, self.policy_path, self.deadline_monotonic)
|
|
|
|
|
|
class BrokerIssueLease:
|
|
"""Validate an authenticated shared managed-lock broker Issue lease."""
|
|
def __init__(self, handle_path, policy_path, deadline_monotonic):
|
|
self.handle_path = handle_path; self.policy_path = policy_path; self.deadline_monotonic = deadline_monotonic
|
|
|
|
def check(self, plan):
|
|
import math, time
|
|
validate_plan(plan)
|
|
if (type(self.deadline_monotonic) not in [int, float] or not math.isfinite(self.deadline_monotonic)
|
|
or time.monotonic() >= self.deadline_monotonic):
|
|
fail('invalid or expired Issue lease')
|
|
t = plan['target']
|
|
try:
|
|
handle = _broker.read_handle_metadata(self.handle_path)
|
|
status = _broker.request_handle(self.handle_path, 'status')
|
|
except Exception:
|
|
raise ValidationError('broker Issue lease unavailable') from None
|
|
if (type(status) is not dict or status.get('status') not in ('held', 'repository-held')
|
|
or status.get('owner') != t['owner'] or status.get('repository') != t['repository']
|
|
or status.get('issue') != t['issue'] or status.get('role') != 'gitea-review-agent'
|
|
or type(status.get('pid')) is not int or status.get('pid') != handle.get('pid')
|
|
or type(handle.get('pid')) is not int or status.get('deadline') != handle.get('deadline')
|
|
or type(status.get('deadline')) not in [int, float]
|
|
or status.get('deadline') > self.deadline_monotonic or time.monotonic() >= status.get('deadline')):
|
|
fail('broker Issue lease identity mismatch')
|
|
_validate_registered_source(plan, self.policy_path, self.deadline_monotonic)
|
|
|
|
|
|
def main(argv=None):
|
|
import argparse, sys
|
|
parser = argparse.ArgumentParser(description='Read-only native review artifact preflight/replay. Live publication is the Python publish API.')
|
|
commands = parser.add_subparsers(dest='command', required=True)
|
|
pre = commands.add_parser('preflight')
|
|
pre.add_argument('--plan', required=True); pre.add_argument('--baseline', required=True)
|
|
verify = commands.add_parser('verify')
|
|
for name in ['plan', 'before', 'after', 'exact']:
|
|
verify.add_argument('--' + name, required=True)
|
|
args = parser.parse_args(argv)
|
|
def read(path):
|
|
return loads_json(Path(path).read_text(encoding='utf-8'))
|
|
try:
|
|
plan = read(args.plan)
|
|
if args.command == 'preflight':
|
|
snapshot = validate_snapshot(plan, read(args.baseline))
|
|
out = {'ready': True, 'read_only': True, 'artifact_validation_only': True,
|
|
'head': plan['target']['head'], 'event': build_payload(plan)['event'],
|
|
'reviewer_id': plan['reviewer']['id'], 'spec_review_id': plan['spec_review_id'],
|
|
'snapshot_sha256': hashlib.sha256(canonical(snapshot)).hexdigest()}
|
|
else:
|
|
out = verify_publication(plan, read(args.before), read(args.after), read(args.exact))
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
return 0
|
|
except Exception:
|
|
# No arbitrary artifact or transport exception is echoed to the terminal.
|
|
print(json.dumps({'error': 'invalid or unavailable review artifacts', 'read_only': True}), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|