diff --git a/src/shared/bun-file-shim.test.ts b/src/shared/bun-file-shim.test.ts new file mode 100644 index 000000000..7be9fa4d7 --- /dev/null +++ b/src/shared/bun-file-shim.test.ts @@ -0,0 +1,300 @@ +/// + +import { Buffer as NodeBuffer } from "node:buffer" +import { readFileSync } from "node:fs" +import { access, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { afterAll, beforeAll, describe, expect, it } from "bun:test" + +import { bunFile, bunWrite } from "./bun-file-shim" + +type NodeFallbackBunFileLike = { + text(): Promise + arrayBuffer(): Promise + exists(): Promise + delete(): Promise +} + +type NodeFallbackBunFile = (path: string) => NodeFallbackBunFileLike +type NodeFallbackBunWrite = (path: string, data: string | ArrayBuffer | Uint8Array) => Promise + +type NodeFallbackExports = { + bunFile: NodeFallbackBunFile + bunWrite: NodeFallbackBunWrite +} + +type BunFileTestRuntime = { + Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string } +} + +type BunFileSandbox = { + access: typeof access + Buffer: typeof NodeBuffer + console: Console + Promise: PromiseConstructor + readFile: typeof readFile + TextEncoder: typeof TextEncoder + Uint8Array: Uint8ArrayConstructor + unlink: typeof unlink + writeFile: typeof writeFile + __exports?: NodeFallbackExports +} + +const runtime = globalThis as typeof globalThis & { Bun: BunFileTestRuntime } +const NODE_FALLBACK = loadNodeFallbackBunFileShim() + +let temporaryDirectory = "" +let nodeFallbackTemporaryDirectory = "" + +function temporaryPath(fileName: string): string { + return join(temporaryDirectory, fileName) +} + +function nodeFallbackPath(fileName: string): string { + return join(nodeFallbackTemporaryDirectory, fileName) +} + +function loadNodeFallbackBunFileShim(): NodeFallbackExports { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-file-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const importStatement = 'import { access, readFile, unlink, writeFile } from "node:fs/promises"\n\n' + const interfaceSignature = "export interface BunFileLike {" + const bunFileSignature = "export function bunFile(path: string): BunFileLike {" + const bunWriteSignature = + "export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise {" + + if (!source.startsWith(importStatement)) { + throw new Error("bun-file-shim import statement changed") + } + + for (const signature of [interfaceSignature, bunFileSignature, bunWriteSignature]) { + if (!source.includes(signature)) { + throw new Error(`bun-file-shim signature changed: ${signature}`) + } + } + + const transformedSource = source + .slice(importStatement.length) + .replace(interfaceSignature, "interface BunFileLike {") + .replace(bunFileSignature, "function bunFile(path: string): BunFileLike {") + .replace( + bunWriteSignature, + "async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise {", + ) + const scriptSource = `${transformedSource}\nglobalThis.__exports = { bunFile, bunWrite }\n` + const transpiler = new runtime.Bun.Transpiler({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandbox: BunFileSandbox = { + access, + Buffer: NodeBuffer, + console, + Promise, + readFile, + TextEncoder, + Uint8Array, + unlink, + writeFile, + } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + if (!sandbox.__exports) { + throw new Error("Node fallback bun-file-shim loader failed") + } + + return sandbox.__exports +} + +function arrayBufferFromBytes(bytes: number[]): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(bytes.length) + const view = new Uint8Array(arrayBuffer) + + view.set(bytes) + + return arrayBuffer +} + +describe("bun-file-shim", () => { + beforeAll(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-")) + }) + + afterAll(async () => { + if (temporaryDirectory.length === 0) return + + await rm(temporaryDirectory, { recursive: true, force: true }) + }) + + describe("#given bunFile", () => { + it("#when text is called then it reads file contents", async () => { + const filePath = temporaryPath("text.txt") + const content = "hello from file" + + await writeFile(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + + it("#when arrayBuffer is called then it returns exact file bytes", async () => { + const filePath = temporaryPath("bytes.bin") + const bytes = new Uint8Array([0, 1, 2, 255]) + + await writeFile(filePath, bytes) + + const arrayBuffer = await bunFile(filePath).arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(bytes.byteLength) + expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes)) + }) + + it("#when exists is called then it reflects file presence", async () => { + const existingPath = temporaryPath("existing.txt") + const missingPath = temporaryPath("missing.txt") + + await writeFile(existingPath, "present") + + expect(await bunFile(existingPath).exists()).toBe(true) + expect(await bunFile(missingPath).exists()).toBe(false) + }) + + it("#when delete is called then it removes the file", async () => { + const filePath = temporaryPath("delete-me.txt") + + await writeFile(filePath, "remove") + await bunFile(filePath).delete() + + expect(await bunFile(filePath).exists()).toBe(false) + }) + }) + + describe("#given bunWrite", () => { + it("#when writing string data then it writes contents and returns byte count", async () => { + const filePath = temporaryPath("write-string.txt") + const content = "write me" + const bytesWritten = await bunWrite(filePath, content) + + expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength) + expect(await readFile(filePath, "utf8")).toBe(content) + }) + + it("#when writing array buffer data then it writes exact bytes", async () => { + const filePath = temporaryPath("write-array-buffer.bin") + const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68]) + const bytesWritten = await bunWrite(filePath, arrayBuffer) + const written = await readFile(filePath) + + expect(bytesWritten).toBe(arrayBuffer.byteLength) + expect(Array.from(written)).toEqual([65, 66, 67, 68]) + }) + + it("#when writing then reading text then it round trips content", async () => { + const filePath = temporaryPath("round-trip.txt") + const content = "round trip content" + + await bunWrite(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + + it("#when writing unicode text then it round trips content", async () => { + const filePath = temporaryPath("unicode-round-trip.txt") + const content = "Hello 世界 🌍" + + await bunWrite(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + }) + + describe("#given Node fallback without Bun global", () => { + beforeAll(async () => { + nodeFallbackTemporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-node-")) + }) + + afterAll(async () => { + if (nodeFallbackTemporaryDirectory.length === 0) return + + await rm(nodeFallbackTemporaryDirectory, { recursive: true, force: true }) + }) + + it("#when text is called then it reads file contents", async () => { + const filePath = nodeFallbackPath("text.txt") + const content = "hello from Node fallback" + + await writeFile(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + + it("#when arrayBuffer is called then it returns exact file bytes", async () => { + const filePath = nodeFallbackPath("bytes.bin") + const bytes = new Uint8Array([0, 1, 2, 255, 128]) + + await writeFile(filePath, bytes) + + const arrayBuffer = await NODE_FALLBACK.bunFile(filePath).arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(bytes.byteLength) + expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes)) + }) + + it("#when exists is called then it reflects file presence", async () => { + const existingPath = nodeFallbackPath("existing.txt") + const missingPath = nodeFallbackPath("missing.txt") + + await writeFile(existingPath, "present") + + expect(await NODE_FALLBACK.bunFile(existingPath).exists()).toBe(true) + expect(await NODE_FALLBACK.bunFile(missingPath).exists()).toBe(false) + }) + + it("#when delete is called then it removes the file", async () => { + const filePath = nodeFallbackPath("delete-me.txt") + + await writeFile(filePath, "remove") + await NODE_FALLBACK.bunFile(filePath).delete() + + expect(await NODE_FALLBACK.bunFile(filePath).exists()).toBe(false) + }) + + it("#when writing string data then it writes contents and returns byte count", async () => { + const filePath = nodeFallbackPath("write-string.txt") + const content = "write me from Node fallback" + const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, content) + + expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength) + expect(await readFile(filePath, "utf8")).toBe(content) + }) + + it("#when writing array buffer data then it writes exact bytes", async () => { + const filePath = nodeFallbackPath("write-array-buffer.bin") + const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68, 69]) + const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, arrayBuffer) + const written = await readFile(filePath) + + expect(bytesWritten).toBe(arrayBuffer.byteLength) + expect(Array.from(written)).toEqual([65, 66, 67, 68, 69]) + }) + + it("#when writing then reading text then it round trips content", async () => { + const filePath = nodeFallbackPath("round-trip.txt") + const content = "round trip through Node fallback" + + await NODE_FALLBACK.bunWrite(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + + it("#when writing unicode text then it round trips content", async () => { + const filePath = nodeFallbackPath("unicode-round-trip.txt") + const content = "Hello 世界 🌍 from Node fallback" + + await NODE_FALLBACK.bunWrite(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + }) +}) diff --git a/src/shared/bun-file-shim.ts b/src/shared/bun-file-shim.ts new file mode 100644 index 000000000..970853184 --- /dev/null +++ b/src/shared/bun-file-shim.ts @@ -0,0 +1,65 @@ +import { access, readFile, unlink, writeFile } from "node:fs/promises" + +export interface BunFileLike { + text(): Promise + arrayBuffer(): Promise + exists(): Promise + delete(): Promise +} + +type BunFileRuntime = { + file(path: string): BunFileLike + write(path: string, data: string | ArrayBuffer | Uint8Array): Promise +} + +const runtime = globalThis as typeof globalThis & { Bun?: BunFileRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" + +function byteLength(data: string | ArrayBuffer | Uint8Array): number { + if (typeof data === "string") return Buffer.byteLength(data, "utf8") + + return data.byteLength +} + +function toWritableData(data: string | ArrayBuffer | Uint8Array): string | Uint8Array { + if (typeof data === "string") return data + if (data instanceof Uint8Array) return data + + return new Uint8Array(data) +} + +function createNodeFile(path: string): BunFileLike { + return { + text() { + return readFile(path, "utf8") + }, + async arrayBuffer() { + const buffer = await readFile(path) + + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + }, + exists() { + return access(path).then( + () => true, + () => false, + ) + }, + delete() { + return unlink(path) + }, + } +} + +export function bunFile(path: string): BunFileLike { + if (IS_BUN) return runtime.Bun!.file(path) + + return createNodeFile(path) +} + +export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise { + if (IS_BUN) return runtime.Bun!.write(path, data) + + await writeFile(path, toWritableData(data)) + + return byteLength(data) +} diff --git a/src/shared/bun-hash-shim.test.ts b/src/shared/bun-hash-shim.test.ts new file mode 100644 index 000000000..417553907 --- /dev/null +++ b/src/shared/bun-hash-shim.test.ts @@ -0,0 +1,175 @@ +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) + } + } + }) +}) diff --git a/src/shared/bun-hash-shim.ts b/src/shared/bun-hash-shim.ts new file mode 100644 index 000000000..d77bbda10 --- /dev/null +++ b/src/shared/bun-hash-shim.ts @@ -0,0 +1,89 @@ +type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } } + +const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" +const encoder = new TextEncoder() + +const PRIME32_1 = 0x9e3779b1 +const PRIME32_2 = 0x85ebca77 +const PRIME32_3 = 0xc2b2ae3d +const PRIME32_4 = 0x27d4eb2f +const PRIME32_5 = 0x165667b1 + +function rotateLeft32(value: number, bits: number): number { + return ((value << bits) | (value >>> (32 - bits))) >>> 0 +} + +function readUint32LittleEndian(input: Uint8Array, offset: number): number { + return ( + ((input[offset] ?? 0) | + ((input[offset + 1] ?? 0) << 8) | + ((input[offset + 2] ?? 0) << 16) | + ((input[offset + 3] ?? 0) << 24)) >>> + 0 + ) +} + +function round32(accumulator: number, value: number): number { + const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0 + + return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0 +} + +function xxHash32Js(input: Uint8Array, seed: number): number { + let offset = 0 + const length = input.length + let hash: number + + if (length >= 16) { + const limit = length - 16 + let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0 + let value2 = (seed + PRIME32_2) >>> 0 + let value3 = seed >>> 0 + let value4 = (seed - PRIME32_1) >>> 0 + + while (offset <= limit) { + value1 = round32(value1, readUint32LittleEndian(input, offset)) + offset += 4 + value2 = round32(value2, readUint32LittleEndian(input, offset)) + offset += 4 + value3 = round32(value3, readUint32LittleEndian(input, offset)) + offset += 4 + value4 = round32(value4, readUint32LittleEndian(input, offset)) + offset += 4 + } + + hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0 + hash = (hash + rotateLeft32(value3, 12)) >>> 0 + hash = (hash + rotateLeft32(value4, 18)) >>> 0 + } else { + hash = (seed + PRIME32_5) >>> 0 + } + + hash = (hash + length) >>> 0 + + while (offset + 4 <= length) { + hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0 + offset += 4 + } + + while (offset < length) { + hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0 + offset += 1 + } + + hash = (hash ^ (hash >>> 15)) >>> 0 + hash = Math.imul(hash, PRIME32_2) >>> 0 + hash = (hash ^ (hash >>> 13)) >>> 0 + hash = Math.imul(hash, PRIME32_3) >>> 0 + + return (hash ^ (hash >>> 16)) >>> 0 +} + +export function bunHashXxh32(input: string, seed: number): number { + if (IS_BUN) return runtime.Bun!.hash.xxHash32(input, seed) + + return xxHash32Js(encoder.encode(input), seed >>> 0) +} diff --git a/src/shared/bun-which-shim.test.ts b/src/shared/bun-which-shim.test.ts new file mode 100644 index 000000000..9b55fec8b --- /dev/null +++ b/src/shared/bun-which-shim.test.ts @@ -0,0 +1,149 @@ +import { accessSync, constants, readFileSync } from "node:fs" +import { delimiter, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { describe, expect, test } from "bun:test" + +import { bunWhich } from "./bun-which-shim" + +type BunWhichFunction = (commandName: string) => string | null +type BunWhichRuntime = { + Transpiler?: new (options: { loader: "ts" }) => { transformSync(source: string): string } + which(commandName: string): string | null +} +type SandboxProcess = { + env: { PATH?: string; Path?: string } + platform: typeof process.platform +} +type BunWhichSandbox = { + accessSync: typeof accessSync + constants: typeof constants + console: Console + delimiter: typeof delimiter + join: typeof join + process: SandboxProcess + __bunWhichShim?: { bunWhich: BunWhichFunction } +} + +const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime } +const PATH_TRAVERSAL_COMMAND_NAMES = [ + "../etc/passwd", + "/etc/passwd", + "./tool", + "sub/dir/tool", + "C:\\Windows\\evil", + "C:tool", + ".", + "..", + "node..evil", +] +const NULL_BYTE_COMMAND_NAME = "node\0evil" +const NODE_FALLBACK_BUN_WHICH = loadNodeFallbackBunWhich() + +function loadNodeFallbackBunWhich(): BunWhichFunction { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-which-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const fsImport = 'import { accessSync, constants } from "node:fs"\n' + const pathImport = 'import { delimiter, join } from "node:path"\n' + const exportSignature = "export function bunWhich(commandName: string): string | null {" + + if (!source.includes(fsImport) || !source.includes(pathImport) || !source.includes(exportSignature)) { + throw new Error("bunWhich source shape changed") + } + + const scriptSource = `${source + .replace(fsImport, "") + .replace(pathImport, "") + .replace(exportSignature, "function bunWhich(commandName: string): string | null {")}\nglobalThis.__bunWhichShim = { bunWhich }\n` + const transpilerConstructor = runtime.Bun?.Transpiler + if (!transpilerConstructor) { + throw new Error("Bun Transpiler unavailable") + } + + const transpiler = new transpilerConstructor({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandboxProcess: SandboxProcess = { + env: { PATH: process.env.PATH, Path: process.env.Path }, + platform: process.platform, + } + const sandbox: BunWhichSandbox = { accessSync, constants, console, delimiter, join, process: sandboxProcess } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + const nodeFallbackBunWhich = sandbox.__bunWhichShim?.bunWhich + if (!nodeFallbackBunWhich) { + throw new Error("Node fallback bunWhich loader failed") + } + + return nodeFallbackBunWhich +} + +describe("bunWhich", () => { + test("#given 'node' command #when resolved #then returns a non-null path ending in 'node'", () => { + const resolvedPath = bunWhich("node") + + expect(resolvedPath).not.toBeNull() + expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/) + }) + + test("#given a non-existent command #when resolved #then returns null", () => { + const resolvedPath = bunWhich("this-command-definitely-does-not-exist-abc123xyz") + + expect(resolvedPath).toBeNull() + }) + + test("#given an empty string #when resolved #then returns null", () => { + const resolvedPath = bunWhich("") + + expect(resolvedPath).toBeNull() + }) + + test("#given the result for 'node' #when resolved #then the returned path matches Bun.which('node')", () => { + const nativePath = runtime.Bun?.which("node") + const shimPath = bunWhich("node") + + expect(nativePath).not.toBeNull() + expect(shimPath).toBe(nativePath) + }) + + test("#given path-traversal command names #when resolved through Bun runtime #then returns null", () => { + for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) { + expect(bunWhich(commandName)).toBeNull() + } + }) + + test("#given a null-byte command name #when resolved through Bun runtime #then returns null", () => { + expect(bunWhich(NULL_BYTE_COMMAND_NAME)).toBeNull() + }) +}) + +describe("#given Node fallback bunWhich loaded without Bun global", () => { + test("#when 'node' command is resolved #then returns a non-null path ending in 'node'", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("node") + + expect(resolvedPath).not.toBeNull() + expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/) + }) + + test("#when a non-existent command is resolved #then returns null", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("this-does-not-exist-abc123xyz") + + expect(resolvedPath).toBeNull() + }) + + test("#when an empty string is resolved #then returns null", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("") + + expect(resolvedPath).toBeNull() + }) + + test("#when path-traversal command names are resolved #then returns null", () => { + for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) { + expect(NODE_FALLBACK_BUN_WHICH(commandName)).toBeNull() + } + }) + + test("#when a null-byte command name is resolved #then returns null", () => { + expect(NODE_FALLBACK_BUN_WHICH(NULL_BYTE_COMMAND_NAME)).toBeNull() + }) +}) diff --git a/src/shared/bun-which-shim.ts b/src/shared/bun-which-shim.ts new file mode 100644 index 000000000..47d974918 --- /dev/null +++ b/src/shared/bun-which-shim.ts @@ -0,0 +1,58 @@ +import { accessSync, constants } from "node:fs" +import { delimiter, join } from "node:path" + +type BunWhichRuntime = { which(commandName: string): string | null } +const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" + +function isUnsafeCommandName(commandName: string): boolean { + if (commandName.includes("/") || commandName.includes("\\")) return true + if (commandName === "." || commandName === ".." || commandName.includes("..")) return true + if (/^[a-zA-Z]:/.test(commandName)) return true + if (commandName.includes("\0")) return true + + return false +} + +function isExecutable(filePath: string): boolean { + try { + accessSync(filePath, constants.X_OK) + return true + } catch { + return false + } +} + +function resolvePathValue(): string | undefined { + if (process.platform === "win32") return process.env.Path ?? process.env.PATH + + return process.env.PATH +} + +function getWindowsCandidates(commandName: string): string[] { + if (process.platform !== "win32") return [commandName] + + return [commandName, `${commandName}.exe`, `${commandName}.cmd`, `${commandName}.bat`, `${commandName}.com`] +} + +export function bunWhich(commandName: string): string | null { + if (!commandName) return null + if (isUnsafeCommandName(commandName)) return null + if (IS_BUN) return runtime.Bun?.which(commandName) ?? null + + const pathValue = resolvePathValue() + if (!pathValue) return null + + const pathEntries = pathValue.split(delimiter).filter((pathEntry) => pathEntry.length > 0) + if (pathEntries.length === 0) return null + + const candidateNames = getWindowsCandidates(commandName) + for (const pathEntry of pathEntries) { + for (const candidateName of candidateNames) { + const candidatePath = join(pathEntry, candidateName) + if (isExecutable(candidatePath)) return candidatePath + } + } + + return null +}