OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / pgh-store.test.js

411 lines · javascript ref:
/**
 * TreeStore tests — P.10 Phase 0.5c
 */

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, MATERIALIZE_CONCURRENCY } from '../lib/store.js'
import { KIND_FILE, KIND_DIR, KIND_SYMLINK, computeTreeHash } from 'hyper-overlay/tree'

function mktemp (prefix = 'pgh-store') {
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
}

function hashBytes (buf) {
  return crypto.data(buf).toString('hex')
}

/**
 * Build a mock fetcher that resolves from pre-registered maps:
 *   contents: { <contentHash>: Buffer }
 *   trees:    { <treeHash>: entries[] }
 */
function mockFetcher (contents = {}, trees = {}) {
  return {
    fetchContent: async (hash) => {
      if (!contents[hash]) throw new Error('no content registered for ' + hash)
      return contents[hash]
    },
    fetchTree: async (hash) => {
      if (!trees[hash]) throw new Error('no tree registered for ' + hash)
      return trees[hash]
    }
  }
}

// ── Init / basic ops ─────────────────────────────────────────

test('TreeStore — init creates expected dirs', (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  store.init()
  t.ok(fs.existsSync(path.join(root, 'dependencies')))
  t.ok(fs.existsSync(path.join(root, 'apps')))
  t.ok(fs.existsSync(path.join(root, 'meta')))
  t.ok(fs.existsSync(path.join(root, 'tmp')))
  t.ok(fs.existsSync(path.join(root, 'refs.json')))
  fs.rmSync(root, { recursive: true, force: true })
})

test('TreeStore — hasTree reports false initially', (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  t.absent(store.hasTree('a'.repeat(64)))
  fs.rmSync(root, { recursive: true, force: true })
})

// ── Materialization ──────────────────────────────────────────

test('materialize — single file tree', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })

  const content = Buffer.from('hello world')
  const contentHash = hashBytes(content)
  const entries = [{ kind: KIND_FILE, exec: 0, name: 'hello.txt', childHash: contentHash, size: content.length }]
  // materialize()'s round-trip guard recomputes the tree hash from the
  // on-disk result and rejects a mismatch, so the hash must be the real one.
  const treeHash = computeTreeHash(entries)

  const fetcher = mockFetcher({ [contentHash]: content })
  await store.materialize(treeHash, entries, { ...fetcher, source: 'test' })

  t.ok(store.hasTree(treeHash))
  const materialized = path.join(store.treePath(treeHash), 'hello.txt')
  t.ok(fs.existsSync(materialized))
  t.is(fs.readFileSync(materialized, 'utf8'), 'hello world')

  fs.rmSync(root, { recursive: true, force: true })
})

test('materialize — executable bit preserved', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  const content = Buffer.from('#!/bin/sh\necho hi\n')
  const contentHash = hashBytes(content)
  const entries = [{ kind: KIND_FILE, exec: 1, name: 'run.sh', childHash: contentHash, size: content.length }]
  const treeHash = computeTreeHash(entries)

  await store.materialize(treeHash, entries, mockFetcher({ [contentHash]: content }))

  const materialized = path.join(store.treePath(treeHash), 'run.sh')
  const stat = fs.statSync(materialized)
  t.ok(stat.mode & 0o100, 'owner-exec bit set')

  fs.rmSync(root, { recursive: true, force: true })
})

test('materialize — nested subtree', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })

  const utilContent = Buffer.from('module.exports = {}')
  const utilHash = hashBytes(utilContent)
  const subtreeEntries = [{ kind: KIND_FILE, exec: 0, name: 'util.js', childHash: utilHash, size: utilContent.length }]
  const subtreeHash = computeTreeHash(subtreeEntries)

  const rootEntries = [{ kind: KIND_DIR, exec: 0, name: 'lib', childHash: subtreeHash, size: utilContent.length }]
  const rootHash = computeTreeHash(rootEntries)

  const fetcher = mockFetcher({ [utilHash]: utilContent }, { [subtreeHash]: subtreeEntries })
  await store.materialize(rootHash, rootEntries, fetcher)

  const nestedFile = path.join(store.treePath(rootHash), 'lib', 'util.js')
  t.ok(fs.existsSync(nestedFile))
  t.is(fs.readFileSync(nestedFile, 'utf8'), 'module.exports = {}')

  fs.rmSync(root, { recursive: true, force: true })
})

test('materialize — copy-fallback when hardlink is denied (EACCES / SELinux)', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })

  const content = Buffer.from('no-hardlink-here')
  const contentHash = hashBytes(content)
  const entries = [{ kind: KIND_FILE, exec: 0, name: 'f.txt', childHash: contentHash, size: content.length }]
  const treeHash = computeTreeHash(entries)

  // Simulate a policy that forbids link(2) — e.g. SELinux on a no-root
  // Android node (celltron), where every linkSync throws EACCES. Before the
  // fix, only EXDEV fell back to copy, so EACCES aborted the whole install.
  const realLink = fs.linkSync
  let linkAttempts = 0
  fs.linkSync = () => { linkAttempts++; const e = new Error('EACCES: operation not permitted, link'); e.code = 'EACCES'; throw e }
  t.teardown(() => { fs.linkSync = realLink })

  await store.materialize(treeHash, entries, mockFetcher({ [contentHash]: content }))

  t.ok(linkAttempts > 0, 'linkSync was attempted and denied')
  t.ok(store.hasTree(treeHash), 'tree still materialized via copy-fallback')
  const materialized = path.join(store.treePath(treeHash), 'f.txt')
  t.is(fs.readFileSync(materialized, 'utf8'), 'no-hardlink-here', 'content correct after copy fallback')

  fs.rmSync(root, { recursive: true, force: true })
})

