Files
bun.sh/test/regression/issue/13316.test.ts
robobun 6386eef8aa fix(bunx): handle empty string arguments on Windows (#25025)
## Summary

Fixes #13316
Fixes #18275

Running `bunx cowsay ""` (or any package with an empty string argument)
on Windows caused a panic. Additionally, `bunx concurrently "command
with spaces"` was splitting quoted arguments incorrectly.

**Repro #13316:**
```bash
bunx cowsay ""
# panic(main thread): reached unreachable code
```

**Repro #18275:**
```bash
bunx concurrently "bun --version" "bun --version"
# Only runs once, arguments split incorrectly
# Expected: ["bun --version", "bun --version"]
# Actual: ["bun", "--version", "bun", "--version"]
```

## Root Cause

The bunx fast path on Windows bypasses libuv and calls `CreateProcessW`
directly to save 5-12ms. The command line building logic had two issues:

1. **Empty strings**: Not quoted at all, resulting in invalid command
line
2. **Arguments with spaces**: Not quoted, causing them to be split into
multiple arguments

## Solution

Implement Windows command-line argument quoting using libuv's proven
algorithm:
- Port of libuv's `quote_cmd_arg` function (process backwards + reverse)
- Empty strings become `""`
- Strings with spaces/tabs/quotes are wrapped in quotes
- Backslashes before quotes are properly escaped per Windows rules

**Why not use libuv directly?**
- Normal `Bun.spawn()` uses `uv_spawn()` which handles quoting
internally
- bunx fast path bypasses libuv to save 5-12ms (calls `CreateProcessW`
directly)
- libuv's `quote_cmd_arg` is a static function (not exported)
- Solution: port the algorithm to Zig

## Test Plan

- [x] Added regression test for empty strings (#13316)
- [x] Added regression test for arguments with spaces (#18275)
- [x] Verified system bun (v1.3.3) fails both tests
- [x] Verified fix passes both tests
- [x] Implementation based on battle-tested libuv algorithm

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-12-15 17:29:04 -08:00

157 lines
5.2 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import fs from "node:fs";
import path from "node:path";
// https://github.com/oven-sh/bun/issues/13316
// bunx cowsay "" panicked on Windows due to improper handling of empty string arguments
// The issue was in the BunXFastPath.tryLaunch function which didn't properly quote
// empty string arguments for the Windows command line.
describe.if(isWindows)("#13316 - bunx with empty string arguments", () => {
test("bunx does not panic with empty string argument", async () => {
// Create a minimal package that echoes its arguments
using dir = tempDir("issue-13316", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"echo-args-test": "file:./echo-args-test",
},
}),
"echo-args-test/package.json": JSON.stringify({
name: "echo-args-test",
version: "1.0.0",
bin: {
"echo-args-test": "./index.js",
},
}),
"echo-args-test/index.js": `#!/usr/bin/env node
console.log(JSON.stringify(process.argv.slice(2)));
`,
});
// Install to create the .bunx shim in node_modules/.bin
await using installProc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
await installProc.exited;
// Verify the .bunx file was created (this is what triggers the fast path)
const bunxPath = path.join(String(dir), "node_modules", ".bin", "echo-args-test.bunx");
expect(fs.existsSync(bunxPath)).toBe(true);
// Run with an empty string argument - this was triggering the panic
// We use `bun run` which goes through the same BunXFastPath when .bunx exists
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "echo-args-test", ""],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The main assertion is that the process doesn't panic (exit code 3)
// If the bug is present, this would crash with "reached unreachable code"
expect(exitCode).not.toBe(3); // panic exit code
expect(exitCode).toBe(0);
// The empty string argument should be passed correctly
expect(JSON.parse(stdout.trim())).toEqual([""]);
});
test("bunx handles multiple arguments including empty strings", async () => {
using dir = tempDir("issue-13316-multi", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"echo-args-test": "file:./echo-args-test",
},
}),
"echo-args-test/package.json": JSON.stringify({
name: "echo-args-test",
version: "1.0.0",
bin: {
"echo-args-test": "./index.js",
},
}),
"echo-args-test/index.js": `#!/usr/bin/env node
console.log(JSON.stringify(process.argv.slice(2)));
`,
});
await using installProc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
await installProc.exited;
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "echo-args-test", "hello", "", "world"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).not.toBe(3); // panic exit code
expect(exitCode).toBe(0);
expect(JSON.parse(stdout.trim())).toEqual(["hello", "", "world"]);
});
// Related to #18275 - bunx concurrently "command with spaces"
// Arguments containing spaces must be preserved as single arguments
test("bunx preserves arguments with spaces", async () => {
using dir = tempDir("issue-13316-spaces", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"echo-args-test": "file:./echo-args-test",
},
}),
"echo-args-test/package.json": JSON.stringify({
name: "echo-args-test",
version: "1.0.0",
bin: {
"echo-args-test": "./index.js",
},
}),
"echo-args-test/index.js": `#!/usr/bin/env node
console.log(JSON.stringify(process.argv.slice(2)));
`,
});
await using installProc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
await installProc.exited;
// This simulates: bunx concurrently "bun --version"
// The shell strips the outer quotes, so bunx receives ["concurrently", "bun --version"]
// This must be preserved as a single argument with spaces
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "echo-args-test", "bun --version", "echo hello world"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Each argument with spaces should be preserved as a single argument
expect(JSON.parse(stdout.trim())).toEqual(["bun --version", "echo hello world"]);
});
});