/**
* Conformance vector GENERATOR for the hyper-overlay wire protocol.
*
* WHY THIS EXISTS. The overlay protocol is a self-contained Protomux channel of
* 15 compact-encoded messages — no hypercore replication on the wire — which is
* what makes a native (C++) implementation tractable. But pgh-sidecar and
* pear-git-v3 speak this protocol TODAY, and CI's overlay-sync step depends on
* it, so a second 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.
*
* Run: node test/conformance/generate.mjs > test/conformance/vectors.json
* Check: node test/conformance/verify.mjs
*
* REGENERATE when lib/messages.js changes. The fixtures carry the source version
* so a port can tell it is being checked against a protocol it was not written
* for.
*
* WHAT THE CASES DELIBERATELY COVER
*
* 1. 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 as 0/''
* instead of throwing. This is the single easiest thing to get wrong in a
* port: always reading breaks against old peers, never reading breaks against
* new ones. Every one of them gets a with- AND without-trailer case, and the
* without-case is produced by truncating to what the old encoder emitted.
*
* 2. 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 still round-trips inside one
* implementation and fails against the other.
*
* 3. VARINT BOUNDARIES. c.uint switches width at 0xfd, 0x10000 and 0x100000000;
* a port that assumes one width passes small cases and fails in the field.
*
* 4. EMPTY and UTF-8. Empty arrays/buffers/strings, and non-ASCII paths —
* c.string is UTF-8, not bytes.
*/
import c from 'compact-encoding'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import * as messages from '../../lib/messages.js'
const __dir = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(__dir, '../../package.json'), 'utf8'))
const H = (b) => Buffer.alloc(32, b).toString('hex')
const hex = (buf) => Buffer.from(buf).toString('hex')
function encode (codec, value) {
const state = { start: 0, end: 0, buffer: null }
codec.preencode(state, value)
state.buffer = Buffer.alloc(state.end)
state.start = 0
codec.encode(state, value)
return state.buffer
}
function decode (codec, buf) {
const state = { start: 0, end: buf.length, buffer: Buffer.from(buf) }
return codec.decode(state)
}
// Buffers decode as Buffer; normalise so JSON compare is meaningful and a port
// has an unambiguous expectation to match.
function normalise (v) {
if (Buffer.isBuffer(v)) return { $buf: v.toString('hex') }
if (Array.isArray(v)) return v.map(normalise)
if (v && typeof v === 'object') {
const o = {}
for (const k of Object.keys(v).sort()) o[k] = normalise(v[k])
return o
}
return v
}
const cases = []
// name: message codec name; value: what to encode; truncate: bytes to drop from
// the end, to synthesise the frame a PRE-FIELD peer would have sent.
function add (name, label, value, opts = {}) {
const codec = messages[name]
if (!codec) throw new Error('no such message codec: ' + name)
let bytes = encode(codec, value)
if (opts.truncate) bytes = bytes.subarray(0, bytes.length - opts.truncate)
// Self-check: the fixture must decode under the reference implementation, or
// it is not a vector, it is a guess.
const decoded = decode(codec, bytes)
cases.push({
message: name,
label,
note: opts.note || null,
// A truncated case models a frame from a peer that predates the trailing
// field. Re-encoding it legitimately produces MORE bytes (the modern encoder
// appends the trailer), so byte-exact encoder checks must skip these — the
// DECODE expectation still holds and is the point of the case.
truncated: !!opts.truncate,
bytes: hex(bytes),
decoded: normalise(decoded)
})
}
// ── handshake ────────────────────────────────────────────────────────────────
add('handshake', 'v1 no caps', { version: 1, capabilities: 0 })
add('handshake', 'max uint8', { version: 255, capabilities: 255 })
// ── syncState ────────────────────────────────────────────────────────────────
add('syncState', 'zero seqs', { feedKey: H(0x00), localSeq: 0, remoteSeq: 0 })
add('syncState', 'varint boundary 0xfc/0xfd', { feedKey: H(0xab), localSeq: 0xfc, remoteSeq: 0xfd },
{ note: 'c.uint switches from 1 byte to 3 at 0xfd' })
add('syncState', 'varint 32-bit boundary', { feedKey: H(0x11), localSeq: 0xffff, remoteSeq: 0x10000 })
add('syncState', 'varint 64-bit', { feedKey: H(0x22), localSeq: 0xffffffff, remoteSeq: 0x100000000 })
// ── fileOffer ────────────────────────────────────────────────────────────────
add('fileOffer', 'ascii path', { path: 'assets/models/teleporter.glb', contentHash: H(0x01), size: 1234, mtime: 1786000000, op: 1 })
add('fileOffer', 'utf8 path', { path: 'assets/textures/café/naïve—ü.png', contentHash: H(0x02), size: 0, mtime: 0, op: 0 },
{ note: 'c.string is UTF-8; a byte-oriented port mis-sizes this' })
add('fileOffer', 'empty path', { path: '', contentHash: H(0x03), size: 1, mtime: 1, op: 2 })
// ── fileRequest ──────────────────────────────────────────────────────────────
add('fileRequest', 'no chunksHave', { path: 'a.bin', contentHash: H(0x04) },
{ note: 'chunksHave defaults to a zero-length buffer, still length-prefixed' })
add('fileRequest', 'with chunksHave bitfield', { path: 'a.bin', contentHash: H(0x05), chunksHave: Buffer.from([0xff, 0x00, 0x0f]) })
// ── chunkHashes — OPTIONAL TRAILING `more` ───────────────────────────────────
add('chunkHashes', 'empty chunk list', { path: 'f', tier: 0, chunks: [], more: 0 })
add('chunkHashes', 'two chunks, final page', {
path: 'big.pak', tier: 2,
chunks: [{ hash: H(0xaa), length: 65536 }, { hash: H(0xbb), length: 12 }],
more: 0
}, { note: 'hashes here are RAW fixed-32, NOT length-prefixed like top-level c.buffer' })
add('chunkHashes', 'paged, more=1', {
path: 'big.pak', tier: 2, chunks: [{ hash: H(0xcc), length: 1 }], more: 1
})
add('chunkHashes', 'PRE-PAGING peer (no trailing more)', {
path: 'big.pak', tier: 2, chunks: [{ hash: H(0xcc), length: 1 }], more: 0
}, { truncate: 1, note: 'old encoder omitted `more` entirely; must decode more:0, not throw' })
// ── chunkNeed ────────────────────────────────────────────────────────────────
add('chunkNeed', 'empty indices', { path: 'f', indices: [] })
add('chunkNeed', 'varint-spanning indices', { path: 'f', indices: [0, 252, 253, 65535, 65536, 4294967295] })
// ── chunkData ────────────────────────────────────────────────────────────────
add('chunkData', 'empty payload', { path: 'f', index: 0, data: Buffer.alloc(0) })
add('chunkData', 'binary payload', { path: 'f', index: 7, data: Buffer.from([0x00, 0xff, 0x7f, 0x80, 0x01]) })
// ── chunkCancel — OPTIONAL TRAILING `indices` ────────────────────────────────
add('chunkCancel', 'whole-transfer (pre-0.3.0 shape)', { path: 'f' },
{ note: 'no indices => decodes {path} only, byte-identical to pre-0.3.0' })
add('chunkCancel', 'per-chunk cancel', { path: 'f', indices: [1, 2, 300] })
// ── transferComplete ─────────────────────────────────────────────────────────
add('transferComplete', 'basic', { path: 'f', contentHash: H(0x06) })
// ── conflict ─────────────────────────────────────────────────────────────────
add('conflict', 'no ancestor', { path: 'f', myHash: H(0x07), theirHash: H(0x08) },
{ note: 'ancestorHash defaults to 32 zero bytes, still encoded' })
add('conflict', 'with ancestor', { path: 'f', myHash: H(0x09), theirHash: H(0x0a), ancestorHash: H(0x0b) })
// ── treeRequest — OPTIONAL TRAILING `nonce` ──────────────────────────────────
add('treeRequest', 'with nonce', { hash: H(0x0c), nonce: 42 })
add('treeRequest', 'PRE-NONCE peer', { hash: H(0x0c), nonce: 0 },
{ truncate: 1, note: 'old encoder omitted nonce; must decode nonce:0' })
// ── treeResponse — OPTIONAL TRAILING `more` AND `nonce` ──────────────────────
add('treeResponse', 'empty tree', { hash: H(0x0d), entries: [], more: 0, nonce: 0 })
add('treeResponse', 'file + dir entries', {
hash: H(0x0e),
entries: [
{ kind: 0, exec: 0, name: 'readme.md', childHash: H(0x10), size: 100 },
{ kind: 1, exec: 1, name: 'bin/run.sh', childHash: H(0x11), size: 0 }
],
more: 0,
nonce: 7
})
add('treeResponse', 'PRE-PAGING peer (no more, no nonce)', {
hash: H(0x0e), entries: [], more: 0, nonce: 0
}, { truncate: 2, note: 'both trailers absent; each decodes 0 independently' })
// ── contentRequest — OPTIONAL TRAILING `from` ────────────────────────────────
add('contentRequest', 'with from', { contentHash: H(0x12), chunksHave: Buffer.from([0x01]), from: 'abc123' })
add('contentRequest', 'empty from', { contentHash: H(0x13) })
// ── transferControl ──────────────────────────────────────────────────────────
add('transferControl', 'paused', { contentHash: H(0x14), state: 0 })
add('transferControl', 'stopped', { contentHash: H(0x15), state: 1 })
// ── transferProgress ─────────────────────────────────────────────────────────
add('transferProgress', 'zero have', { contentHash: H(0x16), have: 0 })
add('transferProgress', 'large have', { contentHash: H(0x17), have: 8589934592 })
process.stdout.write(JSON.stringify({
generator: 'hyper-overlay/test/conformance/generate.mjs',
overlayVersion: pkg.version,
protocol: 'hyper-overlay/v2',
messageCount: Object.keys(messages).length,
caseCount: cases.length,
cases
}, null, 2) + '\n')