#!/usr/bin/env python3
"""Fail-closed verifier for retained split-origin auth evidence."""
import argparse, base64, hashlib, json, pathlib, re, sys

ROOT=pathlib.Path(__file__).resolve().parent; SCHEMAS=ROOT/'schemas'
ROUTES=("/auth/register","/auth/login","/auth/logout","/auth/session","/auth/csrf","/auth/refresh-csrf","/auth/refresh","/auth/password-reset/request","/auth/password-reset/complete")
OPTIONS=("options-success","options-acr-method-absent","options-acr-method-malformed","options-acr-method-wrong","options-acr-header-extra")
SEAMS=(
"register.before-idempotency-claim","register.after-idempotency-claim-before-user","register.after-user-before-session","register.after-session-before-outcome","register.after-outcome-before-cookie-build","register.after-cookie-build-before-send","register.after-send-before-cookie-application","register.during-cookie-application",
"login.before-idempotency-claim","login.after-idempotency-claim-before-session","login.after-session-before-outcome","login.after-outcome-before-cookie-build","login.after-cookie-build-before-send","login.after-send-before-cookie-application","login.during-cookie-application",
"csrf.after-hash-persist-before-cookie-build","csrf.after-cookie-build-before-send","csrf.after-send-before-cookie-application","csrf.during-cookie-application",
"bootstrap.before-cas","bootstrap.after-cas-before-response-build","bootstrap.after-response-build-before-send","bootstrap.after-send-before-client-generation-cas",
"refresh.before-prelookup","refresh.after-prelookup-before-csrf","refresh.after-csrf-before-rotation","refresh.after-rotation-before-outcome-commit","refresh.after-outcome-commit-before-cookie-build","refresh.after-cookie-build-before-send","refresh.after-send-before-cookie-application","refresh.during-cookie-application","refresh.after-cookie-application-before-generation-cas",
"logout.after-prelookup-before-csrf","logout.after-csrf-before-revoke","logout.after-revoke-before-outcome","logout.after-outcome-before-clear-build","logout.after-clear-build-before-send","logout.after-send-before-cookie-application","logout.during-cookie-application",
"reset-request.after-token-persist-before-enqueue","reset-request.after-enqueue-before-accepted-outcome","reset-request.after-accepted-outcome-before-send","reset-request.after-send-connection-loss",
"password-reset.after-password-write-before-session-invalidate","password-reset.after-session-invalidate-before-clear-build","password-reset.after-clear-build-before-send","password-reset.after-send-before-cookie-application","password-reset.during-cookie-application")
REQUIRED={"environment.json":"environment-input.schema.json","summary.json":"summary.schema.json","sink-inventory.json":"sink-inventory.schema.json","artifact-manifest.json":"artifact-manifest.schema.json"}
NDJSON={"cases.ndjson":"case-oracle.schema.json","sink-search.ndjson":"sink-search.schema.json","db-assertions.ndjson":"db-assertions.schema.json","crash-injection.ndjson":"crash-injection.schema.json"}
CONTROL={"SHA256SUMS","artifact-manifest.json","summary.json"}
HOP_CLASSES={"browser-client","service-worker","browser-extension","browser-automation","packet-debug-capture","api-gateway-load-balancer","edge-cdn-waf","reverse-proxy","service-mesh-sidecar","app-runtime","request-response-log","observability","database-log","process-dump","crash-reporter","session-replay","support-ticket","warehouse-siem","backup-archive","deletion","vendor-transit-storage","mail-provider","mailbox","mail-queue","mail-dead-letter","mail-template-renderer","mail-template-preview-log","mail-bounce-handler","mail-complaint-handler","mail-link-scanner","mail-security-gateway","mail-link-rewriter"}

def die(reason): print(f"VERDICT=BLOCKED reason={reason}"); raise SystemExit(2)
def load(p):
 try: return json.loads(p.read_text(encoding='utf-8'))
 except Exception as e: die(f"invalid-json:{p.name}:{type(e).__name__}")
def rel_ok(s):
 p=pathlib.PurePosixPath(s); return bool(s) and not p.is_absolute() and '..' not in p.parts and str(p)==s and '\\' not in s

