OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / tree.test.js

358 lines · javascript ref:
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import { mkdtempSync, tmpDir } from './helpers.js'
import {
  MAGIC,
  KIND_FILE, KIND_DIR, KIND_SYMLINK,
  normalizeName,
  windowsIncompatibleReason,
  encodeTree, decodeTree, computeTreeHash,
  walkTree, validateForPublish
} from '../lib/tree.js'

// ── Name validation ──────────────────────────────────────────

test('normalizeName accepts simple ASCII', (t) => {
  t.is(normalizeName('foo.js'), 'foo.js')
  t.is(normalizeName('lib'), 'lib')
  t.is(normalizeName('_underscore'), '_underscore')
})

test('normalizeName rejects "." and ".."', (t) => {
  t.exception(() => normalizeName('.'))
  t.exception(() => normalizeName('..'))
})

test('normalizeName rejects empty and slashes', (t) => {
  t.exception(() => normalizeName(''))
  t.exception(() => normalizeName('foo/bar'))
  t.exception(() => normalizeName('/'))
})

test('normalizeName rejects NUL bytes', (t) => {
  t.exception(() => normalizeName('foo\x00bar'))
})

test('normalizeName normalizes to NFC', (t) => {
  // "é" can be: single code point U+00E9 (NFC) or e + U+0301 combining acute (NFD)
  const nfc = '\u00e9'
  const nfd = 'e\u0301'
  t.is(normalizeName(nfc), nfc)
  t.is(normalizeName(nfd), nfc) // NFD input produces NFC output
})

// ── Windows compatibility ────────────────────────────────────

test('windowsIncompatibleReason flags reserved device names', (t) => {
  t.ok(windowsIncompatibleReason('CON'))
  t.ok(windowsIncompatibleReason('con'))          // case-insensitive
  t.ok(windowsIncompatibleReason('AUX.log'))      // reserved base even with extension
  t.ok(windowsIncompatibleReason('NUL.txt'))
  t.is(windowsIncompatibleReason('confused.txt'), null) // prefix only, not exact
})

test('windowsIncompatibleReason flags forbidden chars', (t) => {
  t.ok(windowsIncompatibleReason('foo:bar.txt'))
  t.ok(windowsIncompatibleReason('a?b'))
  t.ok(windowsIncompatibleReason('a*b'))
  t.ok(windowsIncompatibleReason('a|b'))
  t.ok(windowsIncompatibleReason('a<b'))
  t.ok(windowsIncompatibleReason('a>b'))
  t.ok(windowsIncompatibleReason('a"b'))
  t.ok(windowsIncompatibleReason('a\\b'))
})

test('windowsIncompatibleReason flags trailing dot or space', (t) => {
  t.ok(windowsIncompatibleReason('foo.'))
  t.ok(windowsIncompatibleReason('foo '))
})

test('windowsIncompatibleReason passes normal names', (t) => {
  t.is(windowsIncompatibleReason('foo.js'), null)
  t.is(windowsIncompatibleReason('lib'), null)
  t.is(windowsIncompatibleReason('CONFIG'), null)       // longer than CON
  t.is(windowsIncompatibleReason('auxiliary.log'), null)
})

// ── Encode / decode ─────────────────────────────────────────

function fakeHash (byte) {
  return Buffer.alloc(32, byte).toString('hex')
}

test('encode+decode empty tree', (t) => {
  const bytes = encodeTree([])
  t.is(bytes.slice(0, MAGIC.length).toString(), MAGIC.toString())
  const back = decodeTree(bytes)
  t.alike(back, [])
})

test('encode+decode single file', (t) => {
  const entries = [{ kind: KIND_FILE, exec: 0, name: 'foo.js', childHash: fakeHash(0x11) }]
  const bytes = encodeTree(entries)
  const back = decodeTree(bytes)
  t.is(back.length, 1)
  t.is(back[0].kind, KIND_FILE)
  t.is(back[0].exec, 0)
  t.is(back[0].name, 'foo.js')
  t.is(back[0].childHash, fakeHash(0x11))
})

