tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

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