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