handler_library.py (17884B)
1 #!/usr/bin/env python3 2 """Opt-in native handler-library checks (no compiler-generation changes). 3 4 nix build .# 5 python3 test/handler_library.py result/bin/tricu --directory /tmp/handler-library 6 7 Compiles source with the packaged stable compiler. Uses independent full-tree 8 oracles, real OS permission denials, and complete HTTP-byte expectations. 9 """ 10 import argparse 11 import hashlib 12 import json 13 import os 14 from pathlib import Path 15 import re 16 import statistics 17 18 from http_headers import number, result, tree_list 19 from source_order import Runner, end, field 20 from stable_bundle import byte_tree 21 22 ROOT = Path(__file__).resolve().parent.parent 23 TRUE = b'\1\0' 24 25 26 def tree(value): 27 if isinstance(value, bytes): 28 return value 29 if isinstance(value, str): 30 return byte_tree(value.encode()) 31 if isinstance(value, int): 32 return number(value) 33 return tree_list([tree(v) for v in value]) 34 35 36 def ok(value): 37 return result(True, tree(value)) 38 39 40 def err(value): 41 return result(False, tree(value)) 42 43 44 def diagnostic(kind, service=0, operation=0, detail=0, labels=()): 45 return [kind, service, operation, detail, labels] 46 47 48 def http(status, body): 49 if isinstance(body, str): 50 body = body.encode() 51 phrase = {200: 'OK', 400: 'Bad Request', 404: 'Not Found'}[status] 52 mime = 'application/vnd.arboricx.bundle' if status == 200 else 'text/plain; charset=utf-8' 53 return byte_tree((f'HTTP/1.1 {status} {phrase}\r\nContent-Type: {mime}\r\n' 54 f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode()+body) 55 56 57 def main(): 58 parser = argparse.ArgumentParser(description=__doc__) 59 parser.add_argument('tricu', type=Path) 60 parser.add_argument('--directory', type=Path, required=True) 61 parser.add_argument('--benchmark', action='store_true') 62 args = parser.parse_args() 63 if os.geteuid() == 0: 64 parser.error("OS-denial controls require non-root") 65 directory = args.directory.resolve() 66 directory.mkdir(parents=True, exist_ok=False) 67 runner = Runner(args.tricu, directory) 68 source = ROOT / 'test/handlers/library.tri' 69 bundle = directory / 'library.arboricx' 70 runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle, '--compile-timeout', '270'], seconds=300) 71 pin = runner.imported(bundle, 'import') 72 passed = [] 73 74 def arguments(label, values): 75 out = [] 76 for index, value in enumerate(values): 77 path = directory / f'{label}-{index}.tree' 78 path.write_bytes(tree(value)) 79 out += ['--arg-tree-file', path] 80 return out 81 82 def check(name, expected, values=(), label=None): 83 label = label or name 84 actual = runner.invoke(label, pin, name, arguments(label, values)) 85 wanted = tree(expected) 86 if actual != wanted: 87 (directory / (label+'-expected.tree')).write_bytes(wanted) 88 raise AssertionError(label) 89 passed.append(label) 90 91 for name, value in [('pureValue', 42), ('handled', 8), ('override', 9), 92 ('restore', [9, 8]), ('lexical', 8), ('noReentry', 8), 93 ('escaped', 8), ('transformed', 20), ('discard', 42)]: 94 check(name, ok(value)) 95 for name, labels in [('missing', ['request-42']), ('labelRestore', []), 96 ('labelNested', ['outer', 'inner']), ('captureMissing', ['outer', 'inner'])]: 97 check(name, err(diagnostic('unhandled-request', 'store', 'read', 7, labels))) 98 check('malformed', err(diagnostic('invalid-computation'))) 99 # noIO's argument is an opaque host action; verify service/op/labels and the 100 # negative stdout control rather than deriving the host action as an oracle. 101 raw = runner.invoke('noIO', pin, 'noIO', []) 102 assert field(raw, 0) == b'\0' 103 problem = field(raw, 1, 0) 104 def list_values(raw): 105 values = [] 106 while raw != b'\0': 107 values.append(field(raw, 0)); raw = field(raw, 1) 108 return values 109 pieces = list_values(problem) 110 assert pieces[:3] == [tree('unhandled-request'), tree('host.io'), tree('run')] 111 assert pieces[4] == tree(['pure-only']) 112 assert 'MUST NOT PRINT' not in (directory / 'noIO.log').read_text() 113 passed.append('noIO') 114 check('invalidRequest', err(diagnostic('invalid-request', 'math', 'id', 'not a natural number'))) 115 check('invalidResponse', err(diagnostic('invalid-response', 'math', 'id', 'not a natural number'))) 116 check('brokenContract', err(diagnostic('invalid-request-contract', 'math', 'id', 1))) 117 check('badInputContract', err(diagnostic('invalid-request-contract', 'math', 'id', 1))) 118 check('badOutputContract', err(diagnostic('invalid-response-contract', 'math', 'id', 1))) 119 for name, value in [('checkAccepted', 10), ('checkRejected', ['declined', 12]), 120 ('checkMalformed', 7), ('checkMalformedLazy', 13), ('checkTruthy', 42), 121 ('checkRejectedLazy', 14)]: 122 check(name, ok(value)) 123 for name in ['requestStopped', 'inputBeforeReply']: 124 check(name, [err(diagnostic('invalid-request', 'math', 'id', 'not a natural number', ['checked'])), [], []], [[]]) 125 event = ['math', 'id', 17] 126 check('outputAfterReply', [err(diagnostic('invalid-response-contract', 'math', 'id', 321, ['checked'])), [event], []], [[[event, 321]]]) 127 check('sinkStopped', err(diagnostic('invalid-request', 'logging', 'emit', 'expected [level context payload]', ['sink']))) 128 changed = ['math', 'id', 10] 129 check('transformedScript', [ok(20), [changed], []], [[[changed, 30]]]) 130 check('rawBadLog', err(diagnostic('invalid-request', 'logging', 'emit', 'expected [level context payload]', ['raw']))) 131 events = lambda fields: [['info', fields, 'started'], ['warn', fields, 'finished']] 132 check('captured', ok([42, events([['request', 7]])])) 133 check('nestedContext', ok([42, events([['outer', 1], ['inner', 2]])])) 134 check('nestedCapture', ok([[42, events([])], []])) 135 check('captureOrder', ok([42, events([])])) 136 check('captureFailure', err(diagnostic('example', 'store', 'read', 'failed', ['failure-scope']))) 137 check('captureForward', ok([8, [['info', [], 'before'], ['info', [], 'after']]])) 138 check('invalidContext', err(diagnostic('invalid-request', 'logging', 'context', 'context must be a proper list'))) 139 check('badLogReply', err(diagnostic('invalid-response', 'logging', 'emit', 'expected log Result with empty remainder'))) 140 check('logFailure', ok(err('offline'))) 141 check('unknownLog', err(diagnostic('unhandled-request', 'logging', 'unknown', []))) 142 event = ['store', 'read', 7] 143 script = [[event, 8]] 144 check('scripted', [ok(8), [event], []], [script]) 145 check('scripted', [err(diagnostic('script-unused', detail=script)), [event], script], [script*2], 'script-unused') 146 check('scripted', [err(diagnostic('script-exhausted', 'store', 'read', 7, ['scripted'])), [event], []], [[]], 'script-empty') 147 wrong = [[['other', 'read', 7], 9]] 148 check('scripted', [err(diagnostic('script-mismatch', 'store', 'read', [wrong[0][0], event], ['scripted'])), [event], wrong], [wrong], 'script-mismatch') 149 check('scriptedCapture', [ok([8, []]), [event], []], [script]) 150 151 digest = 'a'*64 152 bad_digest_message = 'expected 64 lowercase hexadecimal hash characters' 153 invalid = err(['invalid-digest', bad_digest_message]) 154 check('artifactLoad', [ok(invalid), [], []], ['../secret', []], 'invalid-digest') 155 for export, service in [('artifactLoad', 'artifacts'), ('artifactNamed', 'source')]: 156 event = [service, 'load', digest] 157 check(export, [ok(ok(byte_tree(b'\0\xffA'))), [event], []], [digest, [[event, ok(byte_tree(b'\0\xffA'))]]]) 158 event = ['artifacts', 'load', digest] 159 check('artifactLoad', [err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact bytes')), [event], []], 160 [digest, [[event, ok([256])]]], 'bad-artifact-bytes') 161 check('artifactLoad', [err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact-load Result with empty remainder')), [event], []], 162 [digest, [[event, result(True, tree([]), tree('extra'))]]], 'bad-artifact-remainder') 163 event = ['artifacts', 'publish', [0, 255]] 164 check('artifactPublish', [ok(ok(digest)), [event], []], [[0, 255], [[event, ok(digest)]]]) 165 check('artifactPublish', [err(diagnostic('invalid-request', 'artifacts', 'publish', 'expected octet')), [], []], [[256], []], 'bad-publish-input') 166 check('artifactPublish', [err(diagnostic('invalid-response', 'artifacts', 'publish', 'expected canonical artifact digest')), [event], []], 167 [[0, 255], [[event, ok('bad')]]], 'bad-publish-digest') 168 169 # The first application consumer, with an independently constructed response. 170 request_event = ['artifacts', 'load', digest] 171 log = ['info', [['request', 7]], [digest, 200]] 172 expected_http = http(200, b'\0\xffA') 173 check('getRequest', [ok([expected_http, [log]]), [request_event], []], 174 [digest, [[request_event, ok(byte_tree(b'\0\xffA'))]]]) 175 check('getDemo', ok([expected_http, [log]])) 176 check('getRequest', [ok([http(400, bad_digest_message), [['info', [['request', 7]], ['bad', 400]]]]), [], []], ['bad', []], 'get-invalid') 177 check('getRequest', [ok([http(404, 'Artifact unavailable\n'), [['info', [['request', 7]], [digest, 404]]]]), [request_event], []], 178 [digest, [[request_event, err('private path must not leak')]]], 'get-failed') 179 180 # Complete handler-server policy: responses/logs/effects, not comparisons 181 # against an implementation-generated oracle. 182 def registry(method, target, body, package_reply, script, response, status, trace, label): 183 check('registryRequest', [ok([response, [['info', [['method', method], ['target', target]], status]]]), trace, []], 184 [method, target, body, package_reply, script], label) 185 target = '/_arboricx/bundle/hash/'+digest 186 event = ['artifacts', 'load', digest] 187 registry('GET', target, [], err('unused'), [[event, ok(byte_tree(b'abc'))]], 188 http(200, b'abc'), 200, [event], 'registry-get') 189 registry('GET', target, [], err('unused'), [[event, err('PRIVATE')]], 190 http(404, 'Artifact unavailable\n'), 404, [event], 'registry-redacted-read') 191 event = ['artifacts', 'publish', [0, 255]] 192 # HTTP.createdResponse uses the ordinary text/plain response MIME type. 193 created = byte_tree((f'HTTP/1.1 201 Created\r\nContent-Type: text/plain; charset=utf-8\r\n' 194 f'Content-Length: 64\r\nConnection: close\r\n\r\n{digest}').encode()) 195 registry('POST', '/_arboricx/bundle', [0, 255], err('unused'), [[event, ok(digest)]], 196 created, 201, [event], 'registry-publish') 197 registry('POST', '/_arboricx/bundle', [0, 255], err('unused'), [[event, err('PRIVATE')]], 198 http(400, 'Upload failed\n'), 400, [event], 'registry-redacted-publish') 199 event = ['artifacts', 'load', digest] 200 check('registryRequest', [err(diagnostic('invalid-response', 'artifacts', 'load', 201 'expected artifact bytes', ['registry-request'])), [event], []], 202 ['GET', target, [], err('unused'), [[event, ok([256])]]], 'registry-invalid-service-reply') 203 204 root = directory / 'artifacts' 205 root.mkdir() 206 grants = () # Native authority is the process OS identity, not grant flags. 207 def io_run(label, export, values, permissions): 208 output = runner.run(label, ['run', '--module', pin, '--name', export, '--store', runner.store, 209 '--io', '--format', 'ternary', '--profile', *arguments(label, values), *permissions]) 210 text = next(re.match(r'^([012]+)', line)[1] for line in output.splitlines() if re.match(r'^[012]+', line)) 211 raw = bytes(map(int, text)); return raw[:end(raw)] 212 assert io_run('registry-stdout', 'registryStdout', [], []) == ok(ok(0)) 213 assert 'registry status=200\n' in (directory / 'registry-stdout.log').read_text() 214 passed.append('registry-stdout') 215 body = b'\0\xffA' 216 digest = hashlib.sha256(body).hexdigest() 217 artifact_path = root / 'objects' / digest[:3] / (digest+'.arboricx') 218 root.chmod(0) 219 try: 220 denied = io_run('publish-denied', 'ioPublish', [str(root), byte_tree(body)], []) 221 finally: 222 root.chmod(0o700) 223 assert field(denied, 0) == TRUE and field(field(denied, 1, 0), 0) == b'\0' 224 assert not artifact_path.exists() 225 assert io_run('prepare-real', 'ioPrepare', [str(root)], grants) == ok(ok(0)) 226 assert io_run('publish-real', 'ioPublish', [str(root), byte_tree(body)], grants) == ok(ok(digest)) 227 assert artifact_path.read_bytes() == body 228 for export in ['ioLoad', 'ioNamed']: 229 assert io_run(export, export, [str(root), digest], grants) == ok(ok(byte_tree(body))) 230 assert io_run('ioCapture', 'ioCapture', [str(root), digest], grants) == ok([ok(byte_tree(body)), [['info', [], 'loaded']]]) 231 root.chmod(0) 232 try: 233 denied = io_run('load-denied', 'ioLoad', [str(root), digest], []) 234 finally: 235 root.chmod(0o700) 236 assert field(denied, 0) == TRUE and field(field(denied, 1, 0), 0) == b'\0' 237 assert io_run('raw-invalid', 'ioRawLoad', [str(root), '../secret'], grants) == ok(invalid) 238 assert io_run('get-real', 'ioGet', [str(root), digest], grants) == ok([http(200, body), [['info', [['request', 7]], [digest, 200]]]]) 239 passed.extend(['publish-denied', 'prepare-real', 'publish-real', 'ioLoad', 'ioNamed', 'ioCapture', 'load-denied', 'raw-invalid', 'get-real']) 240 # Bounded upload sessions through the source handler facade. The preexisting 241 # store is initialized; failed producers must leave no staging or new object. 242 staging = root / 'tmp' 243 assert io_run('lazy-io-label', 'lazyIOLabel', [0], []) == ok(7) 244 assert io_run('lazy-failed-continuation', 'lazyFailedContinuation', [0], []) == err(diagnostic('test-failure', 'test', 'producer')) 245 passed.extend(['lazy-io-label', 'lazy-failed-continuation']) 246 stream_body = b'incremental\0\xffpublication' 247 stream_hash = hashlib.sha256(stream_body).hexdigest() 248 assert io_run('stream-valid', 'streamChunks', [str(root), len(stream_body), 249 [byte_tree(stream_body[:3]), byte_tree(stream_body[3:])]], grants) == ok(ok(stream_hash)) 250 assert (root / 'objects' / stream_hash[:3] / (stream_hash+'.arboricx')).read_bytes() == stream_body 251 assert list(staging.iterdir()) == [] 252 passed.append('stream-valid') 253 for export, wanted in [('streamAbandon', ok(err('stopped'))), 254 ('streamProtocolFailure', err(diagnostic('test-failure', 'test', 'producer'))), 255 ('streamFalseAck', ok(err('Incomplete artifact stream'))), 256 ('streamIgnoreFailure', ok(err(['write-all', 0, ['invalid-argument', 'stream/write', '', 'none']])))]: 257 before = sorted(p.relative_to(root) for p in root.rglob('*')) 258 assert io_run(export, export, [str(root)], grants) == wanted 259 assert sorted(p.relative_to(root) for p in root.rglob('*')) == before 260 assert list(staging.iterdir()) == [] 261 passed.append(export) 262 for name, count, chunks, message in [('stream-short', 2, [[1]], 'Incomplete artifact stream'), 263 ('stream-excess', 1, [[1, 2]], 'Artifact exceeds declared length'), 264 ('stream-empty-chunk', 1, [[]], 'Empty artifact chunk'), 265 ('stream-bad-byte', 1, [[256]], ['write-all', 0, ['invalid-argument', 'stream/write', '', 'none']]), 266 ('stream-large-chunk', 4097, [[0]*4097], ['write-all', 0, ['invalid-argument', 'stream/write', '', 'none']])]: 267 assert io_run(name, 'streamChunks', [str(root), count, chunks], grants) == ok(err(message)) 268 assert list(staging.iterdir()) == [] 269 passed.append(name) 270 benchmarks = [] 271 if args.benchmark: 272 for count in (1, 20): 273 contexts = list(range(count)) 274 expected = ok([[http(200, body)]*count, 275 [['info', [['request', context]], [digest, 200]] for context in contexts]]) 276 samples = {export: [] for export in ('ioDirectMany', 'ioMany', 'ioSinkMany')} 277 for repeat, order in enumerate([('ioDirectMany', 'ioMany', 'ioSinkMany'), 278 ('ioMany', 'ioSinkMany', 'ioDirectMany'), 279 ('ioSinkMany', 'ioDirectMany', 'ioMany')]): 280 for export in order: 281 label = f'bench-{export}-{count}-{repeat}' 282 assert io_run(label, export, [str(root), digest, contexts], grants) == expected, label 283 samples[export].append(runner.records[-1]) 284 passed.append(label) 285 for export, rows in samples.items(): 286 benchmarks.append(dict(export=export, count=count, 287 seconds=statistics.median(row['invoke_seconds'] for row in rows), 288 allocated_bytes=statistics.median(row['allocated_bytes'] for row in rows))) 289 sources = [ROOT / 'lib/handlers.tri', *sorted((ROOT / 'lib/handlers').glob('*.tri')), 290 source, Path(__file__).resolve(), ROOT / 'test/handlers/tricu.workspace', 291 ROOT / 'demos/handlers/artifact-get.tri', ROOT / 'demos/handlers/main.tri', 292 ROOT / 'lib/base.tri', ROOT / 'lib/io.tri', ROOT / 'lib/io.tri', ROOT / 'lib/http.tri', 293 ROOT / 'lib/arboricx/server.tri', ROOT / 'lib/arboricx/handlers.tri', ROOT / 'test/handlers/observe.tri', ROOT / 'test/handlers/state.tri', ROOT / 'bootstrap/build-stable.arboricx'] 294 report = dict(passed=passed, benchmarks=benchmarks, host=str(args.tricu.resolve()), 295 inputs={str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sources}) 296 (directory / 'report.json').write_text(json.dumps(report, indent=2)+'\n') 297 print(json.dumps(report, indent=2)) 298 299 300 if __name__ == '__main__': 301 main()