OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / swarm-device-migration.test.mjs

116 lines · javascript ref:
// Step 2a MIGRATION + ANTI-WIPE proof (the wj62-wipe hazard, reproduced + healed offline).
//
// The hazard: a repo written by the previous FILES-ONLY backend (OverlayRepoSubstrate) has record
// BYTES on disk but NO pointers in the shared autobase. Booting the new PointerSubstrate code over it,
// listRecords() reads the (empty) pointer set while restore() loads a NON-EMPTY head from the firehose
// → the first sign() materializes an empty repo → emits a delete-every-record #commit → wipes the
// account off bsky. This proves both defenses:
//   (1) sign()'s ANTI-WIPE GUARD refuses to sign when the pointer set is empty but record FILES exist,
//       so a not-yet-migrated boot never wipes.
//   (2) migratePointers() backfills a {$oid,size} pointer per file → the record set converges → the
//       next sign() is a byte-identical no-op (head preserved), and a subsequent post emits ONE create
//       (no re-authoring of the migrated records — restore() re-baselines the diff).
// Plus: a GENUINE full-delete (no files left) is NOT blocked — the guard is precise, not count-only.

import '../lib/bare-globals.js' // web-global polyfills for Bare — MUST load first
import test from 'brittle'
import { mkdtempSync, rmSync } from 'bare-fs'
import { join } from 'bare-path'
import os from 'bare-os'
import b4a from 'b4a'
import { SwarmDevice } from '../lib/swarm-device.js'
import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'

const DID = 'did:plc:migtest'
const KEY = (() => { const k = b4a.alloc(32); k[31] = 9; return k })()
const tmp = (t) => { const d = mkdtempSync(join(os.tmpdir(), 'sd-mig-')); t.teardown(() => rmSync(d, { recursive: true, force: true })); return d }
const post = (text, at) => ({ $type: 'app.bsky.feed.post', text, createdAt: at })

// StateBase-shaped KV with a writable `base` (migratePointers gates on sb.base.writable).
class MigKV {
  constructor () { this.m = new Map(); this.base = { writable: true } }
  async set (k, v) { this.m.set(k, v) }
  async get (k) { return this.m.has(k) ? this.m.get(k) : null }
  async getNow (k) { return this.get(k) }
  async del (k) { this.m.delete(k) }
  async list (prefix = '') { const o = {}; for (const [k, v] of this.m) if (k.startsWith(prefix)) o[k] = v; return o }
  async listNow (prefix = '') { return this.list(prefix) }
}

