OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / conformance / generate.mjs

202 lines · javascript ref:
/**
 * 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')