Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
8fe8691b2b fix(resolver): skip case-insensitive false matches during extension probing
When a directory contains files differing only in case and extension
(e.g. `todos.ts` and `Todos.tsx`), the resolver's case-insensitive
directory lookup would incorrectly match `Todos.tsx` when probing for
`todos.tsx`, preventing the correct `todos.ts` from being found.

Fix by checking that the import stem case-sensitively matches the found
file's stem during extension probing. Also fix path caching in
`loadExtension` to use the entry's actual base name instead of the
query buffer, which could produce wrong-cased paths on case-sensitive
filesystems.

Closes #22686

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 11:09:52 +00:00
4 changed files with 133 additions and 289 deletions

View File

@@ -67,6 +67,7 @@ const bufs = struct {
pub threadlocal var check_browser_map: bun.PathBuffer = undefined;
pub threadlocal var remap_path: bun.PathBuffer = undefined;
pub threadlocal var load_as_file: bun.PathBuffer = undefined;
pub threadlocal var load_as_file2: bun.PathBuffer = undefined;
pub threadlocal var remap_path_trailing_slash: bun.PathBuffer = undefined;
pub threadlocal var path_in_global_disk_cache: bun.PathBuffer = undefined;
pub threadlocal var abs_to_rel: bun.PathBuffer = undefined;
@@ -3896,6 +3897,18 @@ pub const Resolver = struct {
@memcpy(buffer[segment.len..buffer.len][0..ext_to_replace.len], ext_to_replace);
if (entries.get(buffer)) |query| {
// If the case-insensitive lookup matched a differently-cased file,
// verify the stem matches to avoid confusing different files.
if (query.diff_case != null) {
const actual_base = query.entry.base();
const actual_stem_end = std.mem.lastIndexOfScalar(u8, actual_base, '.') orelse actual_base.len;
if (!bun.strings.eqlLong(segment, actual_base[0..actual_stem_end], false)) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Skipping \"{s}\" (stem case mismatch during rewrite)", .{actual_base});
}
continue;
}
}
if (query.entry.kind(rfs, r.store_fd) == .file) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Rewrote to \"{s}\" ", .{buffer});
@@ -3904,12 +3917,12 @@ pub const Resolver = struct {
return LoadResult{
.path = brk: {
if (query.entry.abs_path.isEmpty()) {
const actual_name = query.entry.base();
if (query.entry.dir.len > 0 and query.entry.dir[query.entry.dir.len - 1] == std.fs.path.sep) {
const parts = [_]string{ query.entry.dir, buffer };
const parts = [_]string{ query.entry.dir, actual_name };
query.entry.abs_path = PathString.init(r.fs.filename_store.append(@TypeOf(parts), parts) catch unreachable);
// the trailing path CAN be missing here
} else {
const parts = [_]string{ query.entry.dir, std.fs.path.sep_str, buffer };
const parts = [_]string{ query.entry.dir, std.fs.path.sep_str, actual_name };
query.entry.abs_path = PathString.init(r.fs.filename_store.append(@TypeOf(parts), parts) catch unreachable);
}
}
@@ -3954,18 +3967,38 @@ pub const Resolver = struct {
}
if (entries.get(file_name)) |query| {
if (query.diff_case != null) {
// The case-insensitive lookup matched a file with different casing.
// Check if the import stem (e.g. "todos") matches the found file's
// stem (e.g. "Todos"). If the stems differ, this is a different file
// that only matched because the directory lookup is case-insensitive.
// Skip it so the resolver can continue trying other extensions.
// (e.g. "todos" + ".tsx" should NOT match "Todos.tsx" when "todos.ts" exists)
const actual_base = query.entry.base();
const stem_end = std.mem.lastIndexOfScalar(u8, actual_base, '.') orelse actual_base.len;
const actual_stem = actual_base[0..stem_end];
if (!bun.strings.eqlLong(base, actual_stem, false)) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Skipping \"{s}\" (stem case mismatch: \"{s}\" vs \"{s}\")", .{ actual_base, base, actual_stem });
}
return null;
}
}
if (query.entry.kind(rfs, r.store_fd) == .file) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Found file \"{s}\" ", .{buffer});
}
// now that we've found it, we allocate it.
// Use the entry's actual base name to build the absolute path,
// not the query buffer, to ensure correct casing on case-sensitive
// filesystems.
return .{
.path = brk: {
query.entry.abs_path = if (query.entry.abs_path.isEmpty())
PathString.init(r.fs.dirname_store.append(@TypeOf(buffer), buffer) catch unreachable)
else
query.entry.abs_path;
if (query.entry.abs_path.isEmpty()) {
const abs_path_parts = [_]string{ query.entry.dir, query.entry.base() };
query.entry.abs_path = PathString.init(r.fs.dirname_store.append(string, r.fs.absBuf(&abs_path_parts, bufs(.load_as_file2))) catch unreachable);
}
break :brk query.entry.abs_path.slice();
},

View File

@@ -12,79 +12,27 @@ state: union(enum) {
pub fn start(this: *Echo) Yield {
var args = this.bltn().argsSlice();
const no_newline = args.len >= 1 and std.mem.eql(u8, bun.sliceTo(args[0], 0), "-n");
// Parse flags: echo accepts -n, -e, -E in any combination.
// Flag parsing stops at the first arg that doesn't start with '-'
// or contains an invalid flag character.
var no_newline = false;
var escape_sequences = false;
var flags_done = false;
var args_start: usize = 0;
for (args) |arg| {
if (flags_done) break;
const flag = std.mem.span(arg);
if (flag.len < 2 or flag[0] != '-') {
flags_done = true;
break;
}
// Validate all characters are valid echo flags
var valid = true;
for (flag[1..]) |c| {
switch (c) {
'n', 'e', 'E' => {},
else => {
valid = false;
break;
},
}
}
if (!valid) {
flags_done = true;
break;
}
// Apply flags (last -e/-E wins)
for (flag[1..]) |c| {
switch (c) {
'n' => no_newline = true,
'e' => escape_sequences = true,
'E' => escape_sequences = false,
else => unreachable,
}
}
args_start += 1;
}
args = args[args_start..];
args = args[if (no_newline) 1 else 0..];
const args_len = args.len;
var has_leading_newline: bool = false;
var stop_output = false;
// TODO: Should flush buffer after it gets to a certain size
for (args, 0..) |arg, i| {
if (stop_output) break;
const thearg = std.mem.span(arg);
const is_last = i == args_len - 1;
if (escape_sequences) {
stop_output = appendWithEscapes(&this.output, thearg);
} else {
if (is_last) {
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
has_leading_newline = true;
}
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
} else {
bun.handleOom(this.output.appendSlice(thearg));
}
}
if (!stop_output and !is_last) {
if (i < args_len - 1) {
bun.handleOom(this.output.appendSlice(thearg));
bun.handleOom(this.output.append(' '));
} else {
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
has_leading_newline = true;
}
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
}
}
if (!stop_output and !has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
if (!has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
if (this.bltn().stdout.needsIO()) |safeguard| {
this.state = .waiting;
@@ -95,109 +43,6 @@ pub fn start(this: *Echo) Yield {
return this.bltn().done(0);
}
/// Appends `input` to `output`, interpreting backslash escape sequences.
/// Returns true if a \c escape was encountered (meaning stop all output).
fn appendWithEscapes(output: *std.array_list.Managed(u8), input: []const u8) bool {
var i: usize = 0;
while (i < input.len) {
if (input[i] == '\\' and i + 1 < input.len) {
switch (input[i + 1]) {
'\\' => {
bun.handleOom(output.append('\\'));
i += 2;
},
'a' => {
bun.handleOom(output.append('\x07'));
i += 2;
},
'b' => {
bun.handleOom(output.append('\x08'));
i += 2;
},
'c' => {
// \c: produce no further output
return true;
},
'e', 'E' => {
bun.handleOom(output.append('\x1b'));
i += 2;
},
'f' => {
bun.handleOom(output.append('\x0c'));
i += 2;
},
'n' => {
bun.handleOom(output.append('\n'));
i += 2;
},
'r' => {
bun.handleOom(output.append('\r'));
i += 2;
},
't' => {
bun.handleOom(output.append('\t'));
i += 2;
},
'v' => {
bun.handleOom(output.append('\x0b'));
i += 2;
},
'0' => {
// \0nnn: octal value (up to 3 octal digits)
i += 2; // skip \0
var val: u8 = 0;
var digits: usize = 0;
while (digits < 3 and i < input.len and input[i] >= '0' and input[i] <= '7') {
val = val *% 8 +% (input[i] - '0');
i += 1;
digits += 1;
}
bun.handleOom(output.append(val));
},
'x' => {
// \xHH: hex value (up to 2 hex digits)
i += 2; // skip \x
var val: u8 = 0;
var digits: usize = 0;
while (digits < 2 and i < input.len) {
const hex_val = hexDigitValue(input[i]);
if (hex_val) |hv| {
val = val *% 16 +% hv;
i += 1;
digits += 1;
} else {
break;
}
}
if (digits > 0) {
bun.handleOom(output.append(val));
} else {
// No valid hex digits: output \x literally
bun.handleOom(output.appendSlice("\\x"));
}
},
else => {
// Unknown escape: output backslash and the character as-is
bun.handleOom(output.append('\\'));
bun.handleOom(output.append(input[i + 1]));
i += 2;
},
}
} else {
bun.handleOom(output.append(input[i]));
i += 1;
}
}
return false;
}
fn hexDigitValue(c: u8) ?u8 {
if (c >= '0' and c <= '9') return c - '0';
if (c >= 'a' and c <= 'f') return c - 'a' + 10;
if (c >= 'A' and c <= 'F') return c - 'A' + 10;
return null;
}
pub fn onIOWriterChunk(this: *Echo, _: usize, e: ?jsc.SystemError) Yield {
if (comptime bun.Environment.allow_assert) {
assert(this.state == .waiting or this.state == .waiting_write_err);

View File

@@ -1,117 +0,0 @@
import { $ } from "bun";
import { describe, expect, test } from "bun:test";
describe("echo -e flag support", () => {
test("echo -e does not output -e as literal text", async () => {
const result = await $`echo -e hello`.text();
expect(result).toBe("hello\n");
});
test("echo -e interprets backslash-n", async () => {
const result = await $`echo -e ${"hello\\nworld"}`.text();
expect(result).toBe("hello\nworld\n");
});
test("echo -e interprets backslash-t", async () => {
const result = await $`echo -e ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld\n");
});
test("echo -e interprets backslash-backslash", async () => {
const result = await $`echo -e ${"hello\\\\world"}`.text();
expect(result).toBe("hello\\world\n");
});
test("echo -e interprets \\a (bell)", async () => {
const result = await $`echo -e ${"\\a"}`.text();
expect(result).toBe("\x07\n");
});
test("echo -e interprets \\b (backspace)", async () => {
const result = await $`echo -e ${"a\\bb"}`.text();
expect(result).toBe("a\bb\n");
});
test("echo -e interprets \\r (carriage return)", async () => {
const result = await $`echo -e ${"hello\\rworld"}`.text();
expect(result).toBe("hello\rworld\n");
});
test("echo -e interprets \\f (form feed)", async () => {
const result = await $`echo -e ${"\\f"}`.text();
expect(result).toBe("\f\n");
});
test("echo -e interprets \\v (vertical tab)", async () => {
const result = await $`echo -e ${"\\v"}`.text();
expect(result).toBe("\v\n");
});
test("echo -e interprets \\0nnn (octal)", async () => {
// \0101 = 'A' (65 decimal)
const result = await $`echo -e ${"\\0101"}`.text();
expect(result).toBe("A\n");
});
test("echo -e interprets \\xHH (hex)", async () => {
// \x41 = 'A'
const result = await $`echo -e ${"\\x41\\x42\\x43"}`.text();
expect(result).toBe("ABC\n");
});
test("echo -e \\c stops output", async () => {
const result = await $`echo -e ${"hello\\cworld"}`.text();
expect(result).toBe("hello");
});
test("echo -e with \\e (escape character)", async () => {
const result = await $`echo -e ${"\\e"}`.text();
expect(result).toBe("\x1b\n");
});
test("echo -E disables escape interpretation", async () => {
const result = await $`echo -E ${"hello\\nworld"}`.text();
expect(result).toBe("hello\\nworld\n");
});
test("echo -eE (last wins: -E disables)", async () => {
const result = await $`echo -eE ${"hello\\tworld"}`.text();
expect(result).toBe("hello\\tworld\n");
});
test("echo -Ee (last wins: -e enables)", async () => {
const result = await $`echo -Ee ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld\n");
});
test("echo -ne (no newline + escapes)", async () => {
const result = await $`echo -ne ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld");
});
test("echo -en (same as -ne)", async () => {
const result = await $`echo -en ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld");
});
test("echo -n still works (no newline)", async () => {
const result = await $`echo -n hello`.text();
expect(result).toBe("hello");
});
test("echo with invalid flag outputs literally", async () => {
const result = await $`echo -x hello`.text();
expect(result).toBe("-x hello\n");
});
test("echo -e piped to cat (original issue scenario)", async () => {
const pw = "mypassword";
const result = await $`echo -e ${pw} | cat`.text();
expect(result).toBe("mypassword\n");
});
test("echo without -e still works normally", async () => {
const result = await $`echo hello world`.text();
expect(result).toBe("hello world\n");
});
});

View File

@@ -0,0 +1,83 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/22686
// When a directory has files that differ only in case and extension
// (e.g. todos.ts and Todos.tsx), extensionless imports should resolve
// to the correct file based on case-sensitive stem matching.
test("extensionless import resolves correct file when similar names differ by case", async () => {
using dir = tempDir("issue-22686", {
"src/todos.ts": `export const todos = ["todo1", "todo2"];`,
"src/Todos.tsx": `export function Todos() { return "Todos Component"; }`,
"src/index.tsx": `
import { todos } from "./todos";
import { Todos } from "./Todos";
console.log(JSON.stringify(todos));
console.log(Todos());
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "src/index.tsx"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe('["todo1","todo2"]\nTodos Component\n');
expect(exitCode).toBe(0);
});
test("bundler resolves correct file when similar names differ by case", async () => {
using dir = tempDir("issue-22686-bundler", {
"src/todos.ts": `export const todos = ["todo1", "todo2"];`,
"src/Todos.tsx": `export function Todos() { return "Todos Component"; }`,
"src/index.tsx": `
import { todos } from "./todos";
import { Todos } from "./Todos";
console.log(JSON.stringify(todos));
console.log(Todos());
`,
});
// First, bundle
await using buildProc = Bun.spawn({
cmd: [bunExe(), "build", "src/index.tsx", "--outdir=dist"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [buildStdout, buildStderr, buildExitCode] = await Promise.all([
buildProc.stdout.text(),
buildProc.stderr.text(),
buildProc.exited,
]);
expect(buildStderr).toBe("");
expect(buildExitCode).toBe(0);
// Then run the bundled output
await using runProc = Bun.spawn({
cmd: [bunExe(), "run", "dist/index.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [runStdout, runStderr, runExitCode] = await Promise.all([
runProc.stdout.text(),
runProc.stderr.text(),
runProc.exited,
]);
expect(runStderr).toBe("");
expect(runStdout).toBe('["todo1","todo2"]\nTodos Component\n');
expect(runExitCode).toBe(0);
});