tricu

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

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