tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

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