test('materialize — fetches run concurrently, bounded, and tree round-trips', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })

  // A wide tree (more files than the concurrency bound) so overlap is
  // observable and the bound is exercised.
  const N = MATERIALIZE_CONCURRENCY + 8
  const contents = {}
  const entries = []
  for (let i = 0; i < N; i++) {
    const c = Buffer.from('file-' + i + '-' + 'x'.repeat(40))
    const h = hashBytes(c)
    contents[h] = c
    entries.push({ kind: KIND_FILE, exec: 0, name: 'f' + i + '.txt', childHash: h, size: c.length })
  }
  const treeHash = computeTreeHash(entries)

  let inFlight = 0
  let maxInFlight = 0
  const fetcher = {
    fetchContent: async (h) => {
      inFlight++
      if (inFlight > maxInFlight) maxInFlight = inFlight
      await new Promise((r) => setTimeout(r, 15)) // hold the slot so overlap is real
      inFlight--
      return contents[h]
    },
    fetchTree: async () => { throw new Error('no subtrees in this fixture') }
  }

  await store.materialize(treeHash, entries, { ...fetcher, source: 'test' })

  // Correctness: materialize()'s own round-trip guard already ran; also spot-check.
  t.ok(store.hasTree(treeHash), 'tree materialized + round-trips to its hash')
  t.is(fs.readFileSync(path.join(store.treePath(treeHash), 'f0.txt'), 'utf8'), 'file-0-' + 'x'.repeat(40), 'file content correct')

  // Parallelism: overlap actually happened, and never exceeded the bound.
  t.ok(maxInFlight > 1, 'fetches overlapped (was serial before) — maxInFlight=' + maxInFlight)
  t.ok(maxInFlight <= MATERIALIZE_CONCURRENCY, 'concurrency stayed within bound (' + MATERIALIZE_CONCURRENCY + ')')

  fs.rmSync(root, { recursive: true, force: true })
})

test('materialize — idempotent (already-present tree is a no-op)', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })

  const content = Buffer.from('x')
  const contentHash = hashBytes(content)
  const entries = [{ kind: KIND_FILE, exec: 0, name: 'a', childHash: contentHash, size: 1 }]
  const treeHash = computeTreeHash(entries)

  let fetchCount = 0
  const fetcher = {
    fetchContent: async (h) => { fetchCount++; return content },
    fetchTree: async () => { throw new Error('should not be called') }
  }
  await store.materialize(treeHash, entries, fetcher)
  t.is(fetchCount, 1)

  await store.materialize(treeHash, entries, fetcher)
  t.is(fetchCount, 1, 'second materialize skipped fetch')

  fs.rmSync(root, { recursive: true, force: true })
})

test('materialize — atomic: tmp cleaned up on error', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })

  const entries = [{ kind: KIND_FILE, exec: 0, name: 'a', childHash: 'z'.repeat(64), size: 1 }]
  const treeHash = 'f'.repeat(64)

  const fetcher = {
    fetchContent: async () => { throw new Error('fetch failed') },
    fetchTree: async () => { throw new Error('fetch failed') }
  }

  let err
  try { await store.materialize(treeHash, entries, fetcher) } catch (e) { err = e }
  t.ok(err, 'errored')
  t.absent(store.hasTree(treeHash), 'tree not half-materialized')

  // tmp dir should be empty
  const tmpContents = fs.readdirSync(store.tmpDir)
  t.is(tmpContents.length, 0, 'tmp cleaned up on error')

  fs.rmSync(root, { recursive: true, force: true })
})

// ── Refcounts ────────────────────────────────────────────────

test('refs — add, query, remove', (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  const h = 'q'.repeat(64)

  t.alike(store.getRefs(h), [])
  store.addRef(h, 'app-a')
  store.addRef(h, 'app-b')
  store.addRef(h, 'app-a') // idempotent
  t.alike(store.getRefs(h).sort(), ['app-a', 'app-b'])

  store.removeRef(h, 'app-a')
  t.alike(store.getRefs(h), ['app-b'])

  store.removeRef(h, 'app-b')
  t.alike(store.getRefs(h), [])

  fs.rmSync(root, { recursive: true, force: true })
})

// ── Pinning ──────────────────────────────────────────────────

test('pin / unpin', (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  store.init()
  const h = 'p'.repeat(64)

  // Create a minimal meta entry
  fs.mkdirSync(path.join(store.dependenciesDir, h))
  store._writeMeta(h, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })

  t.absent(store.isPinned(h))
  store.pin(h)
  t.ok(store.isPinned(h))
  store.unpin(h)
  t.absent(store.isPinned(h))

  fs.rmSync(root, { recursive: true, force: true })
})

