http_headers.py (24553B)
1 #!/usr/bin/env python3 2 """Opt-in artifact-only HTTP header regression; no compilation or socket IO. 3 4 --subject must be a full, explicitly compiled HTTP module (unqualified exports). 5 The immutable reference predates the rewrite; it is never rebuilt from subject. 6 Default coverage checks strict CRLF/header syntax, with explicit reasons and old/ 7 new expectations for semantic changes. --legacy-behavior reproduces the original 8 readability-only corpus, including retired malformed-numeral characterizations. 9 Current lowering accepts canonical bytes; out-of-contract legacy cases are listed 10 explicitly in reports, not executed against a candidate or silently re-oracled. 11 Preparation/provenance stays with native_library_migration.py; this runner proves 12 behavior of the selected artifact, not its correspondence to working source. 13 """ 14 import argparse 15 from dataclasses import dataclass, replace 16 import hashlib 17 import json 18 from pathlib import Path 19 import re 20 import time 21 22 from bundle_dag import merge_bundles, read 23 from fixture_sources import snapshot_bytes 24 from process_rss import run_guarded 25 from stable_bundle import byte_tree 26 27 ROOT = Path(__file__).resolve().parent.parent 28 REFERENCE_SHA256 = 'dc7b9f12056ceb08ca0487ad19886b0c0c8643802363ff1dd7a91e46af73a1d1' 29 REFERENCE_METADATA_SHA256 = '7cdc0e247e7c3a13bcc7634735eee270a315f2c86939832a1d103aa445faeb9b' 30 EXPORTS = ('toLowerAsciiByte', 'httpHeaderLineReply', 'parseHeaders', 31 'readLineBytes', 'httpLineReply') 32 LEAF, TRUE = b'\0', b'\1\0' 33 34 35 def sha(payload): 36 return hashlib.sha256(payload).hexdigest() 37 38 39 def pair(left, right): 40 return b'\2' + left + right 41 42 43 def number(value): 44 if value < 0: 45 raise ValueError('negative tree numeral') 46 bits = [] 47 while value: 48 bits.append(TRUE if value & 1 else LEAF) 49 value >>= 1 50 return tree_list(bits) 51 52 53 def tree_list(values, tail=LEAF): 54 return b''.join(b'\2' + value for value in values) + tail 55 56 57 def result(ok, payload, rest=LEAF): 58 return pair(TRUE if ok else LEAF, pair(payload, rest)) 59 60 61 def reply(ok, payload, rest=LEAF, consumed=True, committed=False): 62 return result(ok, payload, pair(rest, pair(TRUE if consumed else LEAF, 63 TRUE if committed else LEAF))) 64 65 66 def diagnostic(code, expected, found=LEAF, context=b'header-line'): 67 return pair(number(code), pair(byte_tree(expected), pair(found, 68 pair(LEAF, tree_list([byte_tree(context)]))))) 69 70 71 def headers(values, rest=b''): 72 return result(True, tree_list([pair(byte_tree(k), byte_tree(v)) for k, v in values]), 73 byte_tree(rest)) 74 75 76 BAD_REQUEST = result(False, number(400), byte_tree(b'Bad Request\n')) 77 78 79 @dataclass(frozen=True) 80 class Case: 81 label: str 82 export: str 83 input: bytes 84 expected: bytes | None 85 category: str = 'independent' 86 change: str | None = None 87 reference_expected: bytes | None = None 88 89 90 def cases(): 91 rows = [Case(f'ascii-{n:03}', 'toLowerAsciiByte', number(n), 92 number(n + 32 if 65 <= n <= 90 else n)) for n in range(256)] 93 # Bytes and expected fields are independently authored, not parsed by a 94 # second implementation of the subject algorithm. "characterization" labels 95 # permissiveness that must be reviewed explicitly before a policy change. 96 examples = [ 97 ('one', b'Host: localhost\r\n\r\nbody', [(b'host', b'localhost')], b'body'), 98 ('order', b'Z: last\r\nA: first\r\n\r\nbody', [(b'z', b'last'), (b'a', b'first')], b'body'), 99 ('duplicates', b'Host: one\r\nHOST: two\r\n\r\nx', [(b'host', b'one'), (b'host', b'two')], b'x'), 100 ('empty-value', b'X:\r\n\r\nx', [(b'x', b'')], b'x'), 101 ('colons', b'X: a:b:c\r\n\r\nx', [(b'x', b'a:b:c')], b'x'), 102 ('leading-spaces', b'X: value\r\n\r\n', [(b'x', b'value')], b''), 103 ('blank', b'\r\nbody', [], b'body'), 104 ('binary-remainder', b'X: y\r\n\r\n\x00\xff\r\nX: z', [(b'x', b'y')], b'\x00\xff\r\nX: z'), 105 ('many', b'X: y\r\n' * 32 + b'\r\nbody', [(b'x', b'y')] * 32, b'body'), 106 ('long-value', b'X: ' + b'a' * 512 + b'\r\n\r\nend', [(b'x', b'a' * 512)], b'end'), 107 ] 108 permissive = [ 109 ('empty-input', b'', [], b''), 110 ('eof-header', b'Host: localhost', [(b'host', b'localhost')], b''), 111 ('eof-after-line', b'Host: localhost\r\n', [(b'host', b'localhost')], b''), 112 ('bare-lf', b'X: y\n\nbody', [(b'x', b'y')], b'body'), 113 ('embedded-cr', b'X\r-Name: a\rb\r\n\r\nx', [(b'x-name', b'ab')], b'x'), 114 ('cr-only', b'\r', [], b''), 115 ('tab-value', b'X: \t value\r\n\r\n', [(b'x', b'\t value')], b''), 116 ('trailing-whitespace', b'X: value \t\r\n\r\n', [(b'x', b'value \t')], b''), 117 ('space-name', b'Bad Name: value\r\n\r\n', [(b'bad name', b'value')], b''), 118 ('space-before-colon', b'Host : value\r\n\r\n', [(b'host ', b'value')], b''), 119 ('leading-tab-name', b'\tHost: value\r\n\r\n', [(b'\thost', b'value')], b''), 120 ('nul-name', b'X\x00: y\r\n\r\n', [(b'x\x00', b'y')], b''), 121 ('high-byte-name', b'X\xff: y\r\n\r\n', [(b'x\xff', b'y')], b''), 122 ('nul-value', b'X: a\x00b\r\n\r\n', [(b'x', b'a\x00b')], b''), 123 ] 124 for category, group in [('independent', examples), ('characterization', permissive)]: 125 rows += [Case(label, 'parseHeaders', byte_tree(raw), headers(fields, rest), category) 126 for label, raw, fields, rest in group] 127 rows += [Case(label, 'parseHeaders', byte_tree(raw), BAD_REQUEST) for label, raw in [ 128 ('missing-colon', b'Host\r\n\r\n'), ('missing-colon-eof', b'Host'), 129 ('empty-name', b': bad\r\n\r\n'), ('whitespace-line', b' \r\n\r\n'), 130 ('second-invalid', b'X: y\r\nBad\r\n\r\nbody'), 131 ('folded-line', b'X: y\r\n continuation\r\n\r\n'), 132 ]] 133 for label, raw, line, rest in [ 134 ('line-crlf', b'hi\r\nx', b'hi', b'x'), 135 ('line-blank', b'\r\nx', b'', b'x'), 136 ('line-lf', b'hi\nx', b'hi', b'x'), 137 ('line-eof', b'hi', b'hi', b''), 138 ('line-empty', b'', b'', b''), 139 ('line-embedded-cr', b'h\ri\r\nx', b'hi', b'x'), 140 ]: 141 rows.append(Case(label, 'readLineBytes', byte_tree(raw), 142 pair(byte_tree(line), byte_tree(rest)), 'characterization')) 143 rows.append(Case(label + '-reply', 'httpLineReply', byte_tree(raw), 144 reply(True, byte_tree(line), byte_tree(rest), bool(raw)), 'characterization')) 145 rows += [ 146 Case('reply-valid', 'httpHeaderLineReply', byte_tree(b'Host: a:b'), 147 reply(True, pair(byte_tree(b'host'), byte_tree(b'a:b')))), 148 Case('reply-empty-value', 'httpHeaderLineReply', byte_tree(b'X:'), 149 reply(True, pair(byte_tree(b'x'), LEAF))), 150 Case('reply-missing-colon', 'httpHeaderLineReply', byte_tree(b'Host'), 151 reply(False, diagnostic(1, b'colon'))), 152 Case('reply-empty-name', 'httpHeaderLineReply', byte_tree(b': bad'), 153 reply(False, diagnostic(2, b'header-name', number(58)), byte_tree(b': bad'), False)), 154 Case('reply-empty-input', 'httpHeaderLineReply', LEAF, 155 reply(False, diagnostic(1, b'header-name'), consumed=False)), 156 ] 157 # Noncanonical numerals and malformed list/tree shapes have deliberately 158 # no independent policy oracle yet. Preserve the old result for discussion. 159 malformed = [TRUE, b'\1\1\0', pair(pair(LEAF, LEAF), LEAF), 160 pair(LEAF, TRUE)] 161 lower_inputs = malformed + [number(n)[:-1] + b'\2\0\0' for n in (0, 65, 90, 97)] 162 lower_inputs += [number(256), number(65535)] 163 rows += [Case(f'lower-shape-{i}', 'toLowerAsciiByte', value, None, 'differential-only') 164 for i, value in enumerate(lower_inputs)] 165 # Exercise the old lowercaser's seven-cell path with malformed *bits*, not 166 # just short malformed numerals. It can preserve a malformed low bit while 167 # changing the high bits: treating every noncanonical input as identity 168 # would silently change behavior. 169 for n in (64, 65, 90, 91): 170 bits = [TRUE if n & (1 << bit) else LEAF for bit in range(7)] 171 for position in range(7): 172 for shape, bad in enumerate((b'\1\1\0', pair(LEAF, LEAF), pair(TRUE, LEAF))): 173 changed = bits.copy() 174 changed[position] = bad 175 rows.append(Case(f'lower-bit-{n}-{position}-{shape}', 'toLowerAsciiByte', 176 tree_list(changed), None, 'differential-only')) 177 for tail in (TRUE, pair(LEAF, LEAF)): 178 rows.append(Case(f'lower-tail-{n}-{sha(tail)[:6]}', 'toLowerAsciiByte', 179 tree_list(bits, tail), None, 'differential-only')) 180 stream_inputs = [TRUE, b'\1\1\0', b'\1\2\0\0', byte_tree(b'X: y')[:-1] + TRUE, 181 pair(TRUE, byte_tree(b': y\r\n\r\nbody')), 182 byte_tree(b'\r\n')[:-1] + TRUE, 183 byte_tree(b'X: y\r\n\r\n')[:-1] + TRUE] 184 rows += [Case(f'headers-shape-{i}', 'parseHeaders', value, None, 'differential-only') 185 for i, value in enumerate(stream_inputs)] 186 return rows 187 188 189 190 def retired_case(case): 191 """Only the approved canonical-byte boundary; keep structural/suffix checks.""" 192 return (case.label.startswith(('lower-shape-', 'lower-bit-', 'lower-tail-')) 193 or case.label == 'headers-shape-4') 194 195 196 def syntax_cases(): 197 """RFC 9110 §§5.1,5.5,5.6.2/3; RFC 9112 §§2.2,5/5.1/5.2. 198 199 Independent grammar membership and complete expected trees, not a second 200 parser implementation. Old observations stay in cases() and the frozen bundle. 201 """ 202 corrections = {} 203 def correct(labels, expected, reason): 204 for label in labels.split(): 205 corrections[label] = (expected, reason) 206 correct('empty-input eof-header eof-after-line cr-only', BAD_REQUEST, 207 'require CRLF lines and a terminating blank line') 208 correct('bare-lf embedded-cr', BAD_REQUEST, 'reject bare LF/CR instead of deleting bytes') 209 correct('space-name space-before-colon leading-tab-name nul-name high-byte-name', 210 BAD_REQUEST, 'field names must be nonempty HTTP tokens') 211 correct('nul-value', BAD_REQUEST, 'reject field-value controls except HTAB') 212 for label in ('tab-value', 'trailing-whitespace'): 213 correct(label, headers([(b'x', b'value')]), 'trim SP/HTAB at both field-value edges') 214 correct('line-crlf', result(True, byte_tree(b'hi'), byte_tree(b'x')), 215 'line helper returns Result, not an ambiguous pair') 216 correct('line-blank', result(True, LEAF, byte_tree(b'x')), 217 'line helper returns Result, not an ambiguous pair') 218 correct('line-lf line-eof line-empty line-embedded-cr', BAD_REQUEST, 219 'line helper propagates invalid/truncated lines as 400') 220 for label, code, found, rest, consumed in [ 221 ('line-lf-reply', 2, number(10), b'\nx', True), 222 ('line-eof-reply', 1, LEAF, b'', True), 223 ('line-empty-reply', 1, LEAF, b'', False), 224 ('line-embedded-cr-reply', 2, number(13), b'\ri\r\nx', True), 225 ]: 226 correct(label, reply(False, diagnostic(code, b'CRLF', found, b'http-line'), 227 byte_tree(rest), consumed), 'line failure reports suffix and consumption') 228 correct('headers-shape-0 headers-shape-1 headers-shape-2 headers-shape-3', BAD_REQUEST, 229 'malformed/truncated list cannot stand in for a terminating blank line') 230 rows = [] 231 for case in cases(): 232 if retired_case(case): 233 continue 234 if case.label in corrections: 235 expected, reason = corrections[case.label] 236 case = replace(case, expected=expected, reference_expected=case.expected, 237 category='corrected', change=reason) 238 rows.append(case) 239 240 token = b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 241 for n in range(256): 242 byte = bytes([n]) 243 name_ok = n in token 244 value_ok = n == 9 or 32 <= n <= 126 or 128 <= n <= 255 245 rows.append(Case(f'name-byte-{n:03}', 'parseHeaders', byte_tree(byte + b': y\r\n\r\nbody'), 246 headers([(byte.lower(), b'y')], b'body') if name_ok else BAD_REQUEST, 247 change=None if name_ok else 'reject invalid token-name bytes')) 248 rows.append(Case(f'value-byte-{n:03}', 'parseHeaders', byte_tree(b'X: a' + byte + b'b\r\n\r\nbody'), 249 headers([(b'x', b'a' + byte + b'b')], b'body') if value_ok else BAD_REQUEST, 250 change=None if value_ok else 'reject invalid field-value bytes')) 251 for label, raw, fields, rest, change in [ 252 ('all-token-punctuation', token + b': ok\r\n\r\n', [(token.lower(), b'ok')], b'', None), 253 ('ows-only', b'X:\t \t\r\n\r\n', [(b'x', b'')], b'', 'trim SP/HTAB at both edges'), 254 ('ows-and-interior', b'X:\t a \t b\t \r\n\r\n', [(b'x', b'a \t b')], b'', 'trim edges, preserve interior'), 255 ('obs-text', b'X: \x80\xff\r\n\r\n', [(b'x', b'\x80\xff')], b'', None), 256 ('opaque-body', b'X: y\r\n\r\n\r\n\x00\xffBad Name: x\n', [(b'x', b'y')], 257 b'\r\n\x00\xffBad Name: x\n', None), 258 ]: 259 rows.append(Case(label, 'parseHeaders', byte_tree(raw), headers(fields, rest), change=change)) 260 block = b'A: b\r\nB:\t c\r\n\r\n' 261 for end in range(len(block)): 262 rows.append(Case(f'truncated-block-{end:02}', 'parseHeaders', byte_tree(block[:end]), BAD_REQUEST, 263 change='every incomplete header-block prefix must fail')) 264 for label, raw in [ 265 ('bare-cr-name', b'X\rY: z\r\n\r\n'), ('bare-cr-value', b'X: a\rb\r\n\r\n'), 266 ('extra-cr', b'X: y\r\r\n\r\n'), ('lf-blank', b'X: y\r\n\nbody'), 267 ('cr-blank', b'X: y\r\n\r'), ('tab-fold', b'X: y\r\n\tcontinued\r\n\r\n'), 268 ]: 269 rows.append(Case(label, 'parseHeaders', byte_tree(raw), BAD_REQUEST, 270 change='reject malformed line endings, truncation and folding')) 271 for label, raw, line, rest in [ 272 ('request-get', b'GET / HTTP/1.1\r\nx', (b'GET', b'/', b'HTTP/1.1'), b'x'), 273 ('request-post', b'POST /foo HTTP/1.1\r\n', (b'POST', b'/foo', b'HTTP/1.1'), b''), 274 ('request-empty-fields-unchanged', b' \r\nx', (b'', b'', b''), b'x'), 275 ]: 276 fields = pair(byte_tree(line[0]), pair(byte_tree(line[1]), byte_tree(line[2]))) 277 rows.append(Case(label, 'parseRequestLine', byte_tree(raw), result(True, fields, byte_tree(rest)))) 278 for label, raw in [ 279 ('request-lf', b'GET / HTTP/1.1\n'), ('request-eof', b'GET / HTTP/1.1'), 280 ('request-cr-eof', b'GET / HTTP/1.1\r'), ('request-cr-method', b'GE\rT / HTTP/1.1\r\n'), 281 ('request-cr-target', b'GET /a\rb HTTP/1.1\r\n'), 282 ('request-cr-version', b'GET / HTTP/1.\r1\r\n'), ('request-empty', b''), 283 ('request-blank', b'\r\n'), ('request-extra-field', b'GET / HTTP/1.1 extra\r\n'), 284 ('request-no-version', b'GET /\r\n'), 285 ]: 286 rows.append(Case(label, 'parseRequestLine', byte_tree(raw), BAD_REQUEST)) 287 for label, raw, expected, change in [ 288 ('reply-space-name', b'Bad Name: v', 289 reply(False, diagnostic(2, b'colon', number(32)), byte_tree(b' Name: v')), 290 'reject spaces within token names'), 291 ('reply-leading-tab', b'\tX: v', 292 reply(False, diagnostic(2, b'header-name', number(9)), byte_tree(b'\tX: v'), False), 293 'reject leading whitespace/folding'), 294 ('reply-nul-value', b'X: a\x00b', 295 reply(False, diagnostic(2, b'header-value', number(0)), byte_tree(b'\x00b')), 296 'reject invalid value suffix instead of accepting its prefix'), 297 ('reply-del-value', b'X: a\x7fb', 298 reply(False, diagnostic(2, b'header-value', number(127)), byte_tree(b'\x7fb')), 299 'reject invalid value suffix instead of accepting its prefix'), 300 ('reply-ows', b'X:\t a b\t ', reply(True, pair(byte_tree(b'x'), byte_tree(b'a b'))), 301 'trim edges, preserve interior'), 302 ]: 303 rows.append(Case(label, 'httpHeaderLineReply', byte_tree(raw), expected, change=change)) 304 for label, raw, found, rest, consumed in [ 305 ('line-bare-cr-reply', b'hi\r', 13, b'\r', True), 306 ('line-initial-cr-reply', b'\rx', 13, b'\rx', False), 307 ('line-initial-lf-reply', b'\nx', 10, b'\nx', False), 308 ]: 309 rows.append(Case(label, 'httpLineReply', byte_tree(raw), 310 reply(False, diagnostic(2, b'CRLF', number(found), b'http-line'), byte_tree(rest), consumed), 311 change='reject bare CR/LF; keep offending suffix and prior consumption')) 312 return rows 313 314 def parse_tree_output(text): 315 text = text.strip() 316 if not re.fullmatch('[012]+', text): 317 raise ValueError('expected only a complete ternary result') 318 tree = bytes(int(char) for char in text) 319 slots = 1 320 for tag in tree: 321 if slots == 0: 322 raise ValueError('trailing ternary tree') 323 slots += tag - 1 324 if slots: 325 raise ValueError('truncated ternary tree') 326 return tree 327 328 329 def expect(actual, expected, label): 330 if actual != expected: 331 raise ValueError(f'{label}: complete result differs (actual {sha(actual)}, expected {sha(expected)})') 332 333 334 def check_case(case, before, after): 335 if (case.change or before is None) and case.expected is None: 336 raise ValueError('changed/new behavior requires an independent oracle: ' + case.label) 337 if case.expected is not None: 338 expect(after, case.expected, case.label + ' subject oracle') 339 if before is not None: 340 old_expected = case.reference_expected if case.change else case.expected 341 if old_expected is not None: 342 expect(before, old_expected, case.label + ' reference oracle') 343 if not case.change: 344 expect(after, before, case.label + ' differential') 345 346 347 def reference(fixtures): 348 bundle = fixtures / 'http-headers-reference.arboricx' 349 encoded = (fixtures / 'http-headers-reference.json').read_bytes() 350 if sha(encoded) != REFERENCE_METADATA_SHA256: 351 raise ValueError('immutable HTTP reference metadata mismatch') 352 metadata = json.loads(encoded) 353 if sha(bundle.read_bytes()) != REFERENCE_SHA256 or metadata['bundle_sha256'] != REFERENCE_SHA256: 354 raise ValueError('immutable HTTP reference digest mismatch') 355 snapshot_bytes(fixtures, metadata['source_digest']) 356 if set(read(bundle)[3]) != set(EXPORTS) or metadata['exports'] != list(EXPORTS): 357 raise ValueError('HTTP reference export mismatch') 358 return bundle, metadata 359 360 361 class Runner: 362 def __init__(self, executable, directory): 363 self.exe, self.root = executable.resolve(), directory 364 self.store = directory / 'store' 365 self.started = time.monotonic() 366 self.calls = [] 367 368 def run(self, label, arguments): 369 remaining = 600 - (time.monotonic() - self.started) 370 if remaining <= 0: 371 raise RuntimeError('HTTP regression exceeded ten-minute whole-run guard') 372 command = [str(self.exe), 'arboricx', *map(str, arguments), 373 '+RTS', '-N1', '-M512m', '-RTS'] 374 started = time.monotonic() 375 try: 376 output, peak = run_guarded(command, memory_mib=1024, seconds=min(30, remaining)) 377 except Exception as error: 378 (self.root / (label + '.log')).write_text(str(getattr(error, 'output', error))) 379 raise 380 self.calls.append(dict(label=label, seconds=time.monotonic()-started, peak_kib=peak)) 381 return output 382 383 def imported(self, bundle, label): 384 text = self.run(label, ['import', '--file', bundle, '--store', self.store, '--indexed']) 385 match = re.search(r'Created module alias .* -> ([0-9a-f]{64})', text) 386 if not match: 387 raise ValueError('missing imported module identity') 388 return match[1] 389 390 def invoke(self, pin, case, side): 391 source = self.root / (case.label + '.input.tree') 392 source.write_bytes(case.input) 393 value = parse_tree_output(self.run(case.label + '-' + side, 394 ['run', '--module', pin, '--name', case.export, '--store', self.store, 395 '--arg-tree-file', source, '--format', 'ternary'])) 396 (self.root / (case.label + '-' + side + '.tree')).write_bytes(value) 397 return value 398 399 400 def main(): 401 parser = argparse.ArgumentParser(description=__doc__) 402 parser.add_argument('tricu', type=Path) 403 parser.add_argument('--subject', type=Path, required=True) 404 parser.add_argument('--directory', type=Path, required=True, help='must not exist') 405 parser.add_argument('--case', action='append', help='repeatable exact case label; default all') 406 parser.add_argument('--legacy-behavior', action='store_true', help='reproduce pre-correction readability corpus') 407 args = parser.parse_args() 408 selected = cases() if args.legacy_behavior else syntax_cases() 409 if args.case: 410 unknown = set(args.case) - {case.label for case in selected} 411 if unknown: 412 parser.error('unknown cases: ' + ', '.join(sorted(unknown))) 413 selected = [case for case in selected if case.label in args.case] 414 root = args.directory.resolve() 415 root.mkdir(parents=True, exist_ok=False) 416 runner = Runner(args.tricu, root) 417 report = dict(complete=False, scope='selected artifact behavior; not source provenance or HTTP conformance', 418 runtime_sha256=sha(runner.exe.read_bytes()), 419 runner_sha256=sha(Path(__file__).read_bytes()), cases=[], 420 retired_out_of_contract_cases=[] if args.legacy_behavior else 421 [case.label for case in cases() if retired_case(case)]) 422 try: 423 baseline, metadata = reference(ROOT / 'test/fixtures') 424 report['reference'] = metadata 425 # Copy first: all reported identities refer to the exact selected bytes, 426 # not a path that could be republished midway through the run. 427 subject = root / 'subject-original.arboricx' 428 subject.write_bytes(args.subject.read_bytes()) 429 report['subject_sha256'] = sha(subject.read_bytes()) 430 workers = root / 'subject.arboricx' 431 exports = sorted(set(EXPORTS) | {case.export for case in selected}) 432 merge_bundles(workers, [(subject, [(name, name) for name in exports])], prune_unreachable=True) 433 old = runner.imported(baseline, 'reference-import') 434 new = runner.imported(workers, 'subject-import') 435 report.update(reference_manifest=old, subject_manifest=new, 436 selected_bundle_sha256=sha(workers.read_bytes())) 437 print('HTTP subject SHA256:', report['subject_sha256'], flush=True) 438 for case in selected: 439 before = runner.invoke(old, case, 'reference') if case.export in EXPORTS else None 440 after = runner.invoke(new, case, 'subject') 441 check_case(case, before, after) 442 report['cases'].append(dict(label=case.label, export=case.export, category=case.category, 443 input_sha256=sha(case.input), result_sha256=sha(after), 444 reference_result_sha256=sha(before) if before is not None else None, 445 change=case.change, differs_from_reference=(before != after) if before is not None else None, 446 independent_oracle=case.expected is not None)) 447 # A changed selected subject must not silently resolve to the reference. 448 # Substitute a different real export; no source/compiler preparation. 449 mutant = root / 'wrong-subject.arboricx' 450 merge_bundles(mutant, [(subject, [(('readLineBytes' if name == 'parseHeaders' else name), name) 451 for name in EXPORTS])], prune_unreachable=True) 452 wrong = runner.imported(mutant, 'wrong-subject-import') 453 control = Case('selection-control', 'parseHeaders', byte_tree(b'X: y\r\n\r\nbody'), 454 headers([(b'x', b'y')], b'body')) 455 actual = runner.invoke(wrong, control, 'mutant') 456 try: 457 expect(actual, control.expected, 'wrong selected subject') 458 except ValueError: 459 report['wrong_subject_rejected'] = True 460 else: 461 raise ValueError('wrong selected subject unexpectedly passed') 462 report['complete'] = True 463 except Exception as error: 464 report['failure'] = str(error) 465 raise 466 finally: 467 report.update(seconds=time.monotonic()-runner.started, invocations=runner.calls) 468 (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') 469 print(f'PASS: {len(selected)} header cases + wrong-subject control; {report["seconds"]:.2f}s') 470 471 472 if __name__ == '__main__': 473 main()