install: keep the pnpm block in package.json when migrating from pnpm - #38775
install: keep the pnpm block in package.json when migrating from pnpm#38775robobun wants to merge 5 commits into
Conversation
The pnpm-lock.yaml migration copied pnpm.overrides and pnpm.patchedDependencies to the root of package.json and then deleted them from the pnpm block (deleting the block itself when nothing else was in it). pnpm only reads its configuration from that block, so after one bun install, pnpm install --frozen-lockfile failed for everyone still on pnpm with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and a plain pnpm install dropped the overrides from pnpm-lock.yaml. The entries are now copied and the block is left as it was. The copy written to the root is a separate object, so rewriting bare patch keys to name@version and merging in pnpm-workspace.yaml entries no longer shows up inside the block. The four merge-into-root blocks share one helper, and the message says "copied" instead of "moved". The rewritten package.json now goes through print_package_json_into_cache_entry and reparse_root like the other package.json editors. Before, the cache entry kept the edited tree, which pointed into the contents buffer that had just been freed and into Store nodes that the next initialize_store() resets, so bun update run directly on a pnpm project read freed memory when it edited the root package.json after the migration.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
WalkthroughChangesThe pnpm migration now copies pnpm migration
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 11:59 PM PT - Aug 14th, 2026
✅ @robobun, your commit cb7af7477b7de75f7f629998dff2a034dbcee1de passed in 🧪 To try this PR locally: bunx bun-pr 38775That installs a local version of the PR into your bun-38775 --bun |
|
Status: reproduced on bun 1.4.0 with |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes user-facing bun pm migrate behavior (leaving the pnpm block in place instead of pruning it) and reworks the package.json cache-entry write path to fix a use-after-free, a human look would still be worthwhile.
Checked: copy_object gives the root a distinct property list so rewrite_bare_patch_keys and the workspace-yaml merge cannot mutate the pnpm block; copy_into_root preserves the old merge-vs-create semantics and now correctly skips announcing when the existing root field is not an object; the print_package_json_into_cache_entry + reparse_root sequence matches bun add/bun remove/add_catalog; wrote_workspaces still gates the write via copied.
Extended reasoning...
Overview
This PR changes update_package_json_after_migration in src/install/pnpm.rs so that migrating from pnpm copies pnpm.overrides / pnpm.patchedDependencies to root-level fields without removing them from the "pnpm" block (previously the block was pruned and deleted when empty). It also replaces the hand-rolled BufferPrinter + source.contents = Cow::Owned(...) sequence — which freed the old contents while the cached root tree still borrowed them — with the shared print_package_json_into_cache_entry + reparse_root pattern. Four near-identical merge-or-create blocks are collapsed into copy_into_root, and copy_object is added so edits to the root copy cannot alias back into the pnpm block. Docs and two test files are updated; three new tests cover the preserved block, the yaml-merge aliasing case, and the bun update UAF.
Security risks
None identified. No untrusted-input parsing paths change; the modified code operates on the already-parsed root package.json and pnpm-workspace.yaml trees.
Level of scrutiny
Medium-high. The functional half is a deliberate product decision (non-destructive migration so pnpm install --frozen-lockfile keeps working for teammates still on pnpm) — reasonable and well-argued, but a user-visible behavior change to bun install that a maintainer should sign off on. The memory-safety half touches AST-node lifetime rules (Store reset, source.contents borrowing, stale_contents retention) and, while the fix reuses the exact pattern from add_remove_with_filter.rs / add_catalog.rs / updatePackageJSONAndInstall.rs, it is the kind of change the repo review guide flags for careful reading.
Other factors
I traced shallow_clone_prop and confirmed copy_object builds a fresh PropertyList, so rewrite_bare_patch_keys reassigning prop.key on the copy cannot touch the original block's list. copy_into_root's Ok(false) branch (existing root field is not an object) is a slight behavior change from the old code — previously the message was printed and the pnpm block was still pruned even though nothing was merged; now nothing is announced or written. That is an improvement in a degenerate case, not a regression. The old silent return Ok(()) on print_json failure becomes a Global::crash() via the shared helper, matching every other caller. No dead imports remain (bun_js_printer is no longer referenced in pnpm.rs). Test coverage looks thorough, including the ASAN repro for bun update and a check that the bare patch key survives in the block while the root gets the versioned key.
|
Confirmed #23694 is this use-after-free: |
|
Independent reproduction of the same two problems landed on the same fix, so no second PR: branch Two notes for whoever merges:
|
…ackage.json print Quoted yaml scalars are copied into the arena the yaml is parsed with. That arena was local to the block that read the file, while the catalog, overrides and patchedDependencies objects taken from the tree are printed into package.json after the block ends.
|
Re the note above: the block-scoped yaml arena is fixed here too as of cb7af74 (the yaml is parsed into the function's |
There was a problem hiding this comment.
The arena-lifetime issue I flagged is fixed in cb7af74 (the yaml parse now uses the function-scope bump, and quoted pnpm-workspace.yaml scalars are written to package.json covers it). No further issues found this pass. Given this reworks arena/Store lifetimes in update_package_json_after_migration, changes the migration's user-visible contract for the pnpm block, and overlaps with #38754, a human look is still worthwhile.
What was reviewed:
&bumpnow backs the yaml parse;bumpat pnpm.rs:2348 outlivesprint_package_json_into_cache_entry, so quoted-scalarExprs stay valid through the print.copy_objectbuilds a freshE::Objectsorewrite_bare_patch_keysand workspace-yaml merges cannot mutate the retainedpnpmblock via the aliasedgetresult.copy_into_rootpreserves the old merge-vs-create semantics, including returningfalse(no "copied" line) when a non-object root field already exists.print_package_json_into_cache_entrystashes the old contents instale_contentsandreparse_rootfollows immediately, matching the patternbun add/--cataloguse.
Extended reasoning...
Overview
The PR touches src/install/pnpm.rs (update_package_json_after_migration and two new helpers), docs/pm/cli/install.mdx, and two test files. It (1) stops deleting pnpm.overrides/pnpm.patchedDependencies from the "pnpm" block during migration and copies them to root-level fields instead, (2) fixes a use-after-free where the migration replaced source.contents on the cached root package.json entry without rebuilding root (crashes bun update/bun update -i on first run in a pnpm project — #23694), (3) fixes a second UAF I flagged last pass where pnpm-workspace.yaml was parsed into a block-scoped arena whose quoted-scalar allocations were read after drop, and (4) collapses four copy-pasted merge blocks into copy_into_root plus a copy_object that severs the alias between the root copy and the pnpm block.
Security risks
None identified. No untrusted-input parsing was added; the yaml/json inputs were already being parsed on this path. The change reduces memory-safety risk by replacing a hand-rolled source.contents = Cow::Owned(...) (which left entry.root dangling) with the shared print_package_json_into_cache_entry + reparse_root sequence, and by extending the yaml arena's lifetime to cover its consumers.
Level of scrutiny
Medium-high. This is package-manager code on the migration path with two memory-lifetime fixes whose correctness depends on bun_alloc::Arena scope, the resettable Expr Store, and WorkspacePackageJSONCache invariants (stale_contents, reparse_root). The behavior change (keep the pnpm block) is a product decision — well-argued in the description, but still a user-visible contract change. The refactor is a net simplification (~130 lines deleted) and matches an in-tree helper pattern, but the aliasing subtlety that motivates copy_object is exactly the kind of thing a maintainer should sign off on.
Other factors
My previous inline finding was addressed in cb7af74 with a dedicated test using single- and double-quoted yaml scalars. Test coverage is thorough: new tests for both UAF repros (including the #23694 update -i shape driven via piped stdin), the block-kept invariant in both the mixed-keys and only-migrated-keys cases, the workspace-yaml-merges-into-root-not-pnpm-block case, and existing moved→copied assertions updated across two files. The comment-cop bot flags were resolved (comments are now single-line). The PR description notes a rebase will be needed against #38754 whichever lands second. CI build #97096 is in progress per the robobun comment.
Problem
bun install/bun pm migrateon a project with apnpm-lock.yamlcopiespnpm.overridesandpnpm.patchedDependenciesto the root ofpackage.jsonand then removes them from the"pnpm"block (update_package_json_after_migration,src/install/pnpm.rs; the block itself is removed when nothing else is in it). Since install: pnpm parity — dedupe, prune, pm licenses, audit fix, add --filter/--catalog, nested overrides, transitive update, and workspace fixes #38333 this is announced asmoved pnpm.overrides to overrides in package.json; the removal itself dates back to the original migration (implement pnpm migration #22262)."pnpm"block and ignores the root-level fields. After onebun installis committed,pnpm install --frozen-lockfilefails for everyone still on pnpm withERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and a plainpnpm installdrops theoverrides:section frompnpm-lock.yaml, so the pins stop applying."pnpm"block (OverrideMapandPackageonly read the root-leveloverrides/resolutions/patchedDependencies;bun-workspaces.test.tspins that rootpnpm.overridesis ignored), so leaving the block in place costs nothing.bun updaterun directly on a pnpm project read freed memory (ASANheap-use-after-freeinE::String::eql_bytes, reached fromPackageJSONEditor::edit_update_entries). The migration replacedsource.contentsof the cached rootpackage.jsonentry but kept the edited tree, whose strings point into the buffer that was just freed (and whose new nodes live in theStore, which the nextinitialize_store()resets). Anybun updatethat triggers the migration hits this whenever the migration rewritespackage.json, which includes every pnpm workspace (thepnpm-workspace.yamlpackages list is written intoworkspaces).bun update --interactiveas the first bun command in a pnpm monorepo, segfault inprintJSONunderupdatePackageJsonAfterMigration):update -iloads the lockfile once to list outdated packages (first migration,package.jsonrewritten, cached tree left dangling), edits the rootpackage.jsonthrough that cached tree, then installs, which migrates again becausebun.lockis still not on disk and prints the dangling tree. Reproduced with the debug build: ASAN reports the use-after-free at the editing step (update_package_json_files_from_updates), the release build gets as far as the second migration's print.Fix
pnpm.overrides/pnpm.patchedDependenciesare copied and the"pnpm"block is left as it was. The block-pruning code is deleted.copy_objectgives the root a separate object, so rewriting bare patch keys toname@version(rewrite_bare_patch_keys) and merging inpnpm-workspace.yamlentries afterwards cannot show up inside the"pnpm"block (theExprreturned bygetaliases the same object).pnpm-workspace.yaml, overrides and patchedDependencies) now sharecopy_into_root. The message readscopied ... in package.json; nothing it lists is modified at its source.package.jsonis produced withprint_package_json_into_cache_entryfollowed byreparse_root, the same sequencebun add/bun remove/bun add --cataloguse, so the cache entry's tree matches the contents bun just wrote. This is what fixes the use-after-free, and it is also needed for the copies above, whichExpr::initplaces in the resettableStore. install: import pnpm-workspace.yaml even without a migratable pnpm-lock.yaml #38754 (open: importpnpm-workspace.yamlwithout a lockfile) adds the same write-back to this function independently and keeps the move semantics; the two PRs overlap only in that tail. Whichever lands second needs a small rebase, and install: import pnpm-workspace.yaml even without a migratable pnpm-lock.yaml #38754's newmoved ...test strings becomecopied ...once this is in.pnpm-workspace.yamlwas parsed into an arena local to the block that read the file, while thecatalog/catalogs/overrides/patchedDependenciesobjects taken from it are printed after that block ends. Quoted yaml scalars ('@scope/pkg': '^1.0.0', the usual spelling for scoped names) are copied into that arena (yaml.rs,NodeScalar::to_expr), so they were read after the arena was destroyed; unquoted ones point into the file contents and were fine. The yaml is now parsed into the function'sbump, which outlives the print. install: import pnpm-workspace.yaml even without a migratable pnpm-lock.yaml #38754 carries the same one-line fix. ASAN does not flag this one reliably (mimalloc keeps the destroyed heap's pages mapped), so the test for it pins the output rather than proving fail-before.docs/pm/cli/install.mdxnow says the fields are copied and the"pnpm"block is kept.test/cli/install/migration/pnpm-lock-v9.test.ts:the pnpm block in package.json is left as it was(block with other settings, and block containing only the migrated keys) andpnpm-workspace.yaml overrides go into the root copy, not into the pnpm block: fail on main (block removed), pass with this change.bare hash whose path is only in package.json pnpm.patchedDependenciesnow also checks the bare key survives in the block while the root getsno-deps@1.0.1.bun update straight from pnpm-lock.yaml edits the package.json the migration rewrote: on main the debug build exits 1 with the ASAN report above; passes with this change.bun update -i in a pnpm workspace migrates twice and keeps package.json intact(the first time running bun in a pnpm monorepo: bun update --interactive #23694 shape, driven through piped stdin like the tests inbun-update-transitive.test.ts): on main the debug build exits 1 with a use-after-free inupdate_package_json_files_from_updates; passes with this change.update -istill runs the migration twice, so thecopied ...line prints twice there; the second pass merges identical values and leaves the file as the first pass wrote it.quoted pnpm-workspace.yaml scalars are written to package.json: quoted keys and values in the yamloverridesandcatalogend up inpackage.json(passes both ways, see above).test/cli/install/nested-overrides.test.tsthat encoded the removal were updated.pnpm-lock-v9.test.ts(82 pass),nested-overrides.test.ts(144 pass),pnpm-lock-migration,migrate,lockfile-only,pnpm-migration,pnpm-comprehensive,pnpm-migration-complete(all pass; the last one is a single 15-spawn test that sits close to the 5 s default timeout on a loaded machine with a debug build, unrelated to this change).Background
"pnpm"key ofpackage.json; pnpm 10 also accepts them inpnpm-workspace.yaml, and the lockfile records the merged set. bun uses npm's root-leveloverridesand its own root-levelpatchedDependencies, which is why the migration writes root-level copies. The migration leavespnpm-lock.yamlandpnpm-workspace.yamluntouched; the"pnpm"block was the one place it edited pnpm's own configuration.WorkspacePackageJSONCacheholds, perpackage.json, the file contents (source) and a parsed tree (root) whose string nodes borrow from those contents.install_with_managerre-parses the root fromsourceafter the lockfile is loaded, butbun update's editor and a few diagnostics use the cachedrootdirectly, so a writer that changessourcehas to rebuildroot(reparse_root) or keep the old buffer alive (stale_contents);print_package_json_into_cache_entrydoes the latter and callers follow it with the former.Expr::initallocates AST nodes in a thread-localStorethatinitialize_store()resets (poisoned in debug builds); nodes that have to outlive that are allocated in an arena (Expr::allocate) or, as here, the tree is re-parsed from the printed text.patchedDependencieskey ("no-deps") is legal for pnpm;bun.lockkeys patches byname@version, so the migration rewrites bare keys in the root copy using the version the lockfile resolved.Reproduction on bun 1.4.0 (release)
With this change the
pnpmblock is unchanged and the root-leveloverridesis added; the line printed iscopied pnpm.overrides to overrides in package.json.ASAN report from the new
bun updatetest on the unfixed codeFixes #23694