All checks were successful
Build and publish policy-nexus image / build-and-push (push) Successful in 1m25s
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
69 lines
3.5 KiB
Python
69 lines
3.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare a candidate against captured public history, without network or authority."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
SEMANTIC_FIELDS = ('source_digest', 'revision', 'status', 'lifecycle', 'currency',
|
|
'canonical_path', 'revision_path', 'source_repo', 'source_path', 'owner')
|
|
|
|
def safe_path(value):
|
|
p = PurePosixPath(value)
|
|
if p.is_absolute() or '..' in p.parts or not p.parts:
|
|
raise ValueError('unsafe publication path')
|
|
return p
|
|
|
|
def verify(candidate, baseline_manifest, immutable_audit, allowed_changed_ids):
|
|
candidate = Path(candidate).resolve()
|
|
old = json.loads(Path(baseline_manifest).read_text())['documents']
|
|
new = json.loads((candidate / 'publication-manifest.json').read_text())['documents']
|
|
old_by = {d['id']: d for d in old}
|
|
new_by = {d['id']: d for d in new}
|
|
if len(old_by) != len(old) or len(new_by) != len(new):
|
|
raise ValueError('duplicate document identity')
|
|
errors = []
|
|
changed = set(new_by) - set(old_by)
|
|
for identity, before in old_by.items():
|
|
after = new_by.get(identity)
|
|
if after is None:
|
|
errors.append(f'{identity}: published document removed')
|
|
elif any(before.get(k) != after.get(k) for k in SEMANTIC_FIELDS):
|
|
changed.add(identity)
|
|
for identity in sorted(changed - set(allowed_changed_ids)):
|
|
errors.append(f'{identity}: unreviewed semantic change')
|
|
history = json.loads(Path(immutable_audit).read_text())['immutable_results']
|
|
if len(history) != len(old) or {h['id'] for h in history} != set(old_by):
|
|
errors.append('baseline capture does not cover every previously published document')
|
|
for record in history:
|
|
identity = record['id']
|
|
if record.get('http_status') != 200 or not record.get('served_html_sha256'):
|
|
errors.append(f'{identity}: prior public history was not successfully captured')
|
|
continue
|
|
if record.get('revision_path') != old_by[identity]['revision_path']:
|
|
errors.append(f'{identity}: captured path differs from baseline manifest')
|
|
continue
|
|
path = candidate / safe_path(record['revision_path'])
|
|
if (not path.is_file() or path.is_symlink()
|
|
or candidate not in path.resolve().parents
|
|
or any(parent.is_symlink() for parent in path.parents if parent != candidate and candidate in parent.parents)) :
|
|
errors.append(f'{identity}: old immutable revision absent or unsafe')
|
|
elif hashlib.sha256(path.read_bytes()).hexdigest() != record['served_html_sha256']:
|
|
errors.append(f'{identity}: old immutable bytes changed')
|
|
if errors:
|
|
raise ValueError('\n'.join(errors))
|
|
return {'previous_documents': len(old), 'candidate_documents': len(new),
|
|
'reviewed_semantic_changes': sorted(changed), 'history_preserved': len(history)}
|
|
|
|
if __name__ == '__main__':
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--candidate', type=Path, required=True)
|
|
p.add_argument('--baseline-manifest', type=Path, required=True)
|
|
p.add_argument('--immutable-audit', type=Path, required=True)
|
|
p.add_argument('--allowed-changed-id', action='append', default=[])
|
|
a = p.parse_args()
|
|
try:
|
|
result = verify(a.candidate, a.baseline_manifest, a.immutable_audit, a.allowed_changed_id)
|
|
except (ValueError, OSError, KeyError, TypeError) as e:
|
|
p.exit(1, str(e) + '\n')
|
|
print(json.dumps(result, sort_keys=True))
|