OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / scope-isolation-cross-machine.js

212 lines · javascript ref:
// Cross-machine scope-isolation proof (post-mortem #35 → #37 fix).
//
// The post-mortem: a scope-peer that mounted the single shared `orgs`
// autobase replicated ALL of the primary's orgs (and cloned 97 repos),
// because hyper-orgs used ONE autobase for every org. The #37 fix moves
// to ONE autobase PER org: a peer joins ONE org via an invite and mounts
// ONLY that org's autobase, so other orgs never reach its disk.
//
// This harness proves the fix over the real Hyperswarm DHT (tabertron ↔
// ashatron), not just the in-process replicateAndSync unit test
// (hyper-orgs/test/scope-isolation.test.js + the in-process daemon test
// 'two daemons mirror per-org records…' in basic.test.js). It catches
// what in-memory replication cannot: NAT punch-through, multi-host
// connections, swarm topic derivation, daemon boot ordering.
//
// Storage is ephemeral (under /tmp). Daemon binds port 5006 to stay
// clear of the production daemon on 5005.
//
// 2-step flow (each command runs on a different machine):
//
//   # Step 1 — on tabertron (PRIMARY). Boots a daemon, creates 3 orgs
//   # (alpha/beta/gamma), mints a PEER invite for alpha ONLY, prints the
//   # invite + swarm topic, then KEEPS RUNNING so the mirror can
//   # replicate. Ctrl+C when done.
//   bare test/scope-isolation-cross-machine.js primary \
//       --storage /tmp/pgv3-scope-tb-primary --port 5006
//   # Outputs:
//   #   ALPHA_INVITE=<invitation>
//   #   SWARM_TOPIC=<hex>
//
//   # Step 2 — on ashatron (MIRROR), with the two values above:
//   bare test/scope-isolation-cross-machine.js mirror \
//       --storage /tmp/pgv3-scope-ash-mirror --port 5006 \
//       --invite <invitation> --swarm-topic <hex>
//   # Mirror joins ONLY alpha, waits up to 60s for it to replicate,
//   # then prints what it saw and exits PASS/FAIL.
//
// PASS = isolation holds (mirror sees ONLY alpha; beta/gamma never
//        mounted, so they're structurally invisible).
// FAIL = scope leak (mirror somehow sees beta or gamma).

import { mkdirSync } from '#node:fs'
import { join } from '#node:path'
import process from '#node:process'
import b4a from 'b4a'

import { startPearGit } from '../index.js'

const args = parseArgs(process.argv.slice(2))
const cmd = args._[0]

async function main () {
  if (cmd === 'primary') return runPrimary()
  if (cmd === 'mirror')  return runMirror()
  console.error('usage: scope-isolation-cross-machine.js {primary|mirror} [opts]')
  console.error('  primary --storage <dir> --port <n>')
  console.error('  mirror  --storage <dir> --port <n> --invite <invitation> --swarm-topic <hex>')
  process.exit(2)
}

function parseArgs (argv) {
  const out = { _: [] }
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (a.startsWith('--')) {
      const key = a.slice(2)
      const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true
      out[key] = val
    } else out._.push(a)
  }
  return out
}

function requireArg (name, value) {
  if (!value || value === true) {
    console.error(`--${name} required`)
    process.exit(2)
  }
  return value
}

function ensureStorage (storage) {
  // mkdir -p. Caller picks a path that doesn't collide with another
  // daemon's storage; test paths should be under /tmp.
  mkdirSync(storage, { recursive: true })
}

async function waitFor (pred, timeoutMs = 60000, pollMs = 500) {
  const t0 = Date.now()
  while (Date.now() - t0 < timeoutMs) {
    try { if (await pred()) return true } catch {}
    await new Promise((r) => setTimeout(r, pollMs))
  }
  return false
}

