OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / overlay-seed.test.js

148 lines · javascript ref:
// ⛔ A restarted node must still SERVE what it wrote before the restart.
//
// hyper-overlay resolves an incoming fetch-by-oid through IN-MEMORY maps that only `registerFile()`
// fills. Nothing rebuilds them at boot, so a node serves only what it registered since it started —
// every other file sits on disk, indexed, with a matching hash, and unreachable. That cost 75+
// silent hours on wj62; 0.3.9 added `seedFilePathsFromIndex(resolve)` to fix it, and the CONSUMER
// has to call it. favela-peer never did.
//
// MEASURED 2026-09-07, live: tabertron held 9 pointers from ashatron and could read 0 of them,
// stable across four attempts, while ashatron read its own posts fine off local disk — because
// ashatron's daemon had been restarted minutes earlier.
//
// ⛔ And the seed could not have worked as the paths stood. A record's overlay path was
// `/records/<collection>/<name>.md` with NO feed in it, while every feed has its own directory —
// so the path could not be resolved back to a file, and two feeds holding `post/p1` registered the
// SAME overlay path, the second overwriting the first.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { RecordStore } from '../lib/record-store.js'
import { FavelaNode } from '../lib/node.js'
import { saveCredential } from '../lib/device.js'
import { identity } from './helpers.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-seed-'))

test('★ a record\'s overlay path names its FEED, so it resolves back to a file', async (t) => {
  const storage = tmp()
  const mine = new RecordStore(join(storage, 'records', 'self'), { base: storage })
  const theirs = new RecordStore(join(storage, 'records', 'ab'.repeat(32)), { base: storage })

  const a = mine._overlayPath('post', 'p1')
  const b = theirs._overlayPath('post', 'p1')

  t.not(a, b, 'two feeds holding the same record name are two different files')
  // ⛔ The property the seed needs: overlay path joined onto the storage root IS the disk path.
  // Without it `seedFilePathsFromIndex` has nothing to resolve to and the map stays empty.
  t.is(join(storage, a), mine._pathFor('post', 'p1'))
  t.is(join(storage, b), theirs._pathFor('post', 'p1'))
})

test('★★ a node SEEDS the serve map at boot, from what it already indexed', async (t) => {
  const storage = tmp()
  const id = await identity()
  saveCredential(storage, await id.device())

  const seen = { calls: 0, resolved: [] }
  // A recording overlay: enough surface for boot, and it captures the resolver so the test can ask
  // what it would map — which is the thing that was broken, not the fact of calling.
  const overlay = {
    async ready () {},
    async seedFilePathsFromIndex (resolve) {
      seen.calls++
      seen.resolve = resolve
      return 0
    },
    attachProtocol () {},
    async registerFile () { return { oid: 'ab'.repeat(32), size: 1 } },
    hashFile: async () => null,
    async fetchFile () { return null },
    async unregisterFile () {},
    async close () {}
  }

  const n = new FavelaNode(storage, { overlay, transport: { async join () {}, async leave () {}, async destroy () {} } })
  await n.ready()
  t.teardown(() => n.close())

  t.is(seen.calls, 1, 'seeded once, at boot — not per feed and not never')
  t.is(typeof seen.resolve, 'function')

  // Every kind of file this node registers must resolve, or that kind stays unservable.
  // ⚠ They have to EXIST — the resolver refuses to point the serve map at a file we do not hold.
  const files = [
    ['records/self/records/post', 'p1.md', '/records/self/records/post/p1.md'],
    ['blobs', 'cd'.repeat(32), '/blobs/' + 'cd'.repeat(32)],
    ['avatars', 'ef'.repeat(32), '/avatars/' + 'ef'.repeat(32)]
  ]
  for (const [dir, name, overlayPath] of files) {
    fs.mkdirSync(join(storage, dir), { recursive: true })
    fs.writeFileSync(join(storage, dir, name), 'bytes')
    t.is(seen.resolve(overlayPath), join(storage, dir, name), overlayPath)
  }
})

test('⛔ the resolver refuses anything outside the storage root', async (t) => {
  const storage = tmp()
  const id = await identity()
  saveCredential(storage, await id.device())
  let resolve
  const overlay = {
    async ready () {}, attachProtocol () {},
    async seedFilePathsFromIndex (r) { resolve = r; return 0 },
    async registerFile () { return {} }, hashFile: async () => null,
    async fetchFile () { return null }, async unregisterFile () {}, async close () {}
  }
  const n = new FavelaNode(storage, { overlay, transport: { async join () {}, async leave () {}, async destroy () {} } })
  await n.ready()
  t.teardown(() => n.close())

  // The index is data on disk. An entry with a traversing path must not be able to point the serve
  // map at a file outside this node's storage — the map is what gets HANDED to peers.
  t.is(resolve('/records/../../../etc/passwd'), null)
  // ⚠ And an entry pointing at a file that is not there — which every entry written under the OLD
  // overlay-path shape now is. Seeding those would offer peers a file we do not have.
  t.is(resolve('/records/self/records/post/never-written.md'), null)
  t.is(resolve('/../etc/passwd'), null)
  t.is(resolve('not-absolute'), null)
})

test('★★ the repair pass re-registers files the seed could not resolve', async (t) => {
  const storage = tmp()
  const registered = []
  const overlay = {
    async registerFile (overlayPath, disk, meta) { registered.push([overlayPath, disk, meta.size]); return { oid: 'ab'.repeat(32), size: meta.size } },
    hashFile: async () => null, async fetchFile () { return null }, async unregisterFile () {}
  }
  const store = new RecordStore(join(storage, 'records', 'self'), { overlay, base: storage })
  await store.put('post', 'p1', { frontmatter: {}, body: 'one' })
  await store.put('post', 'p2', { frontmatter: {}, body: 'two' })
  registered.length = 0

  // ⛔ Nothing else rewrites these. `put()` registers only what it writes, and the seed skips index
  // entries whose path resolves to no file — which is every entry from the older path shape. Without
  // this pass those records stay unservable for ever while sitting on disk.
  const n = await store.reregisterLocal()
  t.is(n, 2, 'both records re-registered')
  t.alike(registered.map((r) => r[0]).sort(),
    ['/records/self/records/post/p1.md', '/records/self/records/post/p2.md'],
    'under the resolvable path shape')
  t.ok(registered.every((r) => r[2] > 0), 'with a real size, read from disk')
})

test('⚠ the repair survives an unreadable file rather than stopping at it', async (t) => {
  const storage = tmp()
  const overlay = {
    async registerFile (p) { if (p.includes('bad')) throw new Error('nope'); return { oid: 'ab'.repeat(32), size: 1 } },
    hashFile: async () => null, async fetchFile () { return null }, async unregisterFile () {}
  }
  const store = new RecordStore(join(storage, 'records', 'self'), { overlay, base: storage })
  await store.put('post', 'good1', { frontmatter: {}, body: 'a' })
  await store.put('post', 'bad', { frontmatter: {}, body: 'b' })
  await store.put('post', 'good2', { frontmatter: {}, body: 'c' })
  t.is(await store.reregisterLocal(), 2, 'one failure does not cost the others')
})