4394f34225
The plugin builds with `bun build --target bun` and runs under OpenCode CLI (Bun SEA) but also under OpenCode Desktop (Electron / Node V8) where `globalThis.Bun` does not exist. Mirror the existing `bun-spawn-shim.ts` pattern for three more Bun runtime APIs: - bun-file-shim: bunFile()/bunWrite() backed by node:fs/promises with ArrayBuffer slicing to avoid Node Buffer pool exposure - bun-hash-shim: pure-JS XXH32, bit-exact with Bun.hash.xxHash32 verified by 1200-pair fuzz comparison so existing hashline LINE#ID tags remain stable across runtimes - bun-which-shim: synchronous PATH walker with Windows .exe/.cmd/.bat/.com extensions plus isUnsafeCommandName guard that rejects path separators, parent traversal, drive letters and null bytes before any probe Each shim uses the canonical `runtime.Bun !== undefined` detection and delegates to native Bun under IS_BUN, otherwise uses Node primitives. Each ships with a co-located test that exercises both branches via the `node:vm.runInNewContext` pattern from bun-hash-shim.test.ts.
176 lines
5.8 KiB
TypeScript
176 lines
5.8 KiB
TypeScript
import { readFileSync } from "node:fs"
|
|
import { dirname, join } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { runInNewContext } from "node:vm"
|
|
import { describe, expect, test } from "bun:test"
|
|
import { bunHashXxh32 as runtimeBunHashXxh32 } from "./bun-hash-shim"
|
|
|
|
type HashFunction = (input: string, seed: number) => number
|
|
type HashPair = { input: string; seed: number }
|
|
type BunHashTestRuntime = {
|
|
hash: { xxHash32(data: string | Uint8Array, seed: number): number }
|
|
Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string }
|
|
}
|
|
type HashSandbox = {
|
|
Math: Math
|
|
TextEncoder: typeof TextEncoder
|
|
Uint8Array: Uint8ArrayConstructor
|
|
__bunHashShim?: { bunHashXxh32: HashFunction }
|
|
}
|
|
|
|
const runtime = globalThis as typeof globalThis & { Bun: BunHashTestRuntime }
|
|
const FUZZ_PAIR_COUNT = 1_200
|
|
const FIXED_LENGTHS = [0, 1, 2, 3, 4, 15, 16, 17, 31, 32, 33, 64, 100, 255, 500]
|
|
const FIXED_SEEDS = [0, 1, 42, 12345, 0xdeadbeef, 0xffffffff]
|
|
const CONTENT_FRAGMENTS = ["你好世界", "\u{1f389}", "\u{1f525}", "\n", "\r\n", "\t", " "]
|
|
const SPECIAL_INPUTS = [
|
|
"",
|
|
" ",
|
|
"\t\n\r\n",
|
|
"hello world",
|
|
"你好世界",
|
|
"\u{1f389}\u{1f525}",
|
|
"mixed 你好 \u{1f389} ascii",
|
|
"line one\nline two\r\n\tindented",
|
|
]
|
|
const PURE_JS_HASH = loadPureJsBunHashXxh32()
|
|
const FUZZ_PAIRS = createFuzzPairs()
|
|
|
|
function loadPureJsBunHashXxh32(): HashFunction {
|
|
const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-hash-shim.ts")
|
|
const source = readFileSync(sourcePath, "utf8")
|
|
const exportSignature = "export function bunHashXxh32(input: string, seed: number): number {"
|
|
|
|
if (!source.includes(exportSignature)) {
|
|
throw new Error("bunHashXxh32 export signature changed")
|
|
}
|
|
|
|
const scriptSource = `${source.replace(
|
|
exportSignature,
|
|
"function bunHashXxh32(input: string, seed: number): number {",
|
|
)}\nglobalThis.__bunHashShim = { bunHashXxh32 }\n`
|
|
const transpiler = new runtime.Bun.Transpiler({ loader: "ts" })
|
|
const script = transpiler.transformSync(scriptSource)
|
|
const sandbox: HashSandbox = { Math, TextEncoder, Uint8Array }
|
|
|
|
runInNewContext(script, sandbox, { filename: sourcePath })
|
|
|
|
const pureJsHash = sandbox.__bunHashShim?.bunHashXxh32
|
|
if (!pureJsHash) {
|
|
throw new Error("pure-JS bunHashXxh32 loader failed")
|
|
}
|
|
|
|
return pureJsHash
|
|
}
|
|
|
|
function createUint32Generator(seed: number): () => number {
|
|
let state = seed >>> 0
|
|
|
|
return () => {
|
|
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
|
|
|
|
return state
|
|
}
|
|
}
|
|
|
|
function createSeed(pairIndex: number, nextUint32: () => number): number {
|
|
if (pairIndex % (FIXED_SEEDS.length + 1) === FIXED_SEEDS.length) return nextUint32()
|
|
|
|
return FIXED_SEEDS[pairIndex % FIXED_SEEDS.length] ?? 0
|
|
}
|
|
|
|
function createRandomString(length: number, nextUint32: () => number): string {
|
|
let value = ""
|
|
|
|
while (value.length < length) {
|
|
if (nextUint32() % 10 < 6) {
|
|
value += String.fromCharCode(32 + (nextUint32() % 95))
|
|
continue
|
|
}
|
|
|
|
const fragment = CONTENT_FRAGMENTS[nextUint32() % CONTENT_FRAGMENTS.length] ?? " "
|
|
if (value.length + fragment.length <= length) {
|
|
value += fragment
|
|
continue
|
|
}
|
|
|
|
value += String.fromCharCode(32 + (nextUint32() % 95))
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
function createFuzzPairs(): HashPair[] {
|
|
const nextUint32 = createUint32Generator(0x5eed1234)
|
|
const pairs: HashPair[] = []
|
|
|
|
for (const input of SPECIAL_INPUTS) {
|
|
pairs.push({ input, seed: createSeed(pairs.length, nextUint32) })
|
|
}
|
|
|
|
for (const length of FIXED_LENGTHS) {
|
|
pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) })
|
|
}
|
|
|
|
while (pairs.length < FUZZ_PAIR_COUNT) {
|
|
const randomLength = nextUint32() % 501
|
|
const length = pairs.length % 13 === 0 ? (FIXED_LENGTHS[pairs.length % FIXED_LENGTHS.length] ?? randomLength) : randomLength
|
|
pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) })
|
|
}
|
|
|
|
return pairs
|
|
}
|
|
|
|
function nativeXxh32(input: string, seed: number): number {
|
|
return runtime.Bun.hash.xxHash32(input, seed)
|
|
}
|
|
|
|
function createMismatchMessage(label: string, input: string, seed: number, expected: number, actual: number): string {
|
|
return `${label} mismatch for input=${JSON.stringify(input)} seed=${seed} expected=${expected} actual=${actual}`
|
|
}
|
|
|
|
function expectPureJsHashToMatchBun(label: string, input: string, seed: number): void {
|
|
const expected = nativeXxh32(input, seed)
|
|
const actual = PURE_JS_HASH(input, seed)
|
|
|
|
if (actual !== expected) {
|
|
throw new Error(createMismatchMessage(label, input, seed, expected, actual))
|
|
}
|
|
}
|
|
|
|
describe("#given known XXH32 test vectors", () => {
|
|
test("#when pure-JS hash is called #then returns canonical values", () => {
|
|
expect(PURE_JS_HASH("", 0)).toBe(0x02cc5d05)
|
|
expect(PURE_JS_HASH("a", 0)).toBe(0x550d7456)
|
|
expect(PURE_JS_HASH("abc", 0)).toBe(0x32d153ff)
|
|
})
|
|
|
|
test("#when a non-zero seed is used #then matches Bun hash", () => {
|
|
expectPureJsHashToMatchBun("seeded vector", "test", 42)
|
|
expect(runtimeBunHashXxh32("test", 42)).toBe(nativeXxh32("test", 42))
|
|
})
|
|
})
|
|
|
|
describe("#given random inputs #when hashed with pure-JS and Bun.hash", () => {
|
|
test("#then all fuzz pairs are bit-exact", () => {
|
|
expect(FUZZ_PAIRS).toHaveLength(FUZZ_PAIR_COUNT)
|
|
|
|
for (const [pairIndex, pair] of FUZZ_PAIRS.entries()) {
|
|
expectPureJsHashToMatchBun(`fuzz pair ${pairIndex}`, pair.input, pair.seed)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("#given production-like inputs", () => {
|
|
test("#when hashed with line-number seeds #then pure-JS matches Bun hash", () => {
|
|
const inputs = [" const x = 42;", "import { foo } from 'bar'", "// comment", ""]
|
|
const seeds = [0, 1, 50, 100, 999]
|
|
|
|
for (const input of inputs) {
|
|
for (const seed of seeds) {
|
|
expectPureJsHashToMatchBun("production-like input", input, seed)
|
|
}
|
|
}
|
|
})
|
|
})
|