// 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')
})