OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / seed-file-paths.test.js

125 lines · javascript ref:
/**
 * seedFilePathsFromIndex — rebuilding the in-memory serve maps at boot.
 *
 * The maps (`_filePaths`, `_contentHashPaths`) are in-memory and only
 * registerFile() fills them, so without this seed a restarted node serves only
 * what it wrote since boot. 0.3.9 added the seed. This file covers the seed
 * itself, which had NO tests — which is how the bug below survived three
 * production sightings (2026-08-08 136 MiB APK, 2026-08-25 471 MB zip,
 * 2026-08-29 168 MB APK: bytes on disk, hash advertised, peer told "no holder").
 *
 * THE BUG: FileIndex.putFile persists `contentHash: meta.contentHash || null`
 * with a `hashing` flag, so a null hash is a first-class stored state. The seed
 * set the PATH map unconditionally but the HASH map only `if (f.contentHash)`,
 * while counting the entry as seeded either way. Such an entry is therefore
 * invisible to fetch-by-oid, reports as healthy, and — because consumers build
 * their "already known" set from what the resolver saw — is skipped by the
 * repair pass written for exactly this failure.
 *
 * FIX CONTRACT (deliberately non-breaking): the return stays a NUMBER, but now
 * counts only entries actually seeded into the HASH map — i.e. genuinely
 * servable by oid. atproto-store logs it as "servable-by-oid after restart",
 * a claim that was false before this change and is true after it, with no edit
 * there. The resolver additionally receives the index ENTRY, so a consumer can
 * tell a fully-seeded entry from a half-seeded one and route the latter to its
 * repair pass instead of skipping it.
 */

import test from 'brittle'
import crypto from 'hypercore-crypto'
import { tmpStore, tmpDir, fs, path } from './helpers.js'
import { HyperOverlay } from '../lib/overlay.js'

async function makeOverlay (label) {
  const ov = new HyperOverlay(tmpStore('seed-' + label), {
    namespace: 'overlay-v2',
    destDir: tmpDir('seed-' + label + '-dest')
  })
  await ov.ready()
  return ov
}

test('seedFilePathsFromIndex: a hashed entry seeds BOTH maps', async (t) => {
  const ov = await makeOverlay('hashed')
  t.teardown(async () => { try { await ov.close() } catch {} })

  const dir = tmpDir('seed-hashed-files')
  const body = crypto.randomBytes(4096)
  const disk = path.join(dir, 'a.bin')
  fs.writeFileSync(disk, body)
  const hash = crypto.data(body).toString('hex')

  await ov._index.putFile('release:a.bin', { contentHash: hash, size: body.length, mtime: Date.now() })

  const res = await ov.seedFilePathsFromIndex(() => disk)

  t.is(res, 1, 'one entry seeded (return stays a NUMBER — atproto-store reads it as one)')
  t.is(ov._filePaths.get('release:a.bin'), disk, 'path map seeded')
  t.is(ov._contentHashPaths.get(hash), disk, 'HASH map seeded — servable by oid')
})

test('seedFilePathsFromIndex: a NULL-contentHash entry is reported, not silently half-seeded', async (t) => {
  // The exact production shape: the file is on disk and correct, but its
  // overlay index entry was written while still `hashing`, so the hash is null.
  const ov = await makeOverlay('nullhash')
  t.teardown(async () => { try { await ov.close() } catch {} })

  const dir = tmpDir('seed-nullhash-files')
  const body = crypto.randomBytes(4096)
  const disk = path.join(dir, 'big.apk')
  fs.writeFileSync(disk, body)

  await ov._index.putFile('release:big.apk', { contentHash: null, size: body.length, mtime: Date.now(), hashing: true })

  const seen = []
  const res = await ov.seedFilePathsFromIndex((overlayPath, entry) => {
    // The consumer must be able to tell a fully-seeded entry from a half-seeded
    // one — it builds its "already known, skip repair" set from this callback.
    seen.push({ overlayPath, contentHash: entry ? entry.contentHash : undefined })
    return disk
  })

  t.is(seen.length, 1, 'resolver called once')
  t.is(seen[0].contentHash, null,
    'resolver RECEIVES the entry, so a null hash is visible to the consumer')

  t.is(res, 0, 'NOT counted as seeded — the count now means "servable by oid"')

  t.is(ov._filePaths.get('release:big.apk'), disk, 'path map still seeded')
  t.is(ov._contentHashPaths.size, 0, 'hash map correctly empty — nothing to serve by oid')
})

test('seedFilePathsFromIndex: counts split so a healthy log cannot hide a broken entry', async (t) => {
  const ov = await makeOverlay('mixed')
  t.teardown(async () => { try { await ov.close() } catch {} })

  const dir = tmpDir('seed-mixed-files')
  const good = crypto.randomBytes(1024)
  const goodPath = path.join(dir, 'good.bin')
  fs.writeFileSync(goodPath, good)
  const goodHash = crypto.data(good).toString('hex')
  const badPath = path.join(dir, 'bad.bin')
  fs.writeFileSync(badPath, crypto.randomBytes(1024))

  await ov._index.putFile('release:good.bin', { contentHash: goodHash, size: good.length, mtime: Date.now() })
  await ov._index.putFile('release:bad.bin', { contentHash: null, size: 1024, mtime: Date.now(), hashing: true })

  const res = await ov.seedFilePathsFromIndex((p) => path.join(dir, p.slice('release:'.length)))

  t.is(res, 1, 'only the servable one counts — a healthy total cannot hide a broken entry')
  t.is(ov._contentHashPaths.get(goodHash), goodPath, 'good file servable by oid')
})

test('seedFilePathsFromIndex: an unresolvable path is skipped entirely', async (t) => {
  const ov = await makeOverlay('unresolved')
  t.teardown(async () => { try { await ov.close() } catch {} })

  await ov._index.putFile('other:x.bin', { contentHash: 'ff'.repeat(32), size: 1, mtime: Date.now() })

  // A consumer that does not own this namespace returns null for it.
  const res = await ov.seedFilePathsFromIndex(() => null)

  t.is(res, 0, 'nothing seeded')
  t.is(ov._filePaths.size, 0)
})