#!/usr/bin/env python3
"""U31公开入口：真实Reactive HTTP/TCP，限定资源，记录一次完整成功。"""
import hashlib,json,os,pathlib,platform,re,signal,subprocess,tempfile,xml.etree.ElementTree as ET
from datetime import datetime
ROOT=pathlib.Path(__file__).resolve().parent
jdk=pathlib.Path(os.environ.get('JAVA_HOME') or subprocess.check_output(['/usr/libexec/java_home','-v','21'],text=True).strip())
env=dict(os.environ,JAVA_HOME=str(jdk),PATH=str(jdk/'bin')+os.pathsep+os.environ.get('PATH',''))
logs_root=pathlib.Path.home()/'.cache/software-engineering-study/java-depth-impl/u31';logs_root.mkdir(parents=True,exist_ok=True)
logs=pathlib.Path(tempfile.mkdtemp(prefix='run-',dir=logs_root))
started=datetime.now().astimezone().isoformat()
results=[]
def run(name,args,timeout=180):
    process=subprocess.Popen(args,cwd=ROOT,env=env,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,start_new_session=True)
    try:
        output,_=process.communicate(timeout=timeout)
    except BaseException:
        # 仅本次创建的进程组；Maven fork的测试JVM也在组中，不能只杀父进程留下listener。
        try: os.killpg(process.pid,signal.SIGTERM)
        except ProcessLookupError: pass
        try: output,_=process.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            try: os.killpg(process.pid,signal.SIGKILL)
            except ProcessLookupError: pass
            output,_=process.communicate(timeout=5)
        (logs/(name+'.log')).write_text(output)
        raise
    (logs/(name+'.log')).write_text(output)
    results.append({'name':name,'command':args,'exitCode':process.returncode,'observed':[l for l in output.splitlines() if l.startswith('U31OBSERVE')]})
    if process.returncode:
        raise RuntimeError(name+' exit='+str(process.returncode)+'; raw log: '+str(logs/(name+'.log'))+'\n'+output[-3500:])
    print('PASS '+name,flush=True)
    return output
version=run('jdk',[str(jdk/'bin/java'),'-version'])
assert re.search(r'version "21[.]',version),version
maven=run('maven',['mvn','-version'])
run('tests',['mvn','-q','clean','test'])
reports=[]
for p in sorted((ROOT/'target/surefire-reports').glob('TEST-*.xml')):
    r=ET.parse(p).getroot();reports.append({k:r.attrib[k] for k in ['name','tests','failures','errors','skipped']})
assert len(reports)==4,reports
assert all(r[k]=='0' for r in reports for k in ['failures','errors','skipped']),reports
observed=next(r['observed'] for r in results if r['name']=='tests')
assert sum('jwks fixtureStopped=true' in l for l in observed)==4,observed[-15:]
assert any('rsocket listenerDisposed=true' in l for l in observed)
tree=run('dependencies',['mvn','dependency:tree','-Dincludes=org.springframework,org.springframework.security,io.projectreactor,io.projectreactor.netty,io.rsocket,com.nimbusds'])
versions={}
for artifact in ['spring-webflux','spring-security-web','spring-security-config','spring-security-rsocket','spring-security-oauth2-resource-server','spring-security-oauth2-jose','reactor-core','reactor-netty-http','rsocket-core','nimbus-jose-jwt']:
    match=re.search(re.escape(artifact)+r':jar:([^:]+):',tree);assert match,artifact
    versions[artifact]=match.group(1)
source={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(ROOT.rglob('*')) if p.suffix in ['.java','.xml','.py','.md'] and 'target' not in p.parts}
evidence={'ticket':'U31 #68','startedAt':started,'finishedAt':datetime.now().astimezone().isoformat(),'input':'JAVA_HOME=$(/usr/libexec/java_home -v21) python3 labs/reactive-security/run.py','exitCode':0,'environment':{'jdk':version.strip(),'maven':maven.strip(),'os':platform.platform(),'arch':platform.machine(),'python':platform.python_version(),'dependencies':versions},'reports':reports,'testCount':sum(int(r['tests']) for r in reports),'results':results,'sourceSha256':source,'rawLogs':str(logs),'cleanup':{'springContextsClosed':4,'jwksFixturesStopped':4,'rsocketListenerDisposed':True,'testJvmExited':True},'limits':['Actual Netty HTTP, BCrypt, reactive method proxies and RSocket TCP; not mocked authenticated contexts.','Actual RSA signature and HTTP JWKS fixture, not an authorization server; actual OAuth2/OIDC issuance/revocation belongs to U30.','Synthetic accounts, no enterprise IdP/LDAP/KDC/certificate/WebAuthn/MFA runtime.','No TLS or browser attack, production capacity, distributed session/revocation or external authority refresh validation.','Reactive OAuth Login/Client/opaque and concurrent sessions are fixed-source/configuration teaching, not claimed as runtime.']}
(ROOT/'evidence').mkdir(exist_ok=True)
(ROOT/'evidence/u31-2026-10-26.json').write_text(json.dumps(evidence,ensure_ascii=False,indent=2)+'\n')
print('PASS U31: '+str(evidence['testCount'])+' tests; evidence written; owned resources closed',flush=True)
