OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / store-integrity.test.js

115 lines · javascript ref:
/**
 * TreeStore integrity hardening — regression for the 2026-06-04 bin/pgh
 * incident: a content-addressed blob got overwritten IN PLACE through one
 * of its hardlinks, silently corrupting every tree that shared it so they
 * no longer round-tripped to their own hash ("tree not found on any peer").
 *
 * Guards proven here:
 *  1. blobs are materialized READ-ONLY → an in-place write to any hardlink
 *     is rejected by the OS (EACCES) instead of mutating the shared inode;
 *  2. materialize() round-trip guard throws EBLOBCORRUPT if a tree no longer
 *     hashes to its own name;
 *  3. verifyBlobs() detects content/hash mismatches;
 *  4. repair() quarantines corrupt blobs + invalidates the trees that
 *     hardlink them (by inode — partial/host-specific trees are not
 *     false-flagged).
 */

import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import crypto from 'hypercore-crypto'
import { TreeStore } from '../lib/store.js'
import { KIND_FILE, computeTreeHash } from 'hyper-overlay/tree'

function mktemp () {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-integrity-'))
}

const hh = (b) => crypto.data(b).toString('hex')

function fixture () {
  const cA = Buffer.from('hello world\n')
  const cR = Buffer.from('#!/bin/sh\necho hi\n')
  const content = { [hh(cA)]: cA, [hh(cR)]: cR }
  const entries = [
    { kind: KIND_FILE, name: 'a.txt', exec: 0, childHash: hh(cA), size: cA.length },
    { kind: KIND_FILE, name: 'run', exec: 1, childHash: hh(cR), size: cR.length }
  ].sort((x, y) => (x.name < y.name ? -1 : 1))
  return { entries, rootHash: computeTreeHash(entries), fetchContent: async (h) => content[h], cR }
}

test('blobs are read-only and in-place writes are rejected', async (t) => {
  const root = mktemp()
  t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
  const store = new TreeStore({ root })
  const { entries, rootHash, fetchContent, cR } = fixture()

  await store.materialize(rootHash, entries, { fetchContent })
  t.ok(store.hasTree(rootHash), 'materialize succeeded + round-trips')

  const blob = store.blobPath(hh(cR), 1)
  t.is(fs.statSync(blob).mode & 0o222, 0, 'exec blob carries no write bits')

  let code = null
  try { fs.writeFileSync(path.join(store.treePath(rootHash), 'run'), Buffer.from('PWNED')) } catch (e) { code = e.code }
  t.ok(code === 'EACCES' || code === 'EPERM', 'in-place write over a hardlinked blob is rejected (' + code + ')')
})

test('materialize round-trip guard rejects a corrupt blob', async (t) => {
  const root = mktemp()
  t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
  const store = new TreeStore({ root })
  const { entries, rootHash, fetchContent, cR } = fixture()
  await store.materialize(rootHash, entries, { fetchContent })

  // Simulate the incident: force a blob writable and overwrite its bytes.
  const blob = store.blobPath(hh(cR), 1)
  fs.chmodSync(blob, 0o644); fs.writeFileSync(blob, Buffer.from('CORRUPT')); fs.chmodSync(blob, 0o444)

  // Drop the tree dir so materialize re-runs (it early-returns on hasTree).
  fs.rmSync(store.treePath(rootHash), { recursive: true, force: true })
  try { fs.unlinkSync(store.metaPath(rootHash)) } catch {}

  await t.exception(
    () => store.materialize(rootHash, entries, { fetchContent }),
    /does not round-trip/,
    'materialize throws EBLOBCORRUPT when a referenced blob is corrupt'
  )
})

test('verifyBlobs + repair detect and quarantine corruption', async (t) => {
  const root = mktemp()
  t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
  const store = new TreeStore({ root })
  const { entries, rootHash, fetchContent, cR } = fixture()
  await store.materialize(rootHash, entries, { fetchContent })

  const blob = store.blobPath(hh(cR), 1)
  fs.chmodSync(blob, 0o644); fs.writeFileSync(blob, Buffer.from('CORRUPT')); fs.chmodSync(blob, 0o444)

  const v = store.verifyBlobs()
  t.is(v.corrupt.length, 1, 'verifyBlobs finds exactly one corrupt blob')
  t.is(v.corrupt[0].expected, hh(cR), 'reports the expected content hash')

  const r = store.repair()
  t.is(r.removedBlobs.length, 1, 'repair removed the corrupt blob')
  t.ok(r.invalidatedTrees.includes(rootHash), 'repair invalidated the tree that hardlinked it')
  t.absent(fs.existsSync(blob), 'corrupt blob gone from disk')
  t.absent(fs.existsSync(store.treePath(rootHash)), 'affected tree dir removed for re-fetch')
})

test('repair is a no-op on a clean store', async (t) => {
  const root = mktemp()
  t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
  const store = new TreeStore({ root })
  const { entries, rootHash, fetchContent } = fixture()
  await store.materialize(rootHash, entries, { fetchContent })

  const r = store.repair()
  t.is(r.corruptBlobs.length, 0, 'no corrupt blobs')
  t.is(r.invalidatedTrees.length, 0, 'nothing invalidated')
  t.ok(store.hasTree(rootHash), 'clean tree untouched')
})