OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy / test / symlink-serve.test.js

95 lines · javascript ref:
// Regression test for the install-stuck-at-symlink bug. Before the fix,
// DeployServer's `walkDir` used statSync and silently dropped symlink
// entries from the index. The tree-hashing pass (hyper-overlay/tree)
// uses lstatSync and DOES record KIND_SYMLINK entries, so the published
// tree contained content hashes pointing at the link-target string. The
// server then had no entry for those hashes, content-requests were
// silently dropped, and the client hung forever on its first symlink.
//
// This test stands up a real DeployServer pointing at a directory with
// a symlink, then directly asks the server to look up the symlink's
// content. After the fix, the server's _fileIndex contains the entry
// with contentHash == blake2b(linkTargetBytes), and a diskPath
// reachable via _onConnection's contentHashPaths map.

import test from 'brittle'
import fs from 'fs'
import path from 'path'
import os from 'os'
import crypto from 'hypercore-crypto'
import { DeployServer } from '../index.js'

function mktemp (prefix) {
  const dir = path.join(os.tmpdir(), 'hd-symlink-test-' + prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8))
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

test('DeployServer indexes symlinks as content-by-hash', async (t) => {
  const dir = mktemp('idx')
  fs.writeFileSync(path.join(dir, 'regular.txt'), 'plain content')
  const linkTarget = '/some/absolute/path/to/elsewhere'
  fs.symlinkSync(linkTarget, path.join(dir, 'a-symlink'))

  const server = new DeployServer(dir)
  await server.ready()

  const linkEntry = await server._fileIndex.getFile('a-symlink')
  t.ok(linkEntry, 'symlink indexed in _fileIndex')
  t.is(linkEntry.size, Buffer.byteLength(linkTarget, 'utf8'),
    'size equals byte length of link target')

  const expectedHash = crypto.data(Buffer.from(linkTarget, 'utf8')).toString('hex')
  t.is(linkEntry.contentHash, expectedHash,
    'contentHash matches hash of link target string')

  const manifestEntry = server.manifest.files.find(f => f.path === 'a-symlink')
  t.ok(manifestEntry && manifestEntry.diskPath, 'manifest entry has diskPath')
  const blobBytes = fs.readFileSync(manifestEntry.diskPath)
  t.is(blobBytes.toString('utf8'), linkTarget,
    'diskPath blob contains exactly the link target string')

  const regEntry = await server._fileIndex.getFile('regular.txt')
  t.ok(regEntry, 'regular file still indexed')
  t.is(regEntry.size, 'plain content'.length, 'regular file size correct')

  await server.close()
  fs.rmSync(dir, { recursive: true, force: true })
})

test('DeployServer cleans up symlink blob dir on close', async (t) => {
  const dir = mktemp('cleanup')
  fs.symlinkSync('/some/path', path.join(dir, 'link'))

  const server = new DeployServer(dir)
  await server.ready()

  const blobDir = server._symlinkBlobsDir
  t.ok(blobDir && fs.existsSync(blobDir), 'blob dir created at ready')

  await server.close()
  t.absent(fs.existsSync(blobDir), 'blob dir removed on close')

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

test('walkDir does not follow symlinks (no traversal of link target)', async (t) => {
  const dir = mktemp('nofollow')
  const outsideDir = mktemp('outside')
  fs.writeFileSync(path.join(outsideDir, 'leaked.txt'), 'should not appear')
  fs.symlinkSync(outsideDir, path.join(dir, 'link-to-outside-dir'))

  const server = new DeployServer(dir)
  await server.ready()

  const paths = server.manifest.files.map(f => f.path)
  t.ok(paths.includes('link-to-outside-dir'),
    'symlink itself indexed as a leaf entry')
  t.absent(paths.some(p => p.includes('leaked.txt')),
    'walkDir did not recurse through the symlink')

  await server.close()
  fs.rmSync(dir, { recursive: true, force: true })
  fs.rmSync(outsideDir, { recursive: true, force: true })
})