def canonical(v): return json.dumps(v,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode()
def verify_sig(pub,sig,payload,label):
 try:
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
  Ed25519PublicKey.from_public_bytes(base64.b64decode(pub,validate=True)).verify(base64.b64decode(sig,validate=True),payload)
 except ImportError: die('ed25519-validator-unavailable')
 except Exception: die(f'invalid-ed25519-signature:{label}')

def digest_file(p): return hashlib.sha256(p.read_bytes()).hexdigest()
def parse_sums(p):
 rows={}
 for n,line in enumerate(p.read_text(encoding='utf-8').splitlines(),1):
  m=re.fullmatch(r'([0-9a-f]{64})  ([^\r\n]+)',line)
  if not m or not rel_ok(m.group(2)) or m.group(2) in rows: die(f'invalid-SHA256SUMS:{n}')
  rows[m.group(2)]=m.group(1)
 return rows

def validate_tree(run, docs):
 actual={p.relative_to(run).as_posix():p for p in run.rglob('*') if p.is_file()}
 sums=parse_sums(run/'SHA256SUMS')
 if set(sums)!=(set(actual)-{'SHA256SUMS'}): die('SHA256SUMS-set-mismatch')
 for r,d in sums.items():
  if digest_file(actual[r])!=d: die(f'SHA256SUMS-digest:{r}')
 listed={x['path']:x for x in docs['artifact-manifest.json']['files']}
 summary={x['path']:x for x in docs['summary.json']['artifacts']}
 payload=set(actual)-CONTROL
 if set(listed)!=payload or set(summary)!=payload: die('artifact-closed-set-mismatch')
 if set(listed)!=set(summary): die('summary-manifest-set-mismatch')
 for r in payload:
  if not rel_ok(r): die(f'unsafe-relative-path:{r}')
  p=actual[r]; d=digest_file(p); b=p.stat().st_size
  if listed[r]['sha256']!=d or listed[r]['bytes']!=b: die(f'artifact-manifest-mismatch:{r}')
  if summary[r]['sha256']!=d or summary[r]['bytes']!=b: die(f'summary-artifact-mismatch:{r}')
 return {r:(digest_file(p),p.stat().st_size) for r,p in actual.items()}

def main():
 ap=argparse.ArgumentParser(); ap.add_argument('--input',required=True); ap.add_argument('--manifest',required=True); ap.add_argument('--publication'); a=ap.parse_args()
 run=pathlib.Path(a.input).resolve(); mp=pathlib.Path(a.manifest).resolve()
 try: import jsonschema
 except ImportError: die('jsonschema-validator-unavailable')
 if not run.is_dir() or mp.parent!=run or mp.name!='auth-sensitive-data-flow.json': die('production-evidence-directory-missing')
 schemas={p.name:load(p) for p in SCHEMAS.glob('*.schema.json')}
 for n,s in schemas.items():
  try: jsonschema.Draft202012Validator.check_schema(s)
  except Exception: die(f'invalid-metaschema:{n}')
 def validate(v,n):
  try: jsonschema.Draft202012Validator(schemas[n],format_checker=jsonschema.FormatChecker()).validate(v)
  except Exception as e: die(f'schema:{n}:{type(e).__name__}')
 docs={}
 for f,s in REQUIRED.items():
  if not (run/f).is_file(): die(f'missing:{f}')
  docs[f]=load(run/f); validate(docs[f],s)
 for f,s in NDJSON.items():
  if not (run/f).is_file(): die(f'missing:{f}')
  rows=[]
  for n,line in enumerate((run/f).read_text(encoding='utf-8').splitlines(),1):
   if not line.strip(): die(f'blank-ndjson:{f}:{n}')
   try: row=json.loads(line)
   except Exception: die(f'invalid-ndjson:{f}:{n}')
   validate(row,s); rows.append(row)
  if not rows: die(f'empty:{f}')
  docs[f]=rows
 manifest=load(mp); validate(manifest,'redaction-manifest.schema.json')
 digest=digest_file(mp); env=docs['environment.json']; inv=docs['sink-inventory.json']; summary=docs['summary.json']
 if env['redaction_manifest']['sha256']!='sha256:'+digest or inv['manifest_sha256']!=digest: die('manifest-digest-mismatch')
 if inv['entries']!=manifest['entries']: die('inventory-not-exact-manifest-copy')
 entries=manifest['entries']; ids=[e['hop_id'] for e in entries]
 if {e['class'] for e in entries}!=HOP_CLASSES: die('hop-class-coverage-not-exact')
 if len(ids)!=len(set(ids)): die('duplicate-hop-id')
 byid={e['hop_id']:e for e in entries}; probes=[]
 for e in entries:
  payload={k:v for k,v in e.items() if k!='owner_signature'}
  verify_sig(e['owner_public_key'],e['owner_signature'],canonical(payload),f"owner:{e['hop_id']}")
  for u in e['upstream']:
   if u not in byid or e['hop_id'] not in byid[u]['downstream']: die('nonreciprocal-upstream')
  for d in e['downstream']:
   if d not in byid or e['hop_id'] not in byid[d]['upstream']: die('nonreciprocal-downstream')
  if e['enabled']: probes.append(e['probe_id'])
 if len(probes)!=len(set(probes)): die('duplicate-enabled-probe')
 sec={k:v for k,v in manifest.items() if k!='security_signature'}
 verify_sig(manifest['security_public_key'],manifest['security_signature'],canonical(sec),'security-closure')
 roots=[x for x in ids if not byid[x]['upstream']]; seen=set(roots); stack=list(roots)
 while stack:
  stack.extend(d for d in byid[stack.pop()]['downstream'] if d not in seen and not seen.add(d))
 if set(ids)!=seen: die('redaction-graph-not-root-connected')
 searches=docs['sink-search.ndjson']; got=[r['probe_id'] for r in searches]
 if sorted(got)!=sorted(probes) or len(got)!=len(set(got)): die('enabled-probe-coverage-not-exact')
 if any(r['hop_id'] not in byid or byid[r['hop_id']]['probe_id']!=r['probe_id'] for r in searches): die('probe-hop-reference')
 cases=docs['cases.ndjson']; cids=[r['case_id'] for r in cases]
 if len(cids)!=len(set(cids)): die('duplicate-current-case')
 if {r['route'] for r in cases}!=set(ROUTES): die('nine-route-coverage')
 for route in ROUTES:
  if {(r['phase'],r['category']) for r in cases if r['route']==route and r['phase']=='options'}!={("options",c) for c in OPTIONS}: die(f'options-coverage:{route}')
  if not any(r['route']==route and r['phase']=='actual' for r in cases): die(f'actual-coverage:{route}')
 for r in cases:
  if r['expected']!=r['observed'] or r['expected_side_effects']!=r['observed_side_effects']: die(f'oracle-mismatch:{r["case_id"]}')
 dbids={r['assertion_id'] for r in docs['db-assertions.ndjson']}; sinkids={r['probe_id'] for r in searches}
 if any(set(r['db_assertions'])-dbids or set(r['sink_probes'])-sinkids for r in cases): die('case-reference-closure')
 seams=[r['crash_seam'] for r in cases if r['phase']=='crash']
 if set(seams)!=set(SEAMS) or len(seams)!=len(SEAMS): die('crash-seam-coverage-not-exact')
 ci=docs['crash-injection.ndjson']; fired=[r['seam'] for r in ci]
 if set(fired)!=set(SEAMS) or len(fired)!=len(SEAMS): die('crash-injection-coverage-not-exact')
 allrows=[*cases,*searches,*docs['db-assertions.ndjson'],*ci]
 counts={"pass":sum(r['verdict']=='PASS' for r in allrows),"fail":sum(r['verdict']=='FAIL' for r in allrows),"blocked":sum(r['verdict']=='BLOCKED' for r in allrows)}
 if summary['counts']!=counts: die('summary-count-mismatch')
 if not env['production_equivalent'] or not summary['production_equivalent'] or not summary['environment_valid'] or summary['verdict']!='PASS' or counts['fail'] or counts['blocked']: die('non-pass-record')
 tree=validate_tree(run,docs)
 if not a.publication: die('publication-copy-not-provided')
 pub=pathlib.Path(a.publication).resolve()
 if pub==run or not pub.is_dir(): die('publication-copy-missing-or-not-distinct')
 pubdocs={f:load(pub/f) for f in REQUIRED}; pubtree=validate_tree(pub,pubdocs)
 if tree!=pubtree or set(tree)!=set(pubtree): die('publication-copy-byte-mismatch')
 print(f"VERDICT=PASS run_id={env['run_id']} cases={len(cases)} sinks={len(entries)}")
if __name__=='__main__': main()