async function runPrimary () {
  const storage = requireArg('storage', args.storage)
  const port    = parseInt(requireArg('port', args.port), 10)
  ensureStorage(storage)

  console.log(`[primary] booting pear-git-v3 daemon — storage=${storage} port=${port}`)
  const node = await startPearGit({
    storage,
    uds: join(storage, 'svc.sock'),
    http: { port },
    deploy: false,
    sync: false
  })

  // Three orgs, each its OWN autobase (the post-mortem setup; the mirror
  // is meant to scope to just one).
  await node.orgs.create('alpha', 'group', { displayName: 'Alpha' })
  await node.orgs.create('beta',  'group', { displayName: 'Beta'  })
  await node.orgs.create('gamma', 'group', { displayName: 'Gamma' })
  await node.orgs.sync() // per-org: sync() iterates every room (base getter throws on >1)

  // PEER invite for alpha ONLY — holding it lets the mirror mount alpha's
  // autobase and nothing else.
  const { invitation } = await node.orgs.createOrgInvite('alpha', { role: 'reader', swarm: node.svc.swarm.swarm })

  // The daemon joins ONE swarm topic at boot (identity-derived). The
  // mirror needs the same topic to find this daemon on the DHT.
  const swarmTopic = node.svc.swarm && node.svc.swarm.topic
    ? b4a.toString(node.svc.swarm.topic, 'hex')
    : null

  console.log('')
  console.log('[primary] 3 orgs created: alpha, beta, gamma (separate autobases)')
  console.log('[primary] alpha PEER invite + swarm topic — paste into the mirror command:')
  console.log('')
  console.log(`ALPHA_INVITE=${invitation}`)
  console.log(`SWARM_TOPIC=${swarmTopic}`)
  console.log('')
  console.log('[primary] daemon staying up. Ctrl+C when the mirror finishes.')

  await new Promise((resolve) => {
    const onSig = () => resolve()
    process.once('SIGINT',  onSig)
    process.once('SIGTERM', onSig)
  })

  console.log('[primary] shutting down')
  try { await node.close() } catch {}
}

async function runMirror () {
  const storage  = requireArg('storage', args.storage)
  const port     = parseInt(requireArg('port', args.port), 10)
  const invite   = requireArg('invite', args.invite)
  const topicHex = requireArg('swarm-topic', args['swarm-topic'])
  ensureStorage(storage)

  console.log(`[mirror] booting pear-git-v3 daemon — storage=${storage} port=${port}`)
  console.log(`[mirror] joining swarm topic ${topicHex.slice(0, 16)}…`)

  const node = await startPearGit({
    storage,
    uds: join(storage, 'svc.sock'),
    http: { port },
    swarm: { topic: b4a.from(topicHex, 'hex') },
    deploy: false,
    sync: false
  })

  // Per-org-autobase flow: consume the alpha invite → mount ONLY alpha.
  // No mounts.orgs (that single-autobase surface is gone).
  console.log('[mirror] joining alpha via invite (mounts ONLY alpha\'s autobase)…')
  const joined = await node.orgs.joinOrg(invite, { swarm: node.svc.swarm.swarm })
  console.log(`[mirror] joined "${joined.name}" (role=${joined.role})`)

  // Replication over the shared swarm should deliver alpha's record.
  console.log('[mirror] waiting up to 60s for alpha to replicate…')
  const sawAlpha = await waitFor(async () => {
    await node.orgs.sync()
    return !!(await node.orgs.get('alpha'))
  })
  if (!sawAlpha) {
    console.error('[mirror] INCONCLUSIVE — never saw alpha. Replication likely never started.')
    console.error('         Check the primary is running and reachable on the DHT.')
    try { await node.close() } catch {}
    process.exit(2)
  }
  console.log('[mirror] alpha replicated')

  // Give any (buggy) leak a chance to surface.
  await new Promise((r) => setTimeout(r, 8000))
  await node.orgs.sync().catch(() => {})

  const alpha = await node.orgs.get('alpha')
  const beta  = await node.orgs.get('beta')
  const gamma = await node.orgs.get('gamma')

  console.log('')
  console.log('[mirror] org visibility after replication settled:')
  console.log(`  alpha: ${alpha ? 'visible (expected)' : 'MISSING'}`)
  console.log(`  beta:  ${beta  ? 'VISIBLE — SCOPE LEAK' : 'hidden (expected)'}`)
  console.log(`  gamma: ${gamma ? 'VISIBLE — SCOPE LEAK' : 'hidden (expected)'}`)
  console.log(`  mirror tracks orgs: ${JSON.stringify(node.orgs.listOrgKeys().map((o) => o.name))}`)

  const pass = !!alpha && !beta && !gamma
  console.log('')
  console.log(pass
    ? '[mirror] PASS — scope isolation holds (only alpha; beta/gamma never mounted)'
    : '[mirror] FAIL — scope leak (post-mortem #35 bug)')

  try { await node.close() } catch {}
  process.exit(pass ? 0 : 1)
}

main().catch((err) => { console.error(err); process.exit(1) })