Skip to content

Percent-decode ESM import specifiers before filesystem lookup#28746

Open
robobun wants to merge 8 commits intomainfrom
farm/bb40d25a/fix-percent-encoded-imports
Open

Percent-decode ESM import specifiers before filesystem lookup#28746
robobun wants to merge 8 commits intomainfrom
farm/bb40d25a/fix-percent-encoded-imports

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 1, 2026

Closes #28745

Problem

When an ESM import specifier contains percent-encoded characters (e.g. %2c for comma, %20 for space, %23 for hash), Bun looks for a file with the literal %XX in the name instead of decoding it first:

// test.mjs
import './foo%2cbar.mjs'; // should resolve to foo,bar.mjs
$ bun run test.mjs
error: Cannot find module './foo%2cbar.mjs'

Node.js resolves this correctly because per the ESM spec, relative and absolute import specifiers are URL-like and percent-encoded characters should be decoded before filesystem lookup.

Root Cause

In src/resolver/resolver.zig, resolveWithoutSymlinks passes the raw import specifier directly to checkRelativePath / loadAsFileOrDirectory without percent-decoding it first.

Fix

Added percent-decoding of import paths for ESM imports (not CJS require()) when the specifier is a relative (./, ../) or absolute path and contains %. Per the Node.js ESM spec, encoded path separators (%2f/%5c) are rejected.

Verification

bun bd test test/regression/issue/28745.test.ts  → 6 pass, 0 fail
USE_SYSTEM_BUN=1 bun test ...                    → 4 fail (bug present)

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, push a new commit or reopen this pull request to trigger a review.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 1, 2026

Updated 5:17 PM PT - Apr 1st, 2026

@robobun, your commit 8aa5493 has 7 failures in Build #43160 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28746

That installs a local version of the PR into your bun-28746 executable, so you can run:

bun-28746 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Added ESM-style percent-decoding for relative/absolute import specifiers in the resolver: percent-decodes when % is present, rejects encoded path separators, NULs, and decode errors (returns .not_found). Added regression tests covering ESM static/dynamic imports and CommonJS require with percent-encoded filenames.

Changes

Cohort / File(s) Summary
Resolver: ESM percent-decoding
src/resolver/resolver.zig
Imported PercentEncoding, added a thread-local percent_decode_buf, and an early ESM-only percent-decode step in resolveWithoutSymlinks. Rejects specifiers containing encoded path separators (%2f/%2F, %5c/%5C), decode errors, or decoded NULs; on success replaces import_path for filesystem lookup. No other control-flow changes.
Regression tests: percent-encoded specifiers
test/regression/issue/28745.test.ts
Added tests that create isolated temp dirs and spawn Bun to verify ESM static imports, ESM dynamic import(), absolute ESM imports, and CommonJS require() behavior with percent-encoded characters (%2c, %20, %23); asserts stdout/stderr and exit codes, and checks that encoded slashes fail resolution.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding percent-decoding for ESM import specifiers before filesystem lookup.
Description check ✅ Passed The description includes problem statement, root cause, fix explanation, and verification results, covering both required template sections.
Linked Issues check ✅ Passed The PR implementation directly addresses issue #28745 by percent-decoding ESM import specifiers for relative/absolute paths containing % characters before filesystem lookup, matching Node.js ESM spec behavior.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the percent-encoded ESM import specifier resolution issue; no out-of-scope modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/resolver/resolver.zig`:
- Around line 1104-1113: After percent-decoding import_path using
PercentEncoding.decode (where buf and fbs are used), check the decoded slice
(buf[0..decoded_len]) for any NUL (byte 0) and reject the import if any are
present; specifically, after computing decoded_len from PercentEncoding.decode
in the resolver code that assigns import_path = buf[0..decoded_len], scan that
slice for a zero byte and return .{ .not_found = {} } (or the existing error
path) if found so that encoded %00 can't bypass the earlier literal NUL check.
- Around line 1089-1094: The percent-decoding gate is too broad: currently it
triggers for any kind where !kind.isCommonJS() and !kind.isFromCSS(), which
unintentionally includes .entry_point_run and .entry_point_build; update the
condition so only true URL-like ESM import kinds are allowed to be
percent-decoded. Replace the current condition using kind.isCommonJS() and
kind.isFromCSS() with an explicit allowlist check (compare kind against the
specific enum variants that represent normal ESM import requests, e.g., the
import request variants used for module resolution) and ensure .entry_point_run
and .entry_point_build are excluded; keep the check that import_path contains
'%' before decoding. Use the same symbols from the diff (kind, import_path, and
the entry point enum names) to locate and change the logic in resolver.zig.

