tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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()