executable_arguments.py (22723B)
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(1), pair(natural(tag), payload)))) 153 options = ['--module', self.identity, '--name', 'id', '--arg-tree-file', str(path)] 154 action(10, byte_tree(b'program output\n')) 155 self.assertEqual(self.call('run', *options, '--io').stdout, 'program output\nt') 156 self.assertEqual(self.call('run', *options, '--io', '--no-result').stdout, 'program output\n') 157 for full in [False, True]: 158 flags = ['--profile'] + (['--profile-full-result'] if full else []) 159 profiled = self.call('run', *options, '--io', '--no-result', *flags) 160 self.assertEqual(profiled.stdout, 'program output\n') 161 labels = [json.loads(line)['profile'] for line in profiled.stderr.splitlines() 162 if line.startswith('{')] 163 expected = ['load-begin', 'load-end', 'invoke-begin-1', 'invoke-end-1'] 164 if full: 165 expected += ['force-begin-1', 'force-end-1'] 166 expected += ['observe-begin-1', 'observe-end-1'] 167 self.assertEqual([label for label in labels if not label.startswith('yield-')], expected) 168 for flags in [['--no-result'], ['--io', '--no-result', '--result']]: 169 reply = self.call('run', *options, *flags, success=False) 170 self.assertEqual(reply.stdout, '') # reject before doing IO 171 self.assertIn('--no-result requires --io', reply.stderr) 172 action(2, byte_tree(b'explicit failure')) 173 failed = self.call('run', *options, '--io', '--no-result', success=False) 174 self.assertEqual(failed.stdout, '') 175 self.assertIn('explicit failure', failed.stderr) 176 177 class ArtifactSafety(unittest.TestCase): 178 """Independent wire fixtures and real CLI paths, all inside temporary stores.""" 179 def setUp(self): 180 temporary = tempfile.TemporaryDirectory(prefix='tricu-artifact-safety-') 181 self.addCleanup(temporary.cleanup) 182 self.root = Path(temporary.name) 183 self.store = self.root / 'store' 184 185 def call(self, *args, success=True): 186 reply = subprocess.run([str(TRICU), *map(str, args), '--store', str(self.store), 187 '+RTS', '-N1', '-T', '-M256m', '-RTS'], 188 cwd=self.root, capture_output=True, text=True, timeout=15) 189 self.assertEqual(reply.returncode == 0, success, reply.stdout + reply.stderr) 190 return reply 191 192 def bundle(self, names=('value',), *, depth=0, result_remainder=False, evaluation='normal-order', kind='term', abi='arboricx.abi.tree.v1'): 193 # One leaf, two indexed sections, manifest v1.1. All lengths/offsets are 194 # rebuilt, not patched so that malformed lengths masquerade as rejection. 195 def word(n): 196 return struct.pack('>I', n) 197 def text(s): 198 b = s.encode('utf-8') 199 return word(len(b)) + b 200 root = depth + 3 if result_remainder else depth 201 fields = ['arboricx.bundle.manifest.v1', 'tree-calculus-executable-object', 202 'tree-calculus.v1', 'indexed', 'arboricx.indexed.node.v1', 203 'arboricx.indexed.payload.v1', 'tree-calculus.v1', evaluation, 'arboricx.abi.tree.v1'] 204 manifest = (b'ARBMNFST' + struct.pack('>HH', 1, 1) + b''.join(map(text, fields)) 205 + word(0) + b'\0' + word(1) + word(root) + text('default') + word(len(names)) 206 + b''.join(text(name) + word(root) + text(kind if i else 'term') 207 + text(abi if i else 'arboricx.abi.tree.v1') for i, name in enumerate(names)) 208 + word(0) + word(0)) 209 nodes = (struct.pack('>Q', root + 1) + word(1) + b'\0' 210 + b''.join(word(9) + b'\x02' + word(i-1) + word(i-1) for i in range(1, depth+1))) 211 if result_remainder: 212 # ok Leaf remainder, with an exponentially large but shared remainder. 213 nodes += (word(5) + b'\x01' + word(0) 214 + word(9) + b'\x02' + word(0) + word(depth) 215 + word(9) + b'\x02' + word(depth + 1) + word(depth + 2)) 216 def section(identifier, offset, length): 217 return struct.pack('>IHHIQQI', identifier, 1, 1, 0, offset, length, 0) 218 data = (b'ARBORICX' + struct.pack('>HHIQQ', 1, 0, 2, 0, 32) 219 + section(1, 96, len(manifest)) + section(2, 96 + len(manifest), len(nodes)) 220 + manifest + nodes) 221 path = self.root / 'input.arboricx' 222 path.write_bytes(data) 223 return path 224 225 def test_tiny_exponential_bundle_stays_indexed_and_flat_fails_before_writes(self): 226 bundle = self.bundle(depth=80) 227 reply = self.call('arboricx', 'import', '--file', bundle, '--flat', success=False) 228 self.assertIn('flat import byte limit exceeded', reply.stderr) 229 self.assertEqual(list(self.store.glob('objects/*/*')), []) 230 self.call('arboricx', 'import', '--file', bundle) 231 self.assertLess(sum(p.stat().st_size for p in self.store.glob('objects/*/*')), 4096) 232 233 def test_profile_observes_selected_result_and_full_forcing_is_explicit(self): 234 reply = self.call('arboricx', 'import', '--file', self.bundle(depth=80, result_remainder=True)) 235 pin = re.search(r'Created module alias .* -> ([0-9a-f]{64})', reply.stdout)[1] 236 command = ['arboricx', 'run', '--module', pin, '--name', 'value', '--result', '--format', 'number'] 237 ordinary = self.call(*command) 238 self.assertEqual(ordinary.stdout, 'ok: 0') 239 for full in [False, True]: 240 options = ['--profile'] + (['--profile-full-result'] if full else []) 241 profiled = self.call(*command, *options) 242 self.assertEqual(profiled.stdout, ordinary.stdout) 243 labels = [json.loads(line)['profile'] for line in profiled.stderr.splitlines() if line.startswith('{')] 244 expected = ['load-begin', 'load-end', 'invoke-begin-1', 'invoke-end-1'] 245 if full: 246 expected += ['force-begin-1', 'force-end-1'] 247 expected += ['observe-begin-1', 'observe-end-1'] 248 self.assertEqual(labels, expected) 249 failure = self.call(*command, '--profile-full-result', success=False) 250 self.assertIn('--profile-full-result requires --profile', failure.stderr) 251 252 def test_flat_expansion_boundary(self): 253 bundle = self.bundle(depth=10) # 2**11 - 1 canonical bytes 254 for budget, success in [('2046', False), ('2047', True)]: 255 reply = self.call('arboricx', 'import', '--file', bundle, '--flat', 256 '--max-flat-bytes', budget, success=success) 257 if not success: 258 self.assertIn('flat import byte limit exceeded', reply.stderr) 259 self.assertEqual(list(self.store.glob('objects/*/*')), []) 260 for bad in ['0', '-1']: 261 self.assertIn('flat import byte limit must be positive', self.call( 262 'arboricx', 'import', '--file', bundle, '--flat', '--max-flat-bytes', bad, success=False).stderr) 263 264 def test_export_rejects_duplicate_names_without_overwriting_output(self): 265 pin, _ = self.put('arboricx.tree-term.v1', b'\0') 266 output = self.root / 'existing.arboricx' 267 output.write_bytes(b'unchanged') 268 reply = self.call('arboricx', 'export', '--target', pin, '--target', pin, 269 '--name', 'same', '--name', 'same', '--output', output, success=False) 270 self.assertIn('duplicate export name', reply.stderr) 271 self.assertEqual(output.read_bytes(), b'unchanged') 272 273 def test_stored_duplicate_exports_rejected(self): 274 pin, manifest, _ = self.stored_module() 275 content = manifest.read_bytes() 276 duplicate, _ = self.put('arboricx.module-manifest.v1', content + content.splitlines(keepends=True)[1]) 277 self.assertIn('duplicate export name', self.call('arboricx', 'run', '--module', duplicate, '--name', 'value', success=False).stderr) 278 279 def test_default_import_is_indexed(self): 280 self.call('arboricx', 'import', '--file', self.bundle()) 281 self.assertIn('arboricx.indexed-tree.v1', (self.store / 'aliases/names/value').read_text()) 282 283 def test_duplicate_exports_rejected_before_publication(self): 284 for mode in ('--flat', '--indexed'): 285 reply = self.call('arboricx', 'import', '--file', self.bundle(('value', 'value')), mode, success=False) 286 self.assertIn('duplicate export name', reply.stderr) 287 self.assertEqual(list(self.store.glob('objects/*/*')), []) 288 289 def test_flat_budget_is_aggregate_and_checked_before_publication(self): 290 bundle = self.bundle(('one', 'two')) # two one-byte exports 291 reply = self.call('arboricx', 'import', '--file', bundle, '--flat', '--max-flat-bytes', '1', success=False) 292 self.assertIn('flat import byte limit exceeded', reply.stderr) 293 self.assertEqual(list(self.store.glob('objects/*/*')), []) 294 self.call('arboricx', 'import', '--file', bundle, '--flat', '--max-flat-bytes', '2') 295 296 def test_bad_export_names_publish_no_aliases(self): 297 outside = self.root / 'sentinel' 298 bad_names = ['../escape', '../../../sentinel', str(outside), 'a\\b', 'C:escape', '.', '..', 'bad\nname'] 299 for indexed in (False, True): 300 for index, name in enumerate(bad_names): 301 with self.subTest(indexed=indexed, name=name): 302 self.store = self.root / f'store-{indexed}-{index}' 303 alias = self.store / 'aliases/names/good' 304 alias.parent.mkdir(parents=True) 305 alias.write_bytes(b'previous pointer\n') 306 outside.write_bytes(b'unchanged') 307 reply = self.call('arboricx', 'import', '--file', self.bundle(('good', name)), 308 *(['--indexed'] if indexed else ['--flat']), success=False) 309 self.assertIn('invalid alias name', reply.stderr) 310 self.assertEqual(alias.read_bytes(), b'previous pointer\n') 311 self.assertEqual(outside.read_bytes(), b'unchanged') 312 self.assertEqual(list((self.store / 'aliases/names').iterdir()), [alias]) 313 self.assertEqual(list((self.store / 'aliases/modules').iterdir()), []) 314 315 def test_bad_module_name_precedes_publication(self): 316 outside = self.root / 'sentinel' 317 outside.write_bytes(b'unchanged') 318 for indexed in (False, True): 319 reply = self.call('arboricx', 'import', '--file', self.bundle(), '--module', outside, 320 *(['--indexed'] if indexed else ['--flat']), success=False) 321 self.assertIn('invalid alias name', reply.stderr) 322 self.assertEqual(outside.read_bytes(), b'unchanged') 323 self.assertFalse(self.store.exists()) 324 325 def test_execution_contract_checked_before_any_object_publication(self): 326 for indexed in (False, True): 327 for option, needle in [('evaluation', 'runtime evaluation'), ('kind', 'export kind'), ('abi', 'export ABI')]: 328 for invalid in ('', 'unsupported'): 329 with self.subTest(indexed=indexed, option=option, invalid=invalid): 330 reply = self.call('arboricx', 'import', '--file', self.bundle(('good', 'bad'), **{option: invalid}), 331 *(['--indexed'] if indexed else ['--flat']), success=False) 332 self.assertIn(needle, reply.stderr) 333 self.assertEqual(list(self.store.glob('objects/*/*')), []) 334 self.assertEqual(list(self.store.glob('aliases/*/*')), []) 335 336 def test_valid_qualified_and_symbolic_names(self): 337 for indexed in (False, True): 338 self.call('arboricx', 'import', '--file', self.bundle(('native_math.isNat?', '++', 'λ')), 339 *(['--indexed'] if indexed else ['--flat'])) 340 for name in ('native_math.isNat?', '++', 'λ'): 341 self.assertTrue((self.store / 'aliases/names' / name).is_file()) 342 343 def put(self, domain, payload): 344 pin = hashlib.sha256(domain.encode() + b'\0' + payload).hexdigest() 345 path = self.store / 'objects' / pin[:3] / pin 346 path.parent.mkdir(parents=True, exist_ok=True) 347 path.write_bytes(payload) 348 return pin, path 349 350 def stored_module(self, *, abi='arboricx.abi.tree.v1', tree_ref=None): 351 tree, path = self.put('arboricx.tree-term.v1', b'\0') 352 pin, manifest = self.put('arboricx.module-manifest.v1', 353 f'arboricx.module-manifest.v1\nexport\tvalue\tarboricx.tree-term.v1\t{tree_ref or tree}\t{abi}\n'.encode()) 354 return pin, manifest, path 355 356 def invoke_stored(self, pin, *, success=True): 357 return self.call('arboricx', 'run', '--module', pin, '--name', 'value', 358 '--format', 'fsl', success=success) 359 360 def test_stored_valid_leaf(self): 361 pin, _, _ = self.stored_module() 362 self.assertEqual(self.invoke_stored(pin).stdout.strip(), 'Leaf') 363 364 def test_stored_rejects_tampered_manifest(self): 365 pin, manifest, _ = self.stored_module() 366 manifest.write_bytes(manifest.read_bytes().replace(b'value', b'other')) 367 self.assertIn('digest mismatch', self.invoke_stored(pin, success=False).stderr) 368 369 def test_stored_rejects_tampered_term(self): 370 pin, _, term = self.stored_module() 371 term.write_bytes(b'\x01\0') 372 self.assertIn('digest mismatch', self.invoke_stored(pin, success=False).stderr) 373 374 def test_stored_rejects_unsupported_abi(self): 375 pin, _, _ = self.stored_module(abi='unsupported') 376 self.assertIn('Unsupported module export ABI', self.invoke_stored(pin, success=False).stderr) 377 378 def test_stored_rejects_outside_object_reference(self): 379 outside = self.root / 'outside.tree' 380 outside.write_bytes(b'\0') 381 pin, _, _ = self.stored_module(tree_ref=str(outside)) 382 self.assertIn('Invalid executable tree hash', self.invoke_stored(pin, success=False).stderr) 383 self.assertEqual(outside.read_bytes(), b'\0') 384 385 class CommandBoundary(unittest.TestCase): 386 def test_archived_scp_command_is_not_installed(self): 387 help_result = subprocess.run([str(TRICU), '--help'], capture_output=True, text=True, timeout=10) 388 self.assertEqual(help_result.returncode, 0) 389 self.assertNotIn('SCP', help_result.stdout) 390 result = subprocess.run([str(TRICU), 'scp', '--help'], capture_output=True, text=True, timeout=10) 391 self.assertNotEqual(result.returncode, 0) 392 self.assertIn("Invalid argument", result.stderr) 393 394 395 if __name__ == '__main__': 396 unittest.main()