OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / b576288

test: conformance vectors for the wire protocol, so a second implementation is verifiable
Groundwork for a native (C++) hyper-overlay. The finding that makes that tractable: the overlay protocol is a SELF-CONTAINED Protomux channel of 15 compact-encoded messages with NO hypercore replication on the wire — grep for .replicate( across lib/ returns nothing. The Corestore/Hyperbee is purely a local index, so a port needs Protomux + compact-encoding + FastCDC + blake2b, not Hypercore, Hyperbee, Corestore or libhc. But pgh-sidecar and pear-git-v3 speak this protocol today and CI's overlay-sync step depends on it, so an implementation that diverges by one byte cannot talk to existing publishers and breaks deploys. The reference is the spec; a port must pass fixtures taken from it before it ever opens a socket. 35 cases, 15/15 message types covered. The cases are chosen for what a port actually gets wrong, not for coverage theatre: - OPTIONAL TRAILING FIELDS. Six messages append a field and decode it only `if (state.start < state.end)` so a pre-field peer's frame decodes 0/'' rather than throwing. Always reading breaks old peers; never reading breaks new ones. Each gets a with- AND without-trailer case, the latter produced by truncating to exactly what the old encoder emitted (chunkHashes.more, treeRequest.nonce, treeResponse.more+nonce). - FIXED-32 vs LENGTH-PREFIXED. Hashes inside chunkInfoArray/treeEntryArray are written RAW at 32 bytes; top-level hashes go through c.buffer, which is varint-length-prefixed. Conflating them round-trips fine inside ONE implementation and fails against the other — invisible without a vector. - VARINT BOUNDARIES at 0xfd / 0x10000 / 0x100000000. - UTF-8 paths and empty strings/buffers/arrays. Every fixture is self-checked at generation: it must decode under the reference or it is not a vector, it is a guess. verify.mjs then asserts DECODE (bytes -> recorded value) and byte-exact ENCODE (value -> same bytes), the latter skipped for truncated cases where a modern encoder legitimately re-adds the trailer. An uncovered message type FAILS the run rather than warning, because that is precisely the hole a port falls through unnoticed. Negative control run before committing: corrupting a single fixture byte takes it to 34/35 FAIL. A green that cannot go red would not be a check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b576288a8b2c1044157a292752e99e1fda7ffe06
by plankatron <fleeky@gmail.com> · 2026-08-10T22:42:06.000Z

Changed files (3) +823 -0

test/conformance/generate.mjs +201 -0
……@@ -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
40import c from 'compact-encoding'
41import { readFileSync } from 'fs'
42import { fileURLToPath } from 'url'
43import { dirname, join } from 'path'
44import * as messages from '../../lib/messages.js'
45
46const __dir = dirname(fileURLToPath(import.meta.url))
47const pkg = JSON.parse(readFileSync(join(__dir, '../../package.json'), 'utf8'))
48
49const H = (b) => Buffer.alloc(32, b).toString('hex')
50const hex = (buf) => Buffer.from(buf).toString('hex')
51
52function 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
61function 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.
68function 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
79const 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.
83function 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 ────────────────────────────────────────────────────────────────
106add('handshake', 'v1 no caps', { version: 1, capabilities: 0 })
107add('handshake', 'max uint8', { version: 255, capabilities: 255 })
108
109// ── syncState ────────────────────────────────────────────────────────────────
110add('syncState', 'zero seqs', { feedKey: H(0x00), localSeq: 0, remoteSeq: 0 })
111add('syncState', 'varint boundary 0xfc/0xfd', { feedKey: H(0xab), localSeq: 0xfc, remoteSeq: 0xfd },
112 { note: 'c.uint switches from 1 byte to 3 at 0xfd' })
113add('syncState', 'varint 32-bit boundary', { feedKey: H(0x11), localSeq: 0xffff, remoteSeq: 0x10000 })
114add('syncState', 'varint 64-bit', { feedKey: H(0x22), localSeq: 0xffffffff, remoteSeq: 0x100000000 })
115
116// ── fileOffer ────────────────────────────────────────────────────────────────
117add('fileOffer', 'ascii path', { path: 'assets/models/teleporter.glb', contentHash: H(0x01), size: 1234, mtime: 1786000000, op: 1 })
118add('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' })
120add('fileOffer', 'empty path', { path: '', contentHash: H(0x03), size: 1, mtime: 1, op: 2 })
121
122// ── fileRequest ──────────────────────────────────────────────────────────────
123add('fileRequest', 'no chunksHave', { path: 'a.bin', contentHash: H(0x04) },
124 { note: 'chunksHave defaults to a zero-length buffer, still length-prefixed' })
125add('fileRequest', 'with chunksHave bitfield', { path: 'a.bin', contentHash: H(0x05), chunksHave: Buffer.from([0xff, 0x00, 0x0f]) })
126
127// ── chunkHashes — OPTIONAL TRAILING `more` ───────────────────────────────────
128add('chunkHashes', 'empty chunk list', { path: 'f', tier: 0, chunks: [], more: 0 })
129add('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' })
134add('chunkHashes', 'paged, more=1', {
135 path: 'big.pak', tier: 2, chunks: [{ hash: H(0xcc), length: 1 }], more: 1
136})
137add('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 ────────────────────────────────────────────────────────────────
142add('chunkNeed', 'empty indices', { path: 'f', indices: [] })
143add('chunkNeed', 'varint-spanning indices', { path: 'f', indices: [0, 252, 253, 65535, 65536, 4294967295] })
144
145// ── chunkData ────────────────────────────────────────────────────────────────
146add('chunkData', 'empty payload', { path: 'f', index: 0, data: Buffer.alloc(0) })
147add('chunkData', 'binary payload', { path: 'f', index: 7, data: Buffer.from([0x00, 0xff, 0x7f, 0x80, 0x01]) })
148
149// ── chunkCancel — OPTIONAL TRAILING `indices` ────────────────────────────────
150add('chunkCancel', 'whole-transfer (pre-0.3.0 shape)', { path: 'f' },
151 { note: 'no indices => decodes {path} only, byte-identical to pre-0.3.0' })
152add('chunkCancel', 'per-chunk cancel', { path: 'f', indices: [1, 2, 300] })
153
154// ── transferComplete ─────────────────────────────────────────────────────────
155add('transferComplete', 'basic', { path: 'f', contentHash: H(0x06) })
156
157// ── conflict ─────────────────────────────────────────────────────────────────
158add('conflict', 'no ancestor', { path: 'f', myHash: H(0x07), theirHash: H(0x08) },
159 { note: 'ancestorHash defaults to 32 zero bytes, still encoded' })
160add('conflict', 'with ancestor', { path: 'f', myHash: H(0x09), theirHash: H(0x0a), ancestorHash: H(0x0b) })
161
162// ── treeRequest — OPTIONAL TRAILING `nonce` ──────────────────────────────────
163add('treeRequest', 'with nonce', { hash: H(0x0c), nonce: 42 })
164add('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` ──────────────────────
168add('treeResponse', 'empty tree', { hash: H(0x0d), entries: [], more: 0, nonce: 0 })
169add('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})
178add('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` ────────────────────────────────
183add('contentRequest', 'with from', { contentHash: H(0x12), chunksHave: Buffer.from([0x01]), from: 'abc123' })
184add('contentRequest', 'empty from', { contentHash: H(0x13) })
185
186// ── transferControl ──────────────────────────────────────────────────────────
187add('transferControl', 'paused', { contentHash: H(0x14), state: 0 })
188add('transferControl', 'stopped', { contentHash: H(0x15), state: 1 })
189
190// ── transferProgress ─────────────────────────────────────────────────────────
191add('transferProgress', 'zero have', { contentHash: H(0x16), have: 0 })
192add('transferProgress', 'large have', { contentHash: H(0x17), have: 8589934592 })
193
194process.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')
test/conformance/vectors.json +487 -0
……@@ -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}
test/conformance/verify.mjs +135 -0
……@@ -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
25import { readFileSync } from 'fs'
26import { fileURLToPath } from 'url'
27import { dirname, join } from 'path'
28import * as messages from '../../lib/messages.js'
29
30const __dir = dirname(fileURLToPath(import.meta.url))
31const vectors = JSON.parse(readFileSync(join(__dir, 'vectors.json'), 'utf8'))
32const pkg = JSON.parse(readFileSync(join(__dir, '../../package.json'), 'utf8'))
33
34function 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
43function decode (codec, buf) {
44 const state = { start: 0, end: buf.length, buffer: Buffer.from(buf) }
45 return codec.decode(state)
46}
47
48function 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.
61function 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
72let pass = 0
73let fail = 0
74const failures = []
75
76if (vectors.overlayVersion !== pkg.version) {
77 console.log(` note fixtures generated at ${vectors.overlayVersion}, package is ${pkg.version}`)
78}
79
80for (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
121const covered = new Set(vectors.cases.map(c => c.message))
122const all = Object.keys(messages)
123const uncovered = all.filter(m => !covered.has(m))
124
125console.log(`hyper-overlay conformance — ${vectors.protocol} @ ${vectors.overlayVersion}`)
126console.log(` ${pass}/${vectors.cases.length} cases pass`)
127console.log(` ${covered.size}/${all.length} message types covered`)
128if (uncovered.length) console.log(` UNCOVERED: ${uncovered.join(', ')}`)
129for (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.
133const ok = fail === 0 && uncovered.length === 0
134console.log(ok ? 'PASS' : 'FAIL')
135process.exit(ok ? 0 : 1)