install: support path-form link: dependencies

Closed
RO
3mo ago
+732-66

Problem

  • "pkg": "link:./lib/pkg" in package.json fails bun install with error: Package "pkg" is not linked (the yarn/pnpm spelling for "symlink this directory"; #4719, #5045).
  • Cause: every consumer of a Symlink resolution treats the value as a name registered by bun link and joins it onto the global link dir (PackageManagerEnqueue.rs Tag::Symlink arm, PackageInstaller.rs, PackageManagerDirectories.rs, isolated_install/Installer.rs::append_store_path).
  • link:../x is the one shape that got further on main: normalize_package_json_path resolves a .-prefixed value against the project root regardless of the global dir, so it resolves, is written to bun.lock as link:../x, and then fails at link time (FileNotFound: failed linking dependency). See lockfile note below.
  • The pnpm-lock.yaml migration (pnpm.rs) already had a notion of path-form link: (it stored pnpm's value verbatim) that disagreed with the rest of install on both what counts as a path and what the stored value is relative to.

Fix

  • dependency::is_link_path: a link: value is a path unless it is an npm package name. This is the rule pnpm.rs already used, and pnpm.rs now calls the shared helper, so the two writers of bun.lock agree (link:lib/foo is a path on both; link:foo and link:@s/foo stay global).
  • Stored form: Package::parse_dependency re-bases a path-form value from the declaring package.json onto the project root (as it already does for file:), and link_path_for_lockfile makes it /-separated and ./-prefixed, so what is written always reads back as a path. pnpm.rs produces the same form from pnpm's importer-relative value.
  • Resolution: a path-form value resolves via a new Relative(Symlink) arm in folder_resolver.rs (project-relative, Features::LINK as before); name-form is unchanged. An overlong target is reported as ENAMETOOLONG, as the other path-buffer overflows in the crate are.
  • Install: the three installer sites symlink a path-form value from the project root instead of the global dir; the isolated linker only pre-opens the global dir if a name-form resolution exists.
  • Containment, same rule as file:: a target inside the project is always allowed, including one declared by a transitive file: package. One that leaves the project (.. or absolute) is allowed only when the root or a workspace declared the dependency, or a root overrides/resolutions entry names it (Lockfile::link_target_allowed_for_*). Checked at resolve time and again in both installers, because an install driven by an existing lockfile never resolves (hoisted: per package in PackageInstaller; isolated: in the pass over resolutions that runs before any task is scheduled, so no worker can create the link first). Refusal is its own error (UnsafeLinkTarget, "refusing to link ...") rather than a missing package.json.
  • bun add --filter (add_remove_with_filter.rs::local_relative_path) re-spells a local path relative to each target; it now decides "is this link: value a path" with is_link_path too, so bun add link:vendor/foo --filter api writes link:../../vendor/foo into the workspace rather than a value the workspace would read as packages/api/vendor/foo. Name-form values are still passed through untouched.
  • Version::eql for Symlink compares literal like Folder does; bun.lock round-trips literal, so comparing the re-based value made every second install see a diff.
  • Missing target reports Could not find package.json at "<path>" instead of the bun link hint.

Why this is right: link: with a path has meant "symlink this directory, do not install its dependencies" in yarn and pnpm for years, and file: in bun already defines where a relative path is anchored and who may point outside the project; this makes link: follow both.

Lockfile note: bun.lock entries of the form x@link:../y or x@link:/abs that main already writes (and then fails to install) start installing once this lands, subject to the containment rule above. Name-form entries are unaffected. Migrated pnpm lockfiles that previously carried importer-relative targets are now written root-relative; ones migrated by older versions were never installable.

Verified:

  • test/cli/install/bun-link.test.ts: ./, bare lib/x (asserts the ./ stored form), ../, absolute, scoped, workspace-member-relative, missing target; each under both linkers with a --frozen-lockfile re-install. Containment block: transitive escape refused at resolve and from an existing lockfile (asserting nothing by that name exists anywhere under node_modules, isolated store entries included; the released binary creates one at .bun/<pkg>/node_modules/<name>), transitive in-project target installed, overrides/resolutions exemption honoured; both linkers.
  • test/cli/install/bun-add-filter.test.ts: link:./vendor/foo and link:vendor/foo are written to the target as link:../../vendor/foo, recorded as foo@link:./vendor/foo, install, and survive --frozen-lockfile; a missing target is refused naming the per-target spelling and writes nothing. This replaces the test that asserted the old "is not linked" failure for a link: path.
  • test/cli/install/migration/pnpm-lock-migration.test.ts: file: specifier from a non-root importer migrates to a root-relative target and installs.
  • Rebased onto main after #38333 (which added bun add --filter, scoped overrides and pnpm-lock-v9.test.ts); that file, the other pnpm migration suites, the file: containment tests in bun-install.test.ts, bun-add-catalog.test.ts local-path tests, and the name-form link: tests in bun-prune / bun-pm-licenses pass locally. In bun-link.test.ts, the pre-existing should link dependency without crashing fails locally under a debug build because the install failure it expects dumps a symbolized trace to stdout (cfg(bun_debug) in PackageInstaller.rs, not touched here); it is green in CI.
  • cargo check / clippy on linux and cargo check for x86_64-pc-windows-msvc.

Fixes #4719
Fixes #5045

Background

  • bun link / name form: bun link in a package dir symlinks it into ~/.bun/install/global/node_modules/<name>; "dep": "link:<name>" elsewhere then symlinks from there. That is the only form bun supported; this PR adds the path form beside it.
  • Symlink resolution: the lockfile entry for a link: dependency. Its one string field was always a name; it is now either a name or a root-relative/absolute path, and is_link_path tells them apart.
  • Features::LINK: the package.json parse mode for link targets; it reads the manifest but does not enqueue the target's dependencies (unchanged).
  • bin_target_escapes_package_dir: the existing file: check for "does this relative path have a .. that climbs above its base, or is it absolute"; reused here with the project root as the base.
  • Resolve vs install: bun install with no usable lockfile runs resolution (PackageManagerEnqueue.rs); with one, it goes straight to the installers. Any policy about a resolution therefore has to be enforced in both places, which is how the file: checks are laid out.
  • bun add --filter: adds a positional to the selected workspaces. A local path on the command line is relative to the cwd, so it is re-spelled relative to each workspace's package.json before being written; local_relative_path decides which positionals get that treatment.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-add-filter.test.ts test/cli/install/bun-link.test.ts