diff --git a/src/bun_core/string/immutable.rs b/src/bun_core/string/immutable.rs index 2754d33c370e..30b8d4a75ef3 100644 --- a/src/bun_core/string/immutable.rs +++ b/src/bun_core/string/immutable.rs @@ -997,6 +997,11 @@ pub fn starts_with_case_insensitive_ascii(self_: &[u8], prefix: &[u8]) -> bool { && eql_case_insensitive_ascii(&self_[0..prefix.len()], prefix, false) } +#[inline] +pub fn ends_with_case_insensitive_ascii(self_: &[u8], suffix: &[u8]) -> bool { + self_.len() >= suffix.len() && self_[self_.len() - suffix.len()..].eq_ignore_ascii_case(suffix) +} + pub use crate::strings_impl::{ has_prefix_t, has_prefix_t as starts_with_generic, has_suffix_t, has_suffix_t as ends_with_generic, @@ -2739,6 +2744,26 @@ mod tests { assert!(!super::eql_case_insensitive_ascii(b"Ab", b"a", true)); } + #[test] + fn ends_with_case_insensitive_ascii_handles_empty_and_oversized_suffixes() { + assert!(super::ends_with_case_insensitive_ascii( + b"bunx.EXE", + b"bunx.exe" + )); + assert!(super::ends_with_case_insensitive_ascii( + b"C:\\bin\\BUNX.EXE", + b"bunx.exe" + )); + assert!(super::ends_with_case_insensitive_ascii(b"BUNX", b"bunx")); + assert!(!super::ends_with_case_insensitive_ascii( + b"bun.exe", b"bunx" + )); + assert!(!super::ends_with_case_insensitive_ascii(b"bun", b"bunx")); + assert!(super::ends_with_case_insensitive_ascii(b"bunx", b"")); + assert!(super::ends_with_case_insensitive_ascii(b"", b"")); + assert!(!super::ends_with_case_insensitive_ascii(b"", b"bunx")); + } + #[test] fn convert_utf8_to_utf16_in_buffer_fallback_rejects_malformed_sequences() { let mut buf = [0u16; 16]; diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 2f5b38f9b895..7d27167d5941 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -440,14 +440,9 @@ fn is_github_tarball_path(dependency: &[u8]) -> bool { // before I add that. #[inline] fn is_tarball(dependency: &[u8]) -> bool { - has_suffix_ignore_ascii_case(dependency, b".tgz") - || has_suffix_ignore_ascii_case(dependency, b".tar.gz") - || has_suffix_ignore_ascii_case(dependency, b".tar") -} - -#[inline] -fn has_suffix_ignore_ascii_case(s: &[u8], suffix: &[u8]) -> bool { - s.len() >= suffix.len() && s[s.len() - suffix.len()..].eq_ignore_ascii_case(suffix) + strings::ends_with_case_insensitive_ascii(dependency, b".tgz") + || strings::ends_with_case_insensitive_ascii(dependency, b".tar.gz") + || strings::ends_with_case_insensitive_ascii(dependency, b".tar") } /// the input is assumed to be either a remote or local tarball diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index f609dfdd4a0b..7373ce0a57b8 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -824,26 +824,21 @@ pub mod command { // `bun_clap::streaming::WARN_ON_UNRECOGNIZED_FLAG` so node-mode argv parsing // stays silent on unknown flags. // ────────────────── - fn is_bun_x(argv0: &[u8]) -> bool { - #[cfg(windows)] - { - return strings::ends_with(argv0, b"bunx.exe") || strings::ends_with(argv0, b"bunx"); - } - #[cfg(not(windows))] - { - strings::ends_with(argv0, b"bunx") + /// Case-insensitive argv[0] suffix match; Windows also drops a trailing `.exe` (#36826). + fn invoked_as(argv0: &[u8], name: &[u8]) -> bool { + let mut argv0 = argv0; + if cfg!(windows) && strings::ends_with_case_insensitive_ascii(argv0, b".exe") { + argv0 = &argv0[..argv0.len() - b".exe".len()]; } + strings::ends_with_case_insensitive_ascii(argv0, name) + } + + fn is_bun_x(argv0: &[u8]) -> bool { + invoked_as(argv0, b"bunx") } fn is_node(argv0: &[u8]) -> bool { - #[cfg(windows)] - { - return strings::ends_with(argv0, b"node.exe") || strings::ends_with(argv0, b"node"); - } - #[cfg(not(windows))] - { - strings::ends_with(argv0, b"node") - } + invoked_as(argv0, b"node") } /// Cheap argv prescan for the dominant `bun ` / `bun .` shape. diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index ec0a9fb5a508..fefe42b3d1dc 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -485,6 +485,40 @@ describe("bunx --no-install", () => { }); }); +// https://github.com/oven-sh/bun/issues/36826 +// Windows resolves executables case-insensitively (PATHEXT commonly lists +// `.EXE`), so argv[0] can be `bunx.EXE` for the same on-disk `bunx.exe`. +// The invocation-name match must ignore ASCII case; same for posix, where +// macOS filesystems are case-insensitive by default. +it.concurrent.each(isWindows ? ["bunx.EXE", "BUNX.EXE"] : ["BUNX", "bunX"])( + "detects bunx mode when invoked as %s", + async name => { + const { x_dir, env } = setup(); + if (isWindows) { + // On disk the file is lowercase; only the invocation casing differs. + copyFileSync(bunExe(), join(x_dir, "bunx.exe")); + } else { + symlinkSync(bunExe(), join(x_dir, name)); + } + + await using proc = spawn({ + cmd: [join(x_dir, name), "--help"], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [out, err, exited] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // `bunx --help` writes its usage to stderr and exits 1; misclassified as + // plain `bun`, it would write the full CLI help to stdout and exit 0. + expect(err).toContain("Usage: bunx"); + expect(out).not.toContain("Bun is a fast JavaScript runtime"); + expect(exited).toBe(1); + }, +); + it.concurrent("should handle postinstall scripts correctly with symlinked bunx", async () => { const { x_dir, env } = setup(); // Create a symlink to bun called "bunx" diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 34ee77efd693..3e977925c0df 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { copyFileSync, symlinkSync } from "node:fs"; import { join } from "path"; -import { bunEnv, bunExe, fakeNodeRun, tempDir } from "../../harness"; +import { bunEnv, bunExe, fakeNodeRun, isWindows, tempDir } from "../../harness"; describe("fake node cli", () => { test("the node cli actually works", () => { @@ -111,4 +112,36 @@ describe("fake node cli", () => { expect(result.stderr.toString()).toContain("Missing script"); expect(result.success).toBe(false); }); + + // https://github.com/oven-sh/bun/issues/36826 + // Windows resolves executables case-insensitively (PATHEXT commonly lists + // `.EXE`), so argv[0] can be `node.EXE`; the invocation-name match must + // ignore ASCII case. Misclassified as plain `bun`, the bare invocation + // below would run the empty piped stdin and exit 0 instead of printing + // "Missing script". + test.concurrent.each(isWindows ? ["node.EXE", "NODE.EXE"] : ["NODE", "nodE"])( + "detects node mode when invoked as %s", + async name => { + using temp = tempDir("fake-node-case", {}); + const dir = String(temp); + if (isWindows) { + // On disk the file is lowercase; only the invocation casing differs. + copyFileSync(bunExe(), join(dir, "node.exe")); + } else { + symlinkSync(bunExe(), join(dir, name)); + } + await using proc = Bun.spawn({ + cmd: [join(dir, name)], + cwd: dir, + env: { ...bunEnv, NODE_ENV: undefined }, + stdin: Buffer.alloc(0), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("Missing script"); + expect(stdout).toBe(""); + expect(exitCode).not.toBe(0); + }, + ); });