http_pipeline.py (25190B)
1 #!/usr/bin/env python3 2 """Opt-in HTTP pipeline probes against explicitly selected executable artifacts. 3 4 Independent full result/effect-trace trees; optional unchanged-case comparisons 5 against a previous full HTTP bundle. No compilation or real socket IO here. 6 """ 7 import argparse 8 from dataclasses import dataclass 9 import json 10 from pathlib import Path 11 import time 12 13 from bundle_dag import merge_bundles 14 from http_headers import Runner, LEAF, BAD_REQUEST, byte_tree, number, pair, tree_list, result, sha, expect, parse_tree_output 15 16 17 @dataclass(frozen=True) 18 class Case: 19 label: str 20 export: str 21 arguments: tuple[bytes, ...] 22 expected: bytes 23 driver: str = '' 24 previous: bytes | None = None 25 26 27 def pure_cases(): 28 rows = [] 29 for method in (b'GET', b'POST', b'HEAD', b'PUT', b'', b'get'): 30 rows.append(Case('method-' + method.hex(), 'validMethod?', (byte_tree(method),), b'\1\0' if method in (b'GET', b'POST', b'HEAD') else LEAF)) 31 rows.append(Case('router-' + method.hex(), 'routerMethod', (byte_tree(method),), byte_tree(b'GET' if method == b'HEAD' else method))) 32 for target in (b'/', b'/path?q=x', b'', b'*'): 33 for version in (b'HTTP/1.1', b'HTTP/1.0', b'HTTP/2', b''): 34 expected = BAD_REQUEST if method not in (b'GET', b'POST', b'HEAD') else ( 35 result(False, number(505), byte_tree(b'HTTP Version Not Supported\n')) if version not in (b'HTTP/1.1', b'HTTP/1.0') else 36 result(True, LEAF) if target.startswith(b'/') else BAD_REQUEST) 37 rows.append(Case('validate-' + '-'.join(v.hex() for v in (method, target, version)), 'validateRequest', 38 (byte_tree(method), byte_tree(target), byte_tree(version), LEAF), expected)) 39 for state in range(6): 40 for char in (0, 10, 13, 65, 255): 41 next_state = (3 if state == 2 else 1) if char == 13 else (4 if state == 3 else 2) if char == 10 and state in (1, 3) else 0 42 rows.append(Case(f'framing-{state}-{char}', 'headerEndState', (number(state), number(char)), number(next_state))) 43 for index, data in enumerate((b'', b'x', b'\r', b'\r\n', b'\r\n\r', b'\r\n\r\nbody', 44 b'HTTP/1.1 200 OK\r\nX: y\r\n\r\nbody', b'\r\r\n\r\nbody', b'\r\n\n\r\n\r\nbody')): 45 prefix = data.split(b'\r\n\r\n', 1)[0] + b'\r\n\r\n' if b'\r\n\r\n' in data else data 46 rows.append(Case(f'headers-only-{index}', 'headersOnly', (byte_tree(data),), byte_tree(prefix))) 47 for method in (b'HEAD', b'GET', b'head'): 48 rows.append(Case(f'response-method-{index}-{method.hex()}', 'responseForMethod', (byte_tree(method), byte_tree(data)), byte_tree(prefix if method == b'HEAD' else data))) 49 for remaining in range(10): 50 for data in (b'', b'a', b'abc', b'abcdefghi'): 51 for initial in (b'', b'YX'): 52 taken = data[:remaining] 53 rows.append(Case(f'take-{remaining}-{len(data)}-{len(initial)}', 'takeBodyBytes', 54 (byte_tree(data), number(remaining), byte_tree(initial)), 55 pair(number(max(0, remaining-len(data))), pair(byte_tree(taken[::-1]+initial), byte_tree(data[remaining:]))))) 56 opaque = bytes(range(256)) * 17 57 for count in (255, 256, 4095, 4096, 4097): 58 rows.append(Case(f'take-binary-{count}', 'takeBodyBytes', (byte_tree(opaque), number(count), LEAF), 59 pair(number(0), pair(byte_tree(opaque[:count][::-1]), byte_tree(opaque[count:]))))) 60 for value in (0, 1, 15, 16, 17, 63, 64, 65, 255, 256, 257, 1023, 1024, 1025, 4095, 4096, 4097, 1048576): 61 expected = next((n for n in (4096, 1024, 256, 64, 16) if value >= n), 1) 62 rows.append(Case(f'recv-bucket-{value}', 'recvChunkMax4096', (number(value),), number(expected))) 63 for export, code, phrase, content_type in [ 64 ('okResponse', 200, b'OK', b'text/plain; charset=utf-8'), 65 ('textResponse', 200, b'OK', b'text/plain; charset=utf-8'), 66 ('jsonResponse', 200, b'OK', b'application/json'), 67 ('htmlResponse', 200, b'OK', b'text/html; charset=utf-8'), 68 ('createdResponse', 201, b'Created', b'text/plain; charset=utf-8'), 69 ('badRequestResponse', 400, b'Bad Request', b'text/plain; charset=utf-8'), 70 ]: 71 for body in (b'', b'hello', b'\x00\xff'): 72 wire = (b'HTTP/1.1 ' + str(code).encode() + b' ' + phrase + b'\r\nContent-Type: ' + content_type + 73 b'\r\nContent-Length: ' + str(len(body)).encode() + b'\r\nConnection: close\r\n\r\n' + body) 74 rows.append(Case('response-' + export + '-' + body.hex(), export, (byte_tree(body),), byte_tree(wire))) 75 for headers in ([], [(b'X', b'one')], [(b'X', b'one'), (b'X', b'two'), (b'Y', b'')]): 76 wire = b'HTTP/1.1 200 OK\r\n' + b''.join(k+b': '+v+b'\r\n' for k,v in headers) + b'\r\nbody' 77 rows.append(Case(f'build-response-{len(headers)}', 'buildResponse', 78 (number(200), field_tree(headers), byte_tree(b'body')), byte_tree(wire))) 79 for code, phrase in [(200, b'OK'), (201, b'Created'), (204, b'No Content'), (400, b'Bad Request'), 80 (404, b'Not Found'), (405, b'Method Not Allowed'), (431, b'Request Header Fields Too Large'), 81 (501, b'Not Implemented'), (505, b'HTTP Version Not Supported'), (999, b'Internal Server Error')]: 82 rows.append(Case(f'status-{code}', 'statusPhrase', (number(code),), byte_tree(phrase))) 83 return rows 84 85 86 87 def action(tag, payload): 88 return pair(number(tag), payload) 89 90 91 def script(entries): 92 return tree_list([pair(number(tag), reply) for tag, reply in entries]) 93 94 95 def finished(value, trace=(), remaining=(), label=b'done'): 96 return pair(byte_tree(label), pair(value, pair(tree_list(list(trace)), script(remaining)))) 97 98 99 def response(code, message, head=False, legacy=False): 100 phrases = {400: b'Bad Request', 413: b'Content Too Large', 431: b'Request Header Fields Too Large', 505: b'HTTP Version Not Supported'} 101 phrase = b'Internal Server Error' if legacy and code == 413 else phrases[code] 102 headers = (b'HTTP/1.1 ' + str(code).encode() + b' ' + phrase + b'\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ' + 103 str(len(message)).encode() + b'\r\nConnection: close\r\n\r\n') 104 return headers if head else headers + message 105 106 107 def routed(method, target, headers, body, head=False): 108 prefix = b'HTTP/1.1 200 OK\r\nX: test\r\n\r\n' 109 return prefix if head else prefix + b'|'.join([method, target, b''.join(k+b':'+v+b';' for k, v in headers), body]) 110 111 112 def field_tree(headers): 113 return tree_list([pair(byte_tree(k), byte_tree(v)) for k, v in headers]) 114 115 116 def effect_cases(): 117 rows = [] 118 sock = number(7) 119 recv = lambda n: action(76, pair(sock, number(n))) 120 send = lambda data: action(77, pair(sock, byte_tree(data))) 121 close = action(71, sock) 122 ok = result(True, LEAF) 123 socket_error = result(False, byte_tree(b'broken')) 124 # Establish the test interpreter's behavior independently of any HTTP code. 125 rows += [ 126 Case('driver-pure', '@driver', (action(0, number(42)), LEAF), finished(number(42)), 'run'), 127 Case('driver-extra-script', '@driver', (action(0, LEAF), script([(71, ok)])), finished(LEAF, remaining=[(71, ok)]), 'run'), 128 Case('driver-unscripted', '@driver', (close, LEAF), finished(close, [close], label=b'unscripted'), 'run'), 129 Case('driver-wrong-tag', '@driver', (close, script([(77, ok)])), finished(close, [close], [(77, ok)], b'unexpected'), 'run'), 130 Case('driver-abort', '@driver', (action(2, byte_tree(b'stop')), LEAF), finished(action(2, byte_tree(b'stop')), [action(2, byte_tree(b'stop'))], label=b'abort'), 'run'), 131 ] 132 rows.append(Case('driver-bind', '@driver', (script([(999, number(42))]),), 133 finished(number(42), [action(999, byte_tree(b'x'))]), 'bindProbe')) 134 rows.append(Case('driver-fuel', '@driver', (LEAF,), finished(action(0, number(0)), label=b'fuel'), 'fuelProbe')) 135 for initial, count in [(b'', 0), (b'abcd', 0), (b'abcXYZ', 3), (b'abc', 3)]: 136 rows.append(Case(f'exact-buffer-{count}-{initial.hex()}', 'readBodyExact', 137 (number(count), byte_tree(initial), LEAF), finished(result(True, byte_tree(initial[:count]), byte_tree(initial[count:]))), 'exact')) 138 for initial in (b'', b'ab'): 139 replies = [(76, result(True, byte_tree(bytes([c])))) for c in b'abcde'[len(initial):]] 140 rows.append(Case('exact-fragmented-' + initial.hex(), 'readBodyExact', 141 (number(5), byte_tree(initial), script(replies)), finished(result(True, byte_tree(b'abcde')), [recv(1)]*len(replies)), 'exact')) 142 rows.append(Case('more-preserves-reversed-prefix', 'readBodyMore', 143 (number(3), byte_tree(b'BA'), script([(76, result(True, byte_tree(bytes([c])))) for c in b'CDE'])), 144 finished(result(True, byte_tree(b'ABCDE')), [recv(1)]*3), 'more')) 145 # Exercise the exact-length invariant with varying initial buffers and 146 # short reads across every receive-bucket boundary. Replies obey the actual 147 # requested size, not an idealized unlimited recv. Oversupplied initial bytes 148 # must survive unchanged as the Result remainder. 149 for count in (0, 1, 2, 15, 16, 17, 63, 64, 65, 255, 256, 257): 150 data = bytes(i % 256 for i in range(count + 3)) 151 for initial_size in sorted({0, 1, count // 2, count, count + 3}): 152 position = min(initial_size, count) 153 remaining = count - position 154 replies, trace = [], [] 155 while remaining: 156 bucket = next((n for n in (4096, 1024, 256, 64, 16) if remaining >= n), 1) 157 size = min(bucket, 1 + position % 19) 158 replies.append((76, result(True, byte_tree(data[position:position+size])))) 159 trace.append(recv(bucket)) 160 position += size 161 remaining -= size 162 rows.append(Case(f'exact-invariant-{count}-{initial_size}', 'readBodyExact', 163 (number(count), byte_tree(data[:initial_size]), script(replies)), 164 finished(result(True, byte_tree(data[:count]), byte_tree(data[count:initial_size])), trace), 'exact')) 165 opaque = bytes(range(256)) * 32 + b'z' 166 chunks = [opaque[:4096], opaque[4096:8192], opaque[8192:]] 167 rows.append(Case('body-binary-buckets', 'readBodyExact', 168 (number(len(opaque)), LEAF, script([(76, result(True, byte_tree(chunk))) for chunk in chunks])), 169 finished(result(True, byte_tree(opaque)), [recv(4096), recv(4096), recv(1)]), 'exact')) 170 rows.append(Case('body-recv-error', 'readBodyExact', (number(5), LEAF, script([(76, socket_error)])), 171 finished(result(False, number(400), byte_tree(b'recv failed while reading body: broken')), [recv(1)]), 'exact')) 172 for initial in (b'', b'ab'): 173 eof_error = result(False, number(400), byte_tree(b'Unexpected end of request body\n')) 174 rows.append(Case('body-eof-' + initial.hex(), 'readBodyExact', (number(5), byte_tree(initial), script([(76, result(True, LEAF))])), 175 finished(eof_error, [recv(1)]), 'exact', finished(recv(1), [recv(1), recv(1)], label=b'unscripted'))) 176 for name, headers, initial, expected in [ 177 ('absent', [], b'body', result(True, LEAF, byte_tree(b'body'))), 178 ('zero', [(b'content-length', b'0')], b'body', result(True, LEAF, byte_tree(b'body'))), 179 ('buffered', [(b'content-length', b'3')], b'abcXYZ', result(True, byte_tree(b'abc'), byte_tree(b'XYZ'))), 180 ('duplicate', [(b'content-length', b'1')]*2, b'body', BAD_REQUEST), 181 ('coding', [(b'transfer-encoding', b'chunked')], b'body', BAD_REQUEST), 182 ('too-large', [(b'content-length', b'1048577')], b'body', result(False, number(413), byte_tree(b'Request body too large\n'))), 183 ]: 184 rows.append(Case('body-selection-' + name, 'readBody', (field_tree(headers), byte_tree(initial), LEAF), finished(expected), 'body')) 185 for name, limit, chunks, outcome in [ 186 ('complete', 12, [b'abc\r\n\r\nbody'], result(True, byte_tree(b'abc\r\n\r\nbody'))), 187 ('split', 12, [b'abc\r\n\r', b'\nbody'], result(True, byte_tree(b'abc\r\n\r\nbody'))), 188 ('exact-limit', 7, [b'abc\r\n\r\n'], result(True, byte_tree(b'abc\r\n\r\n'))), 189 ('over-limit', 6, [b'abc\r\n\r\n'], result(False, number(431), byte_tree(b'abc\r\n\r\n'))), 190 ('prefetch-counts', 7, [b'abc\r\n\r\nbody'], result(False, number(431), byte_tree(b'abc\r\n\r\nbody'))), 191 ('eof', 12, [b'abc', b''], result(False, number(400), byte_tree(b'abc'))), 192 ]: 193 rows.append(Case('receive-' + name, 'recvUntilMax', (byte_tree(b'\r\n\r\n'), number(limit), script([(76, result(True, byte_tree(c))) for c in chunks])), 194 finished(outcome, [recv(4096)]*len(chunks)), 'receive')) 195 # A separately computed byte-string oracle covers arbitrary delimiters, 196 # overlap, binary bytes and every two-chunk boundary. Whole-buffer limits 197 # precede successful matching, including prefetched suffix bytes. 198 samples = [(b'', b'abc'), (b'\r\n\r\n', b'x\r\r\n\r\nBODY'), 199 (b'aba', b'aababaTAIL'), (b'aaab', b'aaaaabTAIL'), 200 (b'abab', b'aabababTAIL'), (b'aaaa', b'aaaaaTAIL'), 201 (b'\x00\xff', b'\x00x\x00\xfftail'), (b'xyz', b'xyxyxy')] 202 for sample, (delimiter, raw) in enumerate(samples): 203 partitions = [[raw[:cut], raw[cut:]] for cut in range(len(raw)+1)] 204 partitions.append([bytes([byte]) for byte in raw]) 205 for partition, chunks in enumerate(partitions): 206 for limit in sorted({0, 1, len(raw)-1, len(raw)}): 207 accumulated, replies = b'', [] 208 for chunk in chunks + [b'']: 209 replies.append((76, result(True, byte_tree(chunk)))) 210 if not chunk: 211 expected = result(False, number(400), byte_tree(accumulated)) 212 break 213 accumulated += chunk 214 if len(accumulated) > limit: 215 expected = result(False, number(431), byte_tree(accumulated)) 216 break 217 if delimiter in accumulated: 218 expected = result(True, byte_tree(accumulated)) 219 break 220 rows.append(Case(f'delimiter-{sample}-{partition}-{limit}', 'recvUntilMax', 221 (byte_tree(delimiter), number(limit), script(replies)), 222 finished(expected, [recv(4096)]*len(replies)), 'receive')) 223 # The old error binder shadows the err constructor: an empty error string is 224 # Leaf, so applying it to 400 and accumulated bytes produces that raw pair. 225 rows.append(Case('receive-recv-failure', 'recvUntilMax', 226 (byte_tree(b'\r\n\r\n'), number(12), script([(76, result(True, byte_tree(b'abc'))), (76, result(False, LEAF))])), 227 finished(result(False, number(400), byte_tree(b'abc')), [recv(4096)]*2), 'receive', 228 finished(pair(number(400), byte_tree(b'abc')), [recv(4096)]*2))) 229 for send_reply in (ok, socket_error): 230 for close_reply in (ok, socket_error): 231 rows.append(Case(f'respond-{len(rows)}', 'respondAndClose', (byte_tree(b'response'), script([(77, send_reply), (71, close_reply)])), 232 finished(ok if send_reply == ok else socket_error, [send(b'response'), close]), 'respond')) 233 # Both entrypoints must have identical wire behavior; only an IO router adds 234 # its explicit observable router action before the send. 235 requests = [ 236 ('get', b'GET / HTTP/1.1\r\n\r\n', b'GET', b'/', [], b'', None), 237 ('post', b'POST /echo HTTP/1.0\r\nContent-Length: 3\r\nX: yes\r\n\r\nabcSUFFIX', b'POST', b'/echo', [(b'content-length', b'3'), (b'x', b'yes')], b'abc', None), 238 ('head', b'HEAD / HTTP/1.1\r\n\r\n', b'HEAD', b'/', [], b'', None), 239 ('bad-line', b'GET / HTTP/1.1 extra\r\n\r\n', b'', b'', [], b'', (400, b'Bad Request\n', False)), 240 ('bad-header-head', b'HEAD / HTTP/1.1\r\nBad Header: x\r\n\r\n', b'HEAD', b'/', [], b'', (400, b'Bad Request\n', False)), 241 ('bad-method', b'PUT / HTTP/1.1\r\nContent-Length: 3\r\n\r\n', b'PUT', b'/', [], b'', (400, b'Bad Request\n', False)), 242 ('bad-version-head', b'HEAD / HTTP/2\r\n\r\n', b'HEAD', b'/', [], b'', (505, b'HTTP Version Not Supported\n', True)), 243 ('bad-target-head', b'HEAD * HTTP/1.1\r\n\r\n', b'HEAD', b'*', [], b'', (400, b'Bad Request\n', True)), 244 ('duplicate', b'POST / HTTP/1.1\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\n', b'POST', b'/', [], b'', (400, b'Bad Request\n', False)), 245 ('coding-head', b'HEAD / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n', b'HEAD', b'/', [], b'', (400, b'Bad Request\n', True)), 246 ('too-large', b'POST / HTTP/1.1\r\nContent-Length: 1048577\r\n\r\n', b'POST', b'/', [], b'', (413, b'Request body too large\n', False)), 247 ] 248 for flavor in ('pure', 'io'): 249 for name, raw, method, target, headers, body, failure in requests: 250 replies = [(76, result(True, byte_tree(raw)))] 251 trace = [recv(4096)] 252 if failure is None and flavor == 'io': 253 trace.append(action(999, pair(byte_tree(b'GET' if method == b'HEAD' else method), pair(byte_tree(target), pair(field_tree(headers), byte_tree(body)))))) 254 replies.append((999, LEAF)) 255 wire = response(*failure) if failure else routed(b'GET' if method == b'HEAD' else method, target, headers, body, method == b'HEAD') 256 replies += [(77, ok), (71, ok)] 257 old = finished(ok, trace+[send(response(*failure, legacy=True)), close]) if failure and failure[0] == 413 else None 258 rows.append(Case(f'handler-{flavor}-{name}', 'httpHandlerIO' if flavor == 'io' else 'httpHandler', (script(replies),), 259 finished(ok, trace+[send(wire), close]), 'handlerIO' if flavor == 'io' else 'handler', old)) 260 for label, chunks, code, message, old_code in [ 261 ('header-eof', [b'GET / HTTP/1.1\r\n', b''], 400, b'Bad Request\n', 400), 262 ('header-limit', [b'x'*4096]*16+[b'x'], 431, b'Request Header Fields Too Large\n', 400), 263 ]: 264 replies = [(76, result(True, byte_tree(c))) for c in chunks] + [(77, ok), (71, ok)] 265 trace = [recv(4096)]*len(chunks) 266 expected = finished(ok, trace+[send(response(code, message)), close]) 267 previous = None if code == old_code else finished(ok, trace+[send(response(old_code, b'Bad Request\n')), close]) 268 rows.append(Case(f'handler-{flavor}-{label}', 'httpHandlerIO' if flavor == 'io' else 'httpHandler', (script(replies),), expected, 269 'handlerIO' if flavor == 'io' else 'handler', previous)) 270 for flavor in ('pure', 'io'): 271 for outcome in ('fragmented', 'recv-error', 'eof', 'send-error', 'close-error'): 272 headers = [(b'content-length', b'3')] 273 initial = b'POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\na' 274 replies = [(76, result(True, byte_tree(initial)))] 275 trace = [recv(4096)] 276 if outcome in ('recv-error', 'eof'): 277 replies.append((76, socket_error if outcome == 'recv-error' else result(True, LEAF))) 278 trace.append(recv(1)) 279 message = b'recv failed while reading body: broken' if outcome == 'recv-error' else b'Unexpected end of request body\n' 280 wire = response(400, message) 281 else: 282 replies += [(76, result(True, byte_tree(b'b'))), (76, result(True, byte_tree(b'c')))] 283 trace += [recv(1), recv(1)] 284 if flavor == 'io': 285 replies.append((999, LEAF)) 286 trace.append(action(999, pair(byte_tree(b'POST'), pair(byte_tree(b'/'), pair(field_tree(headers), byte_tree(b'abc')))))) 287 wire = routed(b'POST', b'/', headers, b'abc') 288 replies += [(77, socket_error if outcome == 'send-error' else ok), (71, socket_error if outcome == 'close-error' else ok)] 289 old = finished(recv(1), trace+[recv(1)], replies[-2:], b'unexpected') if outcome == 'eof' else None 290 rows.append(Case(f'handler-{flavor}-body-{outcome}', 'httpHandlerIO' if flavor == 'io' else 'httpHandler', (script(replies),), 291 finished(socket_error if outcome == 'send-error' else ok, trace+[send(wire), close]), 292 'handlerIO' if flavor == 'io' else 'handler', old)) 293 abort = action(2, byte_tree(b'router aborted')) 294 rows.append(Case('handler-terminal-abort-limit', 'httpHandlerIO', (script([(76, result(True, byte_tree(b'GET / HTTP/1.1\r\n\r\n')))]),), 295 finished(abort, [recv(4096), abort], label=b'abort'), 'handlerAbort')) 296 rows.append(Case('status-413', 'statusPhrase', (number(413),), byte_tree(b'Content Too Large'), previous=byte_tree(b'Internal Server Error'))) 297 return rows 298 299 300 def invoke(runner, pin, case, side, driver): 301 args = [] 302 for index, value in enumerate(case.arguments): 303 path = runner.root / (case.label + f'-{index}.tree') 304 path.write_bytes(value) 305 args += ['--arg-tree-file', path] 306 target, name = pin, case.export 307 if case.driver: 308 if not driver: 309 raise ValueError('effect cases require --driver') 310 target, name = driver, case.driver 311 if case.export != '@driver': 312 args = ['--arg-export', pin + ':' + case.export, *args] 313 return parse_tree_output(runner.run(case.label + '-' + side, ['run', '--module', target, '--name', name, 314 '--store', runner.store, *args, '--format', 'ternary'])) 315 316 317 def check_case(case, actual, before=None, reference_current=False): 318 expect(actual, case.expected, case.label) 319 if before is not None: 320 expected = case.expected if reference_current or case.previous is None else case.previous 321 expect(before, expected, case.label + ' previous') 322 if reference_current or case.previous is None: 323 expect(actual, before, case.label + ' differential') 324 325 326 def main(): 327 parser = argparse.ArgumentParser(description=__doc__) 328 parser.add_argument('tricu', type=Path) 329 parser.add_argument('--subject', type=Path, required=True) 330 parser.add_argument('--reference', type=Path) 331 parser.add_argument('--reference-current', action='store_true', 332 help='reference already includes the four pipeline corrections; compare all results unchanged') 333 parser.add_argument('--driver', type=Path) 334 parser.add_argument('--directory', type=Path, required=True) 335 parser.add_argument('--case', action='append') 336 args = parser.parse_args() 337 if args.reference_current and not args.reference: 338 parser.error("--reference-current requires --reference") 339 cases = pure_cases() 340 if args.driver: 341 cases += effect_cases() 342 if args.case: 343 unknown = set(args.case) - {c.label for c in cases} 344 if unknown: 345 parser.error('unknown cases: ' + ', '.join(sorted(unknown))) 346 cases = [c for c in cases if c.label in args.case] 347 root = args.directory.resolve() 348 root.mkdir(parents=True, exist_ok=False) 349 runner = Runner(args.tricu, root) 350 report = dict(complete=False, runtime_sha256=sha(runner.exe.read_bytes()), 351 harness_sha256=sha(Path(__file__).read_bytes()), 352 reference_behavior='current' if args.reference_current else 'pre-pipeline-corrections', cases=[]) 353 def selected(path, name): 354 local = root / (name + '.arboricx') 355 local.write_bytes(path.read_bytes()) 356 report[name + '_sha256'] = sha(local.read_bytes()) 357 return runner.imported(local, name + '-import') 358 try: 359 subject = selected(args.subject, 'subject') 360 reference = selected(args.reference, 'reference') if args.reference else None 361 driver = selected(args.driver, 'driver') if args.driver else None 362 for case in cases: 363 actual = invoke(runner, subject, case, 'subject', driver) 364 before = invoke(runner, reference, case, 'reference', driver) if reference else None 365 check_case(case, actual, before, args.reference_current) 366 report['cases'].append(dict(label=case.label, result_sha256=sha(actual), intentional_change=bool(case.previous is not None and not args.reference_current))) 367 wrong = root / 'wrong-subject.arboricx' 368 merge_bundles(wrong, [(root / 'subject.arboricx', [('validVersion?', 'validMethod?')])], prune_unreachable=True) 369 mutant = runner.imported(wrong, 'mutant-import') 370 control = Case('selection-control', 'validMethod?', (byte_tree(b'GET'),), b'\1\0') 371 if invoke(runner, mutant, control, 'mutant', driver) == control.expected: 372 raise ValueError('wrong selected subject unexpectedly passed') 373 report.update(complete=True, wrong_subject_rejected=True) 374 except Exception as error: 375 report['failure'] = str(error) 376 raise 377 finally: 378 report.update(seconds=time.monotonic()-runner.started, invocations=runner.calls) 379 (root / 'report.json').write_text(json.dumps(report, indent=2)+'\n') 380 print(f'PASS: {len(cases)} pipeline cases; {report["seconds"]:.2f}s') 381 382 383 if __name__ == '__main__': 384 main()