executable_arguments.py (23415B)
1 #!/usr/bin/env python3 2 """Cheap artifact-only CLI transport checks; no source compilation or generations.""" 3 import hashlib 4 import json 5 from pathlib import Path 6 import re 7 import subprocess 8 import struct 9 import sys 10 import tempfile 11 import unittest 12 13 REPO = Path(__file__).resolve().parent.parent 14 TRICU = Path(sys.argv.pop(1) if len(sys.argv) > 1 else REPO / 'result/bin/tricu').resolve() 15 16 17 class ExecutableArguments(unittest.TestCase): 18 def setUp(self): 19 self.temporary = tempfile.TemporaryDirectory(prefix='tricu-executable-arguments-') 20 self.addCleanup(self.temporary.cleanup) 21 self.root = Path(self.temporary.name) 22 self.store = self.root / 'store' 23 self.identity = self.imported(REPO / 'test/fixtures/id.arboricx', indexed=True) 24 # Many duplicate subtrees, with canonical bytes independent of sharing. 25 value = b'\x02\x01\x00\x00' 26 for _ in range(10): 27 value = b'\x02' + value + value 28 self.value = value 29 self.tree = self.put('arboricx.tree-term.v1', value) 30 self.file = self.root / 'value.tree' 31 self.file.write_bytes(value) 32 self.bundle = self.root / 'value.arboricx' 33 self.call('export', '--target', self.tree, '--name', 'value', '--output', str(self.bundle)) 34 self.flat = self.imported(self.bundle) 35 self.indexed = self.imported(self.bundle, indexed=True) 36 37 def call(self, command, *options, success=True): 38 result = subprocess.run([str(TRICU), 'arboricx', command, '--store', str(self.store), 39 *options, '+RTS', '-N1', '-T', '-M256m', '-RTS'], 40 capture_output=True, text=True, timeout=15) 41 self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) 42 return result 43 44 def imported(self, path, indexed=False): 45 reply = self.call('import', '--file', str(path), *(['--indexed'] if indexed else ['--flat'])) 46 return re.search(r'Created module alias .* -> ([0-9a-f]{64})', reply.stdout)[1] 47 48 def object(self, pin): 49 return self.store / 'objects' / pin[:3] / pin 50 51 def put(self, domain, payload): 52 pin = hashlib.sha256(domain.encode() + b'\0' + payload).hexdigest() 53 path = self.object(pin) 54 path.parent.mkdir(parents=True, exist_ok=True) 55 path.write_bytes(payload) 56 return pin 57 58 def manifest(self, kind, pin, abi='arboricx.abi.tree.v1'): 59 return self.put('arboricx.module-manifest.v1', 60 f'arboricx.module-manifest.v1\nexport\tvalue\t{kind}\t{pin}\t{abi}\n'.encode()) 61 62 def invoke(self, *options, success=True): 63 return self.call('run', '--module', self.identity, '--name', 'id', 64 *options, success=success) 65 66 def indexed_root(self): 67 row = self.object(self.indexed).read_text().splitlines()[1].split('\t') 68 self.assertEqual(row[2], 'arboricx.indexed-tree.v1') 69 return row[3] 70 71 def test_exact_argument_identity(self): 72 expected = self.invoke('--arg-tree-file', str(self.file)).stdout 73 for options in [('--arg-tree-pin', self.tree), 74 ('--arg-export', self.flat + ':value'), 75 ('--arg-export', self.indexed + ':value')]: 76 self.assertEqual(self.invoke(*options).stdout, expected) 77 # Indexed executable input is substantially smaller than its logical tree. 78 self.assertLess(self.object(self.indexed_root()).stat().st_size, len(self.value) // 4) 79 80 def test_indexed_and_mixed_export_paths_preserve_exact_trees(self): 81 expected = self.invoke('--arg-tree-file', str(self.file)).stdout 82 for label, options in [('digest', ['--target', self.indexed_root()]), 83 ('module', ['--module', self.indexed])]: 84 output = self.root / (label + '.arboricx') 85 self.call('export', *options, '--name', 'value', '--output', str(output)) 86 manifest = self.imported(output, indexed=True) 87 self.assertEqual(self.invoke('--arg-export', manifest + ':value').stdout, expected) 88 output = self.root / 'mixed.arboricx' 89 self.call('export', '--target', self.tree, '--target', self.indexed_root(), 90 '--name', 'flat', '--name', 'indexed', '--output', str(output)) 91 manifest = self.imported(output, indexed=True) 92 for name in ('flat', 'indexed'): 93 self.assertEqual(self.invoke('--arg-export', manifest + ':' + name).stdout, expected) 94 95 def test_indexed_export_rejects_corruption_and_wrong_declared_domain(self): 96 output = self.root / 'preserved.arboricx' 97 output.write_bytes(b'old output') 98 wrong = self.manifest('arboricx.tree-term.v1', self.indexed_root()) 99 self.call('export', '--module', wrong, '--output', str(output), success=False) 100 self.assertEqual(output.read_bytes(), b'old output') 101 unsupported = self.manifest('arboricx.indexed-tree.v1', self.indexed_root(), 'unsupported') 102 self.assertIn('ABI', self.call('export', '--module', unsupported, 103 '--output', str(output), success=False).stderr) 104 self.object(self.indexed_root()).write_bytes(b'corrupt') 105 self.call('export', '--target', self.indexed_root(), '--output', str(output), success=False) 106 self.assertEqual(output.read_bytes(), b'old output') 107 108 def test_no_domain_abi_or_alias_fallback(self): 109 pin = self.indexed_root() 110 self.assertIn('digest/domain mismatch', self.invoke('--arg-tree-pin', pin, success=False).stderr) 111 wrong = self.manifest('arboricx.tree-term.v1', pin) 112 self.assertIn('digest mismatch', self.invoke('--arg-export', wrong + ':value', success=False).stderr) 113 for kind, abi, diagnostic in [('unknown.kind', 'arboricx.abi.tree.v1', 'object kind'), 114 ('arboricx.indexed-tree.v1', 'wrong.abi', 'ABI')]: 115 wrong = self.manifest(kind, pin, abi) 116 self.assertIn(diagnostic, self.invoke('--arg-export', wrong + ':value', success=False).stderr) 117 self.invoke('--arg-export', 'id:id', success=False) 118 119 def test_corruption_missing_export_and_bad_selection(self): 120 self.invoke('--arg-export', self.indexed + ':absent', success=False) 121 for selection in [self.indexed, self.indexed + ':', self.indexed + ':value:extra']: 122 self.assertIn('expected MANIFEST:EXPORT', self.invoke('--arg-export', selection, success=False).stderr) 123 path = self.object(self.indexed_root()) 124 original = path.read_bytes() 125 path.write_bytes(original + b'corrupt') 126 self.assertIn('digest mismatch', self.invoke('--arg-export', self.indexed + ':value', success=False).stderr) 127 path.write_bytes(original) 128 manifest = self.object(self.indexed) 129 manifest.write_bytes(manifest.read_bytes() + b'corrupt') 130 self.invoke('--arg-export', self.indexed + ':value', success=False) 131 # Valid digest but invalid syntax must fail after digest verification. 132 malformed = self.put('arboricx.tree-term.v1', b'\x02\x00') 133 wrong = self.manifest('arboricx.tree-term.v1', malformed) 134 self.assertIn('Invalid executable tree', self.invoke('--arg-export', wrong + ':value', success=False).stderr) 135 malformed = self.put('arboricx.indexed-tree.v1', b'not a bundle') 136 wrong = self.manifest('arboricx.indexed-tree.v1', malformed) 137 self.assertIn('Invalid executable tree', self.invoke('--arg-export', wrong + ':value', success=False).stderr) 138 139 140 def test_io_no_result_keeps_output_and_errors(self): 141 from stable_bundle import byte_tree 142 def natural(value): 143 result = b'' 144 while value: 145 result += b'\x02' + (b'\x01\0' if value & 1 else b'\0') 146 value >>= 1 147 return result + b'\0' 148 def pair(a, b): 149 return b'\x02' + a + b 150 path = self.root / 'io.tree' 151 def action(tag, payload): 152 path.write_bytes(pair(byte_tree(b'tricuIO'), pair(natural(2), pair(natural(tag), payload)))) 153 options = ['--module', self.identity, '--name', 'id', '--arg-tree-file', str(path)] 154 # Authenticated artifact-only v2 output program; no source compiler in 155 # this transport test. Expected output remains an independent literal. 156 from bundle_dag import merge_bundles 157 program = self.root / 'output.arboricx' 158 merge_bundles(program, [(REPO / 'test/fixtures/support-library.arboricx', 159 [('demos_interactionTrees_yield.main', 'main')])], prune_unreachable=True) 160 output_module = self.imported(program, indexed=True) 161 options = ['--module', output_module, '--name', 'main'] 162 expected_output = 'A says 1\nA says 2\nA says 3\nB says 1\nB says 2\nB says 3\nFinished: doneA doneB\n' 163 self.assertEqual(self.call('run', *options, '--io').stdout, expected_output + 't') 164 self.assertEqual(self.call('run', *options, '--io', '--no-result').stdout, expected_output) 165 for full in [False, True]: 166 flags = ['--profile'] + (['--profile-full-result'] if full else []) 167 profiled = self.call('run', *options, '--io', '--no-result', *flags) 168 self.assertEqual(profiled.stdout, expected_output) 169 labels = [json.loads(line)['profile'] for line in profiled.stderr.splitlines() 170 if line.startswith('{')] 171 expected = ['load-begin', 'load-end', 'invoke-begin-1', 'invoke-end-1'] 172 if full: 173 expected += ['force-begin-1', 'force-end-1'] 174 expected += ['observe-begin-1', 'observe-end-1'] 175 self.assertEqual([label for label in labels if not label.startswith('yield-')], expected) 176 for flags in [['--no-result'], ['--io', '--no-result', '--result']]: 177 reply = self.call('run', *options, *flags, success=False) 178 self.assertEqual(reply.stdout, '') # reject before doing IO 179 self.assertIn('--no-result requires --io', reply.stderr) 180 options = ['--module', self.identity, '--name', 'id', '--arg-tree-file', str(path)] 181 action(2, byte_tree(b'explicit failure')) 182 failed = self.call('run', *options, '--io', '--no-result', success=False) 183 self.assertEqual(failed.stdout, '') 184 self.assertIn('IO task failed', failed.stderr) 185 186 class ArtifactSafety(unittest.TestCase): 187 """Independent wire fixtures and real CLI paths, all inside temporary stores.""" 188 def setUp(self): 189 temporary = tempfile.TemporaryDirectory(prefix='tricu-artifact-safety-') 190 self.addCleanup(temporary.cleanup) 191 self.root = Path(temporary.name) 192 self.store = self.root / 'store' 193 194 def call(self, *args, success=True): 195 reply = subprocess.run([str(TRICU), *map(str, args), '--store', str(self.store), 196 '+RTS', '-N1', '-T', '-M256m', '-RTS'], 197 cwd=self.root, capture_output=True, text=True, timeout=15) 198 self.assertEqual(reply.returncode == 0, success, reply.stdout + reply.stderr) 199 return reply 200 201 def bundle(self, names=('value',), *, depth=0, result_remainder=False, evaluation='normal-order', kind='term', abi='arboricx.abi.tree.v1'): 202 # One leaf, two indexed sections, manifest v1.1. All lengths/offsets are 203 # rebuilt, not patched so that malformed lengths masquerade as rejection. 204 def word(n): 205 return struct.pack('>I', n) 206 def text(s): 207 b = s.encode('utf-8') 208 return word(len(b)) + b 209 root = depth + 3 if result_remainder else depth 210 fields = ['arboricx.bundle.manifest.v1', 'tree-calculus-executable-object', 211 'tree-calculus.v1', 'indexed', 'arboricx.indexed.node.v1', 212 'arboricx.indexed.payload.v1', 'tree-calculus.v1', evaluation, 'arboricx.abi.tree.v1'] 213 manifest = (b'ARBMNFST' + struct.pack('>HH', 1, 1) + b''.join(map(text, fields)) 214 + word(0) + b'\0' + word(1) + word(root) + text('default') + word(len(names)) 215 + b''.join(text(name) + word(root) + text(kind if i else 'term') 216 + text(abi if i else 'arboricx.abi.tree.v1') for i, name in enumerate(names)) 217 + word(0) + word(0)) 218 nodes = (struct.pack('>Q', root + 1) + word(1) + b'\0' 219 + b''.join(word(9) + b'\x02' + word(i-1) + word(i-1) for i in range(1, depth+1))) 220 if result_remainder: 221 # ok Leaf remainder, with an exponentially large but shared remainder. 222 nodes += (word(5) + b'\x01' + word(0) 223 + word(9) + b'\x02' + word(0) + word(depth) 224 + word(9) + b'\x02' + word(depth + 1) + word(depth + 2)) 225 def section(identifier, offset, length): 226 return struct.pack('>IHHIQQI', identifier, 1, 1, 0, offset, length, 0) 227 data = (b'ARBORICX' + struct.pack('>HHIQQ', 1, 0, 2, 0, 32) 228 + section(1, 96, len(manifest)) + section(2, 96 + len(manifest), len(nodes)) 229 + manifest + nodes) 230 path = self.root / 'input.arboricx' 231 path.write_bytes(data) 232 return path 233 234 def test_tiny_exponential_bundle_stays_indexed_and_flat_fails_before_writes(self): 235 bundle = self.bundle(depth=80) 236 reply = self.call('arboricx', 'import', '--file', bundle, '--flat', success=False) 237 self.assertIn('flat import byte limit exceeded', reply.stderr) 238 self.assertEqual(list(self.store.glob('objects/*/*')), []) 239 self.call('arboricx', 'import', '--file', bundle) 240 self.assertLess(sum(p.stat().st_size for p in self.store.glob('objects/*/*')), 4096) 241 242 def test_profile_observes_selected_result_and_full_forcing_is_explicit(self): 243 reply = self.call('arboricx', 'import', '--file', self.bundle(depth=80, result_remainder=True)) 244 pin = re.search(r'Created module alias .* -> ([0-9a-f]{64})', reply.stdout)[1] 245 command = ['arboricx', 'run', '--module', pin, '--name', 'value', '--result', '--format', 'number'] 246 ordinary = self.call(*command) 247 self.assertEqual(ordinary.stdout, 'ok: 0') 248 for full in [False, True]: 249 options = ['--profile'] + (['--profile-full-result'] if full else []) 250 profiled = self.call(*command, *options) 251 self.assertEqual(profiled.stdout, ordinary.stdout) 252 labels = [json.loads(line)['profile'] for line in profiled.stderr.splitlines() if line.startswith('{')] 253 expected = ['load-begin', 'load-end', 'invoke-begin-1', 'invoke-end-1'] 254 if full: 255 expected += ['force-begin-1', 'force-end-1'] 256 expected += ['observe-begin-1', 'observe-end-1'] 257 self.assertEqual(labels, expected) 258 failure = self.call(*command, '--profile-full-result', success=False) 259 self.assertIn('--profile-full-result requires --profile', failure.stderr) 260 261 def test_flat_expansion_boundary(self): 262 bundle = self.bundle(depth=10) # 2**11 - 1 canonical bytes 263 for budget, success in [('2046', False), ('2047', True)]: 264 reply = self.call('arboricx', 'import', '--file', bundle, '--flat', 265 '--max-flat-bytes', budget, success=success) 266 if not success: 267 self.assertIn('flat import byte limit exceeded', reply.stderr) 268 self.assertEqual(list(self.store.glob('objects/*/*')), []) 269 for bad in ['0', '-1']: 270 self.assertIn('flat import byte limit must be positive', self.call( 271 'arboricx', 'import', '--file', bundle, '--flat', '--max-flat-bytes', bad, success=False).stderr) 272 273 def test_export_rejects_duplicate_names_without_overwriting_output(self): 274 pin, _ = self.put('arboricx.tree-term.v1', b'\0') 275 output = self.root / 'existing.arboricx' 276 output.write_bytes(b'unchanged') 277 reply = self.call('arboricx', 'export', '--target', pin, '--target', pin, 278 '--name', 'same', '--name', 'same', '--output', output, success=False) 279 self.assertIn('duplicate export name', reply.stderr) 280 self.assertEqual(output.read_bytes(), b'unchanged') 281 282 def test_stored_duplicate_exports_rejected(self): 283 pin, manifest, _ = self.stored_module() 284 content = manifest.read_bytes() 285 duplicate, _ = self.put('arboricx.module-manifest.v1', content + content.splitlines(keepends=True)[1]) 286 self.assertIn('duplicate export name', self.call('arboricx', 'run', '--module', duplicate, '--name', 'value', success=False).stderr) 287 288 def test_default_import_is_indexed(self): 289 self.call('arboricx', 'import', '--file', self.bundle()) 290 self.assertIn('arboricx.indexed-tree.v1', (self.store / 'aliases/names/value').read_text()) 291 292 def test_duplicate_exports_rejected_before_publication(self): 293 for mode in ('--flat', '--indexed'): 294 reply = self.call('arboricx', 'import', '--file', self.bundle(('value', 'value')), mode, success=False) 295 self.assertIn('duplicate export name', reply.stderr) 296 self.assertEqual(list(self.store.glob('objects/*/*')), []) 297 298 def test_flat_budget_is_aggregate_and_checked_before_publication(self): 299 bundle = self.bundle(('one', 'two')) # two one-byte exports 300 reply = self.call('arboricx', 'import', '--file', bundle, '--flat', '--max-flat-bytes', '1', success=False) 301 self.assertIn('flat import byte limit exceeded', reply.stderr) 302 self.assertEqual(list(self.store.glob('objects/*/*')), []) 303 self.call('arboricx', 'import', '--file', bundle, '--flat', '--max-flat-bytes', '2') 304 305 def test_bad_export_names_publish_no_aliases(self): 306 outside = self.root / 'sentinel' 307 bad_names = ['../escape', '../../../sentinel', str(outside), 'a\\b', 'C:escape', '.', '..', 'bad\nname'] 308 for indexed in (False, True): 309 for index, name in enumerate(bad_names): 310 with self.subTest(indexed=indexed, name=name): 311 self.store = self.root / f'store-{indexed}-{index}' 312 alias = self.store / 'aliases/names/good' 313 alias.parent.mkdir(parents=True) 314 alias.write_bytes(b'previous pointer\n') 315 outside.write_bytes(b'unchanged') 316 reply = self.call('arboricx', 'import', '--file', self.bundle(('good', name)), 317 *(['--indexed'] if indexed else ['--flat']), success=False) 318 self.assertIn('invalid alias name', reply.stderr) 319 self.assertEqual(alias.read_bytes(), b'previous pointer\n') 320 self.assertEqual(outside.read_bytes(), b'unchanged') 321 self.assertEqual(list((self.store / 'aliases/names').iterdir()), [alias]) 322 self.assertEqual(list((self.store / 'aliases/modules').iterdir()), []) 323 324 def test_bad_module_name_precedes_publication(self): 325 outside = self.root / 'sentinel' 326 outside.write_bytes(b'unchanged') 327 for indexed in (False, True): 328 reply = self.call('arboricx', 'import', '--file', self.bundle(), '--module', outside, 329 *(['--indexed'] if indexed else ['--flat']), success=False) 330 self.assertIn('invalid alias name', reply.stderr) 331 self.assertEqual(outside.read_bytes(), b'unchanged') 332 self.assertFalse(self.store.exists()) 333 334 def test_execution_contract_checked_before_any_object_publication(self): 335 for indexed in (False, True): 336 for option, needle in [('evaluation', 'runtime evaluation'), ('kind', 'export kind'), ('abi', 'export ABI')]: 337 for invalid in ('', 'unsupported'): 338 with self.subTest(indexed=indexed, option=option, invalid=invalid): 339 reply = self.call('arboricx', 'import', '--file', self.bundle(('good', 'bad'), **{option: invalid}), 340 *(['--indexed'] if indexed else ['--flat']), success=False) 341 self.assertIn(needle, reply.stderr) 342 self.assertEqual(list(self.store.glob('objects/*/*')), []) 343 self.assertEqual(list(self.store.glob('aliases/*/*')), []) 344 345 def test_valid_qualified_and_symbolic_names(self): 346 for indexed in (False, True): 347 self.call('arboricx', 'import', '--file', self.bundle(('native_math.isNat?', '++', 'λ')), 348 *(['--indexed'] if indexed else ['--flat'])) 349 for name in ('native_math.isNat?', '++', 'λ'): 350 self.assertTrue((self.store / 'aliases/names' / name).is_file()) 351 352 def put(self, domain, payload): 353 pin = hashlib.sha256(domain.encode() + b'\0' + payload).hexdigest() 354 path = self.store / 'objects' / pin[:3] / pin 355 path.parent.mkdir(parents=True, exist_ok=True) 356 path.write_bytes(payload) 357 return pin, path 358 359 def stored_module(self, *, abi='arboricx.abi.tree.v1', tree_ref=None): 360 tree, path = self.put('arboricx.tree-term.v1', b'\0') 361 pin, manifest = self.put('arboricx.module-manifest.v1', 362 f'arboricx.module-manifest.v1\nexport\tvalue\tarboricx.tree-term.v1\t{tree_ref or tree}\t{abi}\n'.encode()) 363 return pin, manifest, path 364 365 def invoke_stored(self, pin, *, success=True): 366 return self.call('arboricx', 'run', '--module', pin, '--name', 'value', 367 '--format', 'fsl', success=success) 368 369 def test_stored_valid_leaf(self): 370 pin, _, _ = self.stored_module() 371 self.assertEqual(self.invoke_stored(pin).stdout.strip(), 'Leaf') 372 373 def test_stored_rejects_tampered_manifest(self): 374 pin, manifest, _ = self.stored_module() 375 manifest.write_bytes(manifest.read_bytes().replace(b'value', b'other')) 376 self.assertIn('digest mismatch', self.invoke_stored(pin, success=False).stderr) 377 378 def test_stored_rejects_tampered_term(self): 379 pin, _, term = self.stored_module() 380 term.write_bytes(b'\x01\0') 381 self.assertIn('digest mismatch', self.invoke_stored(pin, success=False).stderr) 382 383 def test_stored_rejects_unsupported_abi(self): 384 pin, _, _ = self.stored_module(abi='unsupported') 385 self.assertIn('Unsupported module export ABI', self.invoke_stored(pin, success=False).stderr) 386 387 def test_stored_rejects_outside_object_reference(self): 388 outside = self.root / 'outside.tree' 389 outside.write_bytes(b'\0') 390 pin, _, _ = self.stored_module(tree_ref=str(outside)) 391 self.assertIn('Invalid executable tree hash', self.invoke_stored(pin, success=False).stderr) 392 self.assertEqual(outside.read_bytes(), b'\0') 393 394 class CommandBoundary(unittest.TestCase): 395 def test_archived_scp_command_is_not_installed(self): 396 help_result = subprocess.run([str(TRICU), '--help'], capture_output=True, text=True, timeout=10) 397 self.assertEqual(help_result.returncode, 0) 398 self.assertNotIn('SCP', help_result.stdout) 399 result = subprocess.run([str(TRICU), 'scp', '--help'], capture_output=True, text=True, timeout=10) 400 self.assertNotEqual(result.returncode, 0) 401 self.assertIn("Invalid argument", result.stderr) 402 403 404 if __name__ == '__main__': 405 unittest.main()