Skip to content

install(hoisted): link a file: override applied to a dependency of an installed package - #38994

Open
robobun wants to merge 1 commit into
mainfrom
farm/20ab719a/hoisted-override-folder-base
Open

robobun wants to merge 1 commit into
mainfrom
farm/20ab719a/hoisted-override-folder-base

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With the hoisted linker, a root overrides / resolutions rule such as "sub": "file:./vendor/sub" applied to a dependency of a registry, tarball or git package is silently not installed: bun install exits 0 and reports the package installed, node_modules/<pkg>/node_modules/ stays empty, and require("<pkg>") fails with Cannot find package 'sub'. bun.lock is right ("pkg/sub": ["sub@file:./vendor/sub", {}]), and the isolated linker installs the same project correctly. Reproduces on 1.4.0 and main; nested rules ({"pkg": {"sub": "file:..."}}, "pkg/sub", "sub@^1") behave the same.
  • Cause: src/install/PackageInstaller.rs, transitive folder branch. A Resolution::Folder string has one of two bases (see Background), and the installer picks the base from the tree it is installing into: the project dir when the declaring package is a local file: package (install: link a transitive file: dependency of a local file: package #33159), otherwise the declaring package's own directory, where an ENOENT is rewritten to InstallResult::Success because published packages may declare folders they do not ship. An override's path is written in the root package.json, so it is project-relative whatever package declares the dependency; under an installed package the installer opened node_modules/pkg/./vendor/sub, got ENOENT, and counted the row as installed. The resolver already classifies these rows as root-authored (PackageManagerEnqueue.rs, Folder arm, lets them through the escape check), so they reach the installer.
  • This is the "override rows under an installed package" case install: migrate a package's own file: directories from package-lock.json relative to the package #38990 lists as not addressed there.

Fix

  • Lockfile::is_overridden_dependency(id): is there a root rule, plain or scoped, for this dependency edge (OverrideMap::get, the lookup the resolver applied to it).
  • The installer installs a transitive folder row from the top-level dir when the declaring package is a local folder (unchanged) or when the row is selected by a rule (new); the declaring-package base and its ENOENT tolerance stay for rows declared by installed packages only. A rule target that does not exist now fails the install with the existing Could not find folder "file:./vendor/sub" for dependency "sub" error instead of exiting 0.
  • Why this is correct: the base of the path is a property of who wrote it, and for these rows that is the root package.json, the same base the resolver and the isolated linker already use for them. The predicate is per edge, so a scoped rule does not touch another package's own file: dependency of the same name (pinned by a test); a file: path can only be a transitive Folder row through the declaring package itself or through a rule, so a rule on the edge means the rule wrote the path. OverrideMap::contains_name (plain rules only) is not used here because it answers the trust question for the escape check at the name level; the path base question is per edge and has no trust component, since an escaping path from a scoped rule is rejected while resolving.
  • A node_modules produced by the old behavior is repaired by the next bun install: the row verifies by the presence of its directory, which the old build never created.
  • Verified with:
    • test/cli/install/bun-install.test.ts, in the existing resolutions / overrides loop: links a <plain|nested> "<field>" file: rule applied to a registry package's dependency (dummy-registry is-even requiring is-odd, rule pointing is-odd at vendor/is-odd; asserts the registry is never asked for is-odd, the lockfile row, the layout under is-even/node_modules, and that require("is-even") runs the vendored copy, after a fresh resolve, an install on top of it, and a --frozen-lockfile install from the lockfile), fails when a file: override for a registry package's dependency names a missing folder (error text, exit 1, nothing linked), and isolated linker: ... for both rule shapes, which pass before and after and pin the parity. The four hoisted tests and the missing-folder test fail on main ([] where ["is-odd"] is expected; exit 0 where 1 is expected).
    • test/cli/install/bun-install-registry.test.ts, transitive file dependencies: a root override redirects a registry package's own file: dependency to a folder in the project (file-dep@1.0.0 declares files: file:./the-files; the override wins and the vendored folder's entries are what gets linked, fresh and frozen; fails on main with ENOENT) and a scoped override for another dependent leaves a registry package's own file: dependency alone (passes before and after; guards the per-edge predicate).
    • Also run with the change: bun-install.test.ts -t "file:|folder|override|resolutions|transitive" (38 pass), all of transitive file dependencies in bun-install-registry.test.ts, nested-overrides.test.ts (144 pass), overrides.test.ts, and the override / file: subset of isolated-install.test.ts.
  • install: keep file: folder paths declared by git and tarball packages relative to the package #38816 rewords the comment directly below the changed lines and the is_folder_tree_id doc comment above the new helper; whichever lands second needs a trivial rebase. install: link file: dependencies of registry packages from inside the package with the isolated linker #38856 (isolated linker) currently keys the same base decision on contains_name, so under it a nested rule would regress the isolated parity test added here; the per-edge helper from this PR is the drop-in replacement.

