Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/bun_core/string/immutable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
Expand Down Expand Up @@ -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];
Expand Down
11 changes: 3 additions & 8 deletions src/install/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 11 additions & 16 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` / `bun .` shape.
Expand Down
34 changes: 34 additions & 0 deletions test/cli/install/bunx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 32 additions & 1 deletion test/cli/run/as-node.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -111,4 +112,34 @@
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),
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

Check warning on line 140 in test/cli/run/as-node.test.ts

View check run for this annotation

Claude / Claude Code Review

as-node case test leaves stdout pipe undrained

`Bun.spawn`'s stdout defaults to `"pipe"` when unspecified, so this leaves an undrained pipe while awaiting `proc.exited` — the sibling `bunx.test.ts` test this was asked to mirror drains all three (`[proc.stdout.text(), proc.stderr.text(), proc.exited]`). No deadlock in practice here (a regressed build writes ~2KB of help to stdout, well under the ~64KB OS pipe buffer), so this is a harness-convention nit: either add `proc.stdout.text()` to the `Promise.all` or set `stdout: "ignore"`.
Comment thread
robobun marked this conversation as resolved.
Outdated
expect(stderr).toContain("Missing script");
expect(exitCode).not.toBe(0);
},
Comment thread
robobun marked this conversation as resolved.
);
});
Loading