install: keep the dependency closure of a trusted package out of the global store

Closed
RO
1mo ago
+161-25

Problem

  • With install.globalStore = true, a trusted package's postinstall can mutate the shared store. The npm bun wrapper resolves @oven/bun-linux-x64 through its dep symlink and calls renameSync() on the binary. The shared entry loses bin/bun, so the next project that shares the cache fails with error: Your package manager doesn't seem to support bun. With the cache on another filesystem, the rename fails and the first install exits 1 (#40237).
  • The cause is the eligibility pass in src/install/isolated_install.rs. It makes a trusted entry itself project-local, but the entry's dependencies stay in the global store. The trusted package's script writes through its node_modules/<dep> symlinks into the shared directories.

Fix

  • Record trusted entries during the eligibility DFS. After the DFS, walk each trusted entry's transitive dependency closure and mark it ineligible for the global store, so it is materialized project-local.
  • The walk crosses entries that are already ineligible for other reasons, because their deps are still reachable from the script. The existing fixed-point pass then poisons dependents of the newly ineligible entries.
  • This extends the trade the code already documents for the trusted entry itself: over-exclude to protect the shared store, with no lockfile-format change.
  • Verified: test/cli/install/isolated-install.test.ts (two new tests, both fail on the unfixed build). Also the full isolated-install and bun-prune suites, and the issue's two-project repro end to end.

Background

  • The isolated linker gives each package an entry under node_modules/.bun/<name@version>/node_modules/, with sibling symlinks for its deps. Scripts run inside the entry, so ../<dep> resolves through those symlinks.
  • With the global store, an eligible entry is a symlink to <cache>/links/<storepath>-<hash>/, a directory shared by every project that uses the same cache. Nothing protects it from writes through the symlink.
  • Eligibility is computed per entry. An entry whose scripts may run (trusted, or default-trusted) is project-local. A dep of an ineligible entry poisons its dependents, since a shared entry cannot symlink to a project-local path.
Notes
  • Repro (issue): a project that depends on bun@1.4.0 with trustedDependencies: ["bun"], globalStore = true, linker = "isolated". Cold install moves bin/bun out of <cache>/links/@oven+bun-linux-x64@1.4.0-<hash>/.... A second project sharing the cache then fails its postinstall. With the fix, both installs succeed and <cache>/links/ has no @oven/bun-* entries at all.
  • The trusted check now runs before the patched check in the eligibility match. The outcome is the same (both break ineligible), but a package that is both patched and trusted must still be recorded so its closure is poisoned.
  • has_trusted_dependency includes bun's default trusted list when the project declares no trustedDependencies. Those packages (esbuild, sharp, ...) were already project-local. Their closures now are too. In practice the closures are mostly platform binary packages, which are exactly the dirs those scripts mutate.
  • The new test pins transitivity with a depth-2 chain (depend-on-debug-1 -> debug-1 -> ms-1), checks a package outside the closure still uses the store, checks <cache>/links/ contains nothing from the closure, and repeats the layout assertions on a warm install (the hasInstallScript flag is not serialized in bun.lock, so the warm install must reach the same layout from the trusted list alone).
  • A review pass found the same gap for trusted file: folder deps: their scripts also run, but only the registry resolutions recorded trusted entries. A Folder arm now records them too, with its own test. Workspace and root stay excluded on purpose: their scripts always run, and poisoning their closures would force most of the graph out of the global store.
  • Suites run: test/cli/install/isolated-install.test.ts (84 pass), test/cli/install/bun-prune.test.ts (110 pass).

no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/isolated-install.test.ts