OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / cores.test.js

223 lines · javascript ref:
// cores — mapping a discovery key from a `[hypercore] conflict detected in <dkey>` log line back to a core.
//
// That upstream log (hypercore/lib/core.js:685, a `// tmp log so we can see these`) is the ONLY trace a
// conflict leaves — nothing in this stack listens for the `conflict` event — and the id it prints is the
// DISCOVERY key, `blake2b("hypercore", key)`, which is one-way. loser-club logged conflicts on `4a6f1b30`
// across restarts with no way to name the core. The lookup is possible because every corestore already
// stores the pair, so these tests pin the properties that make the answer trustworthy:
//
//   1. the mapping is real against an ON-DISK corestore, not a stub. The whole tool is a claim about a
//      storage encoding; a fake store would prove nothing about it.
//   2. it opens read-only WITHOUT taking the CORESTORE lock, so it can be run against a live daemon. This
//      is the operational claim the tool is used on — if it needed `pgh app stop` first it would not get
//      used during an incident, which is the only time it matters.
//   3. `writable` is honest. It decides whether a conflict is OUR key authoring two histories or someone
//      else's fork arriving, and everything downstream reads it that way.
//   4. DUPLICATE-WRITER is distinguished from a plain replica. The same key in two stores is normal; the
//      same key WRITABLE in two stores is how "two conflicting signatures exist for length N" becomes true.
//      Conflating them would make the report cry wolf on every healthy side store.
//
// Run: bare test/cores.test.js
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import { join } from 'bare-path'
import b4a from 'b4a'
import Corestore from 'corestore'
import crypto from 'hypercore-crypto'
import { findStores, readStore, findDuplicates, compareCopies } from '../lib/cores.js'

const tmp = () => {
  const d = '/tmp/hpp-cores-' + Math.floor(Math.random() * 1e9)
  fs.mkdirSync(d, { recursive: true })
  return d
}

// A real corestore with one named, writable, appended-to core — the shape every claim below is checked on.
async function makeStore (dir, { name = 'alpha', blocks = 3 } = {}) {
  const store = new Corestore(dir)
  const core = store.get({ name })
  await core.ready()
  for (let i = 0; i < blocks; i++) await core.append(b4a.from('block-' + i))
  return { store, core, key: b4a.toString(core.key, 'hex'), discoveryKey: b4a.toString(core.discoveryKey, 'hex') }
}

test('discovery key maps back to the core: key, alias name, length, writable', async (t) => {
  const dir = join(tmp(), 'store')
  const { store, key, discoveryKey } = await makeStore(dir, { name: 'alpha', blocks: 3 })
  await store.close()

  const cores = await readStore(dir)
  t.absent(cores.some((c) => c.error), 'store opened cleanly')

  // The lookup the operator actually performs: they have ONLY the discovery key from the log line.
  const hit = cores.find((c) => c.discoveryKey === discoveryKey)
  t.ok(hit, 'the discovery key from the log is found in the store')
  t.is(hit.key, key, 'and resolves to the correct public key — this is the whole point of the tool')
  t.ok(hit.names.includes('alpha'), 'corestore alias name reported, so the match is nameable not just hex')
  t.is(hit.length, 3, 'head length reported (it is what identifies a writer against members-diag authored=)')
  t.is(hit.writable, true, 'we created it here, so we hold its secret key')
})

test('opens read-only WITHOUT the CORESTORE lock — safe against a running daemon', async (t) => {
  const dir = join(tmp(), 'store')
  const { store, discoveryKey } = await makeStore(dir)

  // Deliberately leave the writer OPEN, exactly as a live daemon holds it. A normal (read-write) open here
  // would fail on the device-file lock; this is the property that lets the tool be used mid-incident.
  const cores = await readStore(dir)
  t.absent(cores.some((c) => c.error), 'read succeeded while another handle holds the store open')
  t.ok(cores.some((c) => c.discoveryKey === discoveryKey), 'and still returns the mapping')

  await store.close()
})

