Skip to content

Validate require.resolve options.paths entries and handle relative paths - #35191

Closed
robobun wants to merge 2 commits into
mainfrom
farm/c32eab3b/resolve-paths-validation
Closed

robobun wants to merge 2 commits into
mainfrom
farm/c32eab3b/resolve-paths-validation

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Fuzzer found a panic: cannot resolve DirInfo for non-absolute path: [object DataView].

What

require.resolve('x', { paths: [...] }) and Module._resolveFilename(..., { paths: [...] }) were coercing every paths entry to a string via toWTFString, so an object became "[object Foo]". That string then flowed into the resolver's check_package_path as a search directory and hit the dir_info_cached assertion that the input is absolute. A plain relative string like "rel/dir" hit the same assertion.

Fix

  • ImportMetaObject.cpp / NodeModuleModule.cpp: throw ERR_INVALID_ARG_TYPE with paths[i] when an entry is not a string, matching Node.js.
  • resolver.rs: when iterating custom_dir_paths for package resolution, absolutize non-absolute entries against top_level_dir before calling check_package_path. This matches Node, which resolves relative paths entries against cwd.

Repro

require.resolve('x', { paths: [new DataView(new ArrayBuffer(8))] }); // panicked, now ERR_INVALID_ARG_TYPE
require.resolve('x', { paths: ['rel/dir'] });                        // panicked, now MODULE_NOT_FOUND

require.resolve('x', { paths: [nonString] }) and the equivalent
Module._resolveFilename call were coercing each paths entry via
toWTFString, so objects became '[object Foo]' and reached the resolver
as a non-absolute directory, tripping the dir_info_cached assertion.
Relative string paths hit the same assertion.

Both callers now throw ERR_INVALID_ARG_TYPE for non-string entries to
match Node.js, and the resolver absolutizes custom search directories
against cwd before calling check_package_path so relative string paths
behave like Node instead of panicking.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d65d47b2-9088-4449-aab5-755fdd1d5076

📥 Commits

Reviewing files that changed from the base of the PR and between 7d913b4 and 80a887d.

📒 Files selected for processing (4)
  • src/jsc/bindings/ImportMetaObject.cpp
  • src/jsc/modules/NodeModuleModule.cpp
  • src/resolver/resolver.rs
  • test/js/node/module/module-resolve-filename-paths.test.js

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:52 PM PT - Jul 22nd, 2026

@autofix-ci[bot], your commit 80a887d is building: #78209

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. require.resolve with paths no longer working correctly (bun 1.2.9 onwards) #19419 - require.resolve with paths option broke in 1.2.9; this PR's fix to absolutize relative paths in the paths option directly addresses this class of resolution failure.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #19419

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix crash in require.resolve when options.paths contains invalid entries #31566 - Both fix the same require.resolve panic on non-absolute/non-string options.paths entries by adding type validation in ImportMetaObject.cpp and NodeModuleModule.cpp, and absolutizing relative paths in resolver.rs

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #31566, which already covers this crash more thoroughly: it absolutizes with native-platform semantics (so "C:/foo" on POSIX does not slip through, which this PR misses), handles both custom_dir_paths loops, and demotes the assertion to debug_assert! as a safety net. Closing in favor of that one.

@robobun robobun closed this Jul 22, 2026
Comment on lines +212 to +218
err = undefined;
try {
require.resolve("nonexistent", { paths: ["/tmp", {}] });
} catch (e) {
err = e;
}
expect(err.code).toBe("ERR_INVALID_ARG_TYPE");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 The new tests only assert err.code, but both C++ sites go to the trouble of building makeString("paths["_s, i, "]"_s) so the error names the offending index — and the ["/tmp", {}] case is clearly designed to exercise index 1. Consider also asserting expect(err.message).toContain("paths[1]") here (and "paths[0]" for the DataView / _resolveFilename cases) so that an off-by-one in pathIndex++ or the loop counter would be caught.

Extended reasoning...

What the gap is

Both changed C++ sites deliberately construct an indexed argument name for the error:

  • ImportMetaObject.cpp: Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, makeString("paths["_s, i, "]"_s), "string"_s, path) inside the for (size_t i = 0; ...) loop.
  • NodeModuleModule.cpp: a manually-maintained size_t pathIndex = 0; incremented inside the forEachInIterable lambda, with makeString("paths["_s, pathIndex, "]"_s).

The new tests, however, only assert err.code === "ERR_INVALID_ARG_TYPE". They never look at err.message, so the index that the C++ code computes is untested.

Why it matters

The ["/tmp", {}] case at lines ~212-218 exists specifically to put the bad entry at a non-zero index. But if the NodeModuleModule.cpp implementation had pathIndex++ placed before the isString() check instead of after — a very easy mistake with a captured counter inside a lambda — the error would say paths[1] for the first element and paths[2] for the second, and every test in this file would still pass. Similarly, if the ImportMetaObject.cpp loop reported the wrong i, or if either site dropped the index entirely and just said "paths", nothing would fail.

REVIEW.md's test guidance is explicit: "assert the strongest invariant… specific error class/code/message (never bare toThrow())" and "Error messages are reviewed word-for-word as code". The tests already assert the specific error code (which is good — better than bare toThrow()), but since this PR's diff went out of its way to add index formatting, the tests should lock that in.

