Skip to content

paths: heap-backed relative_alloc and join_abs_string_buf_spill; use them in _nodeModulePaths and the runtime linker - #38392

Open
robobun wants to merge 4 commits into
mainfrom
farm/2191c16d/paths-join-abs-relative-spill
Open

paths: heap-backed relative_alloc and join_abs_string_buf_spill; use them in _nodeModulePaths and the runtime linker#38392
robobun wants to merge 4 commits into
mainfrom
farm/2191c16d/paths-join-abs-relative-spill

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • require("module")._nodeModulePaths(p) with a p longer than a path buffer aborts the process: panic: range end index 5000 out of range for slice of length 4095 (Node returns the lookup list for any string; this is string manipulation). Site: node_module_paths_js_value in src/jsc/resolver_jsc.rs joins the argument onto the cwd with join_abs_string_buf into a pooled PathBuffer.
  • A runtime Bun.plugin onResolve callback that returns a file-namespace path longer than a path buffer for an import or require inside a module aborts the process: panic: range end index 150000 out of range for slice of length 4095. Site: Linker::generate_import_path (src/bundler/linker.rs) computes the import's display name with resolve_path::relative(source_dir, plugin_path). The 1.5 x MAX_PATH_BYTES specifier cap in VirtualMachine::resolve does not apply here because the path never goes through it before the linker; a 70 KB or 150 KB path still aborts. Both CJS and ESM parents hit it.
  • Both joins bottom out in normalize_string_generic_tz (src/paths/resolve_path.rs), which writes into whatever buffer it is given with plain slice indexing. relative() normalizes both inputs into thread-local PathBuffers and writes the result into a third; relative_alloc was just a Box around that result, so it had the same bound. The result buffer is also too small for valid inputs: relative of two paths that each fit a PathBuffer aborts once the ../ chain plus the tail does not (pinned by a unit test in this PR).

