install: keep the dependency closure of a trusted package out of the global store
Closed
RO
1mo ago+161+161+012345678901234567890123456789-25-25-01234567890123456789
Problem
- With
install.globalStore = true, a trusted package's postinstall can mutate the shared store. The npmbunwrapper resolves@oven/bun-linux-x64through its dep symlink and callsrenameSync()on the binary. The shared entry losesbin/bun, so the next project that shares the cache fails witherror: 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 itsnode_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.0withtrustedDependencies: ["bun"],globalStore = true,linker = "isolated". Cold install movesbin/bunout 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_dependencyincludes bun's default trusted list when the project declares notrustedDependencies. 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 (thehasInstallScriptflag is not serialized inbun.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. AFolderarm 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