install: stop `bun prune` and `bun update` removing packages behind a symlinked workspace node_modules

Closed
RO
24d ago
+645-30

Fixes #44669

Problem

  • bun prune removes what is behind a workspace node_modules that is a symlink. So do bun update, bun dedupe and bun audit fix with the hoisted linker. A link to the root's costs the root its packages.
  • Cause: src/install/prune.rs opens <workspace>/node_modules at six places with Dir::open, which follows the link.

Fix

  • open_workspace_folder opens every workspace node_modules, for both linkers, and refuses a link. bun prune then prunes the rest and exits 1. bun update warns.
  • Plan::spare_link_targets drops the removals in the folder the link leads to, where an install writes the workspace's entries.
  • With the isolated linker, such a run removes nothing from node_modules/.bun.
  • Verified: 21 new tests, 20 fail on main. 18 reverted clauses each fail a test. Self-reviewed: 29 concerns raised, 18 addressed, 9 moot, 2 not adopted.

Background

  • bun prune removes what bun.lock does not place. After a hoisted bun update, remove_collapsed_copies removes copies a folder above provides. Both walk each workspace's node_modules.
  • This guards a link the user made, not a security boundary. A root node_modules or node_modules/.bun that is a link is still followed (#44671).
  • Cut after review: a new folder model that removes copies 1.4.2 keeps (#44670).

Downsides

  • bun prune exits 1 for a project with a linked workspace node_modules, and bun update no longer cleans behind the link. 1.4.2 cleans a link to the workspace's own storage.
  • bun install still writes through the link (#42039).
  • +0 syscalls and +0 allocator calls on bun install, no-op bun update and no-op bun prune. +7,680 bytes of text.
Notes

Where it came from. A review of #44406 found it. No user reported it. src/install/prune.rs is the same in bun 1.4.2, and the repro of #44669 gives the same result there.

What the user sees. From bun prune, exit code 1:

error: packages/a/node_modules is a symlink, so Bun did not prune it
note: to have Bun prune that folder, remove the link and run 'bun install'

From bun update, bun dedupe and bun audit fix with the hoisted linker, exit code unchanged:

warn: packages/a/node_modules is a symlink, so Bun did not look behind it
note: to have Bun clean that folder, remove the link and run 'bun install'

bun prune fails because the user asked for that folder to be pruned. The pass after bun update is a cleanup the user did not ask for, so it only warns.

Cases, each one a test in test/cli/install/bun-prune.test.ts (the last two are in bun-workspaces.test.ts)

Setupmainthis PR
the link leads to the root's node_modules, hoisted and isolatedremoves the root's packages (isolated: the root's link to its dependency)error, removes nothing
the link leads to a folder elsewhere, both linkersremoves the directories in that foldererror, removes nothing
the workspace has its own copies with a nested tree below them, the link leads to its storageprunes behind the linkone error, leaves it
two workspaces behind linksno messagetwo errors in sorted order, one note
strays in the root and in another workspace, one workspace behind a link, both linkers, --dry-run then a real runalso prunes behind the linkremoves the two strays, leaves the folder behind the link, exits 1 both times
a foreign folder behind the link holds a link that looks like one of the isolated linker's, hoisted install, --linker isolatedtakes the layout for mixed, runs the isolated planner, removes the root's a-dephoisted planner, removes nothing
isolated, a dev dependency behind the link, --productionremoves the store entry and the linkkeeps both
isolated, bun.lock dropped a dependency whose link is still behind the refused folderremoves the store entry and the linkkeeps both
isolated, the link leads to the root, and an install wrote the workspace's link into the rootremoves it from the rootkeeps it, and a scoped stray beside it
hoisted, the link leads to a sibling's folder, and an install wrote the workspace's copy thereremoves itkeeps it
the same with a copy nested below a package of the siblingremoves itkeeps it
hoisted, the link leads to the root, a stray in another workspace's real folderremoves the root's packagesremoves only that stray
hoisted, the link leads to the root, the only entry to remove is below a root packageremoves itkeeps it
hoisted, the link leads to a scope folder of the rootremoves the entries of that scope folderkeeps them
a regular file at packages/a/node_modulesno messageno message, exit 0
--silentprints nothing, exit 0prints nothing, exit 1
bun update, hoisted, the link leads to the rootremoves the root's packages, prints (no changes)warning, removes nothing
bun update, hoisted, the link leads to a sibling's folder that holds the workspace's copyremoves that copywarning, keeps it

The six opens. detect_layout, the workspace loop of plan_hoisted, the workspace arm of open_tree_folder, the workspace loop of the pass (plan_and_remove_collapsed_copies), the workspace arm of plan_isolated, and the reopen in housekeeping.

How the link is detected. On POSIX the folder is opened with O_NOFOLLOW | O_DIRECTORY, which fails for a link, and only a failed open other than ENOENT is followed by one lstat. A real folder and a missing folder cost what they cost on main. On Windows O_NOFOLLOW opens the link itself, so the lstat comes first: one more call per workspace folder there. That cost is not measured, no Windows machine was at hand.

spare_link_targets. It runs only when a link was refused. It takes the device and inode of the directory each refused link leads to, and compares them with the folders of the plan: the open Dir when the plan kept one, else stat of the folder's path. A folder that matches is taken out of the plan. So is a folder inside it, when every component on the way down is a real directory (lstat). The tree of a workspace has the workspace's link in the root on that way, so it is not inside the root and is pruned as before.

The store rule. With the isolated linker a workspace's node_modules holds links into node_modules/.bun. When that folder is refused its links stay, and one of them can lead to any store entry, also to one that bun.lock dropped. So a run that refuses a folder removes no store entry. The check for a linked workspace runs only when a store entry is about to be removed.

Each clause against a revert, on a debug build. With the clause reverted, the named tests fail:

RevertedTests that fail
the guard in the plan_hoisted workspace loop5 hoisted link tests
the guard in plan_isolated4 isolated link tests
the guard in open_tree_folder"own copies", "two workspaces"
the guard in the passthe first bun update test
the guard in detect_layout"the layout is not read"
the lstat after a failed open"a file"
the sort, the dedup of the messages"two workspaces", "own copies"
the check for --silent"--silent"
exit 1 after removals"--silent", "a run that removes entries" (2)
the store rule"--production", "an entry bun.lock dropped"
spare_link_targets in bun prune, in the pass2 "through a link" tests, the second bun update test
the scope folder in the match, in the filter"a scope folder of the root", "the root keeps"
the walk down with lstat"does not stop the pruning of a sibling"
the folders inside a spared one"below a sibling's package"
the identity from the path"below a root package"

The reopen in housekeeping has no such test. It sees a folder only after plan_isolated opened it in the same run.

Measurements. Release builds of main 4b02e1031d and of this branch at 8ca1184f50, linux x64. Filesystem syscalls are counted with a ptrace counter over every thread, allocator calls with gdb breakpoints on mimalloc's entry points. The project has 21 registry packages in the root and one nested copy per workspace.

Runmainthis PR
no-op bun install, hoisted, 0 / 5 / 50 workspaces, syscalls162 / 250 / 970162 / 250 / 970
no-op bun install, hoisted, 5 / 50 workspaces, allocator calls1,001 / 4,3581,001 / 4,358
bun install after one package is deleted, 0 / 5 / 50 workspaces, syscalls173 / 261 / 981173 / 261 / 981
no-op bun update, 5 / 50 workspaces, syscalls and allocator calls334 / 1,414 and 2,511 / 7,387the same
no-op bun prune, hoisted, 5 / 50 workspaces, syscalls and allocator calls289 / 1,324 and 963 / 4,362the same
no-op bun prune, isolated, 5 / 50 workspaces, syscalls and allocator calls246 / 1,236 and 1,264 / 5,443the same
no-op bun install, isolated, 5 / 50 workspaces, syscalls256 / 1,024256 / 1,024
bun update with the linked workspace folder, syscalls636, removes the root's packages235, removes none
text size80,678,99380,686,673

One of four runs of the isolated no-op install at 5 workspaces counted 259 for this PR (one more openat, read, close). bun install does not reach prune.rs, and a second run of main showed the same three calls in another row, so this is noise.

Not fixed here

  • bun install follows a linked workspace node_modules and writes behind it. #42039 changes the installer.
  • A root node_modules or node_modules/.bun that is a link is followed, and a root that links into a workspace's folder makes that folder count twice: #44671.
  • A workspace that a self-contained workspace depends on has two trees for one folder, and bun prune and bun update remove what bun.lock places there: #44670.
  • The pass after bun update runs before a failed run exits, and its removals do not show in the summary or the exit code: #44672.
  • With --filter, a workspace that is not selected keeps the behaviour of main: the store is pruned from bun.lock, and a stale link in that workspace can dangle, linked folder or not.

The two self-reviews. The first version also changed the folder model for a workspace with two trees, moved the pass after the failed-run exit and kept unsaved links. The first review found three layouts where the model removes a copy 1.4.2 keeps, and that the guard missed the isolated linker. It offered to grow the PR or to carve out the guard at all six opens. This PR is the carve-out. The second review cut a filter for a root that links into a workspace's folder, because it missed nested folders (that case is #44671). It asked for the remedy in the message, the test for detect_layout, exact counts in the tests, a run that removes entries, and the issues linked above. The 9 moot concerns were about the parts that left this PR. The two concerns not adopted: the removal count in the summary of bun update (#44672 tracks it), and pub(crate) on open_workspace_folder for a caller that is not on main.

After the self-reviews, from the review bots on this PR. Exit 1 from bun prune when a folder was refused. No store entry removed in such a run. spare_link_targets, which starts from a link that was refused: it is a different rule from the filter the second self-review cut. Each of these has tests in the table above.

A question for a maintainer. Three rules for a linked node_modules are in flight: refuse (this PR, for a workspace's folder), replace the link with a folder (#42039, for bun install), follow it (1.4.2). #44671 asks for one.

Suites run on the debug build at the head: bun-prune (140 pass), bun-workspaces (84), bun-update (159), bun-dedupe (82), bun-audit (182), bun-workspaces-self-contained (24). cargo clippy -p bun_install is clean. bun run rust:check-all: 12 of 12 targets.


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-workspaces.test.ts, test/cli/install/bun-prune.test.ts