/**
* 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')
})