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