test('2a migration: not-yet-migrated boot does NOT wipe (anti-wipe guard), then backfill heals it', async (t) => {
  const sb = new MigKV()
  const store = await new OverlayRepoStore(tmp(t)).ready()

  // ── Phase A: a files-only repo's END state — record BYTES on disk + a signed firehose head, but the
  // pointer set is ABSENT (as if written by OverlayRepoSubstrate). Establish it via one device, then
  // strip the pointers to simulate the pre-migration on-disk reality.
  const d1 = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'creator', firehose: true, recordStore: store })
  await d1.putRecord('app.bsky.feed.post', '1', post('one', '2026-01-01T00:00:00.000Z'))
  await d1.putRecord('app.bsky.feed.post', '2', post('two', '2026-01-02T00:00:00.000Z'))
  const signed = await d1.sign({ force: true })
  t.ok(signed && signed.rev, 'phase A: creator signed a non-empty commit')
  const headDataCid = signed.dataCid.toString()
  t.is((await d1.firehoseLog()).length, 1, 'phase A: firehose has one #commit')
  // strip the pointers → files remain on disk, autobase record set now empty (the files-only reality)
  await sb.del('app.bsky.feed.post/1')
  await sb.del('app.bsky.feed.post/2')
  t.is(Object.keys(await sb.list('app.bsky.feed.post/')).length, 0, 'phase A: pointer set stripped (files-only state)')
  t.is((await store.records()).length, 2, 'phase A: the 2 record FILES are still on disk')

  // ── Phase B: boot the NEW PointerSubstrate code over that state (fresh device object = a restart).
  const d2 = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'creator', firehose: true, recordStore: store })
  t.is((await d2.repo.listRecords()).length, 0, 'phase B: listRecords reads ZERO (the wipe hazard is live)')

  // sign() WITHOUT force — restore() loads the non-empty head, listRecords() reads empty → the guard
  // MUST fire and refuse to sign (no delete-all commit).
  const wipeAttempt = await d2.sign()
  t.is(wipeAttempt, null, 'phase B: anti-wipe guard refused the sign (returned null)')
  t.is((await d2.firehoseLog()).length, 1, 'phase B: NO new #commit — the account was NOT wiped')
  t.is(d2.head && d2.head.dataCid.toString(), headDataCid, 'phase B: head still points at the non-empty repo')

  // migrate: backfill the pointers, then re-baseline like the boot path (restore()).
  const mig = await d2.migratePointers()
  t.alike(mig, { written: 2, skipped: 0, total: 2 }, 'phase B: migratePointers backfilled both records')
  await d2.restore() // boot path calls this once existing>0 — re-seeds head + the diff baseline
  t.is((await d2.repo.listRecords()).length, 2, 'phase B: record set converged (2) after backfill')
  t.alike((await d2.repo.getRecord('app.bsky.feed.post', '1')), post('one', '2026-01-01T00:00:00.000Z'), 'byte-exact record resolves')

  // the next sign is a byte-identical no-op — head preserved, no spurious #commit.
  const noop = await d2.sign()
  t.is(noop, null, 'phase B: post-migration sign is a no-op (unchanged data root)')
  t.is((await d2.firehoseLog()).length, 1, 'phase B: still one #commit (no re-author)')

  // ── Phase C: a NEW post after migration emits exactly ONE create — the migrated records are the
  // diff baseline (restore re-based them), so they are NOT re-authored.
  await d2.putRecord('app.bsky.feed.post', '3', post('three', '2026-01-03T00:00:00.000Z'))
  const h3 = await d2.sign()
  t.ok(h3 && h3.rev, 'phase C: new post signed a fresh commit')
  const log = await d2.firehoseLog()
  t.is(log.length, 2, 'phase C: exactly one new #commit for the new post')
  const frame = log[log.length - 1]
  t.is(frame.ops.length, 1, 'phase C: the new #commit has exactly ONE op (no re-authoring of migrated records)')
  t.alike(frame.ops[0], { action: 'create', path: 'app.bsky.feed.post/3', cid: frame.ops[0].cid, prev: null }, 'phase C: the single op is the new record create')

  await store.close()
})

test('anti-wipe guard is PRECISE: a genuine full-delete (no files left) is NOT blocked', async (t) => {
  const store = await new OverlayRepoStore(tmp(t)).ready()
  const d = new SwarmDevice(new MigKV(), { did: DID, privateKey: KEY, self: 'creator', firehose: true, recordStore: store })
  await d.putRecord('app.bsky.feed.post', '1', post('only', '2026-01-01T00:00:00.000Z'))
  await d.sign({ force: true })
  t.is((await d.firehoseLog()).length, 1, 'one record signed')

  // delete it through the repo (removes BOTH the pointer AND the file) — a legitimate empty repo.
  await d.deleteRecord('app.bsky.feed.post', '1')
  t.is((await store.records()).length, 0, 'no record files remain (genuine empty)')

  const h = await d.sign() // head != null, records 0, files 0 → guard does NOT fire → emit the delete
  t.ok(h && h.rev, 'sign proceeded (genuine empty is allowed)')
  const frame = (await d.firehoseLog())[1]
  t.is(frame.ops.length, 1, 'the delete op was emitted')
  t.is(frame.ops[0].action, 'delete', 'it is a delete (relay reflects the intentional removal)')

  await store.close()
})