tricu

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

registry_server.py (23114B)


      1 #!/usr/bin/env python3
      2 """Opt-in artifact-only registry tests: independent complete values and IO traces.
      3 
      4 No compilation, filesystem effects or sockets. --baseline checks historical
      5 expectations, including unsafe write paths; never use them as candidate oracles.
      6 """
      7 import argparse
      8 from dataclasses import dataclass, replace
      9 import hashlib
     10 import json
     11 from pathlib import Path
     12 import time
     13 
     14 from bundle_dag import merge_bundles, read
     15 from http_headers import Runner, LEAF, TRUE, byte_tree as bs, number, pair, tree_list, result, sha, expect
     16 from http_pipeline import Case, action, script, finished, invoke, check_case
     17 
     18 ROOT = b'/registry'
     19 HASH = b'0123456789abcdef' * 4
     20 DIAGNOSTIC = b'expected 64 lowercase hexadecimal hash characters'
     21 PREFIX = b'/_arboricx/bundle/hash/'
     22 LANDING = b"<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width, initial-scale=1'><title>Arboricx Server</title></head><body><h1>Arboricx Server</h1><p>Bundle registry</p><p><a href='https://git.eversole.co/James/tricu'>Made with Love (and trees, lots of trees)</a></p></body></html>"
     23 OK = result(True, LEAF)
     24 FAIL = result(False, bs(b'denied'), number(9))
     25 
     26 
     27 @dataclass(frozen=True)
     28 class RegistryCase(Case):
     29     previous_arguments: tuple | None = None
     30     previous_export: str | None = None
     31     change: str = ''
     32 
     33 
     34 def wire(code, body, content_type=b'text/plain; charset=utf-8'):
     35     phrase = {200: b'OK', 201: b'Created', 400: b'Bad Request', 404: b'Not Found'}[code]
     36     return (b'HTTP/1.1 ' + str(code).encode() + b' ' + phrase + b'\r\nContent-Type: ' + content_type +
     37             b'\r\nContent-Length: ' + str(len(body)).encode() + b'\r\nConnection: close\r\n\r\n' + body)
     38 
     39 
     40 def path(hash):
     41     return ROOT + b'/objects/' + hash[:3] + b'/' + hash + b'.arboricx'
     42 
     43 
     44 def effect(label, export, arguments, replies, value, trace, *, previous=None, previous_arguments=None,
     45            previous_export=None, change=''):
     46     return RegistryCase(label, export, (tree_list(arguments), script(replies)), finished(value, trace),
     47                         'call', previous, previous_arguments, previous_export, change)
     48 
     49 
     50 def pure_cases():
     51     rows = []
     52     for byte in range(256):
     53         rows.append(RegistryCase(f'hex-byte-{byte}', 'bundleHexByte?', (number(byte),), TRUE if byte in b'0123456789abcdef' else LEAF))
     54     malformed = [TRUE, b'\1\1\0', pair(LEAF, LEAF), pair(TRUE, TRUE), number(256), number(65535)]
     55     for byte in b'0123456789abcdef':
     56         malformed += [number(byte)[:-1] + TRUE, number(byte)[:-1] + pair(LEAF, LEAF)]
     57     for i, value in enumerate(malformed):
     58         rows.append(RegistryCase(f'hex-malformed-{i}', 'bundleHexByte?', (value,), LEAF))
     59         rows.append(RegistryCase(f'hash-malformed-byte-{i}', 'bundleHashValid?',
     60                                  (pair(value, bs(HASH[1:])),), LEAF))
     61     for byte in b'0123456789abcdef':
     62         rows.append(RegistryCase(f'hash-valid-{byte}', 'bundleHashValid?', (bs(bytes([byte])*64),), TRUE))
     63     for count in (0, 1, 2, 3, 31, 63, 64, 65, 66, 256, 65536):
     64         value = bs(b'a'*count)
     65         rows.append(RegistryCase(f'hash-length-{count}', 'bundleHashValid?', (value,), TRUE if count == 64 else LEAF))
     66     for position in range(64):
     67         rows.append(RegistryCase(f'hash-invalid-at-{position}', 'bundleHashValid?', (bs(HASH[:position]+b'G'+HASH[position+1:]),), LEAF))
     68     for i, value in enumerate([TRUE, bs(HASH)[:-1]+TRUE, bs(HASH[:63])[:-1]+TRUE, pair(TRUE, LEAF)]):
     69         rows.append(RegistryCase(f'hash-spine-{i}', 'bundleHashValid?', (value,), LEAF))
     70     for i, value in enumerate([bs(HASH), bs(b'../secret'), TRUE, bs(HASH+b'/../secret')]):
     71         rows.append(RegistryCase(f'contract-{i}', 'bundleHashC', (value, number(7)),
     72             result(i == 0, value if i == 0 else bs(DIAGNOSTIC), number(7))))
     73     for i, value in enumerate([b'', b'a', b'ab', b'abc', b'abcd', HASH]):
     74         rows.append(RegistryCase(f'shard-{i}', 'hashShard', (bs(value),), bs(value[:3])))
     75         rows.append(RegistryCase(f'path-{i}', 'bundleObjectPath', (bs(ROOT), bs(value)), bs(path(value))))
     76     for i, prefix in enumerate([b'', b'a', b'aba', PREFIX]):
     77         for j, value in enumerate([b'', b'a', b'ab', b'aba', b'ababa', PREFIX, PREFIX+HASH, b'/unmatched']):
     78             answer = b'\1' + bs(value[len(prefix):]) if value.startswith(prefix) else LEAF
     79             rows.append(RegistryCase(f'prefix-{i}-{j}', 'stripPrefix', (bs(prefix), bs(value)), answer))
     80     # Exact suffix preservation/rejection replaces malformed legacy Maybe results.
     81     for i, (prefix, value, expected, previous) in enumerate([
     82         (LEAF, TRUE, b'\1'+TRUE, pair(LEAF, LEAF)),
     83         (bs(b'a'), pair(number(97), TRUE), b'\1'+TRUE, pair(LEAF, LEAF)),
     84         (TRUE, bs(b'a'), LEAF, pair(LEAF, LEAF)),
     85         (TRUE, LEAF, LEAF, pair(LEAF, LEAF))]):
     86         rows.append(RegistryCase(f'prefix-structure-{i}', 'stripPrefix', (prefix, value), expected,
     87                                 previous=previous, change='proper-list prefix with exact opaque suffix; replace malformed legacy Maybe results'))
     88     return rows
     89 
     90 
     91 def effect_cases():
     92     rows = []
     93     hostile = [b'', b'../secret', b'../../../../secret', b'abc/../../../../secret', b'/etc/passwd',
     94                b'..\\secret', b'%2e%2e%2fsecret', b'%252e%252e%252fsecret', b'%00', b'\0',
     95                HASH+b'/../../secret', HASH+b'?x=1', HASH+b'\0', b'A'*64, b'g'*64, b'a'*63, b'a'*65]
     96     for i, value in enumerate(hostile):
     97         rows.append(effect(f'get-reject-{i}', 'getBundleByHash', [bs(ROOT), bs(value)], [],
     98                            result(False, number(400), bs(DIAGNOSTIC)), []))
     99         for export, args in [('serveBundleHash', [bs(ROOT), bs(value)]),
    100                              ('getBundleRoute', [bs(ROOT), bs(b'GET'), bs(PREFIX+value)]),
    101                              ('arboricxRouter', [bs(ROOT), bs(b'GET'), bs(PREFIX+value), LEAF, LEAF])]:
    102             rows.append(effect(f'{export}-reject-{i}', export, args, [], bs(wire(400, DIAGNOSTIC)), []))
    103     for payload in (b'', b'bundle', b'\0\xff\x80BODY'):
    104         for failed in (False, True):
    105             reply = FAIL if failed else result(True, bs(payload), number(9))
    106             answer = result(False, number(404), bs(b'denied')) if failed else result(True, bs(payload))
    107             response = wire(404, b'denied') if failed else wire(200, payload, b'application/vnd.arboricx.bundle')
    108             for export, args in [('getBundleByHash', [bs(ROOT), bs(HASH)]),
    109                                  ('serveBundleHash', [bs(ROOT), bs(HASH)]),
    110                                  ('getBundleRoute', [bs(ROOT), bs(b'GET'), bs(PREFIX+HASH)]),
    111                                  ('arboricxRouter', [bs(ROOT), bs(b'GET'), bs(PREFIX+HASH), LEAF, LEAF])]:
    112                 rows.append(effect(f'{export}-read-{payload.hex()}-{failed}', export, args, [(20, reply)],
    113                     answer if export == 'getBundleByHash' else bs(response), [action(20, bs(path(HASH)))]))
    114     for method in (b'GET', b'POST', b'HEAD', b'PUT', b'get', b''):
    115         for target in (b'/', b'/_arboricx/health', b'/unknown', b'/../_arboricx/health'):
    116             response = wire(200, LANDING, b'text/html; charset=utf-8') if method == b'GET' and target == b'/' else (
    117                 wire(200, b'OK\n') if method == b'GET' and target == b'/_arboricx/health' else wire(404, b'Not found\n'))
    118             rows.append(effect(f'route-{method.hex()}-{target.hex()}', 'arboricxRouter',
    119                 [bs(ROOT), bs(method), bs(target), LEAF, bs(b'ignored')], [], bs(response), []))
    120     dirs = [ROOT, ROOT+b'/tmp', ROOT+b'/objects', ROOT+b'/aliases', ROOT+b'/aliases/names', ROOT+b'/aliases/packages', ROOT+b'/manifests']
    121     for failure in range(-1, len(dirs)):
    122         replies = [(25, FAIL if i == failure else OK) for i in range(len(dirs))]
    123         trace = [action(25, bs(p)) for p in dirs]
    124         used = len(dirs) if failure == -1 else failure+1
    125         value = OK if failure == -1 else result(False, bs(b'createDirectory: denied'))
    126         rows.append(effect(f'init-{failure}', 'ensureStore', [bs(ROOT)], replies[:used], value, trace[:used],
    127             previous=finished(LEAF, trace), previous_arguments=(tree_list([bs(ROOT)]), script(replies)),
    128             change='propagate initialization failures and stop immediately; return Result on success'))
    129     for data in (b'', b'bundle', b'\0\xff\x80body'):
    130         hash = hashlib.sha256(data).hexdigest().encode()
    131         mkdir = action(25, bs(ROOT+b'/objects/'+hash[:3]))
    132         publish = action(33, pair(bs(path(hash)), bs(data)))
    133         old_tmp = ROOT+b'/tmp/'+hash+b'.tmp'
    134         old_trace = [action(29, LEAF), action(28, bs(data)), mkdir,
    135                      action(22, pair(bs(old_tmp), bs(data))), action(24, pair(bs(old_tmp), bs(path(hash))))]
    136         for failure in ('none', 'hash', 'mkdir', 'publish'):
    137             trace = [action(28, bs(data)), mkdir, publish]
    138             replies = [(28, result(True, bs(hash))), (25, OK), (33, result(True, TRUE))]
    139             old_replies = [(29, result(True, number(123))), (28, result(True, bs(hash))), (25, OK), (22, OK), (24, OK)]
    140             value = result(True, bs(hash)); old_value = value
    141             if failure != 'none':
    142                 index = {'hash': 0, 'mkdir': 1, 'publish': 2}[failure]
    143                 old_index = {'hash': 1, 'mkdir': 2, 'publish': 3}[failure]
    144                 replies[index] = (replies[index][0], FAIL)
    145                 old_replies[old_index] = (old_replies[old_index][0], FAIL)
    146                 replies, trace = replies[:index+1], trace[:index+1]
    147                 old_replies, selected_old_trace = old_replies[:old_index+1], old_trace[:old_index+1]
    148                 message = {'hash': b'sha256Hex: denied', 'mkdir': b'createDirectory: denied', 'publish': b'atomicWriteBytes: denied'}[failure]
    149                 old_message = {'hash': message, 'mkdir': b'withHash: createDirectory: denied', 'publish': b'withHash: writeBytes: denied'}[failure]
    150                 value, old_value = result(False, bs(message)), result(False, bs(old_message))
    151             else:
    152                 selected_old_trace = old_trace
    153             for export, args in [('putBundle', [bs(ROOT), bs(data)]),
    154                                  ('putBundleRoute', [bs(ROOT), bs(b'POST'), bs(b'/_arboricx/bundle'), bs(data)]),
    155                                  ('arboricxRouter', [bs(ROOT), bs(b'POST'), bs(b'/_arboricx/bundle'), LEAF, bs(data)])]:
    156                 expected, previous = value, old_value
    157                 if export != 'putBundle':
    158                     expected = bs(wire(201, hash) if failure == 'none' else wire(400, b'Upload failed: '+message))
    159                     previous = bs(wire(201, hash) if failure == 'none' else wire(400, b'Upload failed: '+old_message))
    160                 rows.append(effect(f'{export}-{data.hex()}-{failure}', export, args, replies, expected, trace,
    161                     previous=finished(previous, selected_old_trace), previous_arguments=(tree_list(args), script(old_replies)),
    162                     change='atomic publication replaces shared temporary-file writes; remove unused clock and obsolete error layer'))
    163     # Fault-injected digest replies exercise the write boundary. The old public
    164     # hash-taking writer provides the direct traversal characterization too.
    165     for i, hash in enumerate(hostile):
    166         args = [bs(ROOT), bs(b'body')]
    167         mkdir = action(25, bs(ROOT+b'/objects/'+hash[:3]))
    168         tmp = ROOT+b'/tmp/'+hash+b'.tmp'
    169         trace = [mkdir, action(22, pair(bs(tmp), bs(b'body'))), action(24, pair(bs(tmp), bs(path(hash))))]
    170         rows.append(effect(f'write-reject-{i}', 'putBundle', args, [(28, result(True, bs(hash)))],
    171             result(False, bs(b'sha256Hex: '+DIAGNOSTIC)), [action(28, bs(b'body'))],
    172             previous=finished(result(True, bs(hash)), trace),
    173             previous_arguments=(tree_list([bs(ROOT), bs(b'body'), number(123), bs(hash)]), script([(25, OK), (22, OK), (24, OK)])),
    174             previous_export='putBundleWithHash', change='remove unchecked hash-taking writer; validate digest before filesystem IO'))
    175     # Server startup error paths; listenSocket/serveForever policy is unchanged.
    176     for failure in range(3):
    177         trace = [action(70, LEAF), action(72, pair(number(7), pair(bs(b'127.0.0.1'), number(9050)))), action(73, pair(number(7), number(128)))]
    178         replies = [(70, result(True, number(7))), (72, OK), (73, OK)]
    179         replies[failure] = (replies[failure][0], FAIL)
    180         old_trace, old_replies = trace[:failure+1], replies[:failure+1]
    181         trace, replies = list(old_trace), list(old_replies)
    182         args = [bs(ROOT), bs(b'127.0.0.1'), number(9050)]
    183         if failure:
    184             trace.append(action(71, number(7))); replies.append((71, OK))
    185         rows.append(effect(f'listen-failure-{failure}', 'arboricxServer', args,
    186             replies, result(False, bs(b'denied')), trace,
    187             previous=finished(result(False, bs(b'denied')), old_trace) if failure else None,
    188             previous_arguments=(tree_list(args), script(old_replies)) if failure else None,
    189             change='close acquired socket after bind/listen failure' if failure else ''))
    190     # Successful listen reaches accept; an ordinary accept error is still
    191     # ignored by serveForever. Stop at the next unscripted accept, not by fuel.
    192     ready_trace = [action(70, LEAF), action(72, pair(number(7), pair(bs(b'127.0.0.1'), number(9050)))), action(73, pair(number(7), number(128)))]
    193     ready_replies = [(70, result(True, number(7))), (72, OK), (73, OK)]
    194     for accept_failure in (False, True):
    195         trace, replies = list(ready_trace), list(ready_replies)
    196         if accept_failure:
    197             trace.append(action(74, number(7))); replies.append((74, FAIL))
    198         accept = action(74, number(7))
    199         rows.append(RegistryCase(f'listen-ready-{accept_failure}', 'arboricxServer',
    200             (tree_list([bs(ROOT), bs(b'127.0.0.1'), number(9050)]), script(replies)),
    201             finished(accept, trace+[accept], label=b'unscripted'), 'call'))
    202     for failure in (1, 2):
    203         original = next(c for c in rows if c.label == f'listen-failure-{failure}')
    204         replies = ready_replies[:failure+1]
    205         replies[failure] = (replies[failure][0], FAIL)
    206         rows.append(replace(original, label=f'listen-close-error-{failure}',
    207             arguments=(original.arguments[0], script(replies+[(71, FAIL)]))))
    208 
    209     # Exercise the actual registry-to-HTTP adapter, including HEAD translation,
    210     # raw binary bodies, path attacks, read errors and send/close failures.
    211     for method, target, data, outcome in [
    212         (b'GET', b'/_arboricx/health', b'OK\n', 'ok'),
    213         (b'HEAD', b'/_arboricx/health', b'OK\n', 'ok'),
    214         (b'GET', PREFIX+b'../../../secret', DIAGNOSTIC, 'reject'),
    215         (b'GET', PREFIX+HASH, b'\0\xff\x80BODY', 'ok'),
    216         (b'HEAD', PREFIX+HASH, b'\0\xff\x80BODY', 'ok'),
    217         (b'GET', PREFIX+HASH, b'denied', 'missing'),
    218         (b'GET', b'/_arboricx/health', b'OK\n', 'send-error'),
    219         (b'GET', b'/_arboricx/health', b'OK\n', 'close-error')]:
    220         request = method+b' '+target+b' HTTP/1.1\r\n\r\n'
    221         replies, trace = [(76, result(True, bs(request)))], [action(76, pair(number(7), number(4096)))]
    222         code, content_type = 200, b'text/plain; charset=utf-8'
    223         if target == PREFIX+HASH:
    224             trace.append(action(20, bs(path(HASH))))
    225             replies.append((20, FAIL if outcome == 'missing' else result(True, bs(data))))
    226             if outcome == 'missing': code = 404
    227             else: content_type = b'application/vnd.arboricx.bundle'
    228         if outcome == 'reject': code = 400
    229         response = wire(code, data, content_type)
    230         if method == b'HEAD': response = response.split(b'\r\n\r\n')[0]+b'\r\n\r\n'
    231         trace += [action(77, pair(number(7), bs(response))), action(71, number(7))]
    232         replies += [(77, FAIL if outcome == 'send-error' else OK), (71, FAIL if outcome == 'close-error' else OK)]
    233         rows.append(effect(f'handler-{method.decode()}-{outcome}-{target.hex()}', 'arboricxHandler',
    234             [bs(ROOT), number(7), LEAF], replies, FAIL if outcome == 'send-error' else OK, trace))
    235     for failure in (False, True):
    236         data = b'\0\xffbody'; hash = hashlib.sha256(data).hexdigest().encode()
    237         request = b'POST /_arboricx/bundle HTTP/1.1\r\nContent-Length: 6\r\n\r\n'+data
    238         first = action(76, pair(number(7), number(4096)))
    239         digest = action(28, bs(data)); mkdir = action(25, bs(ROOT+b'/objects/'+hash[:3]))
    240         response = wire(400, b'Upload failed: atomicWriteBytes: denied') if failure else wire(201, hash)
    241         previous_response = wire(400, b'Upload failed: withHash: writeBytes: denied') if failure else response
    242         final = [action(77, pair(number(7), bs(response))), action(71, number(7))]
    243         trace = [first, digest, mkdir, action(33, pair(bs(path(hash)), bs(data)))]+final
    244         replies = [(76, result(True, bs(request))), (28, result(True, bs(hash))), (25, OK), (33, FAIL if failure else result(True, TRUE)), (77, OK), (71, OK)]
    245         tmp = ROOT+b'/tmp/'+hash+b'.tmp'
    246         old_trace = [first, action(29, LEAF), digest, mkdir, action(22, pair(bs(tmp), bs(data)))]
    247         old_replies = [replies[0], (29, result(True, number(123))), replies[1], replies[2], (22, FAIL if failure else OK)]
    248         if not failure:
    249             old_trace.append(action(24, pair(bs(tmp), bs(path(hash))))); old_replies.append((24, OK))
    250         old_trace += [action(77, pair(number(7), bs(previous_response))), action(71, number(7))]
    251         old_replies += [(77, OK), (71, OK)]
    252         args = [bs(ROOT), number(7), LEAF]
    253         rows.append(effect(f'handler-POST-{failure}', 'arboricxHandler', args, replies, OK, trace,
    254             previous=finished(OK, old_trace), previous_arguments=(tree_list(args), script(old_replies)),
    255             change='atomic upload through the full HTTP adapter'))
    256     return rows
    257 
    258 
    259 def demo_cases():
    260     root = b'/tmp/store'
    261     dirs = [root, root+b'/tmp', root+b'/objects', root+b'/aliases', root+b'/aliases/names', root+b'/aliases/packages', root+b'/manifests']
    262     print_action = action(10, bs(b'Starting Arboricx server on 127.0.0.1:9050\n'))
    263     startup_trace = [print_action]+[action(25, bs(p)) for p in dirs]+[action(70, LEAF)]
    264     startup_replies = [(10, OK)]+[(25, OK)]*7+[(70, FAIL)]
    265     yield RegistryCase('demo-initialized-listen', 'main', (script(startup_replies),),
    266                        finished(result(False, bs(b'denied')), startup_trace), 'program')
    267     for failed in range(7):
    268         old_replies = [(10, OK)]+[(25, FAIL if i == failed else OK) for i in range(7)]+[(70, FAIL)]
    269         old_trace = [print_action]+[action(25, bs(p)) for p in dirs]+[action(70, LEAF)]
    270         yield RegistryCase(f'demo-init-failure-{failed}', 'main', (script(old_replies[:failed+2]),),
    271             finished(result(False, bs(b'createDirectory: denied')), old_trace[:failed+2]), 'program',
    272             finished(result(False, bs(b'denied')), old_trace), (script(old_replies),),
    273             change='initialization failure prevents opening the listening socket')
    274 
    275 
    276 def main():
    277     p = argparse.ArgumentParser(description=__doc__)
    278     p.add_argument('tricu', type=Path)
    279     p.add_argument('--subject', type=Path, required=True)
    280     p.add_argument('--reference', type=Path)
    281     p.add_argument('--driver', type=Path)
    282     p.add_argument('--directory', type=Path, required=True)
    283     p.add_argument('--baseline', action='store_true')
    284     p.add_argument('--demo', type=Path)
    285     p.add_argument('--previous-demo', type=Path)
    286     p.add_argument('--case', action='append')
    287     args = p.parse_args()
    288     if args.baseline and args.reference:
    289         p.error('--baseline and --reference are mutually exclusive')
    290     if args.demo and not args.driver: p.error('--demo requires --driver')
    291     if args.previous_demo and not args.demo: p.error('--previous-demo requires --demo')
    292     cases = pure_cases() + (effect_cases() if args.driver else [])
    293     if args.demo: cases += list(demo_cases())
    294     if args.case:
    295         unknown = set(args.case) - {c.label for c in cases}
    296         if unknown: p.error('unknown cases: '+', '.join(sorted(unknown)))
    297         cases = [c for c in cases if c.label in args.case]
    298     root = args.directory.resolve(); root.mkdir(parents=True, exist_ok=False)
    299     runner = Runner(args.tricu, root)
    300     report = dict(complete=False, baseline=args.baseline, runtime_sha256=sha(runner.exe.read_bytes()),
    301                   harness_sha256=sha(Path(__file__).read_bytes()), cases=[])
    302     def select(path, name):
    303         local = root/(name+'.arboricx'); local.write_bytes(path.read_bytes())
    304         report[name+'_sha256'] = sha(local.read_bytes())
    305         return runner.imported(local, name+'-import')
    306     def old_case(case):
    307         return replace(case, export=case.previous_export or case.export,
    308                        arguments=case.previous_arguments or case.arguments)
    309     try:
    310         subject = select(args.subject, 'subject')
    311         reference = select(args.reference, 'reference') if args.reference else None
    312         driver = select(args.driver, 'driver') if args.driver else None
    313         demo = select(args.demo, 'demo') if args.demo else None
    314         previous_demo = select(args.previous_demo, 'previous-demo') if args.previous_demo else None
    315         for case in cases:
    316             selected = demo if case.driver == 'program' else subject
    317             previous_selected = previous_demo if case.driver == 'program' else reference
    318             if args.baseline:
    319                 actual = invoke(runner, selected, old_case(case), 'baseline', driver)
    320                 expect(actual, case.previous if case.previous is not None else case.expected, case.label+' baseline')
    321             else:
    322                 actual = invoke(runner, selected, case, 'subject', driver)
    323                 before = invoke(runner, previous_selected, old_case(case), 'reference', driver) if previous_selected else None
    324                 check_case(case, actual, before)
    325             report['cases'].append(dict(label=case.label, result_sha256=sha(actual), change=case.change))
    326         wrong = root/'wrong-subject.arboricx'
    327         merge_bundles(wrong, [(root/'subject.arboricx', [('bundleHashValid?', 'bundleHexByte?')])], prune_unreachable=True)
    328         mutant = runner.imported(wrong, 'mutant-import')
    329         control = Case('wrong-subject', 'bundleHexByte?', (number(97),), TRUE)
    330         if invoke(runner, mutant, control, 'mutant', driver) == TRUE:
    331             raise ValueError('wrong selected subject passed')
    332         if not args.baseline:
    333             removed = {'putBundleWithHash', 'putBundleWrite', 'bundleTmpPath'} & set(read(root/'subject.arboricx')[3])
    334             if removed: raise ValueError('unchecked write/temporary interfaces retained: '+str(removed))
    335         report.update(complete=True, wrong_subject_rejected=True)
    336     except Exception as error:
    337         report['failure'] = str(error); raise
    338     finally:
    339         report.update(seconds=time.monotonic()-runner.started, invocations=runner.calls)
    340         (root/'report.json').write_text(json.dumps(report, indent=2)+'\n')
    341     print(f'PASS: {len(cases)} registry cases; {report["seconds"]:.2f}s')
    342 
    343 
    344 if __name__ == '__main__':
    345     main()