test('encode sorts entries by name', (t) => {
  const unsorted = [
    { kind: KIND_FILE, exec: 0, name: 'c.js', childHash: fakeHash(0x03) },
    { kind: KIND_FILE, exec: 0, name: 'a.js', childHash: fakeHash(0x01) },
    { kind: KIND_FILE, exec: 0, name: 'b.js', childHash: fakeHash(0x02) }
  ]
  const back = decodeTree(encodeTree(unsorted))
  t.is(back[0].name, 'a.js')
  t.is(back[1].name, 'b.js')
  t.is(back[2].name, 'c.js')
})

test('decode rejects unsorted trees', (t) => {
  // Manually construct an unsorted tree and verify decode rejects it.
  const e1 = { kind: KIND_FILE, exec: 0, name: 'b.js', childHash: fakeHash(0x02) }
  const e2 = { kind: KIND_FILE, exec: 0, name: 'a.js', childHash: fakeHash(0x01) }
  // Can't use encodeTree (it sorts). Build bytes manually.
  const chunks = [MAGIC]
  const cu = Buffer.alloc(1); cu[0] = 2; chunks.push(cu) // count
  for (const e of [e1, e2]) {
    const kind = Buffer.alloc(1); kind[0] = e.kind
    const exec = Buffer.alloc(1); exec[0] = e.exec
    const nameBytes = Buffer.from(e.name, 'utf8')
    const nameLen = Buffer.alloc(1); nameLen[0] = nameBytes.length
    chunks.push(kind, exec, nameLen, nameBytes, Buffer.from(e.childHash, 'hex'))
  }
  const bad = Buffer.concat(chunks)
  t.exception(() => decodeTree(bad), /not sorted/)
})

test('decode rejects duplicate names', (t) => {
  const e = { kind: KIND_FILE, exec: 0, name: 'dup.js', childHash: fakeHash(0x01) }
  const chunks = [MAGIC]
  const cu = Buffer.alloc(1); cu[0] = 2; chunks.push(cu)
  for (let i = 0; i < 2; i++) {
    const kind = Buffer.alloc(1); kind[0] = e.kind
    const exec = Buffer.alloc(1); exec[0] = e.exec
    const nameBytes = Buffer.from(e.name, 'utf8')
    const nameLen = Buffer.alloc(1); nameLen[0] = nameBytes.length
    chunks.push(kind, exec, nameLen, nameBytes, Buffer.from(e.childHash, 'hex'))
  }
  const bad = Buffer.concat(chunks)
  t.exception(() => decodeTree(bad), /duplicate/)
})

test('decode rejects wrong magic', (t) => {
  const bad = Buffer.concat([Buffer.from('wrong-magic\n'), Buffer.alloc(1)])
  t.exception(() => decodeTree(bad), /magic/)
})

test('decode rejects trailing bytes', (t) => {
  const bytes = encodeTree([])
  const trailing = Buffer.concat([bytes, Buffer.from([0xaa])])
  t.exception(() => decodeTree(trailing), /trailing/)
})

// ── Hash determinism ─────────────────────────────────────────

test('identical entries produce identical hash', (t) => {
  const entries = [
    { kind: KIND_FILE, exec: 0, name: 'a.js', childHash: fakeHash(0x11) },
    { kind: KIND_FILE, exec: 0, name: 'b.js', childHash: fakeHash(0x22) }
  ]
  const h1 = computeTreeHash(entries)
  const h2 = computeTreeHash(entries)
  t.is(h1, h2)
})

test('entry ordering does not affect hash (sort applied)', (t) => {
  const order1 = [
    { kind: KIND_FILE, exec: 0, name: 'a.js', childHash: fakeHash(0x11) },
    { kind: KIND_FILE, exec: 0, name: 'b.js', childHash: fakeHash(0x22) }
  ]
  const order2 = [
    { kind: KIND_FILE, exec: 0, name: 'b.js', childHash: fakeHash(0x22) },
    { kind: KIND_FILE, exec: 0, name: 'a.js', childHash: fakeHash(0x11) }
  ]
  t.is(computeTreeHash(order1), computeTreeHash(order2))
})

