tricu

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

http_lengths.py (10237B)


      1 #!/usr/bin/env python3
      2 """Opt-in, artifact-only bounded HTTP length tests; no compilation/socket IO.
      3 
      4 Select a full unqualified HTTP module. Optional --reference compares only small
      5 unchanged cases (successful numbers <=42), never sends costly numbers to the old
      6 unbounded implementation. The old near-limit decoder exceeds the 512 MiB heap
      7 guard; those cases use independent expectations, not an unbounded reference run.
      8 All subject cases have independent complete output trees, including inert readBody
      9 error actions proving rejection before body IO. Header names supplied directly
     10 follow parseHeaders' lowercase convention. This is not full HTTP conformance.
     11 """
     12 import argparse
     13 from dataclasses import dataclass
     14 import json
     15 from pathlib import Path
     16 import time
     17 
     18 from bundle_dag import merge_bundles
     19 from http_headers import (Runner, LEAF, BAD_REQUEST, byte_tree, number, pair,
     20                           tree_list, result, sha, expect, parse_tree_output)
     21 
     22 EXPORTS = ('parseContentLengthValue', 'contentLength', 'readBody')
     23 TOO_LARGE = result(False, number(413), byte_tree(b'Request body too large\n'))
     24 
     25 
     26 def length_result(value):
     27     return result(True, LEAF if value is None else b'\1' + number(value))
     28 
     29 
     30 def fields(values):
     31     return tree_list([pair(byte_tree(name), byte_tree(value)) for name, value in values])
     32 
     33 
     34 @dataclass(frozen=True)
     35 class Case:
     36     label: str
     37     export: str
     38     arguments: tuple[bytes, ...]
     39     expected: bytes
     40     compare_reference: bool = False
     41 
     42 
     43 def cases(include_costs=True):
     44     rows = []
     45     for label, raw, expected in [
     46         ('empty', b'', BAD_REQUEST), ('zero', b'0', length_result(0)),
     47         ('zeros', b'0000000000000', length_result(0)), ('one', b'1', length_result(1)),
     48         ('ordinary', b'42', length_result(42)), ('below-max', b'1048575', length_result(1048575)),
     49         ('max', b'1048576', length_result(1048576)), ('above-max', b'1048577', TOO_LARGE),
     50         ('padded-max', b'000001048576', length_result(1048576)),
     51         ('padded-above-max', b'000001048577', TOO_LARGE),
     52         ('seven-digits-small', b'0999999', length_result(999999)),
     53         ('million', b'1000000', length_result(1000000)),
     54         ('two-million', b'2000000', TOO_LARGE),
     55         ('sign', b'+1', BAD_REQUEST), ('negative', b'-1', BAD_REQUEST),
     56         ('leading-space', b' 1', BAD_REQUEST), ('trailing-tab', b'1\t', BAD_REQUEST),
     57         ('interior-space', b'1 2', BAD_REQUEST), ('fraction', b'1.0', BAD_REQUEST),
     58         ('comma', b'1,1', BAD_REQUEST), ('comma-space', b'1, 1', BAD_REQUEST),
     59         ('prefix-junk', b'x12', BAD_REQUEST), ('suffix-junk', b'12x', BAD_REQUEST),
     60         ('oversized-junk', b'1048577x', BAD_REQUEST),
     61     ]:
     62         rows.append(Case('value-' + label, 'parseContentLengthValue', (byte_tree(raw),), expected, label not in {'below-max', 'max', 'above-max', 'padded-max',
     63                              'padded-above-max', 'seven-digits-small', 'million', 'two-million'}))
     64     for n in range(256):
     65         expected = length_result(100 + (n - 48) * 10 + 2) if 48 <= n <= 57 else BAD_REQUEST
     66         rows.append(Case(f'value-byte-{n:03}', 'parseContentLengthValue',
     67                          (byte_tree(b'1' + bytes([n]) + b'2'),), expected))
     68     for count in ((64, 1024, 8192, 65536) if include_costs else ()):
     69         for label, raw, expected in [
     70             ('nines', b'9' * count, TOO_LARGE),
     71             ('zeros', b'0' * count + b'42', length_result(42)),
     72             ('late-junk', b'9' * count + b'x', BAD_REQUEST),
     73         ]:
     74             rows.append(Case(f'cost-{label}-{count}', 'parseContentLengthValue', (byte_tree(raw),), expected))
     75     for label, raw in [('stem', b'\1\0'), ('improper', byte_tree(b'42')[:-1] + b'\1\0'),
     76                        ('improper-overflow', byte_tree(b'99999999')[:-1] + b'\1\0')]:
     77         rows.append(Case('value-' + label, 'parseContentLengthValue', (raw,), BAD_REQUEST))
     78     for label, headers, expected, unchanged in [
     79         ('absent', [], length_result(None), True),
     80         ('unrelated', [(b'x', b'hello')], length_result(None), True),
     81         ('zero', [(b'content-length', b'0')], length_result(0), True),
     82         ('one', [(b'content-length', b'1')], length_result(1), True),
     83         ('surrounded', [(b'x', b'a'), (b'content-length', b'42'), (b'y', b'b')], length_result(42), True),
     84         ('empty', [(b'content-length', b'')], BAD_REQUEST, True),
     85         ('comma', [(b'content-length', b'1, 1')], BAD_REQUEST, True),
     86         ('too-large', [(b'content-length', b'1048577')], TOO_LARGE, False),
     87         ('invalid-after-overflow', [(b'content-length', b'1048577x')], BAD_REQUEST, True),
     88     ]:
     89         rows.append(Case('headers-' + label, 'contentLength', (fields(headers),), expected, unchanged))
     90     # Ambiguity is rejected before decoding, independent of order/values.
     91     for first in (b'0', b'1', b'01', b'1048577', b'x'):
     92         for second in (b'0', b'1', b'01', b'1048577', b'x'):
     93             headers = [(b'content-length', first), (b'x', b'ignored'), (b'content-length', second)]
     94             rows.append(Case(f'duplicate-{first.decode()}-{second.decode()}', 'contentLength',
     95                              (fields(headers),), BAD_REQUEST))
     96     for coding in (b'', b'chunked', b'identity', b'gzip', b'gzip, chunked'):
     97         for length in (None, b'0', b'42', b'1048577', b'x'):
     98             headers = [(b'transfer-encoding', coding)]
     99             if length is not None:
    100                 headers.append((b'content-length', length))
    101             for direction, order in [('forward', headers), ('reverse', list(reversed(headers)))]:
    102                 label = (coding.decode().replace(', ', '-') or 'empty') + '-' + (
    103                     'absent' if length is None else length.decode()) + '-' + direction
    104                 rows.append(Case('coding-' + label, 'contentLength', (fields(order),), BAD_REQUEST))
    105     for label, headers in [('malformed-list', b'\1\0'), ('malformed-field', tree_list([LEAF])),
    106                            ('improper-headers', fields([(b'content-length', b'1')])[:-1] + b'\1\0')]:
    107         rows.append(Case(label, 'contentLength', (headers,), BAD_REQUEST))
    108     # A pure error action contains neither a Bind nor a recv request. Deliberately
    109     # do not run an IO driver: compare the entire action tree before any effects.
    110     for case in list(rows):
    111         if case.export == 'contentLength' and case.expected in (BAD_REQUEST, TOO_LARGE):
    112             rows.append(Case('no-io-' + case.label, 'readBody',
    113                 (LEAF, case.arguments[0], byte_tree(b'already buffered body')),
    114                 pair(number(0), case.expected)))
    115     rows.append(Case('no-length-keeps-buffer', 'readBody', (LEAF, fields([]), byte_tree(b'body')),
    116                      pair(number(0), result(True, LEAF, byte_tree(b'body'))), True))
    117     return rows
    118 
    119 
    120 def invoke(runner, pin, case, side):
    121     args = []
    122     for index, value in enumerate(case.arguments):
    123         path = runner.root / (case.label + f'-{index}.tree')
    124         path.write_bytes(value)
    125         args += ['--arg-tree-file', path]
    126     return parse_tree_output(runner.run(case.label + '-' + side,
    127         ['run', '--module', pin, '--name', case.export, '--store', runner.store, *args, '--format', 'ternary']))
    128 
    129 
    130 def main():
    131     parser = argparse.ArgumentParser(description=__doc__)
    132     parser.add_argument('tricu', type=Path)
    133     parser.add_argument('--subject', type=Path, required=True)
    134     parser.add_argument('--reference', type=Path)
    135     parser.add_argument('--directory', type=Path, required=True)
    136     parser.add_argument('--case', action='append', help='exact label, repeatable')
    137     args = parser.parse_args()
    138     selected = cases()
    139     if args.case:
    140         unknown = set(args.case) - {c.label for c in selected}
    141         if unknown:
    142             parser.error('unknown cases: ' + ', '.join(sorted(unknown)))
    143         selected = [c for c in selected if c.label in args.case]
    144     root = args.directory.resolve()
    145     root.mkdir(parents=True, exist_ok=False)
    146     runner = Runner(args.tricu, root)
    147     report = dict(complete=False, scope='selected artifacts; no source-provenance or full HTTP-conformance claim',
    148                   runtime_sha256=sha(runner.exe.read_bytes()), runner_sha256=sha(Path(__file__).read_bytes()), cases=[])
    149     try:
    150         subject = root / 'subject.arboricx'
    151         subject.write_bytes(args.subject.read_bytes())
    152         report['subject_sha256'] = sha(subject.read_bytes())
    153         pin = runner.imported(subject, 'subject-import')
    154         report['subject_manifest'] = pin
    155         reference = None
    156         if args.reference:
    157             old = root / 'reference.arboricx'
    158             old.write_bytes(args.reference.read_bytes())
    159             report['reference_sha256'] = sha(old.read_bytes())
    160             reference = runner.imported(old, 'reference-import')
    161         for case in selected:
    162             actual = invoke(runner, pin, case, 'subject')
    163             expect(actual, case.expected, case.label)
    164             if reference and case.compare_reference:
    165                 expect(invoke(runner, reference, case, 'reference'), case.expected, case.label + ' unchanged reference')
    166             report['cases'].append(dict(label=case.label, export=case.export, result_sha256=sha(actual),
    167                 input_sha256=[sha(v) for v in case.arguments], reference_compared=bool(reference and case.compare_reference)))
    168         wrong = root / 'wrong-subject.arboricx'
    169         merge_bundles(wrong, [(subject, [('contentLength', 'parseContentLengthValue')])], prune_unreachable=True)
    170         mutant = runner.imported(wrong, 'mutant-import')
    171         control = Case('selection-control', 'parseContentLengthValue', (byte_tree(b'42'),), length_result(42))
    172         if invoke(runner, mutant, control, 'mutant') == control.expected:
    173             raise ValueError('wrong selected subject unexpectedly passed')
    174         report.update(complete=True, wrong_subject_rejected=True)
    175     except Exception as error:
    176         report['failure'] = str(error)
    177         raise
    178     finally:
    179         report.update(seconds=time.monotonic()-runner.started, invocations=runner.calls)
    180         (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
    181     print(f'PASS: {len(selected)} length cases + wrong-subject control; {report["seconds"]:.2f}s')
    182 
    183 
    184 if __name__ == '__main__':
    185     main()