handler_costs.py (18179B)
1 #!/usr/bin/env python3 2 """Matched-obligation handler/direct comparison and test-only check ablations. 3 4 nix build .# 5 python3 test/handler_costs.py result/bin/tricu --directory /tmp/handler-costs 6 7 No production checks are removed. Private contract bodies are shared by exposing 8 extra exports ONLY in a temporary compilation workspace. Every successful result 9 and malformed-reply control has a Python oracle; samples include full rendering. 10 """ 11 import argparse 12 import hashlib 13 import json 14 import os 15 from pathlib import Path 16 import re 17 import statistics 18 19 from handler_library import tree, ok, err, diagnostic, http 20 from http_headers import result, pair, number, tree_list 21 from source_order import Runner, end, field 22 from stable_bundle import byte_tree 23 24 ROOT = Path(__file__).resolve().parent.parent 25 PROFILES = { 26 'matched': [1, 1, 1, 1, 1], 27 'without-api-digest': [0, 1, 1, 1, 1], 28 'without-body-bytes': [1, 0, 1, 1, 1], 29 'without-context-list': [1, 1, 0, 1, 1], 30 'without-event-shape': [1, 1, 1, 0, 1], 31 'without-sink-reply': [1, 1, 1, 1, 0], 32 'without-added-checks': [0, 0, 0, 0, 0], 33 } 34 35 36 def main(): 37 parser = argparse.ArgumentParser(description=__doc__) 38 parser.add_argument('tricu', type=Path) 39 parser.add_argument('--directory', type=Path, required=True) 40 parser.add_argument('--repeats', type=int, default=5) 41 parser.add_argument('--checks-only', action='store_true') 42 args = parser.parse_args() 43 if os.geteuid() == 0: 44 parser.error("OS-denial controls require non-root") 45 if not 3 <= args.repeats <= 10: 46 parser.error('--repeats must be within 3..10') 47 directory = args.directory.resolve() 48 directory.mkdir(parents=True, exist_ok=False) 49 inputs = directory / 'inputs'; inputs.mkdir() 50 original_workspace = ROOT / 'test/handlers/tricu.workspace' 51 locations = {} 52 for line in original_workspace.read_text().splitlines()[1:]: 53 alias, location = line.split('\t') 54 locations[alias] = (original_workspace.parent / location).resolve() 55 originals = dict(locations) 56 for alias, exports in [('handler_artifacts', ['loadReplyC', 'byteC', 'bytesC']), ('handler_log', ['eventC', 'replyC'])]: 57 original = locations[alias].read_text() 58 extra = ''.join('!export '+name+'\n' for name in exports if '!export '+name+'\n' not in original) 59 path = inputs / (alias+'.tri') 60 lines = original.splitlines(keepends=True) 61 boundary = max(i for i, line in enumerate(lines) if line.startswith('!export '))+1 62 prefix, suffix = ''.join(lines[:boundary]), ''.join(lines[boundary:]) 63 path.write_text(prefix+extra+suffix) 64 assert prefix+suffix == original 65 locations[alias] = path 66 locations['handler_fixture'] = ROOT / 'test/handlers/library.tri' 67 (inputs / 'tricu.workspace').write_text('tricu.workspace.v1\n'+''.join( 68 alias+'\t'+os.path.relpath(path, inputs)+'\n' for alias, path in locations.items())) 69 source = ROOT / 'test/handlers/equivalent.tri' 70 subject = inputs / 'equivalent.tri'; subject.write_bytes(source.read_bytes()) 71 runner = Runner(args.tricu, directory) 72 bundle = directory / 'equivalent.arboricx' 73 runner.run('compile', ['compile', '--file', subject, '--store', runner.store, '--output', bundle, '--compile-timeout', '600'], seconds=660) 74 pin = runner.imported(bundle, 'import') 75 passed = [] 76 77 def arguments(label, values): 78 out = [] 79 for i, value in enumerate(values): 80 path = directory / f'{label}-{i}.tree'; path.write_bytes(tree(value)) 81 out += ['--arg-tree-file', path] 82 return out 83 84 def invoke(label, export, values, permissions=()): 85 output = runner.run(label, ['run', '--module', pin, '--name', export, '--store', runner.store, 86 '--io', '--format', 'ternary', '--profile', *arguments(label, values), *permissions]) 87 text = next(re.match(r'^([012]+)', line)[1] for line in output.splitlines() if re.match(r'^[012]+', line)) 88 raw = bytes(map(int, text)); return raw[:end(raw)] 89 90 def check(label, export, values, expected, permissions=()): 91 actual = invoke(label, export, values, permissions) 92 wanted = tree(expected) 93 if actual != wanted: 94 (directory / (label+'-actual.tree')).write_bytes(actual) 95 (directory / (label+'-expected.tree')).write_bytes(wanted) 96 raise AssertionError(label) 97 passed.append(label) 98 99 # Exact structural domains, not numeric coercion: all small trees plus 100 # canonical values and malformed bit/tail mutations. Batch only to avoid 101 # process startup dominating the exhaustive octet/hex checks. 102 shapes = {1: [b'\0']} 103 for nodes in range(2, 8): 104 shapes[nodes] = [b'\1'+v for v in shapes[nodes-1]] 105 for left in range(1, nodes-1): 106 shapes[nodes] += [pair(a, b) for a in shapes[left] for b in shapes[nodes-1-left]] 107 candidates = {number(n) for n in range(1025)} 108 candidates.update(v for group in shapes.values() for v in group) 109 for n in range(256): 110 bits = [b'\1\0' if n >> i & 1 else b'\0' for i in range(n.bit_length())] 111 candidates.add(tree_list(bits+[b'\0'])) # redundant high zero 112 candidates.add(tree_list(bits, b'\1\0')) # improper tail 113 for i in range(len(bits)): 114 for bad in [b'\1\1\0', pair(b'\0', b'\0')]: 115 changed = bits.copy(); changed[i] = bad 116 candidates.add(tree_list(changed)) 117 octets = {number(n) for n in range(256)} 118 hexes = {number(n) for n in b'0123456789abcdef'} 119 def pure_check(label, export, values, expected): 120 actual = runner.invoke(label, pin, export, arguments(label, [values])) 121 wanted = tree(expected) 122 if actual != wanted: 123 (directory / (label+'-expected.tree')).write_bytes(wanted) 124 (directory / (label+'-actual.tree')).write_bytes(actual) 125 raise AssertionError(label) 126 passed.append(label) 127 candidates = sorted(candidates) 128 for start in range(0, len(candidates), 128): 129 batch = candidates[start:start+128] 130 pure_check(f'octets-{start}', 'validateOctets', batch, 131 [result(True, v, number(7)) if v in octets else result(False, tree('expected octet'), number(7)) for v in batch]) 132 pure_check(f'hex-{start}', 'validateHex', batch, 133 [b'\1\0' if v in hexes else b'\0' for v in batch]) 134 def length_oracle(value): 135 count = 0 136 while value[:1] == b'\2': 137 count += 1 138 value = field(value, 1) 139 return number(count) if value == b'\0' else err('not a list') 140 lengths = candidates + [tree([255]*n) for n in (0, 1, 7, 8, 63, 64, 255, 256, 1023, 1024, 4095, 4096)] 141 for start in range(0, len(lengths), 128): 142 batch = lengths[start:start+128] 143 expected = [length_oracle(value) for value in batch] 144 pure_check(f'lengths-{start}', 'validateLengths', batch, expected) 145 pure_check(f'reference-lengths-{start}', 'validateReferenceLengths', batch, expected) 146 pure_check('length-demand', 'lengthDemand', [], [1, err('not a list')]) 147 list_cases = [([], None), (list(range(256)), None), ([0, 256], 'expected octet'), 148 (b'\1\0', 'not a list'), (tree_list([number(0)], b'\1\0'), 'not a list'), 149 (tree_list([number(256)], b'\1\0'), 'expected octet')] 150 pure_check('byte-lists', 'validateByteLists', [v for v, _ in list_cases], 151 [result(True, tree(v), number(7)) if message is None else result(False, tree(message), number(7)) for v, message in list_cases]) 152 hashes = [] 153 for position in (0, 31, 63): 154 for char in range(256): 155 value = [97]*64; value[position] = char 156 hashes.append((tree(value), char in b'0123456789abcdef')) 157 for length in (0, 1, 63, 64, 65, 128): 158 hashes.append((tree([97]*length), length == 64)) 159 hashes += [(tree_list([number(97)]*64, b'\1\0'), False), 160 (tree_list([number(97)]*63+[pair(b'\0', b'\0')]), False)] 161 for start in range(0, len(hashes), 128): 162 batch = hashes[start:start+128] 163 pure_check(f'hashes-{start}', 'validateHashes', [v for v, _ in batch], 164 [result(True, v, number(7)) if valid else result(False, tree('expected 64 lowercase hexadecimal hash characters'), number(7)) for v, valid in batch]) 165 pure_check('hash-demand', 'hashDemand', [], 166 [result(False, tree('expected 64 lowercase hexadecimal hash characters'), number(7))]*2) 167 validator_cases = 2*len(candidates)+len(list_cases)+len(hashes)+2 168 length_cases = 2*len(lengths)+2 169 170 digest = 'a'*64 171 fields = [['request', 7]] 172 body = b'\0\xffA' 173 good_read, good_log = ok(byte_tree(body)), ok(0) 174 read_trace = ['read', digest] 175 def log_trace(hash=digest, status=200, context=fields): 176 return ['log', ['info', context, [hash, status]]] 177 label = ['bundle-get'] 178 invalid_digest = 'expected 64 lowercase hexadecimal hash characters' 179 cases = [ 180 ('success', fields, digest, good_read, good_log, ok(http(200, body)), [read_trace, log_trace()]), 181 ('ordinary-read-error', fields, digest, err('private path'), good_log, 182 ok(http(404, 'Artifact unavailable\n')), [read_trace, log_trace(status=404)]), 183 ('invalid-digest', fields, '../secret', good_read, good_log, 184 ok(http(400, invalid_digest)), [log_trace(hash='../secret', status=400)]), 185 ('invalid-context', b'\1\0', digest, good_read, good_log, 186 err(diagnostic('invalid-request', 'logging', 'context', 'context must be a proper list')), []), 187 ('malformed-read', fields, digest, tree(0), good_log, 188 err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact-load Result with empty remainder', label)), [read_trace]), 189 ('read-remainder', fields, digest, result(True, byte_tree(body), tree(1)), good_log, 190 err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact-load Result with empty remainder', label)), [read_trace]), 191 ('byte-256', fields, digest, ok([256]), good_log, 192 err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact bytes', label)), [read_trace]), 193 ('noncanonical-byte', fields, digest, ok([pair(b'\0', b'\0')]), good_log, 194 err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact bytes', label)), [read_trace]), 195 ('improper-bytes', fields, digest, ok(pair(tree(65), b'\1\0')), good_log, 196 err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact bytes', label)), [read_trace]), 197 ('ordinary-log-error', fields, digest, good_read, err('offline'), 198 err(diagnostic('application-failure', 'logging', 'emit', 'offline', label)), [read_trace, log_trace()]), 199 ('malformed-log', fields, digest, good_read, tree(0), 200 err(diagnostic('invalid-response', 'logging', 'emit', 'expected log Result with empty remainder', label)), [read_trace, log_trace()]), 201 ('log-nonunit', fields, digest, good_read, ok(1), 202 err(diagnostic('invalid-response', 'logging', 'emit', 'expected log Result with empty remainder', label)), [read_trace, log_trace()]), 203 ('log-remainder', fields, digest, good_read, result(True, tree(0), tree(1)), 204 err(diagnostic('invalid-response', 'logging', 'emit', 'expected log Result with empty remainder', label)), [read_trace, log_trace()]), 205 ] 206 for name, context, hash, read_reply, log_reply, value, trace in cases: 207 for export in ['directProbe', 'handlerProbe']: 208 check(export+'-'+name, export, [context, hash, read_reply, log_reply], [value, trace]) 209 210 # Counterexamples establish that the switches really omit checks, while 211 # invariant controls prove the envelope/path protections remain present. 212 invalid_byte_response = tree([*b'HTTP/1.1 200 OK\r\nContent-Type: application/vnd.arboricx.bundle\r\nContent-Length: 1\r\nConnection: close\r\n\r\n', 256]) 213 check('omitted-byte-check', 'directProbeProfile', 214 [PROFILES['without-body-bytes'], fields, digest, ok([256]), good_log], 215 [ok(invalid_byte_response), [read_trace, log_trace()]]) 216 check('envelope-still-checked', 'directProbeProfile', 217 [PROFILES['without-body-bytes'], fields, digest, result(True, byte_tree(body), tree(1)), good_log], 218 [err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact-load Result with empty remainder', label)), [read_trace]]) 219 check('omitted-reply-check', 'directProbeProfile', 220 [PROFILES['without-sink-reply'], fields, digest, good_read, ok(1)], 221 [ok(http(200, body)), [read_trace, log_trace()]]) 222 check('omitted-api-check-with-mock', 'directProbeProfile', 223 [PROFILES['without-api-digest'], fields, 'bad', good_read, good_log], 224 [ok(http(200, body)), [['read', 'bad'], log_trace(hash='bad')]]) 225 226 root = directory / 'artifacts'; root.mkdir() 227 permissions = () 228 # Every profile retains the backend boundary, including the all-off control. 229 check('raw-invalid', 'rawReader', [str(root), '../secret'], err(['invalid-digest', invalid_digest])) 230 check('matched-invalid', 'ioEquivalent', [PROFILES['matched'], str(root), '../secret', [0]], 231 ok([[http(400, invalid_digest)], [['info', [['request', 0]], ['../secret', 400]]]])) 232 check('all-off-invalid', 'ioEquivalent', [PROFILES['without-added-checks'], str(root), '../secret', [0]], 233 ok([[http(400, invalid_digest)], [['info', [['request', 0]], ['../secret', 400]]]])) 234 235 variants = [('direct', 'ioDirect', None), 236 ('matched-direct', 'ioEquivalent', PROFILES['matched']), 237 ('library-sink', 'ioLibrarySink', None), 238 ('library-capture', 'ioLibraryCapture', None)] 239 variants += [(name, 'ioEquivalent', profile) for name, profile in PROFILES.items() if name != 'matched'] 240 benchmarks = [] 241 if not args.checks_only: 242 # Larger bodies expose the cost of the response-octet traversal. These 243 # are still warm local files, not a network or concurrent-server test. 244 for size, count in [(3, 20), (1024, 5)]: 245 body = (bytes(range(256))*((size+255)//256))[:size] 246 digest = hashlib.sha256(body).hexdigest() 247 path = root / 'objects' / digest[:3] / (digest+'.arboricx') 248 path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(body) 249 contexts = list(range(count)) 250 expected = ok([[http(200, body)]*count, 251 [['info', [['request', context]], [digest, 200]] for context in contexts]]) 252 # Negative permission control for each primary path, same policy. 253 for name, export, profile in variants[:4]: 254 values = ([profile] if profile is not None else []) + [str(root), digest, [0]] 255 denied = ok([[http(404, 'Artifact unavailable\n')], 256 [['info', [['request', 0]], [digest, 404]]]]) 257 root.chmod(0) 258 try: 259 check(f'{name}-denied-{size}', export, values, denied) 260 finally: 261 root.chmod(0o700) 262 rows = {name: [] for name, _, _ in variants} 263 for repeat in range(args.repeats): 264 # Deterministic rotation across fresh processes; no --repeat. 265 offset = (repeat*3) % len(variants) 266 order = variants[offset:]+variants[:offset] 267 for name, export, profile in order: 268 label = f'bench-{size}-{name}-{repeat}' 269 values = ([profile] if profile is not None else []) + [str(root), digest, contexts] 270 check(label, export, values, expected, permissions) 271 rows[name].append(runner.records[-1]) 272 for name, export, profile in variants: 273 samples = rows[name] 274 benchmarks.append(dict(variant=name, export=export, profile=profile, 275 body_bytes=size, requests=count, repeats=args.repeats, 276 seconds=statistics.median(r['invoke_seconds'] for r in samples), 277 seconds_min=min(r['invoke_seconds'] for r in samples), 278 seconds_max=max(r['invoke_seconds'] for r in samples), 279 allocated_bytes=statistics.median(r['allocated_bytes'] for r in samples))) 280 paths = {*originals.values(), ROOT / 'test/handlers/library.tri', source, 281 Path(__file__).resolve(), ROOT / 'test/handler_library.py', 282 ROOT / 'bootstrap/build-stable.arboricx', *inputs.iterdir()} 283 report = dict(schema='tricu.handler-costs.v1', passed=passed, benchmarks=benchmarks, 284 comparison='same-run obligation-matched direct versus library sink; simple direct omits handler-layer contracts', 285 obligations=['API digest validation', 'backend digest validation', 'service Result envelope/remainder', 286 'canonical octet list', 'request context list', 'event shape before and after decoration', 287 'sink Result unit/remainder', 'contract success-envelope validation', 288 'identical host IO state logging', 'complete HTTP response/log rendering'], 289 profiles=PROFILES, validator_cases=validator_cases, length_cases=length_cases, host=str(args.tricu.resolve()), 290 bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest(), 291 inputs={str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(paths)}, 292 limits=['all allocation figures are volume, not residency', 293 'direct/library share authenticated filesystem policy; trace accumulation is explicit source state, not native state', 294 'ablations are test-only and never disable backend digest/path or Result envelope checks', 295 'marginal ablation savings are not additive and are not pure contract execution timings', 296 'full response/log observation, warm reads, no networking or concurrency']) 297 (directory / 'report.json').write_text(json.dumps(report, indent=2)+'\n') 298 print(json.dumps(report, indent=2)) 299 300 301 if __name__ == '__main__': 302 main()