Background

  • A file: dependency on a directory resolves to Resolution::Folder, whose payload is a path string with one of two bases. Manifests that live in the project (root, workspaces, local file: packages, and the root's overrides / resolutions) produce project-relative paths; a registry manifest (Package::from_npm) stores the path as declared, relative to the package that declared it, because that folder only exists inside the installed package.
  • Folder packages are never hoisted: the tree builder places each one in the node_modules of the package that depends on it, which is why the row shows up as pkg/sub and why the installer has to know which base a given row uses.
  • For a dependency not declared by the root or a workspace, the resolver records a stub package (name plus path, no dependencies) without reading the folder, so a missing target can only be noticed while installing.
  • OverrideMap holds the root's rules: plain rules ("sub": ..., apply to every edge of that name) and scoped rules ("pkg>sub", {"pkg": {"sub": ...}}, "sub@^1", apply to some edges). OverrideMap::get(lockfile, dependency_id, name_hash) returns the rule the resolver applies to one edge; contains_name only reports plain rules and is what the escape checks use to trust a path.
  • The hoisted installer installs transitive folder rows as a directory of per-file symlinks into the source folder; installer.cache_dir is the directory the source path is opened relative to.
Repro (no network, 1.4.0-canary and main)
mkdir -p app/vendor/sub app/src/package && cd app
echo '{"name":"app","dependencies":{"pkg":"file:./pkg.tgz"},"overrides":{"sub":"file:./vendor/sub"}}' > package.json
echo '{"name":"pkg","version":"1.0.0","dependencies":{"sub":"^1.0.0"}}' > src/package/package.json
echo 'module.exports = require("sub")' > src/package/index.js
tar -C src -czf pkg.tgz package
echo '{"name":"sub","version":"1.0.0"}' > vendor/sub/package.json
echo 'module.exports = "vendor sub"' > vendor/sub/index.js
bun install --linker hoisted          # exit 0, "2 packages installed", node_modules/pkg/node_modules is empty
bun -e 'console.log(require("pkg"))'  # error: Cannot find package 'sub'

With this change the same commands link node_modules/pkg/node_modules/sub/{index.js,package.json} to vendor/sub and print vendor sub; the same holds for "overrides": {"pkg": {"sub": "file:./vendor/sub"}}, "resolutions": {"pkg/sub": ...} and "overrides": {"sub@^1.0.0": ...}, and pointing the rule at a missing folder prints error: Could not find folder "file:./vendor/missing" for dependency "sub" and exits 1.

… installed package

A root overrides/resolutions rule pointing a dependency at a file: folder
resolves to a Resolution::Folder row under whatever package declares the
dependency. The hoisted installer picked the directory to read such a row
from by the kind of tree it was installing into, so under a registry, tarball
or git package it looked for the override's project-relative path inside that
package, swallowed the ENOENT as "nothing to link" and reported the install as
successful with the folder missing.

Install a row selected by an override rule from the top-level dir, the way
rows declared by a local file: package already are. A missing target now
fails the install with the existing "Could not find folder" error.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 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: 21344974-1c52-49f6-84ff-4109ed88952a

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 5095866.

📒 Files selected for processing (4)
  • src/install/PackageInstaller.rs
  • src/install/lockfile.rs
  • test/cli/install/bun-install-registry.test.ts
  • test/cli/install/bun-install.test.ts

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.0-canary (eabb96d) and on main with the repro in the PR description (local tarball pkg depending on sub, root overrides.sub = file:./vendor/sub): hoisted install exits 0 with node_modules/pkg/node_modules empty; isolated links it.

With this branch the folder is linked for plain and nested rules, a missing target fails the install, and the new tests in test/cli/install/bun-install.test.ts and test/cli/install/bun-install-registry.test.ts fail on main and pass here.

CI (build 97913, still finishing): the install tests pass on every lane that has reported, including Windows x64. The one red test so far is test/bake/deinitialization.test.ts (dev server segfault on Windows), which this diff does not touch; reported separately.

@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.

I reviewed this PR and didn't find any bugs. The fix is small and the reasoning is sound, but since it changes install-time path-base selection in the hoisted linker and turns a previously-silent ENOENT into a hard failure, a human look would still be worthwhile.

What was reviewed:

  • Verified is_overridden_dependency uses the same per-edge OverrideMap::get lookup the resolver applies, so scoped rules (pkg>sub, sub@^1) match only their edges and don't redirect an unrelated package's own file: dependency of the same name — pinned by the file-dep guard test.
  • Confirmed the outer resolution.tag == Tag::Folder guard means a matching override that isn't itself a file: rule (e.g. a semver override) never reaches this branch, so the project-relative base is only used when the override actually supplied the folder path.
  • Checked the ENOENT-tolerance fallback still applies to non-overridden transitive folder rows declared by installed packages; only override-supplied paths now fail loudly on a missing target.
Extended reasoning...

Overview

This PR fixes silent failure of file: overrides applied to dependencies of installed (registry/tarball/git) packages under the hoisted linker. Production change is ~10 LOC: a new Lockfile::is_overridden_dependency(id) helper that wraps OverrideMap::get, and one added disjunct in PackageInstaller.rs so the transitive-folder branch installs from the project root when the row was supplied by a root override rule. ~180 lines of tests across bun-install.test.ts and bun-install-registry.test.ts cover plain and nested/scoped rules, both overrides and resolutions, fresh install / re-install / --frozen-lockfile, isolated-linker parity, the missing-folder error path, and a per-edge guard test proving a scoped override for a different dependent leaves a registry package's own file: dependency untouched.

Security risks

None identified. The change does not relax the existing unsafe-path escape check at PackageInstaller.rs:1486-1497 (that check remains gated on contains_name and is orthogonal to which base directory the folder is opened from). Override rules are user-authored in the root package.json, so trusting their file: targets as project-relative is consistent with how the resolver and the isolated linker already treat them.

Level of scrutiny

Medium-high. bun install is a production-critical path exercised by every user, and the fix hinges on a non-obvious invariant: for a transitive Resolution::Folder row, the presence of a matching override on that edge implies the override supplied the path (because a folder resolution can only reach a non-workspace tree via the declaring package's own manifest or via a root rule, and if the rule matched, it was applied). I traced this through OverrideMap::get and the outer Tag::Folder guard and it holds — but it is exactly the kind of layered reasoning a maintainer familiar with the resolver should sanity-check. The PR also converts a previously-tolerated ENOENT into a hard install failure for override targets, which is the right call but a user-visible behavior change.

Other factors

Test coverage is thorough and follows harness conventions (dummy registry, runBunInstall, concurrent tests, drained pipes, exit code asserted after output). The PR description is exceptionally detailed and self-documents interactions with in-flight PRs #38816 (comment reword, trivial rebase) and #38856 (isolated linker should adopt this per-edge helper over contains_name). No CODEOWNERS on src/install/. No prior human review comments to address. Given the critical path and subtle invariant, deferring rather than auto-approving.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 15th, 2026

❌ @robobun, your commit 5095866 has 1 failures in Build #97913 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38994

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

bun-38994 --bun

This branch has not been deployed

No deployments
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