project_inspection.py (8000B)
1 #!/usr/bin/env python3 2 """Offline, read-only project reports against independent DAG/CAS fixtures.""" 3 import argparse 4 import hashlib 5 from pathlib import Path 6 import struct 7 import subprocess 8 import tempfile 9 from stable_bundle import assembled, sections 10 11 ROOT = Path(__file__).resolve().parent.parent 12 ABI = 'arboricx.abi.tree.v1' 13 KIND = 'arboricx.indexed-tree.v1' 14 15 16 def main(): 17 p = argparse.ArgumentParser(description=__doc__) 18 p.add_argument('tricu', type=Path) 19 exe = p.parse_args().tricu.resolve() 20 with tempfile.TemporaryDirectory(prefix='project-inspection-') as temp: 21 root = Path(temp); store = root / 'cas' 22 def put(kind, data): 23 pin = hashlib.sha256(kind.encode() + b'\0' + data).hexdigest() 24 path = store / 'objects' / pin[:3] / pin 25 path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(data) 26 return pin 27 manifest, _ = sections(ROOT / 'test/fixtures/id.arboricx') 28 at = 12 29 for _ in range(9): 30 size = struct.unpack_from('>I', manifest, at)[0]; at += 4 + size 31 assert struct.unpack_from('>I', manifest, at)[0] == 0 32 prefix = manifest[:at + 5] 33 def text(value): 34 data = value.encode(); return struct.pack('>I', len(data)) + data 35 def graph(nodes, selected=None): 36 selected = len(nodes) - 1 if selected is None else selected 37 m = (prefix + struct.pack('>II', 1, selected) + text('default') + struct.pack('>I', 1) 38 + text('root') + struct.pack('>I', selected) + text('term') + text(ABI) + struct.pack('>II', 0, 0)) 39 records = [bytes([len(children)]) + b''.join(struct.pack('>I', c) for c in children) for children in nodes] 40 return assembled(m, struct.pack('>Q', len(nodes)) + b''.join(struct.pack('>I', len(r)) + r for r in records)) 41 def esc(value): 42 return value.replace('%', '%25').replace('\t', '%09').replace('\n', '%0A').replace('\r', '%0D') 43 def module(exports): 44 data = 'arboricx.module-manifest.v1\n' 45 for name, obj, abi, kind in exports: 46 data += '\t'.join(['export', esc(name), esc(kind), obj, esc(abi)]) + '\n' 47 return put('arboricx.module-manifest.v1', data.encode()) 48 def lock(file, pin, version): 49 file.write_text('arboricx.project-lock.v1\nfoundation\thome\thttp://127.0.0.1:1\tbase\t' + version + '\t' + pin + '\t' + 'b'*64 + '\n') 50 before, after = root / 'before.lock', root / 'tricu.lock' 51 declaration = root / 'tricu.project' 52 declaration.write_text('arboricx.project.v1\nfoundation\thome\tbase\t2\n') 53 def snapshot(): 54 return {str(p.relative_to(root)): (p.read_bytes(), p.stat().st_mtime_ns) for p in root.rglob('*') if p.is_file()} 55 def run(action, *args, ok=True, selected_store=store): 56 saved = snapshot() 57 reply = subprocess.run([str(exe), 'project', action, '--store', str(selected_store), *map(str, args), 58 '+RTS', '-N1', '-M512m', '-RTS'], cwd=root, capture_output=True, text=True, timeout=30) 59 assert (reply.returncode == 0) == ok, (reply.stdout, reply.stderr) 60 assert snapshot() == saved, 'report mutated files' 61 assert '\x1b' not in reply.stdout 62 return reply.stdout 63 def diff(ok=True, selected_store=store): 64 return run('diff', '--before', before, '--after', after, ok=ok, selected_store=selected_store) 65 left = put(KIND, graph([[], [0], [0, 0], [1, 2]])) 66 right = put(KIND, graph([[], [0, 0], [0], [2, 1]])) 67 assert left != right # reordered physical nodes, identical ordered tree 68 old = module([('value', left, ABI, KIND)]) 69 new = module([('value', right, ABI, KIND), ('extra', left, ABI, KIND)]) 70 lock(before, old, '1'); lock(after, new, '2') 71 report = diff() 72 assert 'object changed; tree identical' in report and 'export extra: added' in report 73 assert 'Updated foundation' in report 74 # Bind once via the real ownership protocol; reporting must not rewrite it. 75 subprocess.run([str(exe), 'project', 'bind', '--store', str(store), '+RTS', '-N1', '-M512m', '-RTS'], cwd=root, check=True, capture_output=True, timeout=30) 76 assert 'local module: verified' in run('explain') 77 binding_bytes = (root / 'foundation.tri').read_bytes() 78 (root / 'foundation.tri').write_text('edited source\n') 79 assert 'generated binding: differs' in run('explain', ok=False) 80 (root / 'foundation.tri').write_bytes(binding_bytes) 81 pending = root / '.arboricx-project-bindings.pending' 82 pending.write_text('interrupted') 83 assert 'pending or unreadable' in run('explain', ok=False) 84 pending.unlink() 85 assert 'generated binding: matches lock' in run('explain') 86 declaration.write_text(declaration.read_text().replace('base\t2', 'base\t3')) 87 assert 'declared: home base@3' in run('explain', ok=False) 88 # Same pin is NOT sufficient evidence when its local bytes are absent/bad. 89 lock(before, new, '2') 90 target = store / 'objects' / right[:3] / right 91 saved = target.read_bytes(); target.unlink() 92 assert 'tree unknown' in diff(ok=False) and 'missing' in diff(ok=False) 93 target.write_bytes(saved + b'corrupt') 94 assert 'invalid:' in diff(ok=False) 95 target.write_bytes(saved) 96 empty = root / 'absent-store' 97 assert 'unknown (module evidence unavailable)' in diff(ok=False, selected_store=empty) 98 assert not empty.exists() 99 # Exponential logical tree and an extra unreachable record: no unfolding. 100 nodes = [[]] + [[i-1, i-1] for i in range(1, 81)] 101 huge = put(KIND, graph(nodes)) 102 extra = put(KIND, graph(nodes + [[0]], 80)) 103 lock(before, module([('huge', huge, ABI, KIND)]), '1') 104 lock(after, module([('huge', extra, ABI, KIND)]), '2') 105 assert 'object changed; tree identical' in diff() 106 different = put(KIND, graph(nodes[:-1] + [[79]])) 107 lock(after, module([('huge', different, ABI, KIND)]), '2') 108 assert 'tree different' in diff() 109 # Unsupported ABIs/kinds are explicit; no semantic equivalence inferred. 110 lock(after, module([('huge', huge, 'future.abi', KIND)]), '2') 111 report = diff(ok=False) 112 assert 'ABI changed' in report and 'unsupported:' in report and 'tree unknown' in report 113 lock(after, module([('huge', huge, ABI, 'future.kind')]), '2') 114 assert 'kind changed' in diff(ok=False) 115 # Malformed-but-authenticated DAG and invalid object paths fail as evidence. 116 malformed = put(KIND, graph([[], [1]], 0)) 117 lock(after, module([('huge', malformed, ABI, KIND)]), '2') 118 assert 'invalid:' in diff(ok=False) 119 lock(after, module([('huge', '../outside', ABI, KIND)]), '2') 120 assert 'invalid:' in diff(ok=False) 121 unsafe = 'tab\tline\n%\x1b é' 122 lock(after, module([(unsafe, left, ABI, KIND)]), '2') 123 report = diff() 124 assert '%09' in report and '%0a' in report and '%25' in report and '%1b' in report and '%c3%a9' in report 125 assert 'export huge: removed' in report 126 oversized = put('arboricx.module-manifest.v1', b'x' * 65537) 127 lock(after, oversized, '2') 128 assert 'inspection byte budget' in diff(ok=False) 129 # Nonregular CAS files must not be followed or block on FIFO reads. 130 import os 131 path = store / 'objects' / oversized[:3] / oversized 132 path.unlink(); os.mkfifo(path) 133 assert 'expected regular CAS file' in diff(ok=False) 134 path.unlink() 135 after.write_text('arboricx.project-lock.v1\n') 136 assert 'Removed foundation' in diff() 137 print('PASS read-only explain/diff; independent DAG equality, exponential sharing, missing/corrupt/unsupported evidence and display escaping') 138 139 140 if __name__ == '__main__': 141 main()