import { describe, expect, test } from 'bun:test'; import fc from 'fast-check'; import { MerkleLog, auditPath, consistencyProof, emptyRootHash, leafHash, nodeHash, recomputeRootFromAuditPath, verifyConsistencyProof, } from '../src/index.js'; import { mth } from '../src/log.js'; function buildLog(n: number): MerkleLog { const log = new MerkleLog(); for (let i = 0; i < n; i++) { log.appendData(new Uint8Array([i & 0xff, (i >> 8) & 0xff])); } return log; } describe('MerkleLog basics', () => { test('empty tree root = SHA-256(empty)', () => { const log = new MerkleLog(); expect(log.rootHash()).toEqual(emptyRootHash()); }); test('single-leaf tree root = leaf_hash(d)', () => { const log = new MerkleLog(); const d = new Uint8Array([0xab, 0xcd]); log.appendData(d); expect(log.rootHash()).toEqual(leafHash(d)); }); test('two-leaf tree root = node_hash(leaf0, leaf1)', () => { const log = new MerkleLog(); const d0 = new Uint8Array([1]); const d1 = new Uint8Array([2]); log.appendData(d0); log.appendData(d1); expect(log.rootHash()).toEqual(nodeHash(leafHash(d0), leafHash(d1))); }); test('append-only ordering preserved', () => { const log = new MerkleLog(); log.appendData(new Uint8Array([1])); log.appendData(new Uint8Array([2])); expect(log.size).toBe(2); log.appendData(new Uint8Array([3])); expect(log.size).toBe(3); }); }); describe('Audit path verification', () => { test('valid audit path reconstructs root for every leaf, every size 1..32', () => { for (let n = 1; n <= 32; n++) { const log = buildLog(n); const root = log.rootHash(); for (let m = 0; m < n; m++) { const path = log.auditPath(m); const lh = log.leafHashAt(m); const reconstructed = recomputeRootFromAuditPath(lh, m, n, path); expect(Buffer.from(reconstructed).toString('hex')).toBe( Buffer.from(root).toString('hex'), ); } } }); test('property: tampered leaf fails verification', () => { const log = buildLog(7); const root = log.rootHash(); const path = log.auditPath(3); const tampered = new Uint8Array(log.leafHashAt(3)); tampered[0] ^= 0xff; const reconstructed = recomputeRootFromAuditPath(tampered, 3, 7, path); expect(Buffer.from(reconstructed).toString('hex')).not.toBe( Buffer.from(root).toString('hex'), ); }); test('property: tampered audit path fails verification', () => { const log = buildLog(11); const root = log.rootHash(); const path = log.auditPath(5); if (path.length === 0) return; const tampered = path.map((p, i) => { if (i === 0) { const x = new Uint8Array(p); x[0] ^= 0xff; return x; } return p; }); const reconstructed = recomputeRootFromAuditPath(log.leafHashAt(5), 5, 11, tampered); expect(Buffer.from(reconstructed).toString('hex')).not.toBe( Buffer.from(root).toString('hex'), ); }); test('property-based: random N and m', () => { fc.assert( fc.property(fc.integer({ min: 1, max: 100 }), (n) => { const log = buildLog(n); const root = log.rootHash(); const m = Math.min(n - 1, Math.floor(Math.random() * n)); const path = log.auditPath(m); const reconstructed = recomputeRootFromAuditPath(log.leafHashAt(m), m, n, path); return Buffer.from(reconstructed).toString('hex') === Buffer.from(root).toString('hex'); }), { numRuns: 50 }, ); }); }); describe('Consistency proofs', () => { test('m === 0 always consistent', () => { const log = buildLog(5); const newRoot = log.rootHash(); expect(verifyConsistencyProof(0, 5, emptyRootHash(), newRoot, [])).toBe(true); }); test('m === n consistent only when both roots match and proof empty', () => { const log = buildLog(5); const root = log.rootHash(); expect(verifyConsistencyProof(5, 5, root, root, [])).toBe(true); const wrong = new Uint8Array(root); wrong[0] ^= 0xff; expect(verifyConsistencyProof(5, 5, wrong, root, [])).toBe(false); }); test('valid proof verifies for every (m, n) up to 16', () => { for (let n = 1; n <= 16; n++) { const newLog = buildLog(n); const newRoot = newLog.rootHash(); for (let m = 0; m <= n; m++) { const oldLog = buildLog(m); const oldRoot = oldLog.rootHash(); const proof = newLog.consistencyProof(m); expect(verifyConsistencyProof(m, n, oldRoot, newRoot, proof)).toBe(true); } } }); test('fork detection — re-write history fails consistency', () => { const original = buildLog(5); const oldRoot = original.rootHash(); // Server "rewrites" leaf 2: build a new log where leaf 2 has different data. const tampered = new MerkleLog(); for (let i = 0; i < 5; i++) { tampered.appendData( i === 2 ? new Uint8Array([0x99, 0x99]) : new Uint8Array([i & 0xff, (i >> 8) & 0xff]), ); } tampered.appendData(new Uint8Array([0x77])); const tamperedRoot = tampered.rootHash(); const proof = tampered.consistencyProof(5); expect(verifyConsistencyProof(5, 6, oldRoot, tamperedRoot, proof)).toBe(false); }); }); describe('mth helper', () => { test('mth over slice == sub-tree root', () => { const log = buildLog(4); const leaves = log.exportLeaves(); const left = mth(leaves, 0, 2); const right = mth(leaves, 2, 4); const root = mth(leaves, 0, 4); expect(root).toEqual(nodeHash(left, right)); }); }); describe('Direct auditPath helper (tree size 1)', () => { test('singleton tree audit path is empty', () => { const log = buildLog(1); const path = log.auditPath(0); expect(path.length).toBe(0); expect(consistencyProof([log.leafHashAt(0)], 1, 1)).toEqual([]); }); test('auditPath out-of-range throws', () => { expect(() => auditPath([], 0, 0)).toThrow(); const log = buildLog(3); expect(() => log.auditPath(3)).toThrow(); }); });