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