arboricx_readers.py (11263B)
1 #!/usr/bin/env python3 2 """Opt-in reader rewrite checks against explicit full module bundles. 3 4 Independent field/truncation oracles plus full-tree differential malformed-input 5 and recursive-worker probes. Does not compile, publish, or prove source identity. 6 This is a frozen historical-cohort comparator (including unit suffix loss), 7 not the current-candidate oracle. Use bundle_batch.py for rewritten boundaries. 8 """ 9 import argparse 10 import hashlib 11 import json 12 from pathlib import Path 13 14 from http_headers import LEAF, TRUE, expect, number, pair, result, tree_list 15 from source_order import Runner 16 from stable_bundle import byte_tree 17 18 19 def product(xs): 20 return xs[0] if len(xs) == 1 else pair(xs[0], product(xs[1:])) 21 22 23 def u32(n): 24 return n.to_bytes(4, 'big') 25 26 27 def string(s): 28 return u32(len(s)) + s 29 30 31 class Failure(Exception): 32 def __init__(self, code, rest): 33 self.tree = result(False, number(code), byte_tree(rest)) 34 35 36 class Reader: 37 def __init__(self, data): 38 self.data = data 39 40 def take(self, n): 41 if len(self.data) < n: 42 raise Failure(1, self.data) 43 value, self.data = self.data[:n], self.data[n:] 44 return value 45 46 def magic(self, value): 47 original = self.data 48 for expected in value: 49 if not self.data: 50 raise Failure(1, original) 51 if self.take(1)[0] != expected: 52 raise Failure(2, original) 53 54 def string(self): 55 return self.take(int.from_bytes(self.take(4), 'big')) 56 57 def root(self): 58 return pair(byte_tree(self.take(4)), byte_tree(self.string())) 59 60 def export(self): 61 return product([byte_tree(self.string()), byte_tree(self.take(4)), 62 byte_tree(self.string()), byte_tree(self.string())]) 63 64 def tlv(self): 65 return pair(byte_tree(self.take(2)), byte_tree(self.string())) 66 67 def repeated(self, worker): 68 return tree_list([worker() for _ in range(int.from_bytes(self.take(4), 'big'))]) 69 70 def core(self): 71 self.magic(b'ARBMNFST') 72 self.take(2) 73 self.take(2) 74 fields = [byte_tree(self.string()) for _ in range(9)] 75 fields += [self.repeated(lambda: byte_tree(self.string())), byte_tree(self.take(1)), 76 self.repeated(self.root), self.repeated(self.export)] 77 return product(fields) 78 79 80 def cases(): 81 core = (b'ARBMNFST' + b'\x00\x01\x00\x00' + b''.join(string(x) for x in 82 [b'schema', b'bundle', b'tree', b'hash', b'domain', b'payload', b'semantics', b'eval', b'abi']) 83 + u32(2) + string(b'fs') + string(b'net') + b'\0' 84 + u32(1) + u32(0) + string(b'main') 85 + u32(1) + string(b'main') + u32(0) + string(b'tree') + string(b'abi')) 86 specs = [ 87 ('manifest', 'readLengthPrefixedString', string(b'a\0\xff'), lambda r: byte_tree(r.string())), 88 ('manifest', 'readRootEntry', u32(7) + string(b'role'), lambda r: r.root()), 89 ('manifest', 'readExportEntry', string(b'name') + u32(7) + string(b'kind') + string(b'abi'), lambda r: r.export()), 90 ('manifest', 'readTLV', b'\0\x03' + string(b'a\0\xff'), lambda r: r.tlv()), 91 ('manifest', 'readManifestCore', core, lambda r: r.core()), 92 ('common', 'readArboricxHeader', b'ARBORICX' + bytes(range(24)), 93 lambda r: (r.magic(b'ARBORICX'), product([byte_tree(r.take(n)) for n in [2, 2, 4, 8, 8]]))[1]), 94 ('common', 'readSectionRecord', bytes(range(32)), 95 lambda r: product([byte_tree(r.take(n)) for n in [4, 2, 2, 2, 2, 8, 8, 4]])), 96 ('nodes', 'readNodeRecord', u32(5) + b'\x01\0\0\0\0', lambda r: byte_tree(r.string())), 97 ] 98 for module, export, encoded, oracle in specs: 99 inputs = [(f'cut-{i}', encoded[:i]) for i in range(len(encoded))] 100 inputs += [('exact', encoded), ('suffix', encoded + b'\0\xffTAIL')] 101 if export in ('readManifestCore', 'readArboricxHeader'): 102 inputs += [('bad-magic', b'!' + encoded[1:])] 103 for label, data in inputs: 104 r = Reader(data) 105 try: 106 value = oracle(r) 107 expected = result(True, value, byte_tree(r.data)) 108 except Failure as error: 109 expected = error.tree 110 yield module, export + '-' + label, export, [byte_tree(data)], expected 111 # Historical full-manifest entrypoint drops its successful suffix. Retain 112 # this independent expectation separately from the field-reader protocol. 113 full = core + u32(1) + b'\0\x03' + string(b'metadata') + u32(1) + b'\0\x05' + string(b'extension') 114 expected = result(True, pair(Reader(core).core(), pair( 115 tree_list([pair(byte_tree(b'\0\x03'), byte_tree(b'metadata'))]), LEAF))) 116 for suffix in [b'', b'TAIL']: 117 yield 'manifest', 'full-suffix-' + suffix.hex(), 'readManifest', [byte_tree(full + suffix)], expected 118 # Counted workers: zero, multiple items, exact suffix and failure remainder. 119 for module, export, one, value in [ 120 ('manifest', 'readCapabilities', string(b'x'), byte_tree(b'x')), 121 ('manifest', 'readRoots', u32(0) + string(b'r'), pair(byte_tree(u32(0)), byte_tree(b'r'))), 122 ('manifest', 'readExports', string(b'e') + u32(0) + string(b'k') + string(b'a'), 123 product([byte_tree(b'e'), byte_tree(u32(0)), byte_tree(b'k'), byte_tree(b'a')])), 124 ('manifest', 'readTLVList', b'\0\x01' + string(b'v'), pair(byte_tree(b'\0\x01'), byte_tree(b'v'))), 125 ('common', 'readSectionDirectory', bytes(32), product([byte_tree(bytes(n)) for n in [4,2,2,2,2,8,8,4]])), 126 ('nodes', 'readNodeRecords', u32(1) + b'\0', byte_tree(b'\0')), 127 ]: 128 for count in [0, 1, 3]: 129 yield module, export + '-' + str(count), export, [number(count), byte_tree(one*count+b'TAIL')], result(True, tree_list([value]*count), byte_tree(b'TAIL')) 130 yield module, export+'-missing', export, [number(2), byte_tree(one)], result(False, number(1)) 131 # Noncanonical byte-list spines: compatibility comparisons, not new safety 132 # guarantees. Keep counts bounded to avoid unbounded attacker lengths. 133 for module, export in [('nodes','readNodeRecord'), ('manifest','readLengthPrefixedString'), 134 ('common','readSectionRecord')]: 135 for i, raw in enumerate([TRUE, b'\1\1\0', tree_list([number(0)], TRUE)]): 136 yield module, export+'-malformed-'+str(i), export, [raw], None 137 for tag in [0, 1, 2, 3]: 138 for payload in [b'', b'\0', b'\1\0\0\0\0', b'\2'+bytes(8), b'\3']: 139 yield 'nodes', f'tag-{tag}-{payload.hex()}', 'nodePayloadHasTag?', [number(tag), byte_tree(payload)], TRUE if payload and payload[0] == tag else LEAF 140 141 for i, records in enumerate([ 142 [], [b'\0'], [b'\0', b'\0'], [b'\0', b'\1'+u32(0)], 143 [b'\0', b'\1'+u32(0), b'\2'+u32(0)+u32(1)], 144 [b'\1'+u32(0)], [b'\0', b'\1'+u32(1)], 145 [b'\0', b'\2'+u32(0)+u32(2)], [b''], [b'\3'], 146 [b'\0x'], [b'\1'+bytes(3)], [b'\2'+bytes(7)], 147 [b'\1'+u32(99), b'\3'], 148 ]): 149 encoded = tree_list([byte_tree(x) for x in records]) 150 invalid = any(not (x == b'\0' or (len(x) == 5 and x[0] == 1) 151 or (len(x) == 9 and x[0] == 2)) for x in records) 152 bad_index = any(int.from_bytes(x[k:k+4], 'big') >= j 153 for j,x in enumerate(records) for k in range(1,len(x),4)) 154 code = 8 if invalid else 9 if bad_index else None 155 rest = byte_tree(b'TAIL') 156 expected = result(code is None, encoded if code is None else number(code), rest) 157 yield 'nodes', f'validate-{i}', 'validateNodeRecords', [encoded,rest], expected 158 for name in [b'a', b'b', b'missing']: 159 exports = [product([byte_tree(n),byte_tree(u32(i)),byte_tree(b'k'),byte_tree(b'abi')]) 160 for i,n in enumerate([b'a',b'b',b'a'])] 161 value = exports[0] if name == b'a' else exports[1] if name == b'b' else None 162 yield 'manifest', 'find-'+name.hex(), 'findExportByName', [tree_list(exports),byte_tree(name)], LEAF if value is None else b'\1'+value 163 yield 'manifest', 'names-'+name.hex(), 'getExportNames', [tree_list(exports)], tree_list([byte_tree(n) for n in [b'a',b'b',b'a']]) 164 for i, raw in enumerate([LEAF, TRUE, b'\1\1\0']): 165 for module, export, args in [ 166 ('manifest','findExportByName',[raw,byte_tree(b'a')]), 167 ('manifest','getExportNames',[raw]), 168 ('manifest','lookupMetadata',[raw,byte_tree(b'xx')]), 169 ('common','lookupSectionRecord',[byte_tree(b'xxxx'),raw]), 170 ('nodes','nodeRecordsHaveInvalidPayload?',[raw]), 171 ('nodes','nodeChildIndicesInPrefix?',[raw,LEAF,number(0)]), 172 ]: 173 yield module, f'{export}-shape-{i}', export, args, None 174 175 176 def main(): 177 p = argparse.ArgumentParser(description=__doc__) 178 p.add_argument('tricu', type=Path) 179 p.add_argument('--subject', type=Path, required=True, help='candidate directory') 180 p.add_argument('--reference', type=Path, required=True, help='retained baseline directory') 181 p.add_argument('--directory', type=Path, required=True) 182 args = p.parse_args() 183 args.directory.mkdir(parents=True) 184 runner = Runner(args.tricu, args.directory) 185 pins, identities = {}, {} 186 for role, directory in [('subject',args.subject),('reference',args.reference)]: 187 for module in ['manifest','common','nodes']: 188 path = directory / ('arboricx_'+module+'.arboricx') 189 pins[role,module] = runner.imported(path,role+'-'+module) 190 identities[role+'-'+module] = hashlib.sha256(path.read_bytes()).hexdigest() 191 report = dict(complete=False, artifacts=identities, runtime_sha256=hashlib.sha256(args.tricu.read_bytes()).hexdigest(), 192 harness_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), cases=[]) 193 try: 194 for module,label,export,values,expected in cases(): 195 argv=[] 196 for i,value in enumerate(values): 197 path=args.directory/(label+f'-{i}.tree');path.write_bytes(value) 198 argv+=['--arg-tree-file',path] 199 before=runner.invoke(label+'-reference',pins['reference',module],export,argv) 200 after=runner.invoke(label+'-subject',pins['subject',module],export,argv) 201 if expected is not None: 202 expect(before,expected,label+' reference oracle') 203 expect(after,expected,label+' subject oracle') 204 expect(after,before,label+' complete comparison') 205 report['cases'].append(dict(label=label,oracle=expected is not None,result_sha256=hashlib.sha256(after).hexdigest())) 206 # Same arity, wrong export must not satisfy the independently specified result. 207 raw=byte_tree(string(b'x'));path=args.directory/'wrong.tree';path.write_bytes(raw) 208 wrong=runner.invoke('wrong-subject',pins['subject','manifest'],'readRootEntry',['--arg-tree-file',path]) 209 assert wrong != result(True,byte_tree(b'x')), 'wrong export accepted' 210 report.update(complete=True,wrong_subject_rejected=True) 211 finally: 212 (args.directory/'report.json').write_text(json.dumps(report,indent=2)+'\n') 213 print(f'PASS: {len(report["cases"])} reader cases + wrong-subject control') 214 215 216 if __name__ == '__main__': 217 main()