OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc / test / connect-swarm-verify.test.js

152 lines · javascript ref:
// connectSwarm's identity gate + the failed-connect swarm leak.
//
// Context (measured 2026-08-08, not hypothetical): `serverKey` only derives the
// topic — SwarmTransport.listen() builds its Hyperswarm with NO keyPair, so a
// server's Noise key is random per process and never equals the serverKey.
// In a pear-git-v3 fleet a mirror joins its ORIGIN's topic, making it a server
// on the very topic clients use to reach the origin. On stortron,
// connectSwarm({serverKey: <tabertron>}) was answered by STORTRON ITSELF 6/6:
// it asked itself for the origin's release index, got its own, and every
// artifact fetch failed "no holder". A wrong ANSWER, not a transport error.

import test from 'brittle'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import createTestnet from '@hyperswarm/testnet'
import Corestore from 'corestore'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'

import { Service } from '../lib/service.js'
import { Client } from '../lib/client.js'

const tmpStore = () => new Corestore(mkdtempSync(join(tmpdir(), 'hsvc-verify-')))

// Two services sharing ONE topic — the prod shape (origin + mirror).
async function twoOnOneTopic (t, testnet, topic) {
  const made = []
  for (const name of ['origin', 'impostor']) {
    const store = tmpStore()
    const svc = new Service({
      name,
      corestore: store,
      swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }
    })
    svc.handle('status', async () => ({ identity: name }))
    svc.handle('who', async () => name)
    await svc.listen()
    t.teardown(async () => {
      try { await svc.close() } catch {}
      try { await store.close() } catch {}
    })
    made.push(svc)
  }
  return made
}

test('without verify, ANY peer on the topic can answer (the bug)', async (t) => {
  const testnet = await createTestnet(2, t.teardown)
  const serverKey = b4a.alloc(32, 'unverified-topic')
  const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
  await twoOnOneTopic(t, testnet, topic)

  const client = await Client.connectSwarm({
    serverKey, topicSalt: 'pear-git-v3', swarm: { bootstrap: testnet.bootstrap }
  })
  t.teardown(async () => { try { await client.close() } catch {} })

  const who = await client.call('who')
  // Deliberately NOT asserting which one — that is the point. Whoever gets
  // there first answers, and the caller cannot tell. Documented, not desired.
  t.ok(who === 'origin' || who === 'impostor', 'some peer answered: ' + who)
})

test('verify rejects the wrong peer and keeps waiting for the right one', async (t) => {
  const testnet = await createTestnet(2, t.teardown)
  const serverKey = b4a.alloc(32, 'verified-topic')
  const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
  await twoOnOneTopic(t, testnet, topic)

  const seen = []
  const client = await Client.connectSwarm({
    serverKey,
    topicSalt: 'pear-git-v3',
    timeout: 20000,
    swarm: { bootstrap: testnet.bootstrap },
    verify: async (c) => {
      const st = await c.call('status', {})
      seen.push(st.identity)
      return st.identity === 'origin'
    }
  })
  t.teardown(async () => { try { await client.close() } catch {} })

  t.is(await client.call('who'), 'origin', 'reached the daemon that was ASKED for')
  t.ok(seen.includes('origin'), 'verify ran against the accepted peer')
})

test('all peers failing verification is a loud timeout, not a wrong answer', async (t) => {
  const testnet = await createTestnet(2, t.teardown)
  const serverKey = b4a.alloc(32, 'nobody-matches')
  const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
  await twoOnOneTopic(t, testnet, topic)

  await t.exception(
    () => Client.connectSwarm({
      serverKey,
      topicSalt: 'pear-git-v3',
      timeout: 3000,
      swarm: { bootstrap: testnet.bootstrap },
      verify: async () => false
    }),
    /timeout/,
    'fails rather than silently returning the wrong daemon'
  )
})

test('a verify that throws is treated as rejection, not a crash', async (t) => {
  const testnet = await createTestnet(2, t.teardown)
  const serverKey = b4a.alloc(32, 'verify-throws')
  const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
  await twoOnOneTopic(t, testnet, topic)

  await t.exception(
    () => Client.connectSwarm({
      serverKey,
      topicSalt: 'pear-git-v3',
      timeout: 3000,
      swarm: { bootstrap: testnet.bootstrap },
      verify: async () => { throw new Error('handler missing on that peer') }
    }),
    /timeout/,
    'a throwing verify cannot escape as an unhandled rejection'
  )
})

test('a failed connect does not leak the Hyperswarm', async (t) => {
  // The leak that hung pear-git-v3's basic.test.js: 123/123 passed, then the
  // process never exited because each failed connect left a swarm holding the
  // event loop open.
  const testnet = await createTestnet(2, t.teardown)
  const serverKey = b4a.alloc(32, 'nobody-home')   // nothing serves this topic

  const before = process._getActiveHandles ? process._getActiveHandles().length : null
  await t.exception(
    () => Client.connectSwarm({
      serverKey, topicSalt: 'pear-git-v3', timeout: 1500,
      swarm: { bootstrap: testnet.bootstrap }
    }),
    /timeout/,
    'times out with nobody serving'
  )
  // Destruction is awaited inside connectSwarm's catch, so by here the swarm's
  // sockets are already gone rather than merely scheduled for teardown.
  if (before !== null) {
    const after = process._getActiveHandles().length
    t.ok(after <= before + 2, 'no swarm left running (handles ' + before + ' → ' + after + ')')
  } else {
    t.pass('handle introspection unavailable in this runtime')
  }
})