| … | … | @@ -0,0 +1,201 @@ |
| 1 | /** | |
| 2 | * Conformance vector GENERATOR for the hyper-overlay wire protocol. | |
| 3 | * | |
| 4 | * WHY THIS EXISTS. The overlay protocol is a self-contained Protomux channel of | |
| 5 | * 15 compact-encoded messages — no hypercore replication on the wire — which is | |
| 6 | * what makes a native (C++) implementation tractable. But pgh-sidecar and | |
| 7 | * pear-git-v3 speak this protocol TODAY, and CI's overlay-sync step depends on | |
| 8 | * it, so a second implementation that diverges by one byte cannot talk to | |
| 9 | * existing publishers and breaks deploys. The reference IS the spec; a port must | |
| 10 | * pass fixtures taken from it before it ever opens a socket. | |
| 11 | * | |
| 12 | * Run: node test/conformance/generate.mjs > test/conformance/vectors.json | |
| 13 | * Check: node test/conformance/verify.mjs | |
| 14 | * | |
| 15 | * REGENERATE when lib/messages.js changes. The fixtures carry the source version | |
| 16 | * so a port can tell it is being checked against a protocol it was not written | |
| 17 | * for. | |
| 18 | * | |
| 19 | * WHAT THE CASES DELIBERATELY COVER | |
| 20 | * | |
| 21 | * 1. OPTIONAL TRAILING FIELDS. Six messages append a field and decode it only | |
| 22 | * `if (state.start < state.end)`, so a pre-field peer's frame decodes as 0/'' | |
| 23 | * instead of throwing. This is the single easiest thing to get wrong in a | |
| 24 | * port: always reading breaks against old peers, never reading breaks against | |
| 25 | * new ones. Every one of them gets a with- AND without-trailer case, and the | |
| 26 | * without-case is produced by truncating to what the old encoder emitted. | |
| 27 | * | |
| 28 | * 2. FIXED-32 vs LENGTH-PREFIXED. Hashes inside chunkInfoArray/treeEntryArray | |
| 29 | * are written RAW at 32 bytes; top-level hashes go through c.buffer, which is | |
| 30 | * varint-length-prefixed. Conflating them still round-trips inside one | |
| 31 | * implementation and fails against the other. | |
| 32 | * | |
| 33 | * 3. VARINT BOUNDARIES. c.uint switches width at 0xfd, 0x10000 and 0x100000000; | |
| 34 | * a port that assumes one width passes small cases and fails in the field. | |
| 35 | * | |
| 36 | * 4. EMPTY and UTF-8. Empty arrays/buffers/strings, and non-ASCII paths — | |
| 37 | * c.string is UTF-8, not bytes. | |
| 38 | */ | |
| 39 | ||
| 40 | import c from 'compact-encoding' | |
| 41 | import { readFileSync } from 'fs' | |
| 42 | import { fileURLToPath } from 'url' | |
| 43 | import { dirname, join } from 'path' | |
| 44 | import * as messages from '../../lib/messages.js' | |
| 45 | ||
| 46 | const __dir = dirname(fileURLToPath(import.meta.url)) | |
| 47 | const pkg = JSON.parse(readFileSync(join(__dir, '../../package.json'), 'utf8')) | |
| 48 | ||
| 49 | const H = (b) => Buffer.alloc(32, b).toString('hex') | |
| 50 | const hex = (buf) => Buffer.from(buf).toString('hex') | |
| 51 | ||
| 52 | function encode (codec, value) { | |
| 53 | const state = { start: 0, end: 0, buffer: null } | |
| 54 | codec.preencode(state, value) | |
| 55 | state.buffer = Buffer.alloc(state.end) | |
| 56 | state.start = 0 | |
| 57 | codec.encode(state, value) | |
| 58 | return state.buffer | |
| 59 | } | |
| 60 | ||
| 61 | function decode (codec, buf) { | |
| 62 | const state = { start: 0, end: buf.length, buffer: Buffer.from(buf) } | |
| 63 | return codec.decode(state) | |
| 64 | } | |
| 65 | ||
| 66 | // Buffers decode as Buffer; normalise so JSON compare is meaningful and a port | |
| 67 | // has an unambiguous expectation to match. | |
| 68 | function normalise (v) { | |
| 69 | if (Buffer.isBuffer(v)) return { $buf: v.toString('hex') } | |
| 70 | if (Array.isArray(v)) return v.map(normalise) | |
| 71 | if (v && typeof v === 'object') { | |
| 72 | const o = {} | |
| 73 | for (const k of Object.keys(v).sort()) o[k] = normalise(v[k]) | |
| 74 | return o | |
| 75 | } | |
| 76 | return v | |
| 77 | } | |
| 78 | ||
| 79 | const cases = [] | |
| 80 | ||
| 81 | // name: message codec name; value: what to encode; truncate: bytes to drop from | |
| 82 | // the end, to synthesise the frame a PRE-FIELD peer would have sent. | |
| 83 | function add (name, label, value, opts = {}) { | |
| 84 | const codec = messages[name] | |
| 85 | if (!codec) throw new Error('no such message codec: ' + name) | |
| 86 | let bytes = encode(codec, value) | |
| 87 | if (opts.truncate) bytes = bytes.subarray(0, bytes.length - opts.truncate) | |
| 88 | // Self-check: the fixture must decode under the reference implementation, or | |
| 89 | // it is not a vector, it is a guess. | |
| 90 | const decoded = decode(codec, bytes) | |
| 91 | cases.push({ | |
| 92 | message: name, | |
| 93 | label, | |
| 94 | note: opts.note || null, | |
| 95 | // A truncated case models a frame from a peer that predates the trailing | |
| 96 | // field. Re-encoding it legitimately produces MORE bytes (the modern encoder | |
| 97 | // appends the trailer), so byte-exact encoder checks must skip these — the | |
| 98 | // DECODE expectation still holds and is the point of the case. | |
| 99 | truncated: !!opts.truncate, | |
| 100 | bytes: hex(bytes), | |
| 101 | decoded: normalise(decoded) | |
| 102 | }) | |
| 103 | } | |
| 104 | ||
| 105 | // ── handshake ──────────────────────────────────────────────────────────────── | |
| 106 | add('handshake', 'v1 no caps', { version: 1, capabilities: 0 }) | |
| 107 | add('handshake', 'max uint8', { version: 255, capabilities: 255 }) | |
| 108 | ||
| 109 | // ── syncState ──────────────────────────────────────────────────────────────── | |
| 110 | add('syncState', 'zero seqs', { feedKey: H(0x00), localSeq: 0, remoteSeq: 0 }) | |
| 111 | add('syncState', 'varint boundary 0xfc/0xfd', { feedKey: H(0xab), localSeq: 0xfc, remoteSeq: 0xfd }, | |
| 112 | { note: 'c.uint switches from 1 byte to 3 at 0xfd' }) | |
| 113 | add('syncState', 'varint 32-bit boundary', { feedKey: H(0x11), localSeq: 0xffff, remoteSeq: 0x10000 }) | |
| 114 | add('syncState', 'varint 64-bit', { feedKey: H(0x22), localSeq: 0xffffffff, remoteSeq: 0x100000000 }) | |
| 115 | ||
| 116 | // ── fileOffer ──────────────────────────────────────────────────────────────── | |
| 117 | add('fileOffer', 'ascii path', { path: 'assets/models/teleporter.glb', contentHash: H(0x01), size: 1234, mtime: 1786000000, op: 1 }) | |
| 118 | add('fileOffer', 'utf8 path', { path: 'assets/textures/café/naïve—ü.png', contentHash: H(0x02), size: 0, mtime: 0, op: 0 }, | |
| 119 | { note: 'c.string is UTF-8; a byte-oriented port mis-sizes this' }) | |
| 120 | add('fileOffer', 'empty path', { path: '', contentHash: H(0x03), size: 1, mtime: 1, op: 2 }) | |
| 121 | ||
| 122 | // ── fileRequest ────────────────────────────────────────────────────────────── | |
| 123 | add('fileRequest', 'no chunksHave', { path: 'a.bin', contentHash: H(0x04) }, | |
| 124 | { note: 'chunksHave defaults to a zero-length buffer, still length-prefixed' }) | |
| 125 | add('fileRequest', 'with chunksHave bitfield', { path: 'a.bin', contentHash: H(0x05), chunksHave: Buffer.from([0xff, 0x00, 0x0f]) }) | |
| 126 | ||
| 127 | // ── chunkHashes — OPTIONAL TRAILING `more` ─────────────────────────────────── | |
| 128 | add('chunkHashes', 'empty chunk list', { path: 'f', tier: 0, chunks: [], more: 0 }) | |
| 129 | add('chunkHashes', 'two chunks, final page', { | |
| 130 | path: 'big.pak', tier: 2, | |
| 131 | chunks: [{ hash: H(0xaa), length: 65536 }, { hash: H(0xbb), length: 12 }], | |
| 132 | more: 0 | |
| 133 | }, { note: 'hashes here are RAW fixed-32, NOT length-prefixed like top-level c.buffer' }) | |
| 134 | add('chunkHashes', 'paged, more=1', { | |
| 135 | path: 'big.pak', tier: 2, chunks: [{ hash: H(0xcc), length: 1 }], more: 1 | |
| 136 | }) | |
| 137 | add('chunkHashes', 'PRE-PAGING peer (no trailing more)', { | |
| 138 | path: 'big.pak', tier: 2, chunks: [{ hash: H(0xcc), length: 1 }], more: 0 | |
| 139 | }, { truncate: 1, note: 'old encoder omitted `more` entirely; must decode more:0, not throw' }) | |
| 140 | ||
| 141 | // ── chunkNeed ──────────────────────────────────────────────────────────────── | |
| 142 | add('chunkNeed', 'empty indices', { path: 'f', indices: [] }) | |
| 143 | add('chunkNeed', 'varint-spanning indices', { path: 'f', indices: [0, 252, 253, 65535, 65536, 4294967295] }) | |
| 144 | ||
| 145 | // ── chunkData ──────────────────────────────────────────────────────────────── | |
| 146 | add('chunkData', 'empty payload', { path: 'f', index: 0, data: Buffer.alloc(0) }) | |
| 147 | add('chunkData', 'binary payload', { path: 'f', index: 7, data: Buffer.from([0x00, 0xff, 0x7f, 0x80, 0x01]) }) | |
| 148 | ||
| 149 | // ── chunkCancel — OPTIONAL TRAILING `indices` ──────────────────────────────── | |
| 150 | add('chunkCancel', 'whole-transfer (pre-0.3.0 shape)', { path: 'f' }, | |
| 151 | { note: 'no indices => decodes {path} only, byte-identical to pre-0.3.0' }) | |
| 152 | add('chunkCancel', 'per-chunk cancel', { path: 'f', indices: [1, 2, 300] }) | |
| 153 | ||
| 154 | // ── transferComplete ───────────────────────────────────────────────────────── | |
| 155 | add('transferComplete', 'basic', { path: 'f', contentHash: H(0x06) }) | |
| 156 | ||
| 157 | // ── conflict ───────────────────────────────────────────────────────────────── | |
| 158 | add('conflict', 'no ancestor', { path: 'f', myHash: H(0x07), theirHash: H(0x08) }, | |
| 159 | { note: 'ancestorHash defaults to 32 zero bytes, still encoded' }) | |
| 160 | add('conflict', 'with ancestor', { path: 'f', myHash: H(0x09), theirHash: H(0x0a), ancestorHash: H(0x0b) }) | |
| 161 | ||
| 162 | // ── treeRequest — OPTIONAL TRAILING `nonce` ────────────────────────────────── | |
| 163 | add('treeRequest', 'with nonce', { hash: H(0x0c), nonce: 42 }) | |
| 164 | add('treeRequest', 'PRE-NONCE peer', { hash: H(0x0c), nonce: 0 }, | |
| 165 | { truncate: 1, note: 'old encoder omitted nonce; must decode nonce:0' }) | |
| 166 | ||
| 167 | // ── treeResponse — OPTIONAL TRAILING `more` AND `nonce` ────────────────────── | |
| 168 | add('treeResponse', 'empty tree', { hash: H(0x0d), entries: [], more: 0, nonce: 0 }) | |
| 169 | add('treeResponse', 'file + dir entries', { | |
| 170 | hash: H(0x0e), | |
| 171 | entries: [ | |
| 172 | { kind: 0, exec: 0, name: 'readme.md', childHash: H(0x10), size: 100 }, | |
| 173 | { kind: 1, exec: 1, name: 'bin/run.sh', childHash: H(0x11), size: 0 } | |
| 174 | ], | |
| 175 | more: 0, | |
| 176 | nonce: 7 | |
| 177 | }) | |
| 178 | add('treeResponse', 'PRE-PAGING peer (no more, no nonce)', { | |
| 179 | hash: H(0x0e), entries: [], more: 0, nonce: 0 | |
| 180 | }, { truncate: 2, note: 'both trailers absent; each decodes 0 independently' }) | |
| 181 | ||
| 182 | // ── contentRequest — OPTIONAL TRAILING `from` ──────────────────────────────── | |
| 183 | add('contentRequest', 'with from', { contentHash: H(0x12), chunksHave: Buffer.from([0x01]), from: 'abc123' }) | |
| 184 | add('contentRequest', 'empty from', { contentHash: H(0x13) }) | |
| 185 | ||
| 186 | // ── transferControl ────────────────────────────────────────────────────────── | |
| 187 | add('transferControl', 'paused', { contentHash: H(0x14), state: 0 }) | |
| 188 | add('transferControl', 'stopped', { contentHash: H(0x15), state: 1 }) | |
| 189 | ||
| 190 | // ── transferProgress ───────────────────────────────────────────────────────── | |
| 191 | add('transferProgress', 'zero have', { contentHash: H(0x16), have: 0 }) | |
| 192 | add('transferProgress', 'large have', { contentHash: H(0x17), have: 8589934592 }) | |
| 193 | ||
| 194 | process.stdout.write(JSON.stringify({ | |
| 195 | generator: 'hyper-overlay/test/conformance/generate.mjs', | |
| 196 | overlayVersion: pkg.version, | |
| 197 | protocol: 'hyper-overlay/v2', | |
| 198 | messageCount: Object.keys(messages).length, | |
| 199 | caseCount: cases.length, | |
| 200 | cases | |
| 201 | }, null, 2) + '\n') |
| … | … | @@ -0,0 +1,487 @@ |
| 1 | { | |
| 2 | "generator": "hyper-overlay/test/conformance/generate.mjs", | |
| 3 | "overlayVersion": "0.3.12", | |
| 4 | "protocol": "hyper-overlay/v2", | |
| 5 | "messageCount": 15, | |
| 6 | "caseCount": 35, | |
| 7 | "cases": [ | |
| 8 | { | |
| 9 | "message": "handshake", | |
| 10 | "label": "v1 no caps", | |
| 11 | "note": null, | |
| 12 | "truncated": false, | |
| 13 | "bytes": "0100", | |
| 14 | "decoded": { | |
| 15 | "capabilities": 0, | |
| 16 | "version": 1 | |
| 17 | } | |
| 18 | }, | |
| 19 | { | |
| 20 | "message": "handshake", | |
| 21 | "label": "max uint8", | |
| 22 | "note": null, | |
| 23 | "truncated": false, | |
| 24 | "bytes": "ffff", | |
| 25 | "decoded": { | |
| 26 | "capabilities": 255, | |
| 27 | "version": 255 | |
| 28 | } | |
| 29 | }, | |
| 30 | { | |
| 31 | "message": "syncState", | |
| 32 | "label": "zero seqs", | |
| 33 | "note": null, | |
| 34 | "truncated": false, | |
| 35 | "bytes": "2000000000000000000000000000000000000000000000000000000000000000000000", | |
| 36 | "decoded": { | |
| 37 | "feedKey": "0000000000000000000000000000000000000000000000000000000000000000", | |
| 38 | "localSeq": 0, | |
| 39 | "remoteSeq": 0 | |
| 40 | } | |
| 41 | }, | |
| 42 | { | |
| 43 | "message": "syncState", | |
| 44 | "label": "varint boundary 0xfc/0xfd", | |
| 45 | "note": "c.uint switches from 1 byte to 3 at 0xfd", | |
| 46 | "truncated": false, | |
| 47 | "bytes": "20ababababababababababababababababababababababababababababababababfcfdfd00", | |
| 48 | "decoded": { | |
| 49 | "feedKey": "abababababababababababababababababababababababababababababababab", | |
| 50 | "localSeq": 252, | |
| 51 | "remoteSeq": 253 | |
| 52 | } | |
| 53 | }, | |
| 54 | { | |
| 55 | "message": "syncState", | |
| 56 | "label": "varint 32-bit boundary", | |
| 57 | "note": null, | |
| 58 | "truncated": false, | |
| 59 | "bytes": "201111111111111111111111111111111111111111111111111111111111111111fdfffffe00000100", | |
| 60 | "decoded": { | |
| 61 | "feedKey": "1111111111111111111111111111111111111111111111111111111111111111", | |
| 62 | "localSeq": 65535, | |
| 63 | "remoteSeq": 65536 | |
| 64 | } | |
| 65 | }, | |
| 66 | { | |
| 67 | "message": "syncState", | |
| 68 | "label": "varint 64-bit", | |
| 69 | "note": null, | |
| 70 | "truncated": false, | |
| 71 | "bytes": "202222222222222222222222222222222222222222222222222222222222222222feffffffffff0000000001000000", | |
| 72 | "decoded": { | |
| 73 | "feedKey": "2222222222222222222222222222222222222222222222222222222222222222", | |
| 74 | "localSeq": 4294967295, | |
| 75 | "remoteSeq": 4294967296 | |
| 76 | } | |
| 77 | }, | |
| 78 | { | |
| 79 | "message": "fileOffer", | |
| 80 | "label": "ascii path", | |
| 81 | "note": null, | |
| 82 | "truncated": false, | |
| 83 | "bytes": "1c6173736574732f6d6f64656c732f74656c65706f727465722e676c62200101010101010101010101010101010101010101010101010101010101010101fdd204fe8032746a01", | |
| 84 | "decoded": { | |
| 85 | "contentHash": "0101010101010101010101010101010101010101010101010101010101010101", | |
| 86 | "mtime": 1786000000, | |
| 87 | "op": 1, | |
| 88 | "path": "assets/models/teleporter.glb", | |
| 89 | "size": 1234 | |
| 90 | } | |
| 91 | }, | |
| 92 | { | |
| 93 | "message": "fileOffer", | |
| 94 | "label": "utf8 path", | |
| 95 | "note": "c.string is UTF-8; a byte-oriented port mis-sizes this", | |
| 96 | "truncated": false, | |
| 97 | "bytes": "256173736574732f74657874757265732f636166c3a92f6e61c3af7665e28094c3bc2e706e67200202020202020202020202020202020202020202020202020202020202020202000000", | |
| 98 | "decoded": { | |
| 99 | "contentHash": "0202020202020202020202020202020202020202020202020202020202020202", | |
| 100 | "mtime": 0, | |
| 101 | "op": 0, | |
| 102 | "path": "assets/textures/café/naïve—ü.png", | |
| 103 | "size": 0 | |
| 104 | } | |
| 105 | }, | |
| 106 | { | |
| 107 | "message": "fileOffer", | |
| 108 | "label": "empty path", | |
| 109 | "note": null, | |
| 110 | "truncated": false, | |
| 111 | "bytes": "00200303030303030303030303030303030303030303030303030303030303030303010102", | |
| 112 | "decoded": { | |
| 113 | "contentHash": "0303030303030303030303030303030303030303030303030303030303030303", | |
| 114 | "mtime": 1, | |
| 115 | "op": 2, | |
| 116 | "path": "", | |
| 117 | "size": 1 | |
| 118 | } | |
| 119 | }, | |
| 120 | { | |
| 121 | "message": "fileRequest", | |
| 122 | "label": "no chunksHave", | |
| 123 | "note": "chunksHave defaults to a zero-length buffer, still length-prefixed", | |
| 124 | "truncated": false, | |
| 125 | "bytes": "05612e62696e20040404040404040404040404040404040404040404040404040404040404040400", | |
| 126 | "decoded": { | |
| 127 | "chunksHave": { | |
| 128 | "$buf": "" | |
| 129 | }, | |
| 130 | "contentHash": "0404040404040404040404040404040404040404040404040404040404040404", | |
| 131 | "path": "a.bin" | |
| 132 | } | |
| 133 | }, | |
| 134 | { | |
| 135 | "message": "fileRequest", | |
| 136 | "label": "with chunksHave bitfield", | |
| 137 | "note": null, | |
| 138 | "truncated": false, | |
| 139 | "bytes": "05612e62696e20050505050505050505050505050505050505050505050505050505050505050503ff000f", | |
| 140 | "decoded": { | |
| 141 | "chunksHave": { | |
| 142 | "$buf": "ff000f" | |
| 143 | }, | |
| 144 | "contentHash": "0505050505050505050505050505050505050505050505050505050505050505", | |
| 145 | "path": "a.bin" | |
| 146 | } | |
| 147 | }, | |
| 148 | { | |
| 149 | "message": "chunkHashes", | |
| 150 | "label": "empty chunk list", | |
| 151 | "note": null, | |
| 152 | "truncated": false, | |
| 153 | "bytes": "0166000000000000", | |
| 154 | "decoded": { | |
| 155 | "chunks": [], | |
| 156 | "more": 0, | |
| 157 | "path": "f", | |
| 158 | "tier": 0 | |
| 159 | } | |
| 160 | }, | |
| 161 | { | |
| 162 | "message": "chunkHashes", | |
| 163 | "label": "two chunks, final page", | |
| 164 | "note": "hashes here are RAW fixed-32, NOT length-prefixed like top-level c.buffer", | |
| 165 | "truncated": false, | |
| 166 | "bytes": "076269672e70616b0202000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafe00000100bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0c00", | |
| 167 | "decoded": { | |
| 168 | "chunks": [ | |
| 169 | { | |
| 170 | "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |
| 171 | "length": 65536 | |
| 172 | }, | |
| 173 | { | |
| 174 | "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", | |
| 175 | "length": 12 | |
| 176 | } | |
| 177 | ], | |
| 178 | "more": 0, | |
| 179 | "path": "big.pak", | |
| 180 | "tier": 2 | |
| 181 | } | |
| 182 | }, | |
| 183 | { | |
| 184 | "message": "chunkHashes", | |
| 185 | "label": "paged, more=1", | |
| 186 | "note": null, | |
| 187 | "truncated": false, | |
| 188 | "bytes": "076269672e70616b0201000000cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc0101", | |
| 189 | "decoded": { | |
| 190 | "chunks": [ | |
| 191 | { | |
| 192 | "hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", | |
| 193 | "length": 1 | |
| 194 | } | |
| 195 | ], | |
| 196 | "more": 1, | |
| 197 | "path": "big.pak", | |
| 198 | "tier": 2 | |
| 199 | } | |
| 200 | }, | |
| 201 | { | |
| 202 | "message": "chunkHashes", | |
| 203 | "label": "PRE-PAGING peer (no trailing more)", | |
| 204 | "note": "old encoder omitted `more` entirely; must decode more:0, not throw", | |
| 205 | "truncated": true, | |
| 206 | "bytes": "076269672e70616b0201000000cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc01", | |
| 207 | "decoded": { | |
| 208 | "chunks": [ | |
| 209 | { | |
| 210 | "hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", | |
| 211 | "length": 1 | |
| 212 | } | |
| 213 | ], | |
| 214 | "more": 0, | |
| 215 | "path": "big.pak", | |
| 216 | "tier": 2 | |
| 217 | } | |
| 218 | }, | |
| 219 | { | |
| 220 | "message": "chunkNeed", | |
| 221 | "label": "empty indices", | |
| 222 | "note": null, | |
| 223 | "truncated": false, | |
| 224 | "bytes": "016600000000", | |
| 225 | "decoded": { | |
| 226 | "indices": [], | |
| 227 | "path": "f" | |
| 228 | } | |
| 229 | }, | |
| 230 | { | |
| 231 | "message": "chunkNeed", | |
| 232 | "label": "varint-spanning indices", | |
| 233 | "note": null, | |
| 234 | "truncated": false, | |
| 235 | "bytes": "01660600000000fcfdfd00fdfffffe00000100feffffffff", | |
| 236 | "decoded": { | |
| 237 | "indices": [ | |
| 238 | 0, | |
| 239 | 252, | |
| 240 | 253, | |
| 241 | 65535, | |
| 242 | 65536, | |
| 243 | 4294967295 | |
| 244 | ], | |
| 245 | "path": "f" | |
| 246 | } | |
| 247 | }, | |
| 248 | { | |
| 249 | "message": "chunkData", | |
| 250 | "label": "empty payload", | |
| 251 | "note": null, | |
| 252 | "truncated": false, | |
| 253 | "bytes": "01660000", | |
| 254 | "decoded": { | |
| 255 | "data": { | |
| 256 | "$buf": "" | |
| 257 | }, | |
| 258 | "index": 0, | |
| 259 | "path": "f" | |
| 260 | } | |
| 261 | }, | |
| 262 | { | |
| 263 | "message": "chunkData", | |
| 264 | "label": "binary payload", | |
| 265 | "note": null, | |
| 266 | "truncated": false, | |
| 267 | "bytes": "0166070500ff7f8001", | |
| 268 | "decoded": { | |
| 269 | "data": { | |
| 270 | "$buf": "00ff7f8001" | |
| 271 | }, | |
| 272 | "index": 7, | |
| 273 | "path": "f" | |
| 274 | } | |
| 275 | }, | |
| 276 | { | |
| 277 | "message": "chunkCancel", | |
| 278 | "label": "whole-transfer (pre-0.3.0 shape)", | |
| 279 | "note": "no indices => decodes {path} only, byte-identical to pre-0.3.0", | |
| 280 | "truncated": false, | |
| 281 | "bytes": "0166", | |
| 282 | "decoded": { | |
| 283 | "path": "f" | |
| 284 | } | |
| 285 | }, | |
| 286 | { | |
| 287 | "message": "chunkCancel", | |
| 288 | "label": "per-chunk cancel", | |
| 289 | "note": null, | |
| 290 | "truncated": false, | |
| 291 | "bytes": "0166030102fd2c01", | |
| 292 | "decoded": { | |
| 293 | "indices": [ | |
| 294 | 1, | |
| 295 | 2, | |
| 296 | 300 | |
| 297 | ], | |
| 298 | "path": "f" | |
| 299 | } | |
| 300 | }, | |
| 301 | { | |
| 302 | "message": "transferComplete", | |
| 303 | "label": "basic", | |
| 304 | "note": null, | |
| 305 | "truncated": false, | |
| 306 | "bytes": "0166200606060606060606060606060606060606060606060606060606060606060606", | |
| 307 | "decoded": { | |
| 308 | "contentHash": "0606060606060606060606060606060606060606060606060606060606060606", | |
| 309 | "path": "f" | |
| 310 | } | |
| 311 | }, | |
| 312 | { | |
| 313 | "message": "conflict", | |
| 314 | "label": "no ancestor", | |
| 315 | "note": "ancestorHash defaults to 32 zero bytes, still encoded", | |
| 316 | "truncated": false, | |
| 317 | "bytes": "0166200707070707070707070707070707070707070707070707070707070707070707200808080808080808080808080808080808080808080808080808080808080808200000000000000000000000000000000000000000000000000000000000000000", | |
| 318 | "decoded": { | |
| 319 | "ancestorHash": "0000000000000000000000000000000000000000000000000000000000000000", | |
| 320 | "myHash": "0707070707070707070707070707070707070707070707070707070707070707", | |
| 321 | "path": "f", | |
| 322 | "theirHash": "0808080808080808080808080808080808080808080808080808080808080808" | |
| 323 | } | |
| 324 | }, | |
| 325 | { | |
| 326 | "message": "conflict", | |
| 327 | "label": "with ancestor", | |
| 328 | "note": null, | |
| 329 | "truncated": false, | |
| 330 | "bytes": "0166200909090909090909090909090909090909090909090909090909090909090909200a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a200b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", | |
| 331 | "decoded": { | |
| 332 | "ancestorHash": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", | |
| 333 | "myHash": "0909090909090909090909090909090909090909090909090909090909090909", | |
| 334 | "path": "f", | |
| 335 | "theirHash": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a" | |
| 336 | } | |
| 337 | }, | |
| 338 | { | |
| 339 | "message": "treeRequest", | |
| 340 | "label": "with nonce", | |
| 341 | "note": null, | |
| 342 | "truncated": false, | |
| 343 | "bytes": "200c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c2a", | |
| 344 | "decoded": { | |
| 345 | "hash": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c", | |
| 346 | "nonce": 42 | |
| 347 | } | |
| 348 | }, | |
| 349 | { | |
| 350 | "message": "treeRequest", | |
| 351 | "label": "PRE-NONCE peer", | |
| 352 | "note": "old encoder omitted nonce; must decode nonce:0", | |
| 353 | "truncated": true, | |
| 354 | "bytes": "200c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c", | |
| 355 | "decoded": { | |
| 356 | "hash": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c", | |
| 357 | "nonce": 0 | |
| 358 | } | |
| 359 | }, | |
| 360 | { | |
| 361 | "message": "treeResponse", | |
| 362 | "label": "empty tree", | |
| 363 | "note": null, | |
| 364 | "truncated": false, | |
| 365 | "bytes": "200d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d000000000000", | |
| 366 | "decoded": { | |
| 367 | "entries": [], | |
| 368 | "hash": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d", | |
| 369 | "more": 0, | |
| 370 | "nonce": 0 | |
| 371 | } | |
| 372 | }, | |
| 373 | { | |
| 374 | "message": "treeResponse", | |
| 375 | "label": "file + dir entries", | |
| 376 | "note": null, | |
| 377 | "truncated": false, | |
| 378 | "bytes": "200e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e02000000000009726561646d652e6d6410101010101010101010101010101010101010101010101010101010101010106401010a62696e2f72756e2e73681111111111111111111111111111111111111111111111111111111111111111000007", | |
| 379 | "decoded": { | |
| 380 | "entries": [ | |
| 381 | { | |
| 382 | "childHash": "1010101010101010101010101010101010101010101010101010101010101010", | |
| 383 | "exec": 0, | |
| 384 | "kind": 0, | |
| 385 | "name": "readme.md", | |
| 386 | "size": 100 | |
| 387 | }, | |
| 388 | { | |
| 389 | "childHash": "1111111111111111111111111111111111111111111111111111111111111111", | |
| 390 | "exec": 1, | |
| 391 | "kind": 1, | |
| 392 | "name": "bin/run.sh", | |
| 393 | "size": 0 | |
| 394 | } | |
| 395 | ], | |
| 396 | "hash": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e", | |
| 397 | "more": 0, | |
| 398 | "nonce": 7 | |
| 399 | } | |
| 400 | }, | |
| 401 | { | |
| 402 | "message": "treeResponse", | |
| 403 | "label": "PRE-PAGING peer (no more, no nonce)", | |
| 404 | "note": "both trailers absent; each decodes 0 independently", | |
| 405 | "truncated": true, | |
| 406 | "bytes": "200e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e00000000", | |
| 407 | "decoded": { | |
| 408 | "entries": [], | |
| 409 | "hash": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e", | |
| 410 | "more": 0, | |
| 411 | "nonce": 0 | |
| 412 | } | |
| 413 | }, | |
| 414 | { | |
| 415 | "message": "contentRequest", | |
| 416 | "label": "with from", | |
| 417 | "note": null, | |
| 418 | "truncated": false, | |
| 419 | "bytes": "201212121212121212121212121212121212121212121212121212121212121212010106616263313233", | |
| 420 | "decoded": { | |
| 421 | "chunksHave": { | |
| 422 | "$buf": "01" | |
| 423 | }, | |
| 424 | "contentHash": "1212121212121212121212121212121212121212121212121212121212121212", | |
| 425 | "from": "abc123" | |
| 426 | } | |
| 427 | }, | |
| 428 | { | |
| 429 | "message": "contentRequest", | |
| 430 | "label": "empty from", | |
| 431 | "note": null, | |
| 432 | "truncated": false, | |
| 433 | "bytes": "2013131313131313131313131313131313131313131313131313131313131313130000", | |
| 434 | "decoded": { | |
| 435 | "chunksHave": { | |
| 436 | "$buf": "" | |
| 437 | }, | |
| 438 | "contentHash": "1313131313131313131313131313131313131313131313131313131313131313", | |
| 439 | "from": "" | |
| 440 | } | |
| 441 | }, | |
| 442 | { | |
| 443 | "message": "transferControl", | |
| 444 | "label": "paused", | |
| 445 | "note": null, | |
| 446 | "truncated": false, | |
| 447 | "bytes": "20141414141414141414141414141414141414141414141414141414141414141400", | |
| 448 | "decoded": { | |
| 449 | "contentHash": "1414141414141414141414141414141414141414141414141414141414141414", | |
| 450 | "state": 0 | |
| 451 | } | |
| 452 | }, | |
| 453 | { | |
| 454 | "message": "transferControl", | |
| 455 | "label": "stopped", | |
| 456 | "note": null, | |
| 457 | "truncated": false, | |
| 458 | "bytes": "20151515151515151515151515151515151515151515151515151515151515151501", | |
| 459 | "decoded": { | |
| 460 | "contentHash": "1515151515151515151515151515151515151515151515151515151515151515", | |
| 461 | "state": 1 | |
| 462 | } | |
| 463 | }, | |
| 464 | { | |
| 465 | "message": "transferProgress", | |
| 466 | "label": "zero have", | |
| 467 | "note": null, | |
| 468 | "truncated": false, | |
| 469 | "bytes": "20161616161616161616161616161616161616161616161616161616161616161600", | |
| 470 | "decoded": { | |
| 471 | "contentHash": "1616161616161616161616161616161616161616161616161616161616161616", | |
| 472 | "have": 0 | |
| 473 | } | |
| 474 | }, | |
| 475 | { | |
| 476 | "message": "transferProgress", | |
| 477 | "label": "large have", | |
| 478 | "note": null, | |
| 479 | "truncated": false, | |
| 480 | "bytes": "201717171717171717171717171717171717171717171717171717171717171717ff0000000002000000", | |
| 481 | "decoded": { | |
| 482 | "contentHash": "1717171717171717171717171717171717171717171717171717171717171717", | |
| 483 | "have": 8589934592 | |
| 484 | } | |
| 485 | } | |
| 486 | ] | |
| 487 | } |
| … | … | @@ -0,0 +1,135 @@ |
| 1 | /** | |
| 2 | * Conformance vector VERIFIER — checks the fixtures against THIS build of | |
| 3 | * lib/messages.js. | |
| 4 | * | |
| 5 | * Two jobs, and the second is the one people forget: | |
| 6 | * | |
| 7 | * 1. Guard the fixtures. If lib/messages.js changes the wire format, this fails | |
| 8 | * loudly instead of the C++ port silently being checked against a protocol | |
| 9 | * the JS side no longer speaks. Regenerate deliberately, do not "fix" by | |
| 10 | * regenerating reflexively — a diff here means peers on the old version can | |
| 11 | * no longer talk to peers on the new one, which is a compatibility decision, | |
| 12 | * not a test failure. | |
| 13 | * | |
| 14 | * 2. Document the contract a port must satisfy. Every check below is a check | |
| 15 | * the native implementation has to pass too: | |
| 16 | * DECODE — fixture bytes decode to exactly the recorded value | |
| 17 | * ENCODE — re-encoding that value reproduces the fixture bytes exactly | |
| 18 | * (skipped for `truncated` cases, where a modern encoder | |
| 19 | * legitimately appends the trailing field a pre-field peer | |
| 20 | * omitted — the decode expectation is the point of those) | |
| 21 | * | |
| 22 | * Run: node test/conformance/verify.mjs | |
| 23 | */ | |
| 24 | ||
| 25 | import { readFileSync } from 'fs' | |
| 26 | import { fileURLToPath } from 'url' | |
| 27 | import { dirname, join } from 'path' | |
| 28 | import * as messages from '../../lib/messages.js' | |
| 29 | ||
| 30 | const __dir = dirname(fileURLToPath(import.meta.url)) | |
| 31 | const vectors = JSON.parse(readFileSync(join(__dir, 'vectors.json'), 'utf8')) | |
| 32 | const pkg = JSON.parse(readFileSync(join(__dir, '../../package.json'), 'utf8')) | |
| 33 | ||
| 34 | function encode (codec, value) { | |
| 35 | const state = { start: 0, end: 0, buffer: null } | |
| 36 | codec.preencode(state, value) | |
| 37 | state.buffer = Buffer.alloc(state.end) | |
| 38 | state.start = 0 | |
| 39 | codec.encode(state, value) | |
| 40 | return state.buffer | |
| 41 | } | |
| 42 | ||
| 43 | function decode (codec, buf) { | |
| 44 | const state = { start: 0, end: buf.length, buffer: Buffer.from(buf) } | |
| 45 | return codec.decode(state) | |
| 46 | } | |
| 47 | ||
| 48 | function normalise (v) { | |
| 49 | if (Buffer.isBuffer(v)) return { $buf: v.toString('hex') } | |
| 50 | if (Array.isArray(v)) return v.map(normalise) | |
| 51 | if (v && typeof v === 'object') { | |
| 52 | const o = {} | |
| 53 | for (const k of Object.keys(v).sort()) o[k] = normalise(v[k]) | |
| 54 | return o | |
| 55 | } | |
| 56 | return v | |
| 57 | } | |
| 58 | ||
| 59 | // The decoded form carries {$buf:hex} for buffers; turn it back into something | |
| 60 | // the encoder accepts, so ENCODE is a real round trip and not a tautology. | |
| 61 | function denormalise (v) { | |
| 62 | if (v && typeof v === 'object' && typeof v.$buf === 'string') return Buffer.from(v.$buf, 'hex') | |
| 63 | if (Array.isArray(v)) return v.map(denormalise) | |
| 64 | if (v && typeof v === 'object') { | |
| 65 | const o = {} | |
| 66 | for (const k of Object.keys(v)) o[k] = denormalise(v[k]) | |
| 67 | return o | |
| 68 | } | |
| 69 | return v | |
| 70 | } | |
| 71 | ||
| 72 | let pass = 0 | |
| 73 | let fail = 0 | |
| 74 | const failures = [] | |
| 75 | ||
| 76 | if (vectors.overlayVersion !== pkg.version) { | |
| 77 | console.log(` note fixtures generated at ${vectors.overlayVersion}, package is ${pkg.version}`) | |
| 78 | } | |
| 79 | ||
| 80 | for (const t of vectors.cases) { | |
| 81 | const codec = messages[t.message] | |
| 82 | const where = `${t.message} / ${t.label}` | |
| 83 | if (!codec) { | |
| 84 | fail++; failures.push(`${where}: message codec no longer exists`) | |
| 85 | continue | |
| 86 | } | |
| 87 | const bytes = Buffer.from(t.bytes, 'hex') | |
| 88 | ||
| 89 | // DECODE | |
| 90 | let got | |
| 91 | try { | |
| 92 | got = normalise(decode(codec, bytes)) | |
| 93 | } catch (e) { | |
| 94 | fail++; failures.push(`${where}: decode threw — ${e.message}`) | |
| 95 | continue | |
| 96 | } | |
| 97 | if (JSON.stringify(got) !== JSON.stringify(t.decoded)) { | |
| 98 | fail++ | |
| 99 | failures.push(`${where}: decode mismatch\n want ${JSON.stringify(t.decoded)}\n got ${JSON.stringify(got)}`) | |
| 100 | continue | |
| 101 | } | |
| 102 | ||
| 103 | // ENCODE (byte-exact), except where the fixture models a pre-field peer. | |
| 104 | if (!t.truncated) { | |
| 105 | let re | |
| 106 | try { | |
| 107 | re = encode(codec, denormalise(t.decoded)) | |
| 108 | } catch (e) { | |
| 109 | fail++; failures.push(`${where}: re-encode threw — ${e.message}`) | |
| 110 | continue | |
| 111 | } | |
| 112 | if (re.toString('hex') !== t.bytes) { | |
| 113 | fail++ | |
| 114 | failures.push(`${where}: encode mismatch\n want ${t.bytes}\n got ${re.toString('hex')}`) | |
| 115 | continue | |
| 116 | } | |
| 117 | } | |
| 118 | pass++ | |
| 119 | } | |
| 120 | ||
| 121 | const covered = new Set(vectors.cases.map(c => c.message)) | |
| 122 | const all = Object.keys(messages) | |
| 123 | const uncovered = all.filter(m => !covered.has(m)) | |
| 124 | ||
| 125 | console.log(`hyper-overlay conformance — ${vectors.protocol} @ ${vectors.overlayVersion}`) | |
| 126 | console.log(` ${pass}/${vectors.cases.length} cases pass`) | |
| 127 | console.log(` ${covered.size}/${all.length} message types covered`) | |
| 128 | if (uncovered.length) console.log(` UNCOVERED: ${uncovered.join(', ')}`) | |
| 129 | for (const f of failures) console.log(` FAIL ${f}`) | |
| 130 | ||
| 131 | // An uncovered message is a hole a port can fall through unnoticed, so it fails | |
| 132 | // the run rather than printing a warning nobody reads. | |
| 133 | const ok = fail === 0 && uncovered.length === 0 | |
| 134 | console.log(ok ? 'PASS' : 'FAIL') | |
| 135 | process.exit(ok ? 0 : 1) |