test('findStores locates corestore dirs and does not descend into them', async (t) => {
  const root = tmp()
  const a = join(root, 'store')
  const b = join(root, 'nested', 'deep', 'statebase')
  const { store: sa } = await makeStore(a)
  const { store: sb } = await makeStore(b)
  await sa.close()
  await sb.close()
  fs.mkdirSync(join(root, 'not-a-store'), { recursive: true })

  const found = findStores(root)
  t.ok(found.includes(a), 'top-level store found')
  t.ok(found.includes(b), 'nested store found — leftovers hide at depth, which is why this walks')
  t.absent(found.some((d) => d.startsWith(a + '/')), 'does not descend INTO a store and report its internals')
  t.absent(found.some((d) => d.endsWith('not-a-store')), 'a plain directory is not a store')
})

test('DUPLICATE-WRITER is distinguished from a plain replica', async (t) => {
  // Same key, present twice, writable ONCE: an ordinary replica (a side store holding what the shared
  // store also holds). Must NOT be escalated, or every healthy node reports a fork.
  const replica = findDuplicates([
    { key: 'aa', discoveryKey: 'dd', names: [], writable: true, length: 10, fork: 0, store: '/s1' },
    { key: 'aa', discoveryKey: 'dd', names: [], writable: false, length: 10, fork: 0, store: '/s2' }
  ])
  t.is(replica.length, 1, 'the duplicate is reported')
  t.is(replica[0].severity, 'replica', 'one writable copy is normal')
  t.is(replica[0].writableCopies, 1)

  // Same key writable in TWO places: two independent signers of one log. This is the shape that produces
  // conflicts, and it is invisible to every other surface.
  const forked = findDuplicates([
    { key: 'bb', discoveryKey: 'ee', names: ['local'], writable: true, length: 10, fork: 0, store: '/s1' },
    { key: 'bb', discoveryKey: 'ee', names: ['local'], writable: true, length: 12, fork: 0, store: '/s2' }
  ])
  t.is(forked[0].severity, 'DUPLICATE-WRITER', 'two writable copies of one key is the fork shape')
  t.is(forked[0].writableCopies, 2)
  t.is(forked[0].lengthsDiffer, true, 'diverging lengths across copies are flagged')

  // A key present once is not a duplicate at all — the common case must stay silent.
  t.is(findDuplicates([{ key: 'cc', discoveryKey: 'ff', names: [], writable: true, length: 1, fork: 0, store: '/s1' }]).length, 0)
})

// --compare answers the question the conflict log cannot: the log says a fork exists somewhere, but a
// SHORT copy beside a long one is ambiguous — ordinary replication lag looks identical to a fork until you
// compare the trees. These two cases are the whole discriminator, so both are pinned. A wrong verdict here
// is worse than no tool: PREFIX read as FORK sends an operator hunting a fork that does not exist, and FORK
// read as PREFIX declares convergence over a live one — the exact mistake #17 records making by hand.
test('compare: a short copy that is a PREFIX of a long one is not a fork', async (t) => {
  const root = tmp()
  const long = join(root, 'long')
  const short = join(root, 'short')

  // Same key, same history, different lengths — a lagging replica. Built by appending the SAME blocks, so
  // the shorter tree is genuinely a prefix of the longer.
  const a = await makeStore(long, { name: 'w', blocks: 8 })
  const b = new Corestore(short)
  const bc = b.get({ key: a.core.key })
  await bc.ready()
  const s1 = a.store.replicate(true)
  const s2 = b.replicate(false)
  s1.pipe(s2).pipe(s1)
  await bc.download({ start: 0, end: 4 }).done()
  await bc.get(3)
  await new Promise((resolve) => setTimeout(resolve, 100))
  await a.store.close()
  await b.close()

  const r = await compareCopies([long, short], a.discoveryKey)
  if (!r.comparable) { t.pass('second copy did not materialise a head; nothing to compare'); return }
  t.is(r.agree, true, 'the copies agree at the shortest common length')
  t.ok(r.verdict.startsWith('PREFIX'), 'verdict says PREFIX, not fork')
  t.is(r.divergesAt, null, 'and there is no divergence point to report')
})