// ── GC ───────────────────────────────────────────────────────

test('gc — removes unreferenced, keeps referenced + pinned', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  store.init()

  // Three trees: A referenced, B pinned, C orphan
  const hA = 'a'.repeat(64)
  const hB = 'b'.repeat(64)
  const hC = 'c'.repeat(64)
  for (const h of [hA, hB, hC]) {
    fs.mkdirSync(path.join(store.dependenciesDir, h))
    store._writeMeta(h, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })
  }

  store.addRef(hA, 'some-app')
  store.pin(hB)

  const result = store.gc()
  t.alike(result.removed, [hC], 'only orphan removed')
  t.ok(store.hasTree(hA))
  t.ok(store.hasTree(hB))
  t.absent(store.hasTree(hC))

  fs.rmSync(root, { recursive: true, force: true })
})

test('gc --dry-run reports without deleting', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  store.init()

  const h = 'd'.repeat(64)
  fs.mkdirSync(path.join(store.dependenciesDir, h))
  store._writeMeta(h, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })

  const result = store.gc({ dryRun: true })
  t.alike(result.candidates, [h], 'reported as candidate')
  t.alike(result.removed, [], 'nothing actually removed')
  t.ok(store.hasTree(h), 'still present')

  fs.rmSync(root, { recursive: true, force: true })
})

// ── Stats ────────────────────────────────────────────────────

test('stats — aggregates tree counts + sizes', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  store.init()

  const content = Buffer.from('x'.repeat(100))
  const contentHash = hashBytes(content)

  // Tree 1 — referenced
  const e1 = [{ kind: KIND_FILE, exec: 0, name: 'a', childHash: contentHash, size: 100 }]
  const h1 = computeTreeHash(e1)
  await store.materialize(h1, e1, mockFetcher({ [contentHash]: content }))
  store.addRef(h1, 'app')

  // Tree 2 — orphan
  const e2 = [{ kind: KIND_FILE, exec: 0, name: 'b', childHash: contentHash, size: 100 }]
  const h2 = computeTreeHash(e2)
  await store.materialize(h2, e2, mockFetcher({ [contentHash]: content }))

  const s = store.stats()
  t.is(s.treeCount, 2)
  t.is(s.referencedCount, 1)
  t.is(s.unreferencedCount, 1)
  t.ok(s.totalBytes >= 200)

  fs.rmSync(root, { recursive: true, force: true })
})

test('listTrees — enumerates all with refs + pinned state', async (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  store.init()

  const content = Buffer.from('ok')
  const contentHash = hashBytes(content)
  const eL = [{ kind: KIND_FILE, exec: 0, name: 'x', childHash: contentHash, size: 2 }]
  const h = computeTreeHash(eL)
  await store.materialize(h, eL, mockFetcher({ [contentHash]: content }))
  store.addRef(h, 'one')
  store.addRef(h, 'two')
  store.pin(h)

  const list = store.listTrees()
  t.is(list.length, 1)
  t.is(list[0].hash, h)
  t.alike(list[0].refs.sort(), ['one', 'two'])
  t.ok(list[0].pinned)

  fs.rmSync(root, { recursive: true, force: true })
})

test('TreeStore — gc reports orphan apps and sweeps only on opt-in', (t) => {
  const root = mktemp()
  const store = new TreeStore({ root })
  store.init()

  // Two app installs on disk; only one is recorded in refs.json.
  fs.mkdirSync(path.join(root, 'apps', 'kept-app'), { recursive: true })
  fs.writeFileSync(path.join(root, 'apps', 'kept-app', 'index.js'), 'kept')
  fs.mkdirSync(path.join(root, 'apps', 'orphan-app'), { recursive: true })
  fs.writeFileSync(path.join(root, 'apps', 'orphan-app', 'index.js'), 'orphan')
  fs.writeFileSync(store.refsPath, JSON.stringify({ deadbeef: ['kept-app'] }))

  t.alike(store.listOrphanApps(), ['orphan-app'], 'only the unreferenced app is orphaned')

  // Default + dryRun: report but never remove.
  const dry = store.gc({ dryRun: true })
  t.alike(dry.orphanApps, ['orphan-app'], 'gc reports orphan apps')
  t.alike(dry.appsRemoved, [], 'dryRun removes nothing')
  const noflag = store.gc({})
  t.alike(noflag.appsRemoved, [], 'sweep is opt-in — default removes no apps')
  t.ok(fs.existsSync(path.join(root, 'apps', 'orphan-app')), 'orphan dir still present without flag')

  // Opt-in sweep removes only the orphan, keeps the referenced app.
  const swept = store.gc({ sweepOrphanApps: true })
  t.alike(swept.appsRemoved, ['orphan-app'], 'orphan removed on opt-in')
  t.absent(fs.existsSync(path.join(root, 'apps', 'orphan-app')), 'orphan dir gone')
  t.ok(fs.existsSync(path.join(root, 'apps', 'kept-app')), 'referenced app kept')

  fs.rmSync(root, { recursive: true, force: true })
})