[verified] Add fail-closed rework provider readback adapter
This commit is contained in:
@@ -108,8 +108,9 @@ def do_render(a):
|
||||
expected={"body":body+marker,"marker":marker,"marker_free_body":body,"result_sha256":rh,"correlation":corr,"target":{"owner":d['owner'],"repository":d['repository'],"issue":int(vals['issue']),"pr":int(vals['pr'])},"review":int(vals['review']),"reviewed_head":d['reviewed_head'],"head":d['final_head'],"contract_sha256":d['contract_sha256']}
|
||||
atomic(a.expected,(json.dumps(expected,ensure_ascii=False,sort_keys=True,separators=(",",":"))+"\n").encode())
|
||||
|
||||
def do_verify(a):
|
||||
exp=read_json(a.expected); pr=read_json(a.pr_readback); comment_readback=read_json(a.comments_readback); ident=read_json(a.identity_readback)
|
||||
def verify_readbacks(exp, pr, comment_readback, ident, *, action, baseline_count):
|
||||
if action not in ('create', 'reuse') or type(baseline_count) is not int or baseline_count < 0:
|
||||
raise Bad('invalid verification action or baseline count')
|
||||
body=exp.get("body"); rh=exp.get("result_sha256"); corr=exp.get("correlation"); head=exp.get("head")
|
||||
marker=exp.get("marker","")
|
||||
marker_free=exp.get("marker_free_body", body[:-len(marker)] if marker and body.endswith(marker) else body)
|
||||
@@ -145,9 +146,16 @@ def do_verify(a):
|
||||
author=c.get("user",c.get("author",{})); cid=author.get("id") if isinstance(author,dict) else None; clog=author.get("login") if isinstance(author,dict) else None
|
||||
if cid!=aid or clog!=login: raise Bad("author mismatch")
|
||||
if isinstance(c.get("id"),bool) or not isinstance(c.get("id"),int) or c["id"]<1 or not c.get("html_url",c.get("url")) or not c.get("created_at") or not c.get("updated_at"): raise Bad("comment metadata")
|
||||
baseline=a.baseline_count
|
||||
if baseline<0 or (a.action=="create" and len(comments)!=baseline+1) or (a.action=="reuse" and len(comments)!=baseline): raise Bad("cardinality")
|
||||
receipt={"schema":"gitea-rework-handoff/v1","status":"verified","target":{"owner":owner,"repository":repository,"issue":int(issue),"pr":int(pr_number),"review":int(review),"object_kind":"pull_request","endpoint_binding_verified":True},"comment_id":c["id"],"comment_url":c.get("html_url",c.get("url")),"comment_body":cb,"marker":marker,"created_at":c["created_at"],"updated_at":c["updated_at"],"final_head":head,"result_sha256":rh,"correlation":corr,"author":{"id":aid,"login":login,"role":"gitea-impl-agent","authenticated":True,"stable_account_verified":True},"pr_metadata_binding_verified":True,"pr_metadata_unchanged":True,"source_issue_unchanged":True,"review_collection_unchanged":True,"provider_counts":provider_counts,"readback_verified":True,"action":a.action,"cardinality":len(comments),"page_count":comment_readback["page_count"]}
|
||||
baseline=baseline_count
|
||||
if baseline<0 or (action=="create" and len(comments)!=baseline+1) or (action=="reuse" and len(comments)!=baseline): raise Bad("cardinality")
|
||||
receipt={"schema":"gitea-rework-handoff/v1","status":"verified","target":{"owner":owner,"repository":repository,"issue":int(issue),"pr":int(pr_number),"review":int(review),"object_kind":"pull_request","endpoint_binding_verified":True},"comment_id":c["id"],"comment_url":c.get("html_url",c.get("url")),"comment_body":cb,"marker":marker,"created_at":c["created_at"],"updated_at":c["updated_at"],"final_head":head,"result_sha256":rh,"correlation":corr,"author":{"id":aid,"login":login,"role":"gitea-impl-agent","authenticated":True,"stable_account_verified":True},"pr_metadata_binding_verified":True,"pr_metadata_unchanged":True,"source_issue_unchanged":True,"review_collection_unchanged":True,"provider_counts":provider_counts,"readback_verified":True,"action":action,"cardinality":len(comments),"page_count":comment_readback["page_count"]}
|
||||
return receipt
|
||||
|
||||
|
||||
def do_verify(a):
|
||||
receipt = verify_readbacks(read_json(a.expected), read_json(a.pr_readback),
|
||||
read_json(a.comments_readback), read_json(a.identity_readback),
|
||||
action=a.action, baseline_count=a.baseline_count)
|
||||
atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
|
||||
|
||||
def main():
|
||||
@@ -155,8 +163,24 @@ def main():
|
||||
r=sp.add_parser("render"); r.add_argument("--input",required=True); r.add_argument("--comment",required=True); r.add_argument("--expected",required=True)
|
||||
adoption=r.add_mutually_exclusive_group(required=True); adoption.add_argument("--no-delegated-evidence",action="store_true"); adoption.add_argument("--delegation-adoption")
|
||||
v=sp.add_parser("verify"); v.add_argument("--expected",required=True); v.add_argument("--pr-readback",required=True); v.add_argument("--comments-readback",required=True); v.add_argument("--identity-readback",required=True); v.add_argument("--action",choices=("create","reuse"),required=True); v.add_argument("--baseline-count",type=int,required=True); v.add_argument("--output",required=True)
|
||||
for command in ('snapshot', 'verify-live'):
|
||||
live = sp.add_parser(command)
|
||||
live.add_argument('--expected', required=True)
|
||||
live.add_argument('--provider-contract', required=True)
|
||||
live.add_argument('--provider-contract-sha256', required=True)
|
||||
live.add_argument('--actor-id', required=True, type=int)
|
||||
live.add_argument('--output', required=True)
|
||||
if command == 'verify-live':
|
||||
live.add_argument('--baseline', required=True)
|
||||
live.add_argument('--baseline-sha256', required=True)
|
||||
live.add_argument('--action', choices=('create', 'reuse'), required=True)
|
||||
args=p.parse_args()
|
||||
try: do_render(args) if args.cmd=="render" else do_verify(args)
|
||||
except (Bad, OSError, KeyError, TypeError, UnicodeError) as e: print(f"error: {e}",file=sys.stderr); return 2
|
||||
try:
|
||||
if args.cmd == 'render': do_render(args)
|
||||
elif args.cmd == 'verify': do_verify(args)
|
||||
else:
|
||||
from rework_readback_cli import run_live
|
||||
run_live(args, verify_readbacks)
|
||||
except (Bad, OSError, KeyError, TypeError, UnicodeError, ValueError) as e: print(f"error: {e}",file=sys.stderr); return 2
|
||||
return 0
|
||||
if __name__=="__main__": raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Read-only provider snapshots normalized for the canonical rework verifier."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
def integer(value, minimum=0):
|
||||
if type(value) is not int or value < minimum:
|
||||
raise ReadbackError('invalid provider integer')
|
||||
return value
|
||||
|
||||
|
||||
def object_value(value):
|
||||
if type(value) is not dict:
|
||||
raise ReadbackError('provider object required')
|
||||
return value
|
||||
|
||||
ORIGIN = 'https://gitea.jsfamily.quest/api/v1'
|
||||
CREDENTIAL_SOURCE = '$HERMES_HOME/.env:GITEA_TOKEN'
|
||||
|
||||
|
||||
class ReadbackError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
if type(value) is not str or not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})', value):
|
||||
raise ReadbackError('invalid provider timestamp')
|
||||
try: return datetime.fromisoformat(value.replace('Z','+00:00'))
|
||||
except ValueError: raise ReadbackError('invalid provider timestamp') from None
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
|
||||
allow_nan=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def text(value):
|
||||
if type(value) is not str:
|
||||
raise ReadbackError('provider text required')
|
||||
return value
|
||||
|
||||
|
||||
def actor(value):
|
||||
value = object_value(value)
|
||||
integer(value.get('id'), 1)
|
||||
if type(value.get('login')) is not str or not value['login']:
|
||||
raise ReadbackError('invalid provider actor')
|
||||
return value
|
||||
|
||||
|
||||
def issue_metadata(record):
|
||||
record = object_value(record)
|
||||
for field in ('labels', 'assignees'):
|
||||
if field not in record: raise ReadbackError('missing Issue metadata collection')
|
||||
values = record[field]
|
||||
if field == 'assignees' and values is None: continue
|
||||
if type(values) is not list:
|
||||
raise ReadbackError('invalid Issue metadata collection')
|
||||
for value in values:
|
||||
value = object_value(value)
|
||||
integer(value.get('id'), 1)
|
||||
if field == 'labels':
|
||||
text(value.get('name'))
|
||||
else:
|
||||
actor(value)
|
||||
if 'milestone' not in record:
|
||||
raise ReadbackError('missing Issue milestone field')
|
||||
milestone = record.get('milestone')
|
||||
if milestone is not None:
|
||||
milestone = object_value(milestone)
|
||||
integer(milestone.get('id'), 1)
|
||||
text(milestone.get('title'))
|
||||
if milestone.get('state') not in ('open','closed'): raise ReadbackError('invalid milestone state')
|
||||
actor(record.get('user'))
|
||||
timestamp(record.get('created_at'))
|
||||
timestamp(record.get('updated_at'))
|
||||
text(record.get('title'))
|
||||
text(record.get('body'))
|
||||
if record.get('state') not in ('open', 'closed'):
|
||||
raise ReadbackError('invalid Issue state')
|
||||
return record
|
||||
|
||||
|
||||
def branch(value):
|
||||
value = object_value(value)
|
||||
for field in ('ref', 'label', 'sha'):
|
||||
text(value.get(field))
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', value['sha']):
|
||||
raise ReadbackError('invalid PR branch SHA')
|
||||
integer(value.get('repo_id'), 1)
|
||||
repo = object_value(value.get('repo'))
|
||||
if integer(repo.get('id'), 1) != value['repo_id'] or not re.fullmatch(
|
||||
r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}',
|
||||
repo.get('full_name', '')):
|
||||
raise ReadbackError('invalid PR branch repository')
|
||||
return value
|
||||
|
||||
|
||||
def review_record(value):
|
||||
value = object_value(value)
|
||||
integer(value.get('id'), 1)
|
||||
integer(value.get('comments_count'))
|
||||
if value.get('state') not in ('APPROVED', 'REQUEST_CHANGES', 'COMMENT', 'PENDING'):
|
||||
raise ReadbackError('invalid native review state')
|
||||
text(value.get('body'))
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', text(value.get('commit_id'))):
|
||||
raise ReadbackError('invalid native review commit')
|
||||
timestamp(value.get('submitted_at'))
|
||||
timestamp(value.get('updated_at'))
|
||||
if type(value.get('stale')) is not bool or type(value.get('dismissed')) is not bool:
|
||||
raise ReadbackError('invalid native review flags')
|
||||
actor(value.get('user'))
|
||||
return value
|
||||
|
||||
|
||||
class ReworkReadbackAdapter:
|
||||
def __init__(self, request, *, origin, credential_source, actor_id, provider_contract):
|
||||
if (not callable(request) or origin != ORIGIN or credential_source != CREDENTIAL_SOURCE
|
||||
or type(actor_id) is not int or actor_id < 1):
|
||||
raise ReadbackError('invalid authenticated transport binding')
|
||||
self._transport = request
|
||||
self.actor_id = actor_id
|
||||
self.provider = self.provider_contract(provider_contract)
|
||||
|
||||
def provider_contract(self, contract):
|
||||
contract = object_value(contract)
|
||||
if (set(contract) != {'schema','version','aggregate','inline'}
|
||||
or contract.get('schema') != 'gitea-agent.rework-provider/v1'
|
||||
or type(contract.get('version')) is not str or not contract['version']
|
||||
or any(ord(char) < 33 for char in contract['version'])):
|
||||
raise ReadbackError('invalid explicit provider contract')
|
||||
aggregate = object_value(contract.get('aggregate'))
|
||||
if set(aggregate) != {'pr','pr_issue'}: raise ReadbackError('invalid aggregate scope')
|
||||
for scope in ('pr','pr_issue'):
|
||||
spec = object_value(aggregate[scope])
|
||||
parts = spec.get('components')
|
||||
if (set(spec) != {'field','components'} or spec.get('field') != 'comments'
|
||||
or type(parts) is not list or not parts
|
||||
or any(type(part) is not str or part not in ('top_level','reviews','inline') for part in parts)
|
||||
or len(parts) != len(set(parts)) or 'top_level' not in parts):
|
||||
raise ReadbackError('invalid aggregate count declaration')
|
||||
inline = object_value(contract.get('inline'))
|
||||
if (set(inline) != {'mode','field','components'}
|
||||
or inline.get('mode') not in ('per_review','per_review_or_pr_count')
|
||||
or inline.get('field') != 'review_comments'
|
||||
or inline.get('components') not in (['inline'], ['reviews','inline'])):
|
||||
raise ReadbackError('invalid inline count declaration')
|
||||
return copy.deepcopy(contract)
|
||||
|
||||
def request(self, path):
|
||||
name = r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}'
|
||||
route = r'/(?:user|version|repos/'+name+'/'+name+r'(?:/(?:issues/[1-9][0-9]*(?:/comments)?|pulls/[1-9][0-9]*(?:/reviews(?:/[1-9][0-9]*/comments)?)?))?)'
|
||||
if type(path) is not str or not re.fullmatch(route + r'(?:\?page=[1-9][0-9]*&limit=50)?', path):
|
||||
raise ReadbackError('non-canonical provider endpoint')
|
||||
try: result = self._transport(path)
|
||||
except Exception: raise ReadbackError('provider transport failed') from None
|
||||
if type(result) is not tuple or len(result) != 3:
|
||||
raise ReadbackError('invalid provider response tuple')
|
||||
payload, headers, status = result
|
||||
integer(status, 100)
|
||||
headers = object_value(headers)
|
||||
normalized = {}
|
||||
for key, value in headers.items():
|
||||
if type(key) is not str or type(value) is not str or key.lower() in normalized:
|
||||
raise ReadbackError('invalid or conflicting provider header')
|
||||
normalized[key.lower()] = value
|
||||
return copy.deepcopy(payload), normalized, status
|
||||
|
||||
def get(self, path):
|
||||
value, headers, status = self.request(path)
|
||||
if status != 200:
|
||||
raise ReadbackError('provider GET failed')
|
||||
return object_value(value), headers
|
||||
|
||||
def links(self, endpoint, header, page, total):
|
||||
if header is None: return False
|
||||
last_page = max(1, (total + 49) // 50)
|
||||
if page > last_page + 1:
|
||||
raise ReadbackError('pagination page beyond declared total')
|
||||
seen = set()
|
||||
for part in header.split(','):
|
||||
match = re.fullmatch(r'\s*<([^<>\s]+)>;\s*rel=(?:"(next|prev|first|last)"|(next|prev|first|last))\s*', part)
|
||||
relation = match[2] or match[3] if match is not None else None
|
||||
if match is None or relation in seen:
|
||||
raise ReadbackError('malformed pagination Link')
|
||||
seen.add(relation)
|
||||
url = match[1]
|
||||
if url.startswith(ORIGIN + '/'):
|
||||
url = url[len(ORIGIN):]
|
||||
elif url.startswith('/api/v1/'):
|
||||
url = url[len('/api/v1'):]
|
||||
parsed = urlsplit(url)
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
if (parsed.scheme or parsed.netloc or parsed.fragment or parsed.path != endpoint
|
||||
or set(query) != {'page','limit'} or query['limit'] != ['50']
|
||||
or len(query['page']) != 1 or not re.fullmatch(r'[1-9][0-9]*', query['page'][0])):
|
||||
raise ReadbackError('foreign or invalid pagination Link')
|
||||
linked_page = int(query['page'][0])
|
||||
expected = {'first':1, 'last':last_page}.get(relation)
|
||||
if relation == 'prev':
|
||||
if page <= 1 or linked_page != page - 1:
|
||||
raise ReadbackError('pagination Link bound or sequence mismatch')
|
||||
elif relation == 'next':
|
||||
if linked_page != page + 1 or linked_page > last_page:
|
||||
raise ReadbackError('pagination Link bound or sequence mismatch')
|
||||
elif linked_page != expected:
|
||||
raise ReadbackError('pagination Link bound or sequence mismatch')
|
||||
return 'next' in seen
|
||||
|
||||
def collection(self, endpoint, *, allow_missing=False):
|
||||
rows, pages, ids, expected_total = [], [], set(), None
|
||||
for page in range(1, 22):
|
||||
path = endpoint + '?page=' + str(page) + '&limit=50'
|
||||
values, headers, status = self.request(path)
|
||||
if status in (404,405) and allow_missing and page == 1:
|
||||
return {'endpoint':endpoint,'rows':[], 'pages':[{'path':path,'page':1,'count':0,'total':None,'status':status,
|
||||
'link':headers.get('link')}]}
|
||||
if status != 200 or type(values) is not list or len(values) > 50:
|
||||
raise ReadbackError('invalid collection response')
|
||||
declared = headers.get('x-total-count')
|
||||
if declared is None and not values and page == 1: declared = '0'
|
||||
if declared is None or not re.fullmatch(r'0|[1-9][0-9]*', declared):
|
||||
raise ReadbackError('missing or invalid collection total')
|
||||
total = int(declared)
|
||||
if total > 1000 or (expected_total is not None and expected_total != total):
|
||||
raise ReadbackError('collection bound or total drift')
|
||||
expected_total = total
|
||||
for row in values:
|
||||
ident = integer(object_value(row).get('id'), 1)
|
||||
if ident in ids: raise ReadbackError('duplicate collection ID')
|
||||
ids.add(ident)
|
||||
rows.extend(values)
|
||||
pages.append({'path':path,'page':page,'count':len(values),'total':total,'status':200})
|
||||
link = headers.get('link')
|
||||
next_link = self.links(endpoint, link, page, total)
|
||||
pages[-1]['link'] = link
|
||||
if len(rows) > total or (len(rows) == total and next_link):
|
||||
raise ReadbackError('contradictory collection total or Link')
|
||||
if len(rows) == total and len(values) < 50:
|
||||
return {'endpoint':endpoint,'rows':rows,'pages':pages}
|
||||
if len(values) < 50:
|
||||
raise ReadbackError('truncated collection')
|
||||
raise ReadbackError('incomplete provider collection')
|
||||
|
||||
def target(self, expected):
|
||||
expected = object_value(expected)
|
||||
target = object_value(expected.get('target'))
|
||||
if set(target) != {'owner','repository','issue','pr'}:
|
||||
raise ReadbackError('invalid expected target fields')
|
||||
for name in ('owner','repository'):
|
||||
if type(target.get(name)) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}', target[name]):
|
||||
raise ReadbackError('invalid literal target identifier')
|
||||
for name in ('issue','pr'): integer(target.get(name), 1)
|
||||
integer(expected.get('review'), 1)
|
||||
if target['issue'] == target['pr']: raise ReadbackError('source Issue must differ from PR')
|
||||
for name, length in (('head',40),('reviewed_head',40),('contract_sha256',64),('result_sha256',64),('correlation',64)):
|
||||
if type(expected.get(name)) is not str or not re.fullmatch('[0-9a-f]{'+str(length)+'}', expected[name]):
|
||||
raise ReadbackError('invalid literal expected digest')
|
||||
free, body, marker = [expected.get(name) for name in ('marker_free_body','body','marker')]
|
||||
if any(type(value) is not str for value in (free,body,marker)) or len(body.encode()) > 1_000_000:
|
||||
raise ReadbackError('invalid expected body')
|
||||
correlation = hashlib.sha256('\0'.join([target['owner'],target['repository'],str(target['issue']),
|
||||
str(target['pr']),str(expected['review']),expected['reviewed_head'],expected['head'],
|
||||
expected['contract_sha256']]).encode()).hexdigest()
|
||||
wanted = ('<!-- gitea-agent:implementation-rework/v1 owner='+target['owner']+' repository='+target['repository']+
|
||||
' issue='+str(target['issue'])+' pr='+str(target['pr'])+' review='+str(expected['review'])+
|
||||
' reviewed-head='+expected['reviewed_head']+' head='+expected['head']+
|
||||
' contract-sha256='+expected['contract_sha256']+' result-sha256='+expected['result_sha256']+
|
||||
' correlation='+correlation+' -->\n')
|
||||
if (body != free+marker or hashlib.sha256(free.encode()).hexdigest() != expected['result_sha256']
|
||||
or expected['correlation'] != correlation or marker != wanted):
|
||||
raise ReadbackError('expected renderer binding mismatch')
|
||||
return dict(target, review=expected['review'], reviewed_head=expected['reviewed_head'], head=expected['head'])
|
||||
|
||||
def bind(self, target, raw):
|
||||
prefix = ORIGIN + '/repos/' + target['owner'] + '/' + target['repository']
|
||||
html = 'https://gitea.jsfamily.quest/' + target['owner'] + '/' + target['repository']
|
||||
repo = object_value(raw['repository'])
|
||||
repo_id = integer(repo.get('id'), 1)
|
||||
if repo.get('full_name') != target['owner']+'/'+target['repository'] or repo.get('html_url') != html:
|
||||
raise ReadbackError('repository binding mismatch')
|
||||
source, pr, underlying = [object_value(raw[name]) for name in ('source_issue','pr','pr_issue')]
|
||||
for record in (source, pr, underlying):
|
||||
issue_metadata(record)
|
||||
for record, number in ((source,target['issue']), (pr,target['pr']), (underlying,target['pr'])):
|
||||
integer(record.get('id'), 1)
|
||||
if integer(record.get('number'), 1) != number:
|
||||
raise ReadbackError('Issue or PR number mismatch')
|
||||
if source.get('html_url') != html+'/issues/'+str(target['issue']) or source.get('pull_request') is not None:
|
||||
raise ReadbackError('source Issue kind or URL mismatch')
|
||||
if (pr.get('html_url') != html+'/pulls/'+str(target['pr'])
|
||||
or pr.get('url') != html+'/pulls/'+str(target['pr']) or pr.get('state') != 'open'):
|
||||
raise ReadbackError('PR kind or URL mismatch')
|
||||
if (pr['user']['id'] != self.actor_id or pr['user']['login'] != 'gitea-impl-agent'
|
||||
or underlying['user']['id'] != self.actor_id
|
||||
or underlying['user']['login'] != 'gitea-impl-agent'):
|
||||
raise ReadbackError('PR author binding mismatch')
|
||||
closing = re.findall(r'(?i)\b(?:closes?|fixes?|resolves?)\s+#([1-9][0-9]*)\b', pr['body'])
|
||||
canonical_closing = re.findall(r'\bCloses[ \t]+#([1-9][0-9]*)\b', pr['body'])
|
||||
if closing != [str(target['issue'])] or canonical_closing != closing:
|
||||
raise ReadbackError('PR closing relationship mismatch')
|
||||
base_branch, head_branch = branch(pr.get('base')), branch(pr.get('head'))
|
||||
base = object_value(object_value(pr.get('base')).get('repo'))
|
||||
if (integer(base.get('id'), 1) != repo_id or base.get('full_name') != repo['full_name']
|
||||
or base_branch['repo_id'] != repo_id):
|
||||
raise ReadbackError('PR base repository mismatch')
|
||||
if head_branch['sha'] != target['head']:
|
||||
raise ReadbackError('PR head mismatch')
|
||||
# Fork heads are valid; the target repository is bound through base and endpoint.
|
||||
relation = object_value(underlying.get('pull_request'))
|
||||
if (underlying.get('html_url') not in (html+'/pulls/'+str(target['pr']), html+'/issues/'+str(target['pr']))
|
||||
or relation.get('html_url') != html+'/pulls/'+str(target['pr'])
|
||||
or ('url' in relation and relation.get('url') != html+'/pulls/'+str(target['pr']))):
|
||||
raise ReadbackError('underlying Issue PR binding mismatch')
|
||||
review = next((row for row in raw['reviews']['rows'] if row.get('id') == target['review']), None)
|
||||
if review is None or review.get('commit_id') != target['reviewed_head']:
|
||||
raise ReadbackError('source review binding mismatch')
|
||||
if (review.get('dismissed') or review.get('state') == 'PENDING'
|
||||
or review['user']['id'] == self.actor_id
|
||||
or review['user']['login'] not in ('gitea-review-agent', 'gitea-spec-agent')):
|
||||
raise ReadbackError('source review author or state mismatch')
|
||||
|
||||
def counts(self, raw):
|
||||
inline = sum(integer(review.get('comments_count')) for review in raw['reviews']['rows'])
|
||||
fallback = False
|
||||
inline_ids = set()
|
||||
for review in raw['reviews']['rows']:
|
||||
collected = raw['inline'][str(review['id'])]
|
||||
if collected['pages'][0]['status'] in (404,405):
|
||||
if self.provider['inline']['mode'] != 'per_review_or_pr_count':
|
||||
raise ReadbackError('inline fallback not explicitly authorized')
|
||||
fallback = True
|
||||
elif len(collected['rows']) != review['comments_count']:
|
||||
raise ReadbackError('per-review inline count mismatch')
|
||||
for row in collected['rows']:
|
||||
if row['id'] in inline_ids: raise ReadbackError('duplicate cross-review inline ID')
|
||||
inline_ids.add(row['id'])
|
||||
computed = inline + (len(raw['reviews']['rows']) if 'reviews' in self.provider['inline']['components'] else 0)
|
||||
field = self.provider['inline']['field']
|
||||
if field not in raw['pr']:
|
||||
if (self.provider['inline']['mode'] != 'per_review' or fallback or computed != 0
|
||||
or len(inline_ids) != 0):
|
||||
raise ReadbackError('missing inline provider counter')
|
||||
elif integer(raw['pr'].get(field)) != computed:
|
||||
raise ReadbackError('inline provider counter crosscheck failed')
|
||||
if not fallback and len(inline_ids) != inline:
|
||||
raise ReadbackError('inline record cardinality mismatch')
|
||||
counts = {'top_level':len(raw['comments']['rows']),
|
||||
'reviews':len(raw['reviews']['rows']), 'inline':inline}
|
||||
for scope in ('pr','pr_issue'):
|
||||
spec = self.provider['aggregate'][scope]
|
||||
if integer(raw[scope].get(spec['field'])) != sum(counts[name] for name in spec['components']):
|
||||
raise ReadbackError('provider aggregate count mismatch')
|
||||
if integer(raw['source_issue'].get('comments')) != len(raw['source_comments']['rows']):
|
||||
raise ReadbackError('source collection count mismatch')
|
||||
return counts
|
||||
|
||||
def capture(self, target):
|
||||
identity, _ = self.get('/user')
|
||||
if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent':
|
||||
raise ReadbackError('authenticated identity mismatch')
|
||||
version, _ = self.get('/version')
|
||||
if version.get('version') != self.provider['version']:
|
||||
raise ReadbackError('provider version mismatch')
|
||||
prefix = '/repos/' + target['owner'] + '/' + target['repository']
|
||||
raw = {'identity':identity,'version':version}
|
||||
for name, path in {'repository':prefix,'source_issue':prefix+'/issues/'+str(target['issue']),
|
||||
'pr':prefix+'/pulls/'+str(target['pr']),
|
||||
'pr_issue':prefix+'/issues/'+str(target['pr'])}.items():
|
||||
raw[name], _ = self.get(path)
|
||||
for name, endpoint in {'source_comments':prefix+'/issues/'+str(target['issue'])+'/comments',
|
||||
'comments':prefix+'/issues/'+str(target['pr'])+'/comments',
|
||||
'reviews':prefix+'/pulls/'+str(target['pr'])+'/reviews'}.items():
|
||||
raw[name] = self.collection(endpoint)
|
||||
raw['inline'] = {str(review['id']):self.collection(prefix+'/pulls/'+str(target['pr'])+
|
||||
'/reviews/'+str(review['id'])+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count') for review in raw['reviews']['rows']}
|
||||
self.bind(target, raw)
|
||||
self.counts(raw)
|
||||
return raw
|
||||
|
||||
def snapshot(self, expected):
|
||||
target = self.target(expected)
|
||||
raw = self.capture(target)
|
||||
if digest(self.capture(target)) != digest(raw):
|
||||
raise ReadbackError('concurrent provider drift')
|
||||
counts = self.counts(raw)
|
||||
snapshot = {'schema':'gitea-agent.rework-readback/v1','target':target,
|
||||
'expected_sha256':digest(expected),'provider':copy.deepcopy(self.provider),
|
||||
'actor_id':self.actor_id,'origin':ORIGIN,'credential_source':CREDENTIAL_SOURCE,
|
||||
'raw':raw,'review_body_sha256':hashlib.sha256(text(next(row for row in raw['reviews']['rows']
|
||||
if row['id'] == target['review'])['body']).encode()).hexdigest(),
|
||||
'normalized_counts':counts,'fallback':'counts_only' if any(value['pages'][0]['status'] != 200 for value in raw['inline'].values()) else 'none'}
|
||||
self.validate_snapshot(expected, snapshot)
|
||||
return snapshot
|
||||
|
||||
def validate_collection(self, collected, endpoint, *, reviews=False, allow_missing=False):
|
||||
collected = object_value(collected)
|
||||
rows, pages = collected.get('rows'), collected.get('pages')
|
||||
if (collected.get('endpoint') != endpoint or type(rows) is not list or len(rows) > 1000
|
||||
or type(pages) is not list or not 1 <= len(pages) <= 21):
|
||||
raise ReadbackError('collection provenance mismatch')
|
||||
ids = set()
|
||||
for row in rows:
|
||||
row = object_value(row)
|
||||
ident = integer(row.get('id'), 1)
|
||||
if ident in ids: raise ReadbackError('duplicate collection ID')
|
||||
ids.add(ident)
|
||||
user = object_value(row.get('user'))
|
||||
integer(user.get('id'), 1)
|
||||
if type(user.get('login')) is not str or not user['login']:
|
||||
raise ReadbackError('invalid collection author')
|
||||
if reviews:
|
||||
review_record(row)
|
||||
else:
|
||||
if type(row.get('body')) is not str: raise ReadbackError('invalid comment body')
|
||||
if timestamp(row.get('updated_at')) < timestamp(row.get('created_at')):
|
||||
raise ReadbackError('invalid comment timestamp order')
|
||||
first = object_value(pages[0])
|
||||
missing = first.get('status') in (404,405)
|
||||
if missing:
|
||||
if not allow_missing or rows or len(pages) != 1 or first.get('total') is not None:
|
||||
raise ReadbackError('invalid inline capability fallback')
|
||||
elif len(pages) != len(rows)//50+1:
|
||||
raise ReadbackError('incomplete collection page records')
|
||||
for index, page in enumerate(pages, 1):
|
||||
page = object_value(page)
|
||||
count = 0 if missing else min(50, max(0, len(rows)-(index-1)*50))
|
||||
link = page.get('link')
|
||||
if link is not None and type(link) is not str:
|
||||
raise ReadbackError('collection page provenance mismatch')
|
||||
if (integer(page.get('page'), 1) != index or integer(page.get('count')) != count
|
||||
or page.get('path') != endpoint+'?page='+str(index)+'&limit=50'
|
||||
or integer(page.get('status'), 100) not in ((404,405) if missing else (200,))
|
||||
or (not missing and integer(page.get('total')) != len(rows))):
|
||||
raise ReadbackError('collection page provenance mismatch')
|
||||
if not missing:
|
||||
self.links(endpoint, link, index, len(rows))
|
||||
return missing
|
||||
|
||||
def validate_snapshot(self, expected, snapshot):
|
||||
snapshot = object_value(snapshot)
|
||||
target = self.target(expected)
|
||||
if (snapshot.get('schema') != 'gitea-agent.rework-readback/v1'
|
||||
or snapshot.get('origin') != ORIGIN or snapshot.get('credential_source') != CREDENTIAL_SOURCE
|
||||
or integer(snapshot.get('actor_id'), 1) != self.actor_id
|
||||
or digest(snapshot.get('target')) != digest(target)
|
||||
or digest(snapshot.get('provider')) != digest(self.provider)
|
||||
or snapshot.get('expected_sha256') != digest(expected)):
|
||||
raise ReadbackError('snapshot binding mismatch')
|
||||
raw = object_value(snapshot.get('raw'))
|
||||
identity = object_value(raw.get('identity'))
|
||||
if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent':
|
||||
raise ReadbackError('snapshot actor mismatch')
|
||||
if object_value(raw.get('version')).get('version') != self.provider['version']:
|
||||
raise ReadbackError('snapshot provider version mismatch')
|
||||
prefix = '/repos/'+target['owner']+'/'+target['repository']
|
||||
for name, endpoint in {'source_comments':prefix+'/issues/'+str(target['issue'])+'/comments',
|
||||
'comments':prefix+'/issues/'+str(target['pr'])+'/comments',
|
||||
'reviews':prefix+'/pulls/'+str(target['pr'])+'/reviews'}.items():
|
||||
self.validate_collection(raw.get(name), endpoint, reviews=name == 'reviews')
|
||||
inline = object_value(raw.get('inline'))
|
||||
if set(inline) != {str(row['id']) for row in raw['reviews']['rows']}:
|
||||
raise ReadbackError('incomplete review comment endpoints')
|
||||
fallback = False
|
||||
for rid, collected in inline.items():
|
||||
fallback |= self.validate_collection(collected, prefix+'/pulls/'+str(target['pr'])+
|
||||
'/reviews/'+rid+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count')
|
||||
self.bind(target, raw)
|
||||
target_review = next(row for row in raw['reviews']['rows'] if row['id'] == target['review'])
|
||||
if snapshot.get('review_body_sha256') != hashlib.sha256(target_review['body'].encode()).hexdigest():
|
||||
raise ReadbackError('review body digest mismatch')
|
||||
for name in ('source_issue','pr','pr_issue'):
|
||||
timestamp(raw[name].get('updated_at'))
|
||||
if (type(raw[name].get('body')) is not str or type(raw[name].get('title')) is not str
|
||||
or raw[name].get('state') not in ('open','closed')):
|
||||
raise ReadbackError('incomplete Issue or PR metadata')
|
||||
counts = self.counts(raw)
|
||||
if (digest(snapshot.get('normalized_counts')) != digest(counts)
|
||||
or snapshot.get('fallback') != ('counts_only' if fallback else 'none')):
|
||||
raise ReadbackError('snapshot count or capability mismatch')
|
||||
return raw
|
||||
|
||||
def normalized_inputs(self, expected, baseline, after, action):
|
||||
if action not in ('create','reuse'): raise ReadbackError('invalid rework action')
|
||||
b, a = [self.validate_snapshot(expected, value) for value in (baseline, after)]
|
||||
target = self.target(expected)
|
||||
if digest(b['repository']) != digest(a['repository']): raise ReadbackError('repository drift')
|
||||
for name in ('source_comments','reviews','inline'):
|
||||
if digest(b[name]) != digest(a[name]): raise ReadbackError('source or review collection drift')
|
||||
old = {row['id']:row for row in b['comments']['rows']}
|
||||
current = {row['id']:row for row in a['comments']['rows']}
|
||||
for ident, row in old.items():
|
||||
if ident not in current or digest(row) != digest(current[ident]):
|
||||
raise ReadbackError('existing comment changed or missing')
|
||||
added = [row for ident, row in current.items() if ident not in old]
|
||||
if len(added) != (1 if action == 'create' else 0): raise ReadbackError('comment delta cardinality')
|
||||
matches = [row for row in current.values() if expected['correlation'] in row['body']]
|
||||
if len(matches) != 1: raise ReadbackError('comment correlation cardinality')
|
||||
matched = matches[0]
|
||||
if (matched['body'] != expected['body'] or matched['user']['id'] != self.actor_id
|
||||
or matched['user']['login'] != 'gitea-impl-agent'
|
||||
or matched.get('html_url') != a['pr']['html_url']+'#issuecomment-'+str(matched['id'])):
|
||||
raise ReadbackError('canonical comment binding mismatch')
|
||||
if action == 'create':
|
||||
if matched['id'] != added[0]['id'] or matched['updated_at'] != matched['created_at']:
|
||||
raise ReadbackError('created comment causality mismatch')
|
||||
for name in ('pr','pr_issue','source_issue'):
|
||||
if timestamp(matched['created_at']) < timestamp(b[name]['updated_at']):
|
||||
raise ReadbackError('created comment predates baseline')
|
||||
for name in ('source_issue','pr','pr_issue'):
|
||||
before, now = copy.deepcopy(b[name]), copy.deepcopy(a[name])
|
||||
if name == 'source_issue':
|
||||
if digest(before) != digest(now):
|
||||
raise ReadbackError('source Issue metadata drift')
|
||||
else:
|
||||
field = self.provider['aggregate'][name]['field']
|
||||
if integer(now.pop(field)) != integer(before.pop(field)) + (1 if action == 'create' else 0):
|
||||
raise ReadbackError('PR counter delta causality mismatch')
|
||||
if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']:
|
||||
if now['updated_at'] != matched['created_at']:
|
||||
raise ReadbackError('uncausal Issue or PR timestamp')
|
||||
now['updated_at'] = before['updated_at']
|
||||
if digest(before) != digest(now): raise ReadbackError('unrelated Issue or PR metadata drift')
|
||||
counts = self.counts(a)
|
||||
pr = dict(a['pr'], object_kind='pull_request', owner=target['owner'],
|
||||
repository=target['repository'], metadata_binding_verified=True)
|
||||
components = self.provider['aggregate']['pr']['components']
|
||||
provider_counts = dict(counts, aggregate_components=components,
|
||||
aggregate_comments=sum(counts[name] for name in components))
|
||||
comments = {'object_kind':'pull_request_top_level_comments','owner':target['owner'],
|
||||
'repository':target['repository'],'pr':target['pr'],
|
||||
'complete':True,'endpoint_binding_verified':True,
|
||||
'source_issue_unchanged':True,'review_collection_unchanged':True,
|
||||
'pr_metadata_unchanged':True,'comments':a['comments']['rows'],
|
||||
'total_count':len(a['comments']['rows']), 'page_count':len(a['comments']['pages']),
|
||||
'provider_counts':provider_counts}
|
||||
identity = dict(a['identity'], role='gitea-impl-agent', authenticated=True,
|
||||
stable_account_verified=True)
|
||||
return {'pr':pr,'comments':comments,'identity':identity,
|
||||
'baseline_count':len(b['comments']['rows'])}
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Role-local, read-only transport and CLI bridge for rework readback."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import stat
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ORIGIN = 'https://gitea.jsfamily.quest'
|
||||
|
||||
|
||||
def _open_parent(path):
|
||||
path = Path(path)
|
||||
raw = os.fspath(path)
|
||||
if not raw.startswith('/') or any(part in ('', '.', '..') for part in raw.split('/')[1:]):
|
||||
raise ValueError('absolute canonical path required')
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
fd = os.open('/', flags)
|
||||
try:
|
||||
for part in path.parts[1:-1]:
|
||||
child = os.open(part, flags, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
return fd, path.name
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def load_role_token(home):
|
||||
"""Read only the caller's private profile file; never ambient token fallback."""
|
||||
try:
|
||||
parent, name = _open_parent(Path(home) / '.env')
|
||||
try:
|
||||
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
|
||||
try:
|
||||
metadata = os.fstat(fd)
|
||||
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1):
|
||||
raise ValueError('unsafe role credential file')
|
||||
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
||||
raw = stream.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError('role credential file too large')
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(parent)
|
||||
values = []
|
||||
for line in raw.decode('utf-8').splitlines():
|
||||
key, separator, value = line.partition('=')
|
||||
if separator and key.strip() == 'GITEA_TOKEN':
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] in ('\"', "'") and value[-1] == value[0]:
|
||||
value = value[1:-1]
|
||||
values.append(value)
|
||||
if (len(values) != 1 or not 1 <= len(values[0]) <= 4096
|
||||
or any(ord(char) < 33 or ord(char) > 126 for char in values[0])):
|
||||
raise ValueError('invalid role credential')
|
||||
return values[0]
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
raise ValueError('private role credential unavailable') from None
|
||||
|
||||
|
||||
def decode_json(raw):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
if key in result: raise ValueError('duplicate JSON key')
|
||||
result[key] = value
|
||||
return result
|
||||
def invalid_constant(value):
|
||||
raise ValueError('invalid JSON number')
|
||||
try:
|
||||
return json.loads(raw, object_pairs_hook=pairs, parse_constant=invalid_constant)
|
||||
except (ValueError, UnicodeError):
|
||||
raise ValueError('invalid readback JSON') from None
|
||||
|
||||
|
||||
def response_headers(headers):
|
||||
values, seen = {}, set()
|
||||
for key, value in headers.items():
|
||||
if type(key) is not str or type(value) is not str or key.lower() in seen:
|
||||
raise ValueError('invalid or duplicate response header')
|
||||
seen.add(key.lower())
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class RestTransport:
|
||||
"""GET-only fixed-origin transport; opener injection is a test seam."""
|
||||
def __init__(self, token, *, opener=None):
|
||||
if (type(token) is not str or not 1 <= len(token) <= 4096
|
||||
or any(ord(c) < 33 or ord(c) > 126 for c in token)):
|
||||
raise ValueError('invalid role credential')
|
||||
self._credential = token
|
||||
self._deadline = time.monotonic() + 120
|
||||
self._opener = opener or urllib.request.build_opener(
|
||||
NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context()))
|
||||
|
||||
def __call__(self, path):
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(path)
|
||||
if (type(path) is not str or not path.startswith('/') or path.startswith('//')
|
||||
or parts.scheme or parts.netloc or parts.fragment
|
||||
or any(c in path for c in ('\r', '\n', '\\'))
|
||||
or any(p in ('.', '..') for p in urllib.parse.unquote(parts.path).split('/'))
|
||||
or self._credential in path):
|
||||
raise ValueError('invalid readback endpoint')
|
||||
url = ORIGIN + '/api/v1' + path
|
||||
request = urllib.request.Request(url, method='GET', headers={
|
||||
'Authorization': 'token ' + self._credential,
|
||||
'Accept': 'application/json', 'Cache-Control': 'no-cache'})
|
||||
started = time.monotonic()
|
||||
remaining = self._deadline - started
|
||||
if remaining <= 0:
|
||||
raise ValueError('readback operation budget exceeded')
|
||||
try:
|
||||
response = self._opener.open(request, timeout=min(10, remaining))
|
||||
except urllib.error.HTTPError as error:
|
||||
try: return None, response_headers(error.headers), error.code
|
||||
finally: error.close()
|
||||
with response:
|
||||
if response.geturl() != url:
|
||||
raise ValueError('redirected readback response')
|
||||
status = response.status
|
||||
headers = response_headers(response.headers)
|
||||
if status != 200:
|
||||
return None, headers, status
|
||||
content_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
|
||||
if content_type != 'application/json':
|
||||
raise ValueError('non-JSON readback response')
|
||||
chunks, size = [], 0
|
||||
while True:
|
||||
if time.monotonic() - started > 30 or time.monotonic() >= self._deadline:
|
||||
raise ValueError('readback response budget exceeded')
|
||||
chunk = response.read1(min(65536, 1_000_001 - size))
|
||||
if not chunk: break
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
if size > 1_000_000:
|
||||
raise ValueError('readback response too large')
|
||||
raw = b''.join(chunks)
|
||||
if self._credential.encode() in raw:
|
||||
raise ValueError('credential echoed in readback')
|
||||
data = decode_json(raw)
|
||||
if self._credential in json.dumps(data, ensure_ascii=False):
|
||||
raise ValueError('credential echoed in readback')
|
||||
return data, headers, status
|
||||
except Exception:
|
||||
raise ValueError('authenticated readback transport failed') from None
|
||||
|
||||
|
||||
def read_bound_json(path, expected_sha256=None):
|
||||
if expected_sha256 is not None and not re.fullmatch(r'[0-9a-f]{64}', expected_sha256):
|
||||
raise ValueError('invalid independently expected SHA-256')
|
||||
parent, name = _open_parent(path)
|
||||
try:
|
||||
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
|
||||
try:
|
||||
metadata = os.fstat(fd)
|
||||
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
|
||||
or metadata.st_mode & 0o022 or metadata.st_uid != os.getuid()):
|
||||
raise ValueError('unsafe readback input')
|
||||
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
||||
raw = stream.read(16_000_001)
|
||||
if len(raw) > 16_000_000:
|
||||
raise ValueError('readback input too large')
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(parent)
|
||||
if expected_sha256 is not None and hashlib.sha256(raw).hexdigest() != expected_sha256:
|
||||
raise ValueError('readback input fingerprint mismatch')
|
||||
value = decode_json(raw)
|
||||
if type(value) is not dict:
|
||||
raise ValueError('readback object required')
|
||||
return value
|
||||
|
||||
|
||||
class OutputFile:
|
||||
"""Pin a private output directory, and replace only after successful verification."""
|
||||
def __init__(self, path):
|
||||
self.path = Path(path)
|
||||
self.fd, self.name = _open_parent(self.path)
|
||||
try:
|
||||
parent = os.fstat(self.fd)
|
||||
if parent.st_uid != os.getuid() or stat.S_IMODE(parent.st_mode) != 0o700:
|
||||
raise ValueError('private output directory required')
|
||||
self.parent_identity = (parent.st_dev, parent.st_ino)
|
||||
self.original = self.identity()
|
||||
self.done = False
|
||||
except BaseException:
|
||||
os.close(self.fd)
|
||||
raise
|
||||
|
||||
def identity(self):
|
||||
try: metadata = os.stat(self.name, dir_fd=self.fd, follow_symlinks=False)
|
||||
except FileNotFoundError: return None
|
||||
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1):
|
||||
raise ValueError('unsafe output target')
|
||||
return metadata.st_dev, metadata.st_ino
|
||||
|
||||
def check_parent(self):
|
||||
fd, _ = _open_parent(self.path)
|
||||
try:
|
||||
metadata = os.fstat(fd)
|
||||
if ((metadata.st_dev, metadata.st_ino) != self.parent_identity
|
||||
or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700):
|
||||
raise ValueError('output directory drift')
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def write(self, value):
|
||||
raw = (json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False) + '\n').encode()
|
||||
if len(raw) > 16_000_000 or self.done:
|
||||
raise ValueError('invalid output size or repeated write')
|
||||
self.check_parent()
|
||||
if self.identity() != self.original:
|
||||
raise ValueError('output target drift')
|
||||
temporary = '.readback-' + secrets.token_hex(16)
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=self.fd)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, 'wb', closefd=False) as stream:
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(fd)
|
||||
self.check_parent()
|
||||
if self.identity() != self.original:
|
||||
raise ValueError('output target drift')
|
||||
os.replace(temporary, self.name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
|
||||
self.done = True
|
||||
os.fsync(self.fd)
|
||||
self.check_parent()
|
||||
finally:
|
||||
os.close(fd)
|
||||
try: os.unlink(temporary, dir_fd=self.fd)
|
||||
except FileNotFoundError: pass
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): os.close(self.fd)
|
||||
|
||||
|
||||
def run_live(args, verifier):
|
||||
"""Capture a baseline or feed computed fresh readbacks into the existing verifier."""
|
||||
try:
|
||||
input_paths = [Path(args.expected), Path(args.provider_contract)]
|
||||
if args.cmd == 'verify-live': input_paths.append(Path(args.baseline))
|
||||
if Path(args.output) in input_paths:
|
||||
raise ValueError('output aliases protected input')
|
||||
with OutputFile(args.output) as output:
|
||||
expected = read_bound_json(args.expected)
|
||||
contract = read_bound_json(args.provider_contract, args.provider_contract_sha256)
|
||||
expected_digest = hashlib.sha256(json.dumps(
|
||||
expected, sort_keys=True, ensure_ascii=False, allow_nan=False).encode()).hexdigest()
|
||||
baseline = None
|
||||
if args.cmd == 'verify-live':
|
||||
bundle = read_bound_json(args.baseline, args.baseline_sha256)
|
||||
if (bundle.get('schema') != 'gitea-agent.rework-baseline/v1'
|
||||
or bundle.get('expected_sha256') != expected_digest
|
||||
or bundle.get('provider_contract_sha256') != args.provider_contract_sha256
|
||||
or type(bundle.get('snapshot')) is not dict):
|
||||
raise ValueError('baseline binding mismatch')
|
||||
baseline = bundle['snapshot']
|
||||
home = os.environ.get('HERMES_HOME')
|
||||
if not home:
|
||||
raise ValueError('role profile not bound')
|
||||
credential = load_role_token(home)
|
||||
transport = RestTransport(credential)
|
||||
import importlib.util
|
||||
path = Path(__file__).with_name('rework_readback.py')
|
||||
spec = importlib.util.spec_from_file_location('gitea_impl_readback_core', path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValueError('canonical readback core unavailable')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
import sys
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
adapter = module.ReworkReadbackAdapter(transport, origin=ORIGIN + '/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN',
|
||||
actor_id=args.actor_id, provider_contract=contract)
|
||||
after = adapter.snapshot(expected)
|
||||
if args.cmd == 'snapshot':
|
||||
result = {'schema': 'gitea-agent.rework-baseline/v1',
|
||||
'expected_sha256': expected_digest,
|
||||
'provider_contract_sha256': args.provider_contract_sha256,
|
||||
'snapshot': after}
|
||||
else:
|
||||
inputs = adapter.normalized_inputs(expected, baseline, after, args.action)
|
||||
result = verifier(expected, inputs['pr'], inputs['comments'], inputs['identity'],
|
||||
action=args.action, baseline_count=inputs['baseline_count'])
|
||||
result['readback_provenance'] = {'kind': 'fresh_authenticated_GET',
|
||||
'origin': ORIGIN, 'baseline_sha256': args.baseline_sha256,
|
||||
'provider_contract_sha256': args.provider_contract_sha256,
|
||||
'snapshot_sha256': hashlib.sha256(json.dumps(after, sort_keys=True,
|
||||
ensure_ascii=False, allow_nan=False).encode()).hexdigest(),
|
||||
'limitation': 'state-delta/exact-timestamp admission; not proof of causal event attestation'}
|
||||
if credential in json.dumps(result, ensure_ascii=False):
|
||||
raise ValueError('credential material in readback artifact')
|
||||
digest = output.write(result)
|
||||
print(json.dumps({'output': str(args.output), 'sha256': digest, 'read_only': True}))
|
||||
except Exception:
|
||||
raise ValueError('live readback failed; output not verified') from None
|
||||
Reference in New Issue
Block a user