Fix

  • relative_platform_buf is split into a wrapper that supplies the thread-local buffers and relative_platform_in, which takes all three buffers as slices. Its body is unchanged.
  • relative_alloc computes how much scratch each input needs (normalized, plus the cwd for a relative input, using the bound JoinScratch::init already uses) and how long the result can get (every component of from is at least two bytes and becomes at most a three-byte /.., then a separator and the tail of to); when all three fit in MAX_PATH_BYTES it calls the thread-local relative exactly as before, otherwise it runs the same code in heap buffers of those sizes. Its two existing callers (chunk directory templates, source map sources) get this for free.
  • join_abs_string_buf_spill is added next to join_abs_string_buf_checked: same shape as join_z_buf_spill, joining into the caller's buffer when the bound (join_abs_needed, now shared with JoinScratch::init and join_abs_string_buf_checked) says the result fits and into a caller-owned Vec otherwise. Unlike the _checked variant it returns the result however long, which is what _nodeModulePaths needs.
  • Overlap note: sourcemap: don't abort when a remapped source path exceeds the join buffer #37457 and cli: stop aborting on --cwd and --tsconfig-override values longer than the path join buffer #38368 add the same join_abs_needed (same name, signature and formula, wired into the same two existing sites) plus a thread-local join_abs_string_spill, the join_spill counterpart of the caller-buffer variant added here; the two variants are complementary, like join_spill and join_z_buf_spill. Whichever of the three lands last drops its copy of join_abs_needed on rebase; nothing else overlaps.
  • _nodeModulePaths joins through the spill variant; the rest of the function is slicing and formatting and needed no change. All three arms of generate_import_path use relative_alloc (the AbsolutePath arm is the runtime one and the only one reproduced; the other two take the same source_path and already copied the result, so the change costs nothing there). The load that follows then fails with the resolver's normal ENAMETOOLONG ResolveMessage.
  • Verified:
    • cargo test -p bun_paths: 25 pass, 7 new (spill fast path, spill, the exact bound, a Windows share root that grows by a byte, relative_alloc matching relative, a target longer than a PathBuffer, and a result longer than a PathBuffer from two inputs that fit; the last two abort on the thread-local relative, checked with a temporary #[should_panic] copy). The last test uses a two-byte target because the Windows arm of relative drops a one-byte root-level target (C:\d\d to C:\t gives ..\..), a pre-existing bug found while writing it and reported separately.
    • test/js/node/module/node-module-module.test.js, "_nodeModulePaths() accepts paths longer than PATH_MAX": a child bun runs a 100000-byte component (longer than the buffer on Windows too), 100 components of 6 KB total, and a 5000-byte relative input; the output is compared against the lists Node produces (built with path in the test).
    • test/js/bun/plugin/plugins.test.ts, "an onResolve result longer than a path buffer is a resolve error, not a crash": a preload plugin returns a 150001-byte path (past the buffers and the specifier cap on every platform) for ./child.js, required by one parent and re-exported by an ESM parent; both must surface a ResolveMessage naming the path and the parent.
    • USE_SYSTEM_BUN=1: both tests fail with the panics above. bun bd test on this branch: both pass; the full plugins.test.ts (39) and node-module-module.test.js (40) files pass, as do bundler_splitting, bundler_naming, bundler_plugin, bun-build-api and the test/js/bun/sourcemap files (the relative_alloc callers). cargo check of the touched crates for the Windows and macOS targets and cargo clippy are clean.
  • The thread-local relative() itself (about 60 callers, most with paths that came from the filesystem) and the other unchecked writers are left as they are: making them grow in place touches the install crate's direct use of the relative buffers and is the writer-level change being discussed separately; bun test: stop panicking on a path argument or tree entry longer than the path buffer #35863 bounds join_abs_string_buf from the other direction and does not overlap with these lines. The resolver's load_as_file window reached by the plugin path at 4096 to 6144 bytes is resolver: bound load_as_file path before writing into its PathBuffer #35857's site.

Background

  • bun_paths::resolve_path works on byte slices and writes results into caller-provided or thread-local fixed buffers (PathBuffer = [u8; MAX_PATH_BYTES]: 4096 on Linux, 1024 on macOS, 98302 on Windows). The *_spill family (join_spill, join_z_buf_spill, normalize_string_spill) is the existing pattern for inputs that may be longer: use the fixed buffer when a cheap bound says the result fits, otherwise a Vec the caller owns.
  • join_abs_string_buf is path.resolve: it concatenates the cwd and the parts into a scratch buffer and normalizes that into the output buffer, so the output is never longer than the concatenation (plus one byte Windows normalization can add, e.g. C: becoming C:.).
  • relative is path.relative: it normalizes from and to (resolving relative ones against the cwd), finds their common prefix, and emits one .. per remaining component of from followed by the rest of to.
  • At runtime, every module is transpiled and then linked on its own: the linker resolves the module's import records, consulting runtime onResolve plugins for specifiers that look like they could be plugin-handled (an extension or a ns: prefix), and rewrites each record to the resolved path plus a shorter "pretty" name used for display. _nodeModulePaths is the node:module helper that returns the node_modules lookup chain for a directory; module.paths is computed with it lazily.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/module/node-module-module.test.js

…ver-long paths

relative() and join_abs_string_buf() write into fixed PathBuffers without
bounds checks, so callers that hand them unbounded input abort the process.
relative_alloc was a Box around the thread-local relative() and inherited
the bound; it now sizes heap buffers to its inputs when either input, or the
../ chain the result needs, might not fit, and join_abs_string_buf_spill is
the path.resolve counterpart of join_z_buf_spill.

Module._nodeModulePaths() joins whatever string JS passes (string
manipulation in Node, with no length limit) and now spills; the runtime
linker computes the pretty name of a path returned by an onResolve plugin
with relative_alloc, so a plugin returning a path longer than a PathBuffer
for an import inside a module is a resolve error instead of a crash.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2daf8397-0881-4980-a5ec-ac7bc52da2ac

📥 Commits

Reviewing files that changed from the base of the PR and between a0921e1 and ed7f0aa.

📒 Files selected for processing (5)
  • src/bundler/linker.rs
  • src/jsc/resolver_jsc.rs
  • src/paths/resolve_path.rs
  • test/js/bun/plugin/plugins.test.ts
  • test/js/node/module/node-module-module.test.js

Walkthrough

Path resolution now supports oversized relative and absolute paths through dynamic allocation and spill buffers. Bundler and Node module callers use the new helpers. Native and JavaScript regression tests cover long-path errors, lookup chains, and buffer growth.

Changes

Oversized path handling

Layer / File(s) Summary
Dynamic path capacity and allocation
src/paths/resolve_path.rs
Relative paths now allocate when fixed buffers are insufficient. Shared capacity calculations and spill-capable absolute joins handle oversized inputs and outputs. Native tests cover allocation, normalization, and spill behavior.
Path caller integration
src/bundler/linker.rs, src/jsc/resolver_jsc.rs
Import path generation propagates relative_alloc errors. Node module path generation uses join_abs_string_buf_spill.
Long-path regression coverage
test/js/bun/plugin/plugins.test.ts, test/js/node/module/node-module-module.test.js
Subprocess tests cover long plugin paths, structured ENAMETOOLONG errors, and node module lookup results for long paths.

Possibly related PRs

  • oven-sh/bun#38359: Both PRs add spill-capable handling for oversized filesystem paths and related ENAMETOOLONG coverage.
  • oven-sh/bun#38368: Both PRs update spill-buffer handling in src/paths/resolve_path.rs.
  • oven-sh/bun#38370: Both PRs replace fixed-buffer joins and add long-path regression tests.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the heap-backed path changes and their use in _nodeModulePaths and the runtime linker.
Description check ✅ Passed The description explains the problem, implementation, affected areas, and verification results in substantial detail, despite using different headings from the template.

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

@claude claude Bot left a comment

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.

Beyond the inline findings, I traced the new relative_scratch_needed / relative_out_needed bounds against every write site inside relative_platform_in (the normalize-into-other-scratch step for a relative from, the normalize-into-buf step for a relative to, and the join_abs_string_buf output into each scratch buffer) — each is covered by the computed sizes, so the heap path can't under-allocate.

Extended reasoning...

The core correctness invariant of this PR is that the three heap buffers relative_alloc allocates are large enough for every intermediate write relative_platform_in performs, not just the final result. I checked: (1) relative from normalizes into to_buf (needs from.len()+1, covered since scratch >= top_level_dir.len()+from.len()+4); (2) relative to normalizes into out (needs to.len()+1, covered since out_needed >= scratch); (3) the join_abs_string_buf output into each scratch buffer is bounded by join_abs_needed(top_level_dir, [normalized]), which is exactly what relative_scratch_needed computes for the relative branch. The absolute branch's path.len()+2 covers the leading-separator prefix plus the +1-byte normalization growth. The inline findings (Windows test assertion, join_abs_needed not applied to _checked) stand on their own.

Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/bundler/linker.rs Outdated
Comment thread src/jsc/resolver_jsc.rs Outdated
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs

@claude claude Bot left a comment

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.

Both earlier findings are addressed (the long-result test now uses a two-byte target so it holds on Windows, and join_abs_string_buf_checked goes through join_abs_needed). No new issues found on this pass. Because the fix hinges on hand-derived buffer bounds (relative_scratch_needed / relative_out_needed) that back unchecked slice writes in relative_to_common_path, a human look at those derivations would still be worthwhile.

What was reviewed:

  • Traced relative_out_needed against relative_to_common_path's worst case (every 2-byte component of from → 3-byte /.., plus separator and full to tail); the bound holds.
  • Checked relative_scratch_needed against both the absolute (normalize_string_buf into buf[1..], +1 growth) and relative (normalize into the sibling scratch, then join_abs_string_buf) arms of relative_platform_in; both fit, including the relative-to normalize into the output buffer.
  • Confirmed the three linker.rs sites already copied the result, so relative_alloc costs nothing there beyond OOM propagation; resolver_jsc.rs's spill Vec is dropped at scope end.
  • The comment-cop flags on the current head land on 2–3-line bound doc comments, not workaround justifications.
Extended reasoning...

Overview

Fixes two user-reachable process aborts from over-long paths flowing into fixed-size PathBuffers: _nodeModulePaths(longString) and a runtime Bun.plugin onResolve returning a long file-namespace path. src/paths/resolve_path.rs gains join_abs_string_buf_spill (mirrors the existing join_z_buf_spill pattern) and reworks relative_alloc to size heap scratch/output when the thread-local PathBuffers can't hold the inputs or the ../ chain. relative_platform_buf is split into a thin wrapper plus relative_platform_in so the same body runs over caller-supplied buffers. Three call sites in src/bundler/linker.rs switch from relative to relative_alloc; src/jsc/resolver_jsc.rs switches to the spill variant. Seven new cargo test -p bun_paths unit tests plus two subprocess integration tests.

Security risks

The inputs are user-controlled (_nodeModulePaths argument, plugin onResolve result), and the pre-fix behavior is a process abort — a DoS class the PR removes. The new bounds (join_abs_needed, relative_scratch_needed, relative_out_needed) size buffers that normalize_string_generic_tz and relative_to_common_path write into with plain slice indexing, so an under-bound would still panic (not corrupt memory — safe Rust). I traced each writer against its bound and they hold, including the Windows +1 growth cases already accounted for in join_abs_needed.

Level of scrutiny

High. resolve_path is core infrastructure reached by every module the runtime links, and the fix's correctness rests on arithmetic bounds derived from the writer's control flow rather than a checked write. The _spill addition follows an established in-file pattern; the relative_alloc bound derivation is the novel part.

Other factors

My two earlier findings (Windows-failing unit test assertion; join_abs_string_buf_checked open-coding the shared bound) were both addressed in d2c0a7d and are marked resolved. The comment-cop bot fired again on the current head, but the flagged lines are short doc comments explaining why each bound is what it is — not workaround justifications — so I'd leave that to a maintainer to dismiss. The integration tests are subprocess-isolated, drain both pipes, and assert exact ResolveMessage shape / Node-derived expected lists; the PR description records USE_SYSTEM_BUN=1 failures for both. Given the bound-derivation subtlety and ~60 untouched relative() callers the PR explicitly leaves for a separate change, deferring to a human reviewer rather than approving.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 AM PT - Aug 14th, 2026

@robobun, your commit ed7f0aa has some failures in Build #95718 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38392

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

bun-38392 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status after the review rounds: d2c0a7d switches the long-result unit test to a two-byte target (the one-byte case trips a pre-existing Windows bug in relative(), reported separately) and routes join_abs_string_buf_checked through join_abs_needed; 620cfbe trims the comments and derives the relative scratch bound from join_abs_needed; ed7f0aa names that helper's parameter the way #37457 and #38368 do, since all three PRs add the identical helper (overlap note added to the description: whichever lands last drops its copy on rebase). Every review thread is resolved; the remaining bound docs are intentional, as the latest review summary also concluded. Unit tests pass on the final state; the branch is otherwise unchanged since the integration tests were last run. Waiting on CI.

@claude claude Bot left a comment

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.

LGTM — both earlier findings addressed (d2c0a7d: join_abs_needed shared with _checked, and b"/tt" in the long-result test so it holds on Windows).

Reviewed: the relative_platform_bufrelative_platform_in split is a pure body-move (thread-local callers unchanged), and relative_alloc's fast path calls the same relative_platform as before, so every existing caller with real filesystem paths is byte-identical. The heap path is only reached for inputs that previously panicked; traced relative_out_needed's 3/2·scratch bound against relative_to_common_path's .. emission loop and it holds (first .. is 2 bytes, each subsequent /.. is 3, matching ≥2-byte components). join_abs_string_buf_spill mirrors join_z_buf_spill exactly.

Extended reasoning...

Overview

Two user-reachable panics from over-long paths flowing into fixed-size PathBuffer writes: _nodeModulePaths(longString) and a runtime Bun.plugin onResolve returning a long path. Fix adds heap-spilling variants (join_abs_string_buf_spill, a real relative_alloc) that follow the existing *_spill pattern in this file, plus a mechanical split of relative_platform_buf into a wrapper (supplies thread-local buffers) and relative_platform_in (takes buffers as parameters, body unchanged). Three call sites in linker.rs and one in resolver_jsc.rs switch to the new variants. Seven new cargo unit tests pin the bounds; two new integration tests reproduce both crashes end-to-end.

Prior review resolved

I left two findings on the first revision; both are addressed in d2c0a7d and visible in the current diff:

  • join_abs_string_buf_checked now calls join_abs_needed(cwd.len(), parts) instead of open-coding the loop.
  • relative_alloc_handles_a_result_longer_than_a_path_buffer now uses b"/tt" (two-byte target) so the exact-value assertion holds on Windows too, with a one-line comment noting the pre-existing one-byte-target quirk.

The comment-cop noise was resolved in 620cfbe (comments trimmed; the remaining doc comments on the bound helpers are one-to-two lines stating where the bytes come from, matching the sibling join_z_buf_spill / normalize_string_spill docs).

Security risks

None material. This is pure byte-slice string manipulation with no filesystem, network, or privilege boundary. Rust bounds-checks every slice write in normalize_string_generic_tz and relative_to_common_path, so an under-sized bound would panic (the same failure class as the bug being fixed), not corrupt memory. The unsafe added is two #[no_mangle] extern "C" test stubs for highway kernels, matching the two already present in the same mod tests.

Level of scrutiny

Moderate. Path handling is core infrastructure, but the change is structured to minimize risk: (1) the relative_platform_buf split moves the body verbatim — every existing caller of relative() / relative_platform() runs identical code; (2) relative_alloc's fast path (scratch ≤ MAX_PATH_BYTES && out_needed ≤ MAX_PATH_BYTES) calls the same relative_platform as before, so its two pre-existing callers (chunk naming, sourcemap sources) are unchanged for real paths; (3) the heap path only handles inputs that previously crashed. I traced the relative_out_needed bound: relative_to_common_path emits .. (2 bytes) then /.. (3 bytes) per component, and each component in the normalized from is ≥2 bytes (leading separator + name), so 3/2·scratch covers the .. chain; + 1 + scratch covers the separator and to tail. relative_scratch_needed covers the leading-separator byte plus the one byte Windows normalization can add (share root / C:C:.), and for relative inputs reuses join_abs_needed with +1 for the same reason. join_abs_string_buf_spill is a straight copy of join_z_buf_spill's shape.

Other factors

Tests are well-constructed per REVIEW.md: the unit tests pin the fast-path/spill boundary exactly (join_abs_string_buf_spill_uses_the_buffer_up_to_its_bound), the Windows growth case, and the ../-chain overflow with two inputs that individually fit; the integration tests spawn a child (crash isolation), assert the specific ResolveMessage fields including the long path in the message, cover both CJS and ESM parents, and build expected _nodeModulePaths output with path.join rather than string literals so they hold cross-platform. The PR description confirms USE_SYSTEM_BUN=1 fails both. Windows was cargo checked but not cargo tested — CI will cover it, and the one Windows-specific test issue I found earlier is now fixed.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Ready for a maintainer at head ed7f0aa. Both new tests (_nodeModulePaths past PATH_MAX; onResolve returning a 150 KB path from a CJS and an ESM parent) passed on every lane of build 95718, and the 25 bun_paths unit tests pass; the lanes that build reports as failed are retry-passed flakes in unrelated files (retry-flag, inspect-error-leak, child_process IPC handle, HTTP/3 on Windows aarch64, malformed-integrity-base64, cluster tests), so no retrigger from my side. Review threads are all resolved; the description carries the note about #37457 / #38368 adding the same join_abs_needed helper, so whichever of the three lands last just drops its copy on rebase.

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.

2 participants