/**
* TreeStore — device-wide content-addressable tree store.
*
* Layout:
* ~/.local/share/store/
* apps/<name>/ ← per-install shell (built by a materializer)
* dependencies/<hash>/ ← content-addressed tree — shared across apps
* meta/<hash>.json ← { pulledAt, source?, pinned }
* refs.json ← { <hash>: [<appName>, ...] }
* tmp/<partial>/ ← atomic-rename landing zone
*
* Any runtime's materializer (Node, Godot, future others) reads from the
* same `dependencies/` store — that's what gives cross-runtime dedup. The
* store itself is runtime-agnostic: it holds bytes keyed by content hash.
*
* Materialization is atomic per tree: files land in `tmp/`, rename on
* completion. Cross-app dedup works because identical content produces
* identical tree hashes, so two apps needing the same dep version both
* reference the same materialized tree.
*
* Scope boundary: v1 is top-level-tree dedup only — each tree is
* self-contained, no cross-tree symlinking internally. Blob-level
* dedup via hardlinks is a later optimization (pnpm-style).
*/
import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'
import crypto from 'hypercore-crypto'
import { KIND_FILE, KIND_DIR, KIND_SYMLINK, computeTreeHash, walkTree } from 'hyper-overlay/tree'
// Default store root — env override honored so tests + parallel
// installs on the same machine can run with isolated stores.
export const DEFAULT_ROOT = process.env.PGH_STORE_ROOT ||
path.join(homedir(), '.local', 'share', 'store')
// Bounded concurrency for content/subtree fetches during materialize. The
// original walk fetched every file strictly serially — a cold install of a
// many-file closure (e.g. hyper-tps ~11k files) paid per-file network RTT
// end to end (measured ~1.8 MB/s even over loopback). Overlapping up to N
// fetches removes that serialization. Safe because content-addressed writes
// are already race-tolerant (atomic blob rename + hardlink EEXIST guard), so
// two entries resolving to the same blob concurrently is a no-op collision.
// Tune via PGH_MATERIALIZE_CONCURRENCY (default 16).
export const MATERIALIZE_CONCURRENCY = Math.max(1,
parseInt(process.env.PGH_MATERIALIZE_CONCURRENCY, 10) || 16)
class Semaphore {
constructor (max) { this._max = max; this._active = 0; this._queue = [] }
acquire () {
if (this._active < this._max) { this._active++; return Promise.resolve() }
return new Promise((resolve) => this._queue.push(resolve))
}
release () {
const next = this._queue.shift()
if (next) next() // transfer the slot to a waiter (active count unchanged)
else this._active--
}
async run (fn) {
await this.acquire()
try { return await fn() } finally { this.release() }
}
}
export class TreeStore {
constructor (opts = {}) {
this.root = opts.root || DEFAULT_ROOT
this.dependenciesDir = path.join(this.root, 'dependencies')
this.appsDir = path.join(this.root, 'apps')
this.metaDir = path.join(this.root, 'meta')
this.tmpDir = path.join(this.root, 'tmp')
this.blobsDir = path.join(this.root, 'blobs')
this.refsPath = path.join(this.root, 'refs.json')
this._initialized = false
}
init () {
if (this._initialized) return
for (const d of [this.root, this.dependenciesDir, this.appsDir, this.metaDir, this.tmpDir, this.blobsDir]) {
fs.mkdirSync(d, { recursive: true })
}
if (!fs.existsSync(this.refsPath)) {
fs.writeFileSync(this.refsPath, '{}')
}
this._initialized = true
}
// ── Queries ────────────────────────────────────────────────
treePath (hash) { return path.join(this.dependenciesDir, hash) }
appPath (name) { return path.join(this.appsDir, name) }
metaPath (hash) { return path.join(this.metaDir, hash + '.json') }
// Blob layer: one inode per unique content. Tree dirs hardlink into here.
// Files are sharded by the first two hex chars to keep directory entry
// counts reasonable as the store scales (mirrors git's object layout).
blobPath (contentHash, exec) {
const tail = exec ? contentHash.slice(2) + '.x' : contentHash.slice(2)
return path.join(this.blobsDir, contentHash.slice(0, 2), tail)
}
// BLOB_LAYER_SCHEMA: bumps when the on-disk shape changes in a way
// that breaks walkTree round-trip. Old trees without this in meta are
// treated as not-present and re-materialized on demand. v2 = exec-bit
// sharded blobs (modes preserved); v1 = legacy all-0755.
static BLOB_LAYER_SCHEMA = 2
hasTree (hash) {
if (!fs.existsSync(this.treePath(hash))) return false
const meta = this.getMeta(hash)
if (!meta || (meta.schema || 1) < TreeStore.BLOB_LAYER_SCHEMA) return false
return true
}
// Meta is a CACHE — pulledAt, source, pinned, schema. It is derived bookkeeping, never the tree
// itself, so an unreadable one must degrade to "no meta" rather than take the caller down.
//
// THE BUG THIS FIXES. `existsSync` guarded a MISSING file but not an EMPTY or truncated one, and
// JSON.parse('') throws "Unexpected end of JSON input". A single zero-byte meta file on stortron
// therefore broke `pgh store show` and `pgh store pin` for EVERY hash — the reader throws before it
// can tell you which file is at fault. That is why hyper-social #24 recorded "stortron has no
// pinned hpp rollback tree" as an open item: rollback pins could not be taken on that host at all,
// which is the worst possible moment to lose a safety net.
//
// `_readRefs` in this same class already reads-and-tolerates exactly this way; getMeta was the
// outlier. And `pin()` below already handles a null meta (`|| { pulledAt: Date.now() }`), so
// returning null here lets a corrupt entry HEAL on the next pin rather than needing a manual fix.
getMeta (hash) {
const p = this.metaPath(hash)
if (!fs.existsSync(p)) return null
try {
const raw = fs.readFileSync(p, 'utf8')
if (!raw.trim()) return null
const parsed = JSON.parse(raw)
return (parsed && typeof parsed === 'object') ? parsed : null
} catch {
return null
}
}
// ── Materialization ─────────────────────────────────────────
/**
* Materialize a tree into the store. Idempotent — returns early if
* the tree already exists locally.
*
* @param {string} hash - tree hash (hex)
* @param {Array} entries - tree entries as returned by tree-response / fileIndex.getTree
* @param {object} opts
* @param {Function} opts.fetchContent - async (contentHash) => Buffer
* Called for each file-kind entry to obtain its bytes. Must return a Buffer
* whose blake2b-256 equals contentHash.
* @param {Function} opts.fetchTree - async (treeHash) => Array<entries>
* Called for each dir-kind entry whose subtree isn't yet local. Must return
* the entries list for that subtree.
* @param {Function} [opts.skipPath] - (relPath) => boolean. Called for every
* dir-kind entry with the path-from-tree-root (POSIX, no leading slash).
* Returning true skips fetching that subtree and creating its directory.
* Used by host-aware installs to avoid pulling foreign by-arch subtrees.
* Note: a tree materialized with skipPath is host-specific on disk —
* peers asking for the skipped subtree must fetch from the publisher.
* @param {object} [opts.meta] - { source?, pinned? } to record in meta/
*/
async materialize (hash, entries, opts) {
this.init()
if (this.hasTree(hash)) {
this._writeMeta(hash, { pulledAt: Date.now(), source: opts && opts.source, pinned: false, ...(this.getMeta(hash) || {}), schema: TreeStore.BLOB_LAYER_SCHEMA })
return this.treePath(hash)
}
// Legacy tree dir present but schema is stale (or missing) — drop it
// and re-materialize under the current blob layout. Apps that referenced
// it via symlink stop working until reinstalled; but the tree was
// already non-round-trippable, so peer-serving was broken anyway.
if (fs.existsSync(this.treePath(hash))) {
fs.rmSync(this.treePath(hash), { recursive: true, force: true })
}
const tmp = path.join(this.tmpDir, hash + '-' + Date.now())
fs.mkdirSync(tmp, { recursive: true })
try {
const sem = new Semaphore((opts && opts.concurrency) || MATERIALIZE_CONCURRENCY)
await this._materializeInto(tmp, entries, opts, '', sem)
// Integrity guard: the just-materialized tree MUST round-trip to its
// own hash. If it doesn't, a content blob on disk is corrupt — its
// bytes no longer match its content-hash (a classic in-place overwrite
// of a hardlinked blob), so serving/installing this tree would fail
// with "tree not found" on every peer. Reject loudly HERE instead of
// landing a poisoned tree that silently breaks the whole fleet.
//
// Skipped when opts.skipPath is set: a host-aware materialization omits
// foreign-arch subtrees on purpose, so it legitimately does not
// round-trip to the full hash. opts.verify === false also opts out.
if ((!opts || opts.verify !== false) && !(opts && opts.skipPath)) {
let actual = null
try { actual = walkTree(tmp).get('').hash } catch {}
if (actual !== hash) {
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
const err = new Error(
'materialized tree ' + hash.slice(0, 12) + '… does not round-trip ' +
'(on-disk content hashes to ' + (actual ? actual.slice(0, 12) + '…' : 'null') + '). ' +
'A content-addressed blob is corrupt — most likely an in-place ' +
'write over a hardlinked blob. Run `pgh store repair`.')
err.code = 'EBLOBCORRUPT'
err.expected = hash
err.actual = actual
throw err
}
}
// Atomic rename into place. If another process raced us here, the rename
// fails on Linux (non-empty target), which we treat as success.
const final = this.treePath(hash)
try {
fs.renameSync(tmp, final)
} catch (err) {
if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST') {
// Someone else won the race — drop our tmp copy.
fs.rmSync(tmp, { recursive: true, force: true })
} else {
throw err
}
}
this._writeMeta(hash, {
pulledAt: Date.now(),
source: opts && opts.source,
pinned: (opts && opts.pinned) || false,
schema: TreeStore.BLOB_LAYER_SCHEMA
})
return final
} catch (err) {
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
throw err
}
}
async _materializeInto (destDir, entries, opts, currentPath, sem) {
if (!sem) sem = new Semaphore(MATERIALIZE_CONCURRENCY)
// Entries at each level are processed concurrently; the shared semaphore
// caps in-flight fetchContent/fetchTree calls across the whole recursion.
// Sync fs ops (mkdir/link/write) stay ordered within each entry.
await Promise.all(entries.map(async (e) => {
const target = path.join(destDir, e.name)
const entryPath = currentPath ? currentPath + '/' + e.name : e.name
if (e.kind === KIND_FILE) {
// Blob layer: one canonical inode per (contentHash, exec-bit) at
// `blobs/<aa>/<rest>[.x]`; tree-dir entries hardlink into it.
// Sharding by exec is required because hardlinks share inode
// mode — if the blob is 0755 but the tree expects 0644, a
// walkTree round-trip computes a different tree-hash and the
// tree becomes unreachable by its original hash.
const blobPath = this.blobPath(e.childHash, e.exec)
if (!fs.existsSync(blobPath)) {
const content = await sem.run(() => opts.fetchContent(e.childHash))
if (!Buffer.isBuffer(content)) {
throw new Error('fetchContent must return a Buffer for ' + e.childHash)
}
verifyContent(e.childHash, content, 'file "' + e.name + '"')
fs.mkdirSync(path.dirname(blobPath), { recursive: true })
const tmpBlob = blobPath + '.tmp-' + Date.now() + '-' + process.pid
fs.writeFileSync(tmpBlob, content)
// Blobs are content-addressed and IMMUTABLE. Write them READ-ONLY
// (drop the write bit) so an accidental in-place write to ANY
// hardlink — e.g. a bootstrap/tar/cp/launcher write over a
// materialized `bin/<x>` — is rejected by the OS with EACCES
// instead of silently mutating the shared inode and corrupting
// every tree that hardlinks it (the 2026-06-04 bin/pgh incident).
// The exec bit is still preserved via the sharded blob path.
try { fs.chmodSync(tmpBlob, e.exec ? 0o555 : 0o444) } catch {}
try {
fs.renameSync(tmpBlob, blobPath)
} catch (err) {
if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST') {
try { fs.unlinkSync(tmpBlob) } catch {}
} else {
try { fs.unlinkSync(tmpBlob) } catch {}
throw err
}
}
}
if (typeof fs.linkSync !== 'function') {
// Old bundled bare-fs (< 4.7.0, e.g. a nested closure copy) has NO
// linkSync — `fs.linkSync` is undefined and the call would throw a
// TypeError that propagates mid-materialize and corrupts the partial
// install. Copy the (already content-addressed + verified) blob
// instead; loses hardlink dedup but preserves correctness and the
// read-only immutability the store relies on.
fs.copyFileSync(blobPath, target)
fs.chmodSync(target, e.exec ? 0o555 : 0o444)
} else {
try {
fs.linkSync(blobPath, target)
} catch (err) {
if (err.code === 'EEXIST') {
// Target already exists (concurrent materialize finished).
} else if (err.code === 'EXDEV' || err.code === 'EACCES' || err.code === 'EPERM') {
// Hardlink impossible: EXDEV (cross-device), or the OS/policy
// denies link(2) outright — EPERM (sandboxed fs) or EACCES
// (SELinux `link` denial, e.g. a no-root Android node / celltron,
// where sepolicy forbids link in every unprivileged domain). The
// blob is content-addressed + verified, so copy is correctness-
// preserving; we only lose inode dedup. Matches the documented
// "degrades to copy on EXDEV/EPERM/etc." contract in preflight.js.
const content = await sem.run(() => opts.fetchContent(e.childHash))
verifyContent(e.childHash, content, 'file "' + e.name + '" (link-denied fallback)')
fs.writeFileSync(target, content)
// Read-only: see immutable-blob rationale above.
fs.chmodSync(target, e.exec ? 0o555 : 0o444)
} else {
throw err
}
}
}
} else if (e.kind === KIND_DIR) {
if (opts.skipPath && opts.skipPath(entryPath)) return
const subEntries = await sem.run(() => opts.fetchTree(e.childHash))
if (!Array.isArray(subEntries)) {
throw new Error('fetchTree must return an entries array for ' + e.childHash)
}
// Subtree verification: the returned entries must hash to the
// advertised childHash. Empty entries for a non-empty subtree
// (the stortron-class bug) fails here instead of silently
// materializing an empty dir.
verifyTreeEntries(e.childHash, subEntries, 'dir "' + e.name + '"')
fs.mkdirSync(target, { recursive: true })
await this._materializeInto(target, subEntries, opts, entryPath, sem)
} else if (e.kind === KIND_SYMLINK) {
const linkBytes = await sem.run(() => opts.fetchContent(e.childHash))
verifyContent(e.childHash, linkBytes, 'symlink "' + e.name + '"')
const linkTarget = linkBytes.toString('utf8')
try {
fs.symlinkSync(linkTarget, target)
} catch (err) {
if (err.code === 'EPERM') {
// Windows/permission failure — fall back to writing the target string
fs.writeFileSync(target + '.symlink', linkTarget)
} else {
throw err
}
}
} else {
throw new Error('unknown entry kind: ' + e.kind)
}
}))
}
_writeMeta (hash, data) {
fs.writeFileSync(this.metaPath(hash), JSON.stringify(data, null, 2))
}
// ── Refcounts ──────────────────────────────────────────────
_readRefs () {
this.init()
try {
return JSON.parse(fs.readFileSync(this.refsPath, 'utf8'))
} catch {
return {}
}
}
_writeRefs (refs) {
fs.writeFileSync(this.refsPath, JSON.stringify(refs, null, 2))
}
addRef (hash, appName) {
const refs = this._readRefs()
if (!refs[hash]) refs[hash] = []
if (!refs[hash].includes(appName)) refs[hash].push(appName)
this._writeRefs(refs)
}
removeRef (hash, appName) {
const refs = this._readRefs()
if (refs[hash]) {
refs[hash] = refs[hash].filter(a => a !== appName)
if (refs[hash].length === 0) delete refs[hash]
this._writeRefs(refs)
}
}
/**
* Atomically replace the full set of hashes referenced by an app.
*
* Old behavior was add-only: every install/publish appended refs but
* nothing cleared them, so refs.json accumulated stale per-version
* entries forever and defeated `gc`. setAppRefs is the canonical way
* to record "appName currently references EXACTLY these hashes" —
* any hash previously referenced by appName but absent from `hashes`
* has its appName entry removed.
*
* Materializers and link/swap helpers should call this once at the
* end of a materialize cycle with the complete hash set, instead of
* a stream of addRef calls.
*
* @param {string} appName
* @param {string[]|Set<string>} hashes - all hashes appName should reference
*/
setAppRefs (appName, hashes) {
const want = new Set(hashes)
const refs = this._readRefs()
// First pass — drop appName from any hash not in `want`.
for (const [hash, apps] of Object.entries(refs)) {
if (!want.has(hash) && apps.includes(appName)) {
const next = apps.filter(a => a !== appName)
if (next.length === 0) delete refs[hash]
else refs[hash] = next
}
}
// Second pass — add appName to every hash in `want`.
for (const hash of want) {
if (!refs[hash]) refs[hash] = []
if (!refs[hash].includes(appName)) refs[hash].push(appName)
}
this._writeRefs(refs)
}
/**
* Remove every ref held by appName. Used by uninstall paths that
* want a clean tear-down before the materializer rewires.
*/
clearAppRefs (appName) {
this.setAppRefs(appName, [])
}
getRefs (hash) {
const refs = this._readRefs()
return refs[hash] || []
}
listAllRefs () {
return this._readRefs()
}
// ── Pinning ────────────────────────────────────────────────
pin (hash) {
const meta = this.getMeta(hash) || { pulledAt: Date.now() }
meta.pinned = true
this._writeMeta(hash, meta)
}
unpin (hash) {
const meta = this.getMeta(hash)
if (meta) {
meta.pinned = false
this._writeMeta(hash, meta)
}
}
isPinned (hash) {
const meta = this.getMeta(hash)
return meta ? !!meta.pinned : false
}
/**
* Find app install dirs under `apps/` that no longer appear in refs.json
* (no dependency tree is recorded against them). These are stale installs
* that the dependency/blob GC can never reclaim: `gc()` only sweeps
* `dependencies/` + orphan blobs, so an app that fell out of refs.json
* (e.g. a removed `*-test` install) keeps its files forever as sole
* nlink-1 copies. Detection only — removal is opt-in via
* `gc({ sweepOrphanApps: true })`.
*
* @returns {string[]} app names present on disk but absent from refs.json
*/
listOrphanApps () {
this.init()
const refs = this._readRefs()
const referenced = new Set()
for (const apps of Object.values(refs)) {
for (const a of apps) referenced.add(a)
}
const orphans = []
let names
try { names = fs.readdirSync(this.appsDir) } catch { return orphans }
for (const name of names) {
let st
try { st = fs.statSync(this.appPath(name)) } catch { continue }
if (!st.isDirectory()) continue
if (!referenced.has(name)) orphans.push(name)
}
return orphans
}
// ── GC ─────────────────────────────────────────────────────
/**
* Sweep unreferenced trees. Respects pinned flag and optional age gate.
*
* Default policy is "ephemeral" — any tree that is unreferenced and
* unpinned is a candidate regardless of age. Setting `maxAgeDays`
* gives a grace period: a tree must have been pulled more than
* maxAgeDays days ago before it's eligible. This protects publishes
* that have just landed but haven't been linked into an app yet.
*
* @param {object} [opts]
* @param {boolean} [opts.dryRun] - report only, don't delete
* @param {number} [opts.maxAgeDays] - grace period; only sweep trees
* whose meta.pulledAt is older than this many days. Omitted/null =
* no grace (legacy behavior, equivalent to maxAgeDays=0).
* @param {boolean} [opts.sweepOrphanApps] - also remove `apps/<name>` dirs
* that are absent from refs.json (default false — orphan apps are always
* REPORTED in the return value, but only removed when this is set and
* dryRun is off). Removing them frees their now-redundant blob hardlinks
* in the same pass.
* @param {number} [opts.keepReleases] - with `opts.releases`: keep every tree
* of each app's newest N releases, and sweep the trees of its older
* releases REGARDLESS of age. The age gate alone never collects an app
* that publishes several times a day (2026-10-11: 75 copies of a ~600 MB
* hypertps.pck, all younger than 14 days). Trees in no release keep the
* age gate — it is what protects a publish not yet in the registry.
* @param {object[]} [opts.releases] - release entries (ReleaseRegistry.list())
* @returns {{ candidates, removed, kept, blobsRemoved, orphanApps, appsRemoved }}
*/
gc (opts = {}) {
this.init()
const refs = this._readRefs()
const allHashes = fs.readdirSync(this.dependenciesDir)
const candidates = []
const removed = []
const kept = []
const cutoff = (typeof opts.maxAgeDays === 'number' && opts.maxAgeDays > 0)
? Date.now() - (opts.maxAgeDays * 24 * 60 * 60 * 1000)
: null
const { current, superseded } = (typeof opts.keepReleases === 'number' && opts.keepReleases > 0)
? splitReleaseTrees(opts.releases || [], opts.keepReleases)
: { current: new Set(), superseded: new Set() }
for (const hash of allHashes) {
const referenced = (refs[hash] && refs[hash].length > 0)
const pinned = this.isPinned(hash)
if (referenced || pinned || current.has(hash)) {
kept.push(hash)
continue
}
// Age gate — if a cutoff is set, only sweep trees pulled before it.
// A tree only an older release uses is past its retention whatever its age.
if (cutoff !== null && !superseded.has(hash)) {
const meta = this.getMeta(hash)
const pulledAt = (meta && typeof meta.pulledAt === 'number') ? meta.pulledAt : 0
if (pulledAt > cutoff) {
kept.push(hash)
continue
}
}
candidates.push(hash)
if (!opts.dryRun) {
fs.rmSync(this.treePath(hash), { recursive: true, force: true })
try { fs.unlinkSync(this.metaPath(hash)) } catch {}
removed.push(hash)
}
}
// Orphan-app sweep. `apps/<name>` dirs absent from refs.json are stale
// installs that the dependency/blob GC can never reach (their files are
// hardlinked from deps that may already be gone, leaving sole nlink-1
// copies). Always REPORT them; only remove when explicitly asked. Done
// before the blob cleanup so freed hardlinks get collected this pass.
const orphanApps = this.listOrphanApps()
const appsRemoved = []
if (opts.sweepOrphanApps && !opts.dryRun) {
for (const name of orphanApps) {
try {
fs.rmSync(this.appPath(name), { recursive: true, force: true })
appsRemoved.push(name)
} catch {}
}
}
// Blob layer cleanup. When a tree dir is removed, its hardlinks
// drop the blob's nlink count. Any blob whose nlink is 1 (only the
// blob file itself, no tree refs) is now garbage. Walk shards,
// unlink orphans.
let blobsRemoved = 0
if (!opts.dryRun && fs.existsSync(this.blobsDir)) {
for (const shard of fs.readdirSync(this.blobsDir)) {
const shardDir = path.join(this.blobsDir, shard)
let entries
try { entries = fs.readdirSync(shardDir) } catch { continue }
for (const name of entries) {
const blobFile = path.join(shardDir, name)
let st
try { st = fs.statSync(blobFile) } catch { continue }
if (st.nlink === 1) {
try { fs.unlinkSync(blobFile); blobsRemoved++ } catch {}
}
}
// Drop empty shard dirs to keep the layout tidy.
try {
const remaining = fs.readdirSync(shardDir)
if (remaining.length === 0) fs.rmdirSync(shardDir)
} catch {}
}
}
return { candidates, removed, kept, blobsRemoved, orphanApps, appsRemoved }
}
// ── Integrity / repair ─────────────────────────────────────
/**
* Audit every blob: recompute its content hash and compare to the hash
* encoded in its path (`blobs/<aa>/<rest>[.x]` → `aa+rest`). A mismatch
* means the blob's bytes were mutated after creation — an in-place write
* over a hardlink — and every tree hardlinking it no longer round-trips.
*
* @returns {{ checked: number, corrupt: Array<{ path, exec, expected, actual, ino }> }}
*/
verifyBlobs () {
this.init()
const corrupt = []
let checked = 0
if (!fs.existsSync(this.blobsDir)) return { checked, corrupt }
for (const shard of fs.readdirSync(this.blobsDir)) {
const shardDir = path.join(this.blobsDir, shard)
let names
try { names = fs.readdirSync(shardDir) } catch { continue }
for (const name of names) {
if (name.includes('.tmp-')) continue
const exec = name.endsWith('.x')
const tail = exec ? name.slice(0, -2) : name
const expected = shard + tail
const blobFile = path.join(shardDir, name)
let bytes, st
try { bytes = fs.readFileSync(blobFile); st = fs.statSync(blobFile) } catch { continue }
checked++
const actual = crypto.data(bytes).toString('hex')
if (actual !== expected) corrupt.push({ path: blobFile, exec, expected, actual, ino: st.ino })
}
}
return { checked, corrupt }
}
// Recursively yield absolute file paths under dir (no symlink follow).
_walkFiles (dir, cb) {
let entries
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isDirectory()) this._walkFiles(p, cb)
else if (ent.isFile()) cb(p)
}
}
/**
* Repair a corrupt store. For every blob whose bytes no longer match its
* content hash:
* 1. find every dependency tree that hardlinks the corrupt inode and
* invalidate it (remove the tree dir + meta) so the next install
* re-fetches a clean copy from a peer — partial/host-specific trees
* are NOT false-flagged because matching is by inode, not round-trip,
* 2. quarantine (delete) the corrupt blob so it can't poison future
* materializes.
*
* The correct bytes are, by definition, no longer on this machine, so
* repair cannot reconstruct them — it makes the store self-consistent
* (no poisoned blobs, no half-broken trees) and reports which trees were
* invalidated so the operator can re-fetch / re-publish them.
*
* @param {object} [opts] { dryRun?: boolean }
* @returns {{ checked, corruptBlobs, removedBlobs, invalidatedTrees, dryRun }}
*/
repair (opts = {}) {
this.init()
const { checked, corrupt } = this.verifyBlobs()
const removedBlobs = []
const invalidatedTrees = []
if (corrupt.length) {
const badInodes = new Set(corrupt.map(c => c.ino))
let treeHashes = []
try { treeHashes = fs.readdirSync(this.dependenciesDir) } catch {}
for (const h of treeHashes) {
const dir = this.treePath(h)
let affected = false
this._walkFiles(dir, (p) => {
if (affected) return
let st
try { st = fs.statSync(p) } catch { return }
if (badInodes.has(st.ino)) affected = true
})
if (affected) {
invalidatedTrees.push(h)
if (!opts.dryRun) {
fs.rmSync(dir, { recursive: true, force: true })
try { fs.unlinkSync(this.metaPath(h)) } catch {}
}
}
}
for (const c of corrupt) {
removedBlobs.push(c.path)
if (!opts.dryRun) { try { fs.unlinkSync(c.path) } catch {} }
}
}
return { checked, corruptBlobs: corrupt, removedBlobs, invalidatedTrees, dryRun: !!opts.dryRun }
}
// ── Stats ──────────────────────────────────────────────────
/**
* Directory size in bytes (recursive). Symlinks counted as their target size.
*/
_dirSize (dir) {
let total = 0
try {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name)
let stat
try { stat = fs.lstatSync(p) } catch { continue }
if (stat.isSymbolicLink()) continue // don't double-count through symlinks
if (stat.isDirectory()) total += this._dirSize(p)
else if (stat.isFile()) total += stat.size
}
} catch {}
return total
}
stats () {
this.init()
const refs = this._readRefs()
const hashes = fs.readdirSync(this.dependenciesDir)
let totalBytes = 0
let referencedCount = 0
let pinnedCount = 0
for (const hash of hashes) {
totalBytes += this._dirSize(this.treePath(hash))
if (refs[hash] && refs[hash].length > 0) referencedCount++
if (this.isPinned(hash)) pinnedCount++
}
return {
treeCount: hashes.length,
referencedCount,
unreferencedCount: hashes.length - referencedCount,
pinnedCount,
totalBytes
}
}
listTrees () {
this.init()
const refs = this._readRefs()
const hashes = fs.readdirSync(this.dependenciesDir)
return hashes.map(hash => ({
hash,
size: this._dirSize(this.treePath(hash)),
refs: refs[hash] || [],
pinned: this.isPinned(hash),
meta: this.getMeta(hash)
}))
}
}
// ── Release retention ───────────────────────────────────────────
/**
* Every tree a release entry names: the app root, each dep's tree, and each
* dep's per-arch prebuild trees (publish.js depEntry).
*/
export function releaseTreeHashes (entry) {
const out = new Set()
if (!entry) return out
if (entry.treeHash) out.add(entry.treeHash)
for (const d of entry.deps || []) {
if (d && d.tree) out.add(d.tree)
if (d && d.prebuilds) for (const h of Object.values(d.prebuilds)) if (h) out.add(h)
}
return out
}
/**
* Per app, newest `keep` releases (by publishedAt) → `current`; trees ONLY in
* older releases → `superseded`. A tree shared with a current release is current.
*/
function splitReleaseTrees (releases, keep) {
const byApp = new Map()
for (const e of releases) {
if (!e || !e.name) continue
if (!byApp.has(e.name)) byApp.set(e.name, [])
byApp.get(e.name).push(e)
}
const current = new Set()
const older = new Set()
for (const list of byApp.values()) {
list.sort((a, b) => (a.publishedAt === b.publishedAt ? 0 : (a.publishedAt > b.publishedAt ? -1 : 1)))
list.forEach((e, i) => {
for (const h of releaseTreeHashes(e)) (i < keep ? current : older).add(h)
})
}
const superseded = new Set([...older].filter(h => !current.has(h)))
return { current, superseded }
}
// ── Content-address verification ────────────────────────────────
/**
* Verify fetched bytes hash to the advertised content hash.
* Throws on mismatch with a descriptive error so upstream callers
* (installTree, NetworkFetcher) can retry on a different peer.
*/
function verifyContent (expectedHash, bytes, label) {
const actual = crypto.data(bytes).toString('hex')
if (actual !== expectedHash) {
throw Object.assign(
new Error('content-hash mismatch for ' + label + ': expected ' + expectedHash.slice(0, 12) + '…, got ' + actual.slice(0, 12) + '…'),
{ code: 'EHASHMISMATCH', expected: expectedHash, actual }
)
}
}
/**
* Verify fetched subtree entries hash to the advertised tree hash.
* Same pattern — content-addressed verification is how we catch peers
* that respond with "I don't have this" (empty entries) while the
* advertised hash promised content.
*/
function verifyTreeEntries (expectedHash, entries, label) {
const actual = computeTreeHash(entries)
if (actual !== expectedHash) {
throw Object.assign(
new Error('tree-hash mismatch for ' + label + ': expected ' + expectedHash.slice(0, 12) + '…, got ' + actual.slice(0, 12) + '…'),
{ code: 'EHASHMISMATCH', expected: expectedHash, actual }
)
}
}
export { verifyContent, verifyTreeEntries }