In `@test/regression/issue/28745.test.ts`:
- Line 6: The test only covers a relative ESM specifier ("test.mjs": `import
'./foo%2cbar.mjs';`) so add an absolute-specifier variant to exercise the
altered absolute-path decode/lookup path: add a new fixture entry (e.g.,
"test-abs.mjs") that imports the same module with an absolute specifier such as
`import '/foo%2cbar.mjs';` and ensure the corresponding module file
(foo%2cbar.mjs) exists at the absolute location; apply the same addition for the
other occurrences that use `./foo%2cbar.mjs` (the repeated entries noted in the
comment) so the suite exercises both relative and absolute ESM resolution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b7186037-361e-408b-b745-f6bf4af6bfc0

📥 Commits

Reviewing files that changed from the base of the PR and between 3ed4186 and ac4b681.

📒 Files selected for processing (2)
  • src/resolver/resolver.zig
  • test/regression/issue/28745.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/28745.test.ts`:
- Around line 129-132: The generated import uses an unescaped interpolated path
which breaks on Windows; change the write to quote/escape the absolute specifier
using JSON.stringify(absPath) when building the import line (e.g.,
writeFileSync(`${String(dir)}/test.mjs`, `import
${JSON.stringify(absPath)};\\n`)) and move the fs require (fs) out of the local
block to a module-scope import so fs.writeFileSync is not required inline;
update references to absPath/dir accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9f3a7935-74da-44a5-8b4c-59e0e8c1c51a

📥 Commits

Reviewing files that changed from the base of the PR and between ac4b681 and 053e292.

📒 Files selected for processing (2)
  • src/resolver/resolver.zig
  • test/regression/issue/28745.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
test/regression/issue/28745.test.ts (1)

132-134: ⚠️ Potential issue | 🟡 Minor

Avoid inline require() here and emit the import specifier with JSON.stringify().

Line 133 uses require("fs") inside a test that is not testing require() behavior, and Line 134 still builds a JS string literal via raw interpolation. Use a module-scope import and write the import statement with JSON.stringify(absPath).

Suggested change
+import { writeFileSync } from "node:fs";
 import { expect, test } from "bun:test";
 import { bunEnv, bunExe, tempDir } from "harness";
 ...
-  const { writeFileSync } = require("fs");
-  writeFileSync(`${String(dir)}/test.mjs`, `import '${absPath}';\n`);
+  writeFileSync(`${String(dir)}/test.mjs`, `import ${JSON.stringify(absPath)};\n`);

As per coding guidelines, in test/**/*.test.{ts,js,jsx,tsx,mjs,cjs} only use dynamic import or require when the test is specifically testing dynamic import or require behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/28745.test.ts` around lines 132 - 134, Replace the
inline require("fs") and raw string interpolation when emitting the import in
test.mjs: move to a module-scope import (import { writeFileSync } from "fs")
instead of calling require within the test, and when writing the file use
JSON.stringify(absPath) to produce a safe JS string literal for the import
specifier (i.e. call writeFileSync(`${String(dir)}/test.mjs`, `import
${JSON.stringify(absPath)};\\n`), referencing the existing absPath and
writeFileSync identifiers).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/28745.test.ts`:
- Around line 80-81: The two assertions in the spawned-process test are ordered
incorrectly; move the stderr assertion before the exitCode assertion so process
output is asserted first. Specifically, in test/regression/issue/28745.test.ts
change the expect calls so expect(stderr).toContain("Cannot find module");
appears before expect(exitCode).not.toBe(0); ensuring stderr is checked prior to
exitCode per spawned-process test convention.

---

Duplicate comments:
In `@test/regression/issue/28745.test.ts`:
- Around line 132-134: Replace the inline require("fs") and raw string
interpolation when emitting the import in test.mjs: move to a module-scope
import (import { writeFileSync } from "fs") instead of calling require within
the test, and when writing the file use JSON.stringify(absPath) to produce a
safe JS string literal for the import specifier (i.e. call
writeFileSync(`${String(dir)}/test.mjs`, `import
${JSON.stringify(absPath)};\\n`), referencing the existing absPath and
writeFileSync identifiers).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 72dfcbc2-c879-4ea1-8389-3cb31e5e0eeb

📥 Commits

Reviewing files that changed from the base of the PR and between 053e292 and be17661.

📒 Files selected for processing (1)
  • test/regression/issue/28745.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
test/regression/issue/28745.test.ts (1)

129-134: ⚠️ Potential issue | 🟡 Minor

Replace inline require() and safely quote generated absolute specifier.

Line 133 uses require("fs") in a test that is not validating require() behavior, and Line 134 embeds absPath in single quotes directly. Prefer a module-scope import and JSON.stringify(absPath) when generating import ...; source.

Suggested patch
-import { expect, test } from "bun:test";
+import { expect, test } from "bun:test";
+import { writeFileSync } from "node:fs";
 import { bunEnv, bunExe, tempDir } from "harness";
@@
-  const absPath = `${String(dir)}/foo%2cbar.mjs`.replaceAll("\\", "/");
-  const { writeFileSync } = require("fs");
-  writeFileSync(`${String(dir)}/test.mjs`, `import '${absPath}';\n`);
+  const absPath = `${String(dir)}/foo%2cbar.mjs`.replaceAll("\\", "/");
+  writeFileSync(`${String(dir)}/test.mjs`, `import ${JSON.stringify(absPath)};\n`);
#!/bin/bash
# Verify the two patterns called out in this comment.
rg -n 'require\("fs"\)|import '\''\$\{absPath\}'\'';' test/regression/issue/28745.test.ts

As per coding guidelines, "Only use dynamic import or require when the test is specifically testing dynamic import or require behavior. Otherwise, always use module-scope import statements."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/28745.test.ts` around lines 129 - 134, Replace the
inline require("fs") with a module-scope import of writeFileSync from "fs" and
stop embedding absPath directly in single quotes; update the test so the top of
the file imports { writeFileSync } from "fs" and the generated test.mjs content
uses JSON.stringify(absPath) when creating the import statement (i.e., call
writeFileSync(`${String(dir)}/test.mjs`, `import
${JSON.stringify(absPath)};\\n`), referencing the existing absPath and
writeFileSync identifiers).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/28745.test.ts`:
- Around line 64-68: Update the test "ESM import with encoded slash (%2f) is
rejected per spec" to exercise all encoded-separator variants the resolver
rejects: add import paths using '%2F', '%5c', and '%5C' (in addition to '%2f')
in the generated test.mjs (or add separate test.mjs entries) so the resolver
branches for uppercase percent-encoding and backslash encodings are covered;
ensure each import is asserted to be rejected just like the existing '%2f' case
so Windows/backslash handling and case variants are tested.

---

Duplicate comments:
In `@test/regression/issue/28745.test.ts`:
- Around line 129-134: Replace the inline require("fs") with a module-scope
import of writeFileSync from "fs" and stop embedding absPath directly in single
quotes; update the test so the top of the file imports { writeFileSync } from
"fs" and the generated test.mjs content uses JSON.stringify(absPath) when
creating the import statement (i.e., call
writeFileSync(`${String(dir)}/test.mjs`, `import
${JSON.stringify(absPath)};\\n`), referencing the existing absPath and
writeFileSync identifiers).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bddc87e8-a5a3-435a-be7c-71da40c4d9dd

📥 Commits

Reviewing files that changed from the base of the PR and between be17661 and 8bb2db5.

📒 Files selected for processing (1)
  • test/regression/issue/28745.test.ts

@alii
Copy link
Copy Markdown
Member

alii commented Apr 1, 2026

@robobun What does node.js do?

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 1, 2026

Node.js (v24.3.0) behavior for each case:

Case Node.js This PR
import "./foo%2cbar.mjs" (comma) ✅ Decodes to foo,bar.mjs ✅ Same
import "./foo%20bar.mjs" (space) ✅ Decodes to foo bar.mjs ✅ Same
import "./foo%23bar.mjs" (hash) ✅ Decodes to foo#bar.mjs ✅ Same
import "./sub%2fmod.mjs" (slash) ERR_INVALID_MODULE_SPECIFIER ❌ Resolution fails (not found)
require("./foo%2cbar.cjs") (CJS) ✅ Looks for literal foo%2cbar.cjs ✅ Same
import("./foo%2cbar.mjs") (dynamic) ✅ Decodes to foo,bar.mjs ✅ Same

This PR matches Node.js on all cases. Per the ESM spec, relative/absolute specifiers are URL-like and percent-encoded characters are decoded before filesystem lookup. CJS require() does not decode. Encoded path separators (%2f/%5c) are rejected.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all previously identified issues have been addressed.

Extended reasoning...

Overview

The PR modifies src/resolver/resolver.zig to percent-decode ESM import specifiers (static import and dynamic import()) before filesystem lookup, matching the Node.js ESM spec. It also adds a regression test at test/regression/issue/28745.test.ts covering comma, space, hash, encoded-slash rejection, CJS no-decode, dynamic import, and absolute-path variants.

Security risks

The only meaningful attack surface is path traversal via encoded separators (%2f/%5c), which the PR explicitly rejects. Encoded NULs (%00) are also rejected post-decode. Bare % that is not valid percent-encoding returns .not_found, matching Node.js URIError behavior. The thread-local buffer (percent_decode_buf) is correctly scoped per-thread and cannot be shared across concurrent resolutions.

Level of scrutiny

This touches the hot resolver path, but the change is small, focused, and well-bounded. The gate (kind == .stmt or kind == .dynamic) ensures entry points, CJS, and CSS imports are untouched. The critical dangling-reference bug (decoded path in percent_decode_buf returned as an external Path without copying) was found and fixed in this PR via dirname_store.append, matching the existing pattern in checkRelativePath.

Other factors

All bugs raised in prior review rounds (CodeRabbit and my own inline comments) have been confirmed resolved by the author. The test suite exercises all meaningful branches. No open comments remain.

robobun and others added 8 commits April 1, 2026 23:13
Relative and absolute ESM import specifiers are URL-like per the spec,
so percent-encoded characters (e.g. %2c for comma, %20 for space) should
be decoded before filesystem lookup. CJS require() is unaffected.

Encoded path separators (%2f, %5c) are rejected per the Node.js ESM spec.
…ath test

- Only percent-decode for .stmt and .dynamic kinds (not entry points)
- Reject decoded NUL bytes (%00) that would bypass the earlier literal check
- Add absolute-path ESM import test case
Copy through dirname_store.append to avoid dangling reference to the
threadlocal percent_decode_buf, matching the pattern used by the
relative-path external branch in checkRelativePath.
@robobun robobun force-pushed the farm/bb40d25a/fix-percent-encoded-imports branch from 5b3219d to 8aa5493 Compare April 1, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error importing ES module with an encoded comma in the URL

2 participants