tricu

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

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