test('changed exec bit changes hash', (t) => {
  const e1 = [{ kind: KIND_FILE, exec: 0, name: 'run.sh', childHash: fakeHash(0x11) }]
  const e2 = [{ kind: KIND_FILE, exec: 1, name: 'run.sh', childHash: fakeHash(0x11) }]
  t.unlike(computeTreeHash(e1), computeTreeHash(e2))
})

test('changed content hash changes tree hash', (t) => {
  const e1 = [{ kind: KIND_FILE, exec: 0, name: 'foo.js', childHash: fakeHash(0x11) }]
  const e2 = [{ kind: KIND_FILE, exec: 0, name: 'foo.js', childHash: fakeHash(0x22) }]
  t.unlike(computeTreeHash(e1), computeTreeHash(e2))
})

test('changed name changes hash', (t) => {
  const e1 = [{ kind: KIND_FILE, exec: 0, name: 'foo.js', childHash: fakeHash(0x11) }]
  const e2 = [{ kind: KIND_FILE, exec: 0, name: 'bar.js', childHash: fakeHash(0x11) }]
  t.unlike(computeTreeHash(e1), computeTreeHash(e2))
})

test('file vs dir with same name+hash differ', (t) => {
  const asFile = [{ kind: KIND_FILE, exec: 0, name: 'thing', childHash: fakeHash(0x11) }]
  const asDir = [{ kind: KIND_DIR, exec: 0, name: 'thing', childHash: fakeHash(0x11) }]
  t.unlike(computeTreeHash(asFile), computeTreeHash(asDir))
})

test('NFC-vs-NFD input produces same hash', (t) => {
  const nfc = '\u00e9.txt'  // é + .txt
  const nfd = 'e\u0301.txt' // e + combining acute + .txt
  const e1 = [{ kind: KIND_FILE, exec: 0, name: nfc, childHash: fakeHash(0x11) }]
  const e2 = [{ kind: KIND_FILE, exec: 0, name: nfd, childHash: fakeHash(0x11) }]
  t.is(computeTreeHash(e1), computeTreeHash(e2))
})

// ── Filesystem walk ──────────────────────────────────────────

test('walkTree — empty directory', (t) => {
  const dir = tmpDir('tree-empty')
  const trees = walkTree(dir)
  t.is(trees.size, 1)
  const root = trees.get('')
  t.alike(root.entries, [])
  t.is(root.size, 0)
  // Empty tree hash is deterministic
  t.is(root.hash, computeTreeHash([]))
})

test('walkTree — single file', (t) => {
  const dir = tmpDir('tree-single')
  fs.writeFileSync(path.join(dir, 'hello.txt'), 'hello world')

  const trees = walkTree(dir)
  const root = trees.get('')
  t.is(root.entries.length, 1)
  t.is(root.entries[0].name, 'hello.txt')
  t.is(root.entries[0].kind, KIND_FILE)
  t.is(root.size, 11)
  // childHash should be blake2b of 'hello world'
  const expected = crypto.data(Buffer.from('hello world', 'utf8')).toString('hex')
  t.is(root.entries[0].childHash, expected)
})

test('walkTree — nested directory with subtree hash', (t) => {
  const dir = tmpDir('tree-nested')
  fs.mkdirSync(path.join(dir, 'lib'))
  fs.writeFileSync(path.join(dir, 'lib', 'util.js'), 'module.exports = {}\n')
  fs.writeFileSync(path.join(dir, 'index.js'), 'require("./lib/util")\n')

  const trees = walkTree(dir)
  t.is(trees.size, 2) // root + lib

  const root = trees.get('')
  t.is(root.entries.length, 2)

  const libEntry = root.entries.find(e => e.name === 'lib')
  t.ok(libEntry, 'root has lib entry')
  t.is(libEntry.kind, KIND_DIR)

  const libTree = trees.get('lib')
  t.is(libEntry.childHash, libTree.hash, 'dir entry childHash matches subtree hash')
})