Step-by-step proof

  1. In NodeModuleModule.cpp, the lambda body is:
    if (!item.isString()) { …INVALID_ARG_TYPE(…, makeString("paths["_s, pathIndex, "]"_s), …); return; }
    pathIndex++;
  2. Suppose a future refactor moves pathIndex++; above the isString() check (or switches to a post-increment inside the makeString call and gets it wrong).
  3. For { paths: ["/tmp", {}] }: iteration 0 sees "/tmp" (string) → pathIndex becomes 1; iteration 1 sees {} → error reports paths[1]. Correct today. But with the increment moved before the check: iteration 0 → pathIndex becomes 1, string, continue; iteration 1 → pathIndex becomes 2, not string → error reports paths[2].
  4. The current test only checks err.code === "ERR_INVALID_ARG_TYPE", which is unchanged → test still passes despite the wrong index in the user-visible message.

Suggested fix

Add one assertion per case:

// DataView case
expect(err.code).toBe("ERR_INVALID_ARG_TYPE");
expect(err.message).toContain("paths[0]");

// ["/tmp", {}] case
expect(err.code).toBe("ERR_INVALID_ARG_TYPE");
expect(err.message).toContain("paths[1]");

// Module._resolveFilename [{}] case
expect(err.code).toBe("ERR_INVALID_ARG_TYPE");
expect(err.message).toContain("paths[0]");

This is a test-strengthening suggestion, not a production defect — the runtime behavior is correct as written.

Comment on lines +231 to +239
test("require.resolve with a relative directory in options.paths does not crash", () => {
let err;
try {
require.resolve("nonexistent", { paths: ["not/an/absolute/path"] });
} catch (e) {
err = e;
}
expect(err.code).toBe("MODULE_NOT_FOUND");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 This test only proves the panic is gone — a resolver that skipped every non-absolute entry (replacing the abs_buf_checked call with a bare continue) would also pass it. The PR claims relative paths entries resolve against cwd like Node, but the load-bearing Some(p) => p arm has no coverage. Consider adding a positive case that creates a package under a cwd-relative directory and asserts it actually resolves.

Extended reasoning...

What's missing

The resolver.rs change introduces two distinct behaviors for non-absolute custom_dir_paths entries:

let abs_custom = if bun_paths::is_absolute(custom_slice) {
    custom_slice
} else {
    match self.fs_ref().abs_buf_checked(&[custom_slice], &mut abs_buf) {
        Some(p) => p,      // (a) absolutize against top_level_dir and search there
        None => continue,  // (b) skip on failure
    }
};

The new test at line 231 only asserts require.resolve('nonexistent', { paths: ['not/an/absolute/path'] }) throws MODULE_NOT_FOUND. That covers (a) doesn't panic, but it does not prove (a) actually searches the absolutized path.

Step-by-step: the test can't distinguish the fix from a weaker one

  1. Suppose the else-branch were replaced with just continue; — i.e., silently skip every non-absolute entry without absolutizing.
  2. require.resolve('nonexistent', { paths: ['not/an/absolute/path'] }) runs; the resolver sees one non-absolute entry, hits continue, exhausts custom_paths, and returns MODULE_NOT_FOUND.
  3. The test asserts err.code === 'MODULE_NOT_FOUND' → passes.
  4. Now with the real fix: abs_buf_checked returns Some(<cwd>/not/an/absolute/path), check_package_path looks there, finds nothing (the directory doesn't exist), returns MODULE_NOT_FOUND.
  5. The test asserts err.code === 'MODULE_NOT_FOUND' → also passes.

Both implementations produce identical observable behavior for this input. Per REVIEW.md: "confirm deleting each load-bearing clause of your fix breaks at least one test — a test that passes both ways is worse than no test." The Some(p) => p arm is load-bearing for the PR description's claim ("This matches Node, which resolves relative paths entries against cwd"), and deleting it breaks no test.

Why existing coverage doesn't help

The other tests in this file (Module._resolveFilename respects options.paths for package resolution, require.resolve respects options.paths, etc.) all pass absolute temp-dir paths, so they take the is_absolute fast path and never exercise abs_buf_checked at all.

Suggested addition

Something along these lines (mirroring the existing createTempDir pattern in this file, plus a process.chdir so the relative path is deterministic):

test("require.resolve resolves a relative options.paths entry against cwd", () => {
  const { path: dir, cleanup } = createTempDir("require-resolve-relative-paths", {
    "sub/node_modules/rel-pkg/package.json": JSON.stringify({ name: "rel-pkg", main: "index.js" }),
    "sub/node_modules/rel-pkg/index.js": "module.exports = 1;",
  });
  const prev = process.cwd();
  try {
    process.chdir(dir);
    const resolved = require.resolve("rel-pkg", { paths: ["./sub"] });
    expect(resolved).toBe(resolve(dir, "sub/node_modules/rel-pkg/index.js"));
  } finally {
    process.chdir(prev);
    cleanup();
  }
});

This would fail (MODULE_NOT_FOUND) if the resolver merely skipped relative entries, and pass only when abs_buf_checked actually joins against cwd — so it pins the Node-compat behavior the PR description claims.

Severity

nit — the fuzzer-reported panic (the primary bug) is properly regression-tested: a panic would not yield err.code === 'MODULE_NOT_FOUND'. This is a coverage gap on the secondary Node-parity claim, worth adding but not blocking.

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.

1 participant