test('compare: two histories under one key are a FORK, and the parting block is named', async (t) => {
  const root = tmp()
  const p = join(root, 'p')
  const q = join(root, 'q')

  // The same keypair writing two DIFFERENT histories — the shape that makes "two conflicting signatures
  // exist for length N" true. Shared blocks first, so the fork begins at a KNOWN block and the bisect has
  // a right answer to be checked against.
  const kp = crypto.keyPair()
  const build = async (dir, tail) => {
    const store = new Corestore(dir)
    const core = store.get({ keyPair: kp })
    await core.ready()
    for (const blk of ['same-0', 'same-1', 'same-2', tail]) await core.append(b4a.from(blk))
    const dk = b4a.toString(core.discoveryKey, 'hex')
    await store.close()
    return dk
  }
  const dk = await build(p, 'DIVERGES-here')
  await build(q, 'different-tail')

  const r = await compareCopies([p, q], dk)
  t.is(r.comparable, true)
  t.is(r.agree, false, 'the two histories do not agree')
  t.ok(r.verdict.startsWith('FORK'), 'verdict says FORK')
  t.ok(r.divergesAt, 'a divergence point is reported')
  t.is(r.divergesAt.length, 4, 'and it is block 4 — the first length at which the histories differ')
})

// Cross-machine comparison rests on ONE property: the tree hash at a length is a function of the history
// alone, not of the store that holds it. If it were store-dependent, diffing two hosts' strings would be
// meaningless — and worse, meaningless in the confident-looking way that gets acted on. So this builds two
// INDEPENDENT stores and checks the hash is identical for the same history and different for a forked one,
// which is exactly what an operator does by eye across two boxes.
test('single copy reports a tree hash, and the hash is machine-independent at a pinned length', async (t) => {
  const root = tmp()
  const kp = crypto.keyPair()
  const build = async (dir, blocks) => {
    const store = new Corestore(dir)
    const core = store.get({ keyPair: kp })
    await core.ready()
    for (const b of blocks) await core.append(b4a.from(b))
    const dk = b4a.toString(core.discoveryKey, 'hex')
    await store.close()
    return dk
  }
  const shared = ['a', 'b', 'c', 'd']
  const hostA = join(root, 'hostA')
  const hostB = join(root, 'hostB')
  const hostC = join(root, 'hostC')
  const dk = await build(hostA, [...shared, 'more', 'blocks'])   // longer, same history
  await build(hostB, shared)                                      // shorter, same history
  await build(hostC, ['a', 'b', 'DIFFERENT', 'd'])                // forked at block 3

  const at = 4
  const a = await compareCopies([hostA], dk, { at })
  const b = await compareCopies([hostB], dk, { at })
  const c = await compareCopies([hostC], dk, { at })

  t.is(a.comparable, true, 'a lone copy is still comparable — the common case on a healthy node')
  t.is(a.singleCopy, true)
  t.ok(a.verdict.startsWith('SINGLE'), 'and says so rather than claiming agreement it cannot see')
  t.is(a.comparedAtLength, at, '--at pins the length, so two hosts of DIFFERENT lengths can be compared')
  t.ok(a.copies[0].treeHash, 'a hash is reported')

  t.is(a.copies[0].treeHash, b.copies[0].treeHash, 'same history, independent stores, different lengths => SAME hash')
  t.not(a.copies[0].treeHash, c.copies[0].treeHash, 'forked history => DIFFERENT hash at the same length')

  // Asking beyond what a host holds must report nothing, never a hash that would compare as "different"
  // and be read as a fork. "Cannot say" and "disagrees" are opposite answers.
  const beyond = await compareCopies([hostB], dk, { at: 99 })
  t.absent(beyond.copies[0].treeHash, 'past the end of this copy, no hash is invented')
  t.ok(beyond.verdict.includes('cannot be hashed'), 'and the verdict says why')
})