test('walkTree — symlink is hashed, not followed', (t) => {
  const dir = tmpDir('tree-symlink')
  fs.writeFileSync(path.join(dir, 'target.txt'), 'target content')
  try {
    fs.symlinkSync('target.txt', path.join(dir, 'link'))
  } catch (err) {
    // Windows may not support symlinks without admin — skip
    if (err.code === 'EPERM' || err.code === 'ENOSYS') {
      t.pass('symlink creation not supported on this platform')
      return
    }
    throw err
  }

  const trees = walkTree(dir)
  const root = trees.get('')
  const link = root.entries.find(e => e.name === 'link')
  t.ok(link, 'link entry present')
  t.is(link.kind, KIND_SYMLINK)
  // childHash is blake2b of the target path bytes
  const expected = crypto.data(Buffer.from('target.txt', 'utf8')).toString('hex')
  t.is(link.childHash, expected)
})

test('walkTree — skip list removes entries', (t) => {
  const dir = tmpDir('tree-skip')
  fs.writeFileSync(path.join(dir, 'real.txt'), 'keep me')
  fs.writeFileSync(path.join(dir, '.DS_Store'), 'macos junk')

  const trees = walkTree(dir)
  const root = trees.get('')
  t.is(root.entries.length, 1)
  t.is(root.entries[0].name, 'real.txt')
})

// ── Cross-machine determinism surrogate ──────────────────────

test('walkTree — same content + different mtime = same hash', (t) => {
  const dirA = tmpDir('tree-det-a')
  const dirB = tmpDir('tree-det-b')

  fs.writeFileSync(path.join(dirA, 'foo.js'), 'contents\n')
  fs.writeFileSync(path.join(dirB, 'foo.js'), 'contents\n')

  // Touch file B to a different mtime
  const past = new Date(2000, 0, 1)
  fs.utimesSync(path.join(dirB, 'foo.js'), past, past)

  const treesA = walkTree(dirA)
  const treesB = walkTree(dirB)
  t.is(treesA.get('').hash, treesB.get('').hash, 'mtime changes do not affect tree hash')
})

// ── Publish validation ───────────────────────────────────────

test('validateForPublish — clean tree has no problems', (t) => {
  const dir = tmpDir('tree-clean')
  fs.writeFileSync(path.join(dir, 'ok.txt'), 'fine')
  const trees = walkTree(dir)
  const problems = validateForPublish(trees)
  t.is(problems.length, 0)
})

test('validateForPublish — flags Windows-hostile names', (t) => {
  // We can't create actual 'AUX' or 'foo:bar' on some filesystems,
  // so test the validator against a manually built trees map.
  const trees = new Map()
  trees.set('', {
    hash: fakeHash(0x00),
    size: 100,
    entries: [
      { kind: KIND_FILE, exec: 0, name: 'ok.txt', childHash: fakeHash(0x01) },
      { kind: KIND_FILE, exec: 0, name: 'AUX.log', childHash: fakeHash(0x02) },
      { kind: KIND_FILE, exec: 0, name: 'foo ', childHash: fakeHash(0x03) }
    ]
  })
  const problems = validateForPublish(trees)
  t.is(problems.length, 2)
  t.is(problems[0].name, 'AUX.log')
  t.is(problems[1].name, 'foo ')
})

test('validateForPublish — reports correct path for nested problems', (t) => {
  const trees = new Map()
  trees.set('', { hash: fakeHash(0), size: 0, entries: [
    { kind: KIND_DIR, exec: 0, name: 'lib', childHash: fakeHash(0xaa) }
  ]})
  trees.set('lib', { hash: fakeHash(0xaa), size: 0, entries: [
    { kind: KIND_FILE, exec: 0, name: 'NUL.txt', childHash: fakeHash(0x01) }
  ]})
  const problems = validateForPublish(trees)
  t.is(problems.length, 1)
  t.is(problems[0].path, 'lib/NUL.txt')
})