|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Installed public-only H3 transport: independent clients and native loops.""" |
| 3 | +import socket |
| 4 | +import argparse, asyncio, hashlib, importlib.metadata, importlib.util, json, os, shlex, shutil, ssl, sys, tempfile |
| 5 | +from pathlib import Path |
| 6 | +ROOT=Path(__file__).resolve().parents[1] |
| 7 | +sys.path.insert(0,str(ROOT/'scripts/lib')) |
| 8 | +from v3_package_commands import Commands, runtime_environment |
| 9 | +from v3_package_integration import compile_probe |
| 10 | +from v3_package_matrix import check_linkage |
| 11 | +from v3_package_process import Consumer |
| 12 | +from v3_public_http3_inventory import CLIENTS, ONESHOTS, validate_client, validate_matrix |
| 13 | +from v3_public_http2_h2 import Client as H2Client |
| 14 | + |
| 15 | +def require(value,message): |
| 16 | + if not value: raise ValueError(message) |
| 17 | +def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() |
| 18 | +def load(path,name): |
| 19 | + spec=importlib.util.spec_from_file_location(name,path) |
| 20 | + module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module) |
| 21 | + return module |
| 22 | + |
| 23 | +def scenarios(executable,loop,args,directory,env,fixtures,commands,client): |
| 24 | + rows=[] |
| 25 | + if args.tls=='no': |
| 26 | + output=commands([executable,fixtures,loop],directory,env) |
| 27 | + require(output.strip()=='TLS_OFF_H3_REJECTED','same-source TLS-off typed H3 rejection') |
| 28 | + return [],[dict(case='tls_off_h3_rejected',status='passed')] |
| 29 | + for name in CLIENTS: |
| 30 | + process_receipt=directory/(name+'-basic-process.json') |
| 31 | + with Consumer([str(executable),str(fixtures),loop],directory,env,process_receipt) as child: |
| 32 | + port=child.port() |
| 33 | + with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as malformed: |
| 34 | + for packet in (b'bad Initial',b'\xc0'+b'\x00'*63,b'\xc0'+b'\xff'*1199): |
| 35 | + malformed.sendto(packet,('127.0.0.1',port)) |
| 36 | + if name=='aioquic': |
| 37 | + result=dict(client=name,version=importlib.metadata.version(name),alpn='h3',verified=True, |
| 38 | + quic_version=1,tls_version='TLSv1.3',early_data=False, retry_count=1, |
| 39 | + scenarios=asyncio.run(client.basic(port,fixtures))) |
| 40 | + else: |
| 41 | + output=commands([args.quic_go,'-matrix','public','-port',str(port),'-ca',fixtures/'root.pem'],directory,env,timeout=20) |
| 42 | + result=json.loads(output.strip().splitlines()[-1]) |
| 43 | + validate_client(result); child.stop() |
| 44 | + process=json.loads(process_receipt.read_text()) |
| 45 | + require('H3_ACCEPT_TOTAL 1 0' in process['stderr'],'malformed Initial reached connection admission') |
| 46 | + require(process['exit']==0 and 'H3_STOP ' in process['stderr'],'public owner shutdown absent') |
| 47 | + if loop=='external': require('INACTIVE_DRIVER_REJECTED' in process['stderr'],'inactive external readiness driver') |
| 48 | + rows.append(dict(**result,process_receipt=str(process_receipt))) |
| 49 | + checks=[dict(case=name+'_bad_initial_listener_recovery',status='passed') for name in CLIENTS] |
| 50 | + for mode,expected in ONESHOTS.items(): |
| 51 | + output=commands([executable,fixtures,loop,mode],directory,env) |
| 52 | + require(output.strip()==expected,'prebind/rollback predicate '+mode) |
| 53 | + checks.append(dict(case=mode,status='passed')) |
| 54 | + for family,mode in [('policy','policy'),('timeouts','timeouts'),('failed-handshake','basic'),('stalled','stalled'),('destruction','destroy')]: |
| 55 | + process_receipt=directory/(family+'-process.json') |
| 56 | + with Consumer([str(executable),str(fixtures),loop,mode],directory,env,process_receipt) as child: |
| 57 | + port=child.port() |
| 58 | + if family=='policy': observations=asyncio.run(client.rejection(port,fixtures)) |
| 59 | + elif family=='timeouts': observations=asyncio.run(client.timeout_recovery(port,fixtures)) |
| 60 | + elif family=='failed-handshake': observations=asyncio.run(client.failed_handshake(port,fixtures)) |
| 61 | + elif family=='stalled': observations=asyncio.run(client.stalled_handshake(port,fixtures)) |
| 62 | + else: observations=asyncio.run(client.active_stop(port,fixtures,child.stop)) |
| 63 | + if family!='destruction':child.stop() |
| 64 | + process=json.loads(process_receipt.read_text()) |
| 65 | + if family=='stalled':require('H3_ACCEPT_TOTAL 2 0' in process['stderr'] and 'H3_TLS_COMPLETED 1' in process['stderr'],'stalled handshake retired before recovery') |
| 66 | + if family=='policy':require('POLICY_REFUSAL ' in process['stderr'],'admission hook before TLS') |
| 67 | + if family=='destruction':require('H3_DESTROYED ' in process['stderr'],'active destruction completion') |
| 68 | + checks.extend(observations) |
| 69 | + process_receipt=directory/'quic-go-destruction-process.json' |
| 70 | + with Consumer([str(executable),str(fixtures),loop,'destroy'],directory,env,process_receipt) as server: |
| 71 | + port=server.port() |
| 72 | + with Consumer([str(args.quic_go),'-matrix','public-stop','-port',str(port),'-ca',str(fixtures/'root.pem')],directory,env,directory/'quic-go-active-process.json') as independent: |
| 73 | + require(independent.line()=='H3_ACTIVE','independent active stream absent') |
| 74 | + server.stop() |
| 75 | + result=json.loads(independent.line());require(result.get('status')=='passed' and result.get('client')=='quic-go','independent destruction witness') |
| 76 | + require(independent.process.wait(timeout=5)==0,'independent destruction process failed') |
| 77 | + checks.append(dict(case='quic_go_active_stream_server_destruction',status='passed')) |
| 78 | + for name in CLIENTS: |
| 79 | + with Consumer([str(executable),str(fixtures),loop,'mixed'],directory,env,directory/(name+'-mixed-process.json')) as child: |
| 80 | + port=child.port();mixed_tcp(port,fixtures) |
| 81 | + if name=='aioquic':asyncio.run(client.basic(port,fixtures)) |
| 82 | + else: |
| 83 | + result=json.loads(commands([args.quic_go,'-matrix','public','-port',str(port),'-ca',fixtures/'root.pem'],directory,env,timeout=20).strip().splitlines()[-1]) |
| 84 | + validate_client(result) |
| 85 | + child.stop() |
| 86 | + checks.append(dict(case=name+'_mixed_tcp_udp_same_port',status='passed')) |
| 87 | + return rows,checks |
| 88 | + |
| 89 | +def mixed_tcp(port,fixtures): |
| 90 | + context=ssl.create_default_context(cafile=str(fixtures/'root.pem')) |
| 91 | + context.set_alpn_protocols(['http/1.1']) |
| 92 | + with context.wrap_socket(socket.create_connection(('127.0.0.1',port),timeout=3),server_hostname='a.example') as peer: |
| 93 | + require(peer.selected_alpn_protocol()=='http/1.1','mixed TLS TCP H1 ALPN') |
| 94 | + peer.sendall(b'GET /hello HTTP/1.1\r\nHost: a.example\r\nConnection: close\r\n\r\n') |
| 95 | + data=bytearray() |
| 96 | + while chunk:=peer.recv(4096): |
| 97 | + data.extend(chunk);require(len(data)<=65536,'mixed H1 response bound') |
| 98 | + require(b'hello native h3\n' in data and b'native-fin' in data,'mixed H1 route/trailer') |
| 99 | + client=H2Client(port,fixtures) |
| 100 | + try: |
| 101 | + result=client.get('/hello') |
| 102 | + require(result['body']==b'hello native h3\n','mixed TLS TCP H2 route') |
| 103 | + finally:client.close() |
| 104 | + |
| 105 | +def main(): |
| 106 | + parser=argparse.ArgumentParser(description=__doc__) |
| 107 | + for name in ('prefix','provider-prefix','build','output'): parser.add_argument('--'+name,type=Path,required=True) |
| 108 | + parser.add_argument('--source',type=Path,default=ROOT) |
| 109 | + parser.add_argument('--tls',choices=('yes','no'),required=True) |
| 110 | + parser.add_argument('--compiler',default='/usr/bin/clang++-18') |
| 111 | + parser.add_argument('--quic-go',type=Path,required=True) |
| 112 | + parser.add_argument('--sanitize',default='') |
| 113 | + args=parser.parse_args(); args.output=args.output.resolve(); args.output.mkdir(parents=True,exist_ok=False) |
| 114 | + prefix,source,build,provider=[p.resolve() for p in (args.prefix,args.source,args.build,args.provider_prefix)] |
| 115 | + commands=Commands(args.output); env=runtime_environment(os.environ,prefix,provider,[]) |
| 116 | + env.update(PKG_CONFIG_PATH='',PKG_CONFIG_LIBDIR=str(prefix/'lib/pkgconfig')) |
| 117 | + receipt=dict(version=1,predicate='installed-public-http3',tls=args.tls,prefix=str(prefix),source=str(source),build=str(build), |
| 118 | + cells=[],command=sys.argv,environment={k:env.get(k) for k in ('ASAN_OPTIONS','UBSAN_OPTIONS','TSAN_OPTIONS','LD_LIBRARY_PATH')}) |
| 119 | + try: |
| 120 | + audit=load(source/'scripts/audit-v3-installed-package.py','audit') |
| 121 | + client=load(source/'test/installed/v3_http3_aioquic.py','public_h3_client') if args.tls=='yes' else None |
| 122 | + receipt['revision']=commands(['git','rev-parse','HEAD'],source,env).strip() |
| 123 | + receipt['branch']=commands(['git','branch','--show-current'],source,env).strip() |
| 124 | + receipt['compiler']=commands(shlex.split(args.compiler)+['--version'],source,env) |
| 125 | + receipt['python']=dict(executable=sys.executable,version=sys.version,ssl=ssl.OPENSSL_VERSION,sha256=digest(Path(sys.executable))) |
| 126 | + if args.tls=='yes': |
| 127 | + require(importlib.metadata.version('aioquic')==CLIENTS['aioquic'],'aioquic pin') |
| 128 | + receipt['clients']=dict(quic_go=dict(executable=str(args.quic_go),sha256=digest(args.quic_go),identity=json.loads(commands([args.quic_go,'--identity'],source,env))),aioquic=dict(version='1.3.0')) |
| 129 | + import aioquic |
| 130 | + receipt['clients']['aioquic']['source_hashes']={str(p):digest(p) for p in Path(aioquic.__file__).parent.rglob('*') if p.is_file() and p.suffix in ('.py','.so')} |
| 131 | + receipt['provider']={str(p):digest(p) for pattern in ('libssl.so*','libcrypto.so*','libssl.a','libcrypto.a') for p in (provider/'lib').glob(pattern) if p.is_file()} |
| 132 | + inputs=sorted(p for p in (source/'src').rglob('*') if p.suffix in ('.cpp','.hpp')) |
| 133 | + inputs += [source/p for p in ('src/Makefile.am','test/Makefile.am','configure.ac','test/installed/v3_http3_consumer.cpp','test/installed/v3_http3_aioquic.py','test/integ/http3-client-go/public.go','scripts/check-v3-public-http3.py','scripts/lib/v3_public_http3_inventory.py')] |
| 134 | + inputs += sorted((source/'test/integ/http3-client-go').glob('*.go')) + [source/'test/integ/http3-client-go/go.mod',source/'test/integ/http3-client-go/go.sum'] |
| 135 | + receipt['source_hashes']={str(p.relative_to(source)):digest(p) for p in inputs} |
| 136 | + receipt['source_identity']=hashlib.sha256(json.dumps(receipt['source_hashes'],sort_keys=True).encode()).hexdigest() |
| 137 | + receipt['library_hashes']={str(p):digest(p) for p in (prefix/'lib').glob('libhttpserver.*') if p.is_file()} |
| 138 | + require(Path(commands(['pkg-config','--variable=pcfiledir','libhttpserver'],args.output,env).strip()).resolve()==prefix/'lib/pkgconfig','installed pkg-config identity') |
| 139 | + cflags=shlex.split(commands(['pkg-config','--cflags','libhttpserver'],args.output,env)) |
| 140 | + require(not audit.consumer_flag_violations(cflags,(source,build)),'compile flag contamination') |
| 141 | + directory=Path(tempfile.mkdtemp(prefix='task196-installed-')); receipt['consumer_directory']=str(directory) |
| 142 | + for name in ('test/installed/v3_http3_consumer.cpp','examples/v3_host_poll.hpp'): shutil.copyfile(source/name,directory/Path(name).name) |
| 143 | + fixtures=directory/'fixtures';shutil.copytree(source/'test/data/tls_credentials',fixtures) |
| 144 | + receipt['fixture_hashes']={p.name:digest(p) for p in fixtures.iterdir() if p.is_file()} |
| 145 | + for linkage in ('shared','static'): |
| 146 | + libraries=shlex.split(commands(['pkg-config','--libs']+(['--static'] if linkage=='static' else [])+['libhttpserver'],directory,env)) |
| 147 | + if linkage=='static':libraries=[str(prefix/'lib/libhttpserver.a') if f=='-lhttpserver' else f for f in libraries] |
| 148 | + libraries += ['-pthread','-Wl,-rpath,'+str(prefix/'lib'),'-Wl,-rpath,'+str(provider/'lib')]+shlex.split(args.sanitize) |
| 149 | + executable=directory/linkage |
| 150 | + provenance=compile_probe(args.compiler,[directory/'v3_http3_consumer.cpp'],cflags+shlex.split(args.sanitize),libraries,executable,commands,env,source,build,prefix,False) |
| 151 | + graph=audit.dependency_graph([str(executable)]);check_linkage(graph,linkage,prefix) |
| 152 | + require(not audit.graph_violations(graph,'strict',args.tls,'aggregate',audit.load_policy(),provider),'dependency graph violations') |
| 153 | + for loop in ('managed','external'): |
| 154 | + out=args.output/(linkage+'-'+loop);out.mkdir() |
| 155 | + cell=dict(linkage=linkage,loop=loop,compile=provenance,dependencies=graph);receipt['cells'].append(cell) |
| 156 | + cell['clients'],cell['configuration']=scenarios(executable,loop,args,out,env,fixtures,commands,client) |
| 157 | + cell['status']='passed';print('PASS installed H3',args.tls,linkage,loop,flush=True) |
| 158 | + receipt['status']='passed' |
| 159 | + if args.tls=='yes':validate_matrix(receipt) |
| 160 | + except BaseException as error: |
| 161 | + receipt.update(status='failed',error=str(error));raise |
| 162 | + finally:(args.output/'receipt.json').write_text(json.dumps(receipt,indent=2)+'\n') |
| 163 | + |
| 164 | +if __name__=='__main__':main() |
0 commit comments