Skip to content

install: resolve file: dependencies loaded from the lockfile relative to the top-level dir - #38850

Open
robobun wants to merge 4 commits into
mainfrom
farm/ddc42f5d/rebase-lockfile-folder-deps
Open

install: resolve file: dependencies loaded from the lockfile relative to the top-level dir#38850
robobun wants to merge 4 commits into
mainfrom
farm/ddc42f5d/rebase-lockfile-folder-deps

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A file: package in the project declares its own file: dependency relative to itself (vendor/a/package.json has "b": "file:../b"). Install once with a root override for b, remove the override, and every bun install afterwards fails until bun.lock is deleted:
    error: Could not find package.json for "file:../b" dependency "b"
    error: b@file:../b failed to resolve
    
  • Same failure for a workspace member declaring "b": "file:../../vendor/b", for bun update b in the first setup (no overrides involved), for bun update b run directly against a package-lock.json that is being migrated, and with bun.lockb instead of bun.lock. Reproduces on bun 1.4.0 and main. bun update <name> started re-resolving transitive rows in install: honor --recursive/--filter in non-interactive bun update; re-resolve every named-update target #36360 / install: pnpm parity — dedupe, prune, pm licenses, audit fix, add --filter/--catalog, nested overrides, transitive update, and workspace fixes #38333, so that trigger is new.
  • When the declared path has no .. (vendor/a declares "b": "file:./b") nothing fails: b is resolved against the project root instead, bun.lock records b@file:./b, and whatever sits at <project>/b is installed.
  • Cause: Package::parse_dependency (src/install/lockfile/Package.rs, Tag::Folder arm) stores a folder dependency's Version.value.folder relative to the top-level dir, and the resolver's Folder arm (src/install/PackageManager/PackageManagerEnqueue.rs:2619) assumes that form. A lockfile only stores the declared literal, and every loader keeps it as is, i.e. relative to the declaring package: bun.lock (parse_append_dependencies via dependency::parse), bun.lockb (Version::to_version) and the package-lock.json / yarn.lock importers. Loaded rows are normally never resolved again, so this only surfaces when something re-enqueues them: the overrides-changed loop in src/install/PackageManager/install_with_manager.rs:531 and enqueue_named_updates (bun update <name>). A .. in the verbatim path then trips the transitive folder escape check; a path without .. is joined onto the wrong directory.
  • The pnpm importer has the opposite problem: for a folder package's file: rows it wrote pnpm's lockfile-relative directory as the bun.lock literal ("nested-child": "file:sub-dep/child" where sub-dep/package.json declares file:./child), which is not what bun.lock means by a literal. That code is a day old (install: pnpm parity — dedupe, prune, pm licenses, audit fix, add --filter/--catalog, nested overrides, transitive update, and workspace fixes #38333), so no released bun has written such a lockfile.

Fix

  • Lockfile::rebase_folder_dependencies (src/install/lockfile.rs): for every package whose directory the lockfile records (Root, Workspace, Folder resolutions), re-derive each Folder row's value.folder from its literal and that directory, with folder_relative_to_top_level_dir, the computation lifted out of parse_dependency. load_from_dir runs it once on every successful load, so bun.lock, bun.lockb and the three importers all hand the resolver rows in the shape a package.json parse produces; the old body of load_from_dir is now read_from_dir.
  • The pnpm importer now writes those literals relative to the declaring package (declare_folder_dependencies_relative_to_their_package, after its own resolution pass, which keys on pnpm's form). With the v9-alias-non-registry-dep-path fixture the migrated row becomes "config": "file:../shared/config", which is exactly what outer/package.json declares.
  • Why this is correct: the literal is what bun.lock stores and what the differ compares (Version::eql compares folder rows by literal), so it has to stay the declared, package-relative path; value.folder is derived from it, and the resolver needs the derived form. The pass touches only value.folder, never the literal, so it is idempotent and lockfiles are written back byte for byte (--frozen-lockfile and byte-identical re-saves are asserted in the tests). Rows of registry, git and tarball packages are skipped: Package::from_npm leaves theirs verbatim and the installer resolves those against the installed declaring package (src/install/PackageInstaller.rs:1850), so the loaded form already matches for them.
  • Alternative considered: making the resolver's Folder arm join the declared path onto the declaring package's directory instead of having producers store the rebased value. That arm also receives override and catalog values, which are root-relative, so it would have to track where each version came from, and install: only constrain transitive file: targets of remote packages #33106, install: read the package.json of a file: dependency declared by a local file: package #38814 and install: keep file: folder paths declared by git and tarball packages relative to the package #38816 are all modifying that arm at the moment. This PR keeps the existing convention (rows reach the resolver top-level relative) and makes the remaining producers honour it. install: keep file: folder paths declared by git and tarball packages relative to the package #38816, which stops the parse-time rebase for git and tarball manifests, covers the same set of packages as the pass here; install: read the package.json of a file: dependency declared by a local file: package #38814 reads a file: package's file: dependency from disk and relies on the form this PR supplies for loaded rows.
  • Not addressed here: removing an override for a dependency the root itself declares as file:../x also fails, for a different reason (the root's previous rows are re-enqueued after the differ replaces them); tracked separately.
  • Verified with test/cli/install/bun-lock.test.ts (five cases: override removed with bun.lock and with bun.lockb, a path without .., bun update <name>, a workspace member), test/cli/install/migration/migrate.test.ts (bun update b in the same run as a package-lock.json migration, using the lockfile npm install --package-lock-only writes for the layout) and test/cli/install/migration/pnpm-lock-v9.test.ts (migrated literal, then bun update nested-child against the migrated bun.lock; updated literal expectation and snapshot for the alias fixture). All of them fail on bun 1.4.0 / without the src changes and pass with them.
  • Also run: bun-lockb, bun-install, bun-install-registry, bun-workspaces, isolated-install, bun-update, bun-update-transitive, catalogs, overrides, bun-add, bun-pm, bun-pm-why and the migration suites. Failures seen locally were network-dependent cases (git hosts, remote URLs) that fail identically without this change, and 5s timeouts on a heavily loaded host that pass when run alone. cargo check --target x86_64-pc-windows-msvc for the install crate passes (both new functions have a Windows branch).

Background

  • Folder dependency: a dependency written as file:<dir> (or a bare path). Dependency.version carries the literal as written plus a derived value.folder. Lockfile writers emit only the literal.
  • Top-level dir: the project root bun install runs in. A folder package's Resolution::Folder is its directory relative to it (a@file:vendor/a in bun.lock), a workspace package's resolution is its workspace path, and the root's directory is the top-level dir itself. Those are the directories the pass joins the declared paths onto.
  • Re-enqueue: bun install diffs package.json against the loaded lockfile and re-resolves only rows that changed, plus every row whose name an overrides change affects and the rows bun update <name> targets. All other rows keep their lockfile resolution, which is why loaded rows could stay in the wrong form unnoticed.
  • Importers: package-lock.json, yarn.lock and pnpm-lock.yaml are converted into an in-memory lockfile by migration::detect_and_load_other_lockfile and returned through the same load_from_dir as bun.lock, so the pass covers them too. package-lock.json and yarn.lock record the declared specs; pnpm-lock.yaml records resolved, lockfile-relative references, hence the importer-side rewrite.
Repro script
mkdir -p app/vendor/{a,b,b2} && cd app
echo '{"name":"a","version":"1.0.0","dependencies":{"b":"file:../b"}}' > vendor/a/package.json
echo '{"name":"b","version":"1.0.0"}' > vendor/b/package.json
echo '{"name":"b","version":"2.0.0"}' > vendor/b2/package.json
echo '{"name":"app","dependencies":{"a":"file:./vendor/a"},"overrides":{"b":"file:./vendor/b2"}}' > package.json
bun install   # ok
echo '{"name":"app","dependencies":{"a":"file:./vendor/a"}}' > package.json
bun install   # error: Could not find package.json for "file:../b" dependency "b"

# without overrides:
rm bun.lock && bun install && bun update b   # same error

# straight from a package-lock.json:
rm bun.lock && npm install --package-lock-only && bun update b   # same error

After the fix every command exits 0 and bun.lock contains "a/b": ["b@file:vendor/b", {}] while a's row still reads "b": "file:../b".

Earlier shape of this PR

The first version ran the pass inside the bun.lock and bun.lockb parsers only and rebased the existing value.folder rather than re-deriving it from the literal. Review of that version pointed at the importers: package-lock.json rows have the same problem in the run that migrates them (reproduced; yarn.lock rows go through the same kind of code), and the pnpm importer's rows were already top-level relative, so a pass keyed on the value would have double-applied to them. Deriving from the literal, running it once in load_from_dir, and fixing the pnpm literals covers all five producers.

@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: 3 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: 2092c0c9-ec2f-42ba-9054-944330dbbb9d

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • test/cli/install/migration/__snapshots__/pnpm-lock-v9.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (6)
  • src/install/lockfile.rs
  • src/install/lockfile/Package.rs
  • src/install/pnpm.rs
  • test/cli/install/bun-lock.test.ts
  • test/cli/install/migration/migrate.test.ts
  • test/cli/install/migration/pnpm-lock-v9.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 bun 1.4.0 and on main with the script in the PR description (a file: package declaring "b": "file:../b", root override for b added then removed; also bun update b with no overrides, bun update b straight from a package-lock.json, a workspace member declaring file:../../vendor/b, and the same with bun.lockb). The new cases in test/cli/install/bun-lock.test.ts, test/cli/install/migration/migrate.test.ts and test/cli/install/migration/pnpm-lock-v9.test.ts fail without the src changes (Could not find package.json for "file:../b" dependency "b", or for the file:./b case by installing the package found at the project root) and pass with them.

@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. Because it adds a normalization pass to both lockfile loaders and coordinates with two other open PRs (#38814, #38816) touching the same code, a human look would still be worthwhile.

What was reviewed:

  • The parse_dependency refactor is behavior-preserving — same join/relative sequence, same args; the extra copy into buf on non-Windows is inert (relative() writes to a distinct thread-local buffer, so copy_from_slice does not overlap).
  • rebase_folder_dependencies re-slices string_bytes per iteration and only mutates it via Buf::append after the borrowed slices are dead; SemverString handles stay valid across the reallocation.
  • ? propagation compiles at both call sites (oom_from_alloc!(ParseError) and crate::Error::Alloc(#[from])).
  • The five new tests cover both lockfile formats, the no-.. misresolve, bun update <name>, and the workspace case, and each asserts the literal is preserved alongside the rebased resolution.
Extended reasoning...

Overview

The PR fixes a resolution bug for file: dependencies declared by file:/workspace packages when re-enqueued from a loaded lockfile (override removal, bun update <name>). It extracts folder_relative_to_top_level_dir from Package::parse_dependency and adds Lockfile::rebase_folder_dependencies, called at the end of both the text (bun.lock) and binary (bun.lockb) loaders, so loaded folder-dependency rows carry the same top-level-relative value.folder that a fresh parse would produce. Five new tests in test/cli/install/bun-lock.test.ts cover the variant matrix.

Security risks

None identified. The change only rewrites an in-memory derived path field for locally declared file: dependencies; the on-disk literal is untouched, and lockfile round-trip is byte-identical (verified by --frozen-lockfile in the tests). No new untrusted input surface, no path-traversal exposure beyond what parse_dependency already had.

Level of scrutiny

Medium-high. Lockfile loading runs on every bun install, so a mistake here affects every project. The refactor of parse_dependency was checked line-by-line against the original: same join base and parts, same empty-path handling, same Windows posix-conversion; the only difference is that non-Windows now copies the relative result into the caller buffer before returning, which is semantically identical because the caller immediately appends it. resolve_path::relative writes into RELATIVE_TO_COMMON_PATH_BUF (thread-local), not the caller's buffer, so the copy is non-overlapping.

Other factors

The PR description explicitly flags two overlapping open PRs (#38814 restricts the resolver, #38816 restricts the parse-time rebase to the same resolution set this pass covers) and one intentionally-out-of-scope adjacent bug (root-declared file:../x after override removal). A maintainer should confirm the merge order and that skipping registry/git/tarball parents (_ => continue) matches what Package::from_npm produces, as the description asserts. Test coverage is thorough — both lockfile formats, the silent-wrong-directory case (path without ..), and the workspace path — and each test asserts the literal survives unchanged in the saved lockfile.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the one point left open above, the _ => continue arm for packages that are not root, workspace or file: resolutions:

  • Registry packages: Package::from_npm builds their rows with Dependency::parse (src/install/lockfile/Package.rs:842), which keeps a file: path as written, and the hoisted installer resolves such a row against the installed copy of the declaring package (src/install/PackageInstaller.rs:1850). A loaded row for one of these is verbatim with or without this change, so skipping them keeps the two paths identical.
  • Git and tarball packages: a lockfile never records where their cache checkout lives, so there is nothing to rebase onto; their rows load verbatim before and after this change as well. install: keep file: folder paths declared by git and tarball packages relative to the package #38816 makes the parse-time side match that (no rebase for manifests parsed out of the cache), so once both are in, parsed and loaded rows agree for every resolution kind.

Merge order between this and #38814 / #38816 does not matter functionally; only #38816 shares lines with this PR (the Tag::Folder arm in parse_dependency), and the second one to land needs a small rebase.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 PM PT - Aug 14th, 2026

@robobun, your commit afcda4d is building: #97220

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:58 AM PT - Aug 15th, 2026

@robobun, your commit dafcf82 is building: #97934

…o the top-level dir

Package::parse stores a folder dependency declared by a root, workspace or
file: package relative to the top-level dir, and the resolver relies on that.
A lockfile only keeps the declared literal, so rows loaded from bun.lock,
bun.lockb or a migrated lockfile came back relative to the declaring package
instead. Rows are normally not resolved again, but removing an override for
the dependency's name or running `bun update <name>` re-enqueues them, and the
path was then looked up from the wrong directory: a `..` path failed with
"Could not find package.json", a path without `..` silently resolved against
the project root.

Lockfile::load_from_dir now derives the value of every such row from its
literal and the declaring package's directory, with the helper Package::parse
uses, once the lockfile has been read or migrated. The literal is untouched,
so the lockfile is written back unchanged.

The pnpm migration wrote a folder package's file: rows with pnpm's
lockfile-relative directory as the literal (`file:sub-dep/child` for a
declared `file:./child`); it now writes them relative to the declaring package
like bun.lock expects.
@robobun
robobun force-pushed the farm/ddc42f5d/rebase-lockfile-folder-deps branch from afcda4d to 0939279 Compare August 15, 2026 10:55
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile/Package.rs Outdated
Comment thread src/install/pnpm.rs Outdated
Comment thread src/install/lockfile.rs
Comment thread src/install/lockfile/Package.rs Outdated
Comment thread src/install/pnpm.rs
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Restructured since the first round of review, description updated accordingly:

  • The pass now derives value.folder from each row's literal and runs once in Lockfile::load_from_dir, so it also covers lockfiles imported from package-lock.json / yarn.lock / pnpm-lock.yaml (re-resolving in the same run as a package-lock.json migration failed the same way; test added in migration/migrate.test.ts). Deriving from the literal makes it idempotent.
  • The pnpm importer wrote a folder package's file: rows with pnpm's lockfile-relative directory as the literal; it now writes them relative to the declaring package, as bun.lock expects (migration/pnpm-lock-v9.test.ts, one expectation and snapshot line updated, plus a re-resolve check).
  • Rebased onto current main (install: fix crash loading a bun.lock with workspaces but no packages object #38765 touched the neighbouring lines in bun.lock.rs; this PR no longer changes that file or bun.lockb.rs).

Comment thread test/cli/install/migration/migrate.test.ts

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

The earlier nit (unread stdout pipe in migrate.test.ts) is fixed in 32f025d, and this run found no further issues. Because the change adds a rebase pass to Lockfile::load_from_dir (runs on every install), rewrites what the pnpm importer serializes for folder-package file: literals, and picks one of two considered placements for the fix while three adjacent PRs (#33106, #38814, #38816) are touching the same resolver arm, a maintainer familiar with the install subsystem should confirm the approach.

Checked: folder_relative_to_top_level_dir is a straight extraction of the existing Tag::Folder arm and resolve_path::relative writes into a thread-local scratch buffer, so the trailing buf.copy_from_slice never overlaps.
Checked: the pass only mutates value.folder (never the literal), so --frozen-lockfile byte-identity holds — the tests assert this for bun.lock, bun.lockb and the migration paths.
Checked: the pnpm literal rewrite runs after resolve(), so pnpm's own dep-path keys (which are lockfile-relative) are still matched before the rewrite.

Extended reasoning...

Overview

This PR fixes file: dependencies of file:/workspace packages failing to resolve when they are re-enqueued from a loaded lockfile (override removed, or bun update <name>). It adds Lockfile::rebase_folder_dependencies (called from load_from_dir after every successful lockfile read/import), extracts folder_relative_to_top_level_dir from the existing parse_dependency Folder arm, and adds declare_folder_dependencies_relative_to_their_package to the pnpm importer so its literals match bun.lock's package-relative convention. ~130 lines of new Rust across three files in src/install/, plus seven new tests and one snapshot line updated.

Security risks

None identified. The change is path re-derivation for local file: folder dependencies; no network, auth, or untrusted-input parsing is involved. Path traversal is not a new surface — the same join_abs_string_buf_checked + relative computation already ran in parse_dependency.

Level of scrutiny

High. load_from_dir runs on every bun install; a regression here affects every project with a lockfile. The pnpm importer change alters what is written to bun.lock on disk. Both new functions carry #[cfg(windows)] branches. The PR description explicitly weighs this placement against fixing in the resolver's Folder arm and defers to the existing convention — that is a design call a maintainer should ratify, especially with three other PRs modifying the same arm concurrently and a documented open edge case (root-declared file:../x after override removal) left for a follow-up.

Other factors

The previous inline nit from this bot was addressed. Test coverage is thorough (bun.lock/bun.lockb, path with and without .., workspace member, bun update, package-lock.json migration, pnpm re-resolve; --frozen-lockfile and byte-identical re-save asserted). The author confirmed cargo check for the Windows target and ran the wider install/migration suites. No CODEOWNERS entry covers src/install/. The github-actions comment-cop flags on the doc comments were answered by the author (they document an invariant, not a workaround) and the comments were shortened; that thread does not need further action here.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Review threads are all answered and resolved (the comment-cop ones point at doc comments that have since been shortened; the unread-stdout nit is fixed in 32f025d). Nothing further planned from my side; this is ready for a maintainer to look at the placement question described under "Alternative considered" in the description. Buildkite build for 32f025d is running.

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