Skip to content

install: keep the pnpm block in package.json when migrating from pnpm - #38775

Open
robobun wants to merge 5 commits into
mainfrom
farm/45e63abb/pnpm-migration-keep-pnpm-block
Open

robobun wants to merge 5 commits into
mainfrom
farm/45e63abb/pnpm-migration-keep-pnpm-block

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun install / bun pm migrate on a project with a pnpm-lock.yaml copies pnpm.overrides and pnpm.patchedDependencies to the root of package.json and 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 as moved pnpm.overrides to overrides in package.json; the removal itself dates back to the original migration (implement pnpm migration #22262).
  • pnpm reads its configuration only from the "pnpm" block and ignores the root-level fields. After one bun install is committed, pnpm install --frozen-lockfile fails for everyone still on pnpm with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and a plain pnpm install drops the overrides: section from pnpm-lock.yaml, so the pins stop applying.
  • bun never reads the "pnpm" block (OverrideMap and Package only read the root-level overrides / resolutions / patchedDependencies; bun-workspaces.test.ts pins that root pnpm.overrides is ignored), so leaving the block in place costs nothing.

Fix

  • pnpm.overrides / pnpm.patchedDependencies are copied and the "pnpm" block is left as it was. The block-pruning code is deleted.
  • copy_object gives the root a separate object. The Expr returned by get aliases the object inside the block, so without the copy, rewrite_bare_patch_keys (bare name keys become name@version for bun) and the merge of pnpm-workspace.yaml entries would now show up inside the "pnpm" block.
  • The four identical merge-or-create blocks (package.json and pnpm-workspace.yaml, overrides and patchedDependencies) share copy_into_root. needs_update is gone: the file is written when the list of copied items is not empty. The message reads copied ... in package.json, since nothing it lists is modified at its source. The moved assertion that install: re-parse the root package.json after the pnpm migration rewrites it #40029 added to bun add migrates pnpm-workspace.yaml ... is updated with it.
  • Docs: the migration section of docs/pm/cli/install.mdx says the fields are copied and the "pnpm" block is kept.
  • Verified with test/cli/install/migration/pnpm-lock-v9.test.ts (88 pass) and test/cli/install/nested-overrides.test.ts (144 pass). Fail on main, pass here: the pnpm block in package.json is left as it was (a block with other settings, and a block that holds only the migrated keys), pnpm-workspace.yaml overrides go into the root copy, not into the pnpm block, bare hash whose path is only in package.json pnpm.patchedDependencies (the bare key stays in the block, the root gets no-deps@1.0.1), the two bun update tests (the block survives bun update's own re-print of package.json, and update -i's second migration pass merges into the copies instead of duplicating them), and the existing assertions that encoded the removal. Also ran pnpm-lock-migration, migrate, lockfile-only, pnpm-migration, pnpm-comprehensive, pnpm-migration-complete.

Background

  • pnpm 9 keeps overrides and patched dependencies under the "pnpm" key of package.json; pnpm 10 also accepts them in pnpm-workspace.yaml, and the lockfile records the merged set. bun uses npm's root-level overrides and its own root-level patchedDependencies, which is why the migration writes root-level copies. The migration leaves pnpm-lock.yaml and pnpm-workspace.yaml untouched; the "pnpm" block was the one place it edited pnpm's own configuration.
  • update_package_json_after_migration edits the parsed tree of the root package.json in place and prints it. Since install: re-parse the root package.json after the pnpm migration rewrites it #40029 it then re-parses the printed text into the cache entry, so the copies made here only have to live until that print.
  • A bare patchedDependencies key ("no-deps") is legal for pnpm; bun.lock keys patches by name@version, so the migration rewrites bare keys in the root copy using the version the lockfile resolved.
  • install: import pnpm-workspace.yaml even without a migratable pnpm-lock.yaml #38754 (open) rewrites other parts of the same function; whichever of the two lands second needs a small rebase, and its moved ... test strings become copied ... once this is in.
Notes

History of this PR: it originally also carried the print_package_json_into_cache_entry + reparse_root write-back (a use-after-free hit by bun update and by #23694's bun update -i, found while testing this change) and a fix for the block-scoped pnpm-workspace.yaml arena raised in review. Both landed on main on their own while this was open (#40029 and #39789) and were dropped from here on rebase, so this PR is only the behavior change again. #23694 is fixed by #40029.

Reproduction on bun 1.4.0 (release):

package.json: {"name":"r","private":true,"dependencies":{"a":"1.0.0"},
               "pnpm":{"overrides":{"b":"1.0.0"},"onlyBuiltDependencies":["a"]}}
pnpm-lock.yaml: lockfileVersion: '9.0' / overrides: {b: 1.0.0} / importers: {.: {}}

$ bun pm migrate
$ cat package.json
{
  ...
  "pnpm": {
    "onlyBuiltDependencies": ["a"]      <- overrides removed from the pnpm block
  },
  "overrides": { "b": "1.0.0" }         <- added at the root, which pnpm does not read
}

With this change the pnpm block is unchanged, the root-level overrides is added, and the line printed is copied pnpm.overrides to overrides in package.json. bun update -i prints that line twice because it runs the migration twice; the second pass merges identical values and leaves the file as the first pass wrote it.


[review] gate passed · iteration 6 · 4 files touched

fails on main (without fix)
ASAN without fix: 13 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/cli/install/migration/pnpm-lock-v9.test.ts" test/cli/install/nested-overrides.test.ts
bun test v1.4.1 (4448a2e21)

test/cli/install/nested-overrides.test.ts:
(pass) syntax > $ref inside a nested value resolves against the root's dependencies [774.66ms]
(pass) syntax > $ref inside a nested value that names nothing warns and is skipped [985.16ms]
(pass) syntax > npm object scopes the rule to the parent's edge [1156.98ms]
(pass) syntax > "." overrides the parent itself next to its children [1173.37ms]
(pass) syntax > parent range is matched against the parent's resolved version, through an alias [1299.95ms]
(pass) syntax > yarn resolutions path one-dep/no-deps > applies to one-dep's edge only [1021.63ms]
(pass) syntax > yarn resolutions path **/one-dep/**/no-deps > applies to one-dep's edge only [882.93ms]
(pass) syntax > yarn resolutions path one-dep@npm:1.0.0/no-deps > applies to one-dep's edge only [906.05ms]
(pass) syntax > yarn resolutions path **/one-dep/no-deps > applies to one-dep's edge only [1205.71ms]
(pass) syntax > yarn
... (truncated)

release without fix: 14 FAILED
bun test v1.4.0-canary.1 (4448a2e21)

test/cli/install/nested-overrides.test.ts:
(pass) syntax > $ref inside a nested value resolves against the root's dependencies [179.22ms]
(pass) syntax > $ref inside a nested value that names nothing warns and is skipped [206.54ms]
(pass) syntax > the same rule spelled in two syntaxes: last one wins and one row is written [227.64ms]
(pass) syntax > a "//" comment key in overrides > is ignored without a warning and never reaches bun.lock [52.44ms]
(pass) syntax > pnpm selector one-dep@<2 >=1>no-deps > applies and writes the same bun.lock as the object form [256.05ms]
(pass) syntax > pnpm parent>child selector [268.71ms]
(pass) syntax > yarn resolutions path **/one-dep/**/no-deps > applies to one-dep's edge only [268.93ms]
(pass) syntax > pnpm selector one-dep@1 >0>no-deps > applies and writes the same bun.lock as the object form [281.91ms]
(pass) syntax > yarn resolutions path one-dep@npm:1.0.0/no-deps > applies to one-dep's edge only [282.50ms]
(pass) syntax > yarn resolutions path with scoped parent and child [285.37ms]
(pass) syntax > npm object scopes the rule to the parent's edge [286.28ms]
(pass) syntax > "." overrides the 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/cli/install/migration/pnpm-lock-v9.test.ts" test/cli/install/nested-overrides.test.ts
bun test v1.4.1 (4448a2e21)

test/cli/install/nested-overrides.test.ts:
(pass) syntax > $ref inside a nested value resolves against the root's dependencies [656.47ms]
(pass) syntax > "." overrides the parent itself next to its children [1023.18ms]
(pass) syntax > parent range is matched against the parent's resolved version, through an alias [1049.84ms]
(pass) syntax > $ref inside a nested value that names nothing warns and is skipped [1070.21ms]
(pass) syntax > npm object scopes the rule to the parent's edge [1223.20ms]
(pass) syntax > yarn resolutions path one-dep/no-deps > applies to one-dep's edge only [914.83ms]
(pass) syntax > yarn resolutions path **/one-dep/**/no-deps > applies to one-dep's edge only [798.44ms]
(pass) syntax > yarn resolutions path **/one-dep/no-deps > applies to one-dep's edge only [844.07ms]
(pass) syntax > pnpm parent>child selector [493.49ms]
(pass) syntax > yarn resolutions path one-dep@npm:1.0.0/no-deps > applies t
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 664ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compiling�[0m bun_sys_jsc v0.0.0 (/workspace/bun/src/sys_jsc)
�[1m�[92m   Compiling�[0m bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
�[1m�[92m   Compiling�[0m bun_sql_jsc v0.0.0 (/workspace/bun/src/sql_jsc)
�[1m�[92m   Compiling�[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
�[1m�[92m   Compiling�[0m bun_bundler_jsc v0.0.0 (/workspace/bun/src/bundler_jsc)
�[1m�[92m   Compiling�[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
�[1m�[92m   Compiling�[0m bun_css_jsc v0.0.0 (/workspace/bun/src/css_jsc
... (truncated)
diff hotspot
docs/pm/cli/install.mdx                         |  12 +-
 src/install/pnpm.rs                             | 253 +++++++-----------------
 test/cli/install/migration/pnpm-lock-v9.test.ts | 201 ++++++++++++++++++-
 test/cli/install/nested-overrides.test.ts       |  20 +-
 4 files changed, 285 insertions(+), 201 deletions(-)

gate history · 2 passed · 1 rejected · iteration 6

evidence per changed file
file                                             reads  edits  tests
docs/pm/cli/install.mdx                              2      3      0
src/install/pnpm.rs                                 19     18      0
test/cli/install/migration/pnpm-lock-v9.test.ts      8     15      0
test/cli/install/nested-overrides.test.ts            2      5      0

@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b4891181-7ed0-4d58-92ce-c7f9c12d6e05

📥 Commits

Reviewing files that changed from the base of the PR and between 4bf3f36 and d40cca5.

📒 Files selected for processing (4)
  • docs/pm/cli/install.mdx
  • src/install/pnpm.rs
  • test/cli/install/migration/pnpm-lock-v9.test.ts
  • test/cli/install/nested-overrides.test.ts

Walkthrough

Changes

The pnpm migration now copies overrides and patchedDependencies to root-level package.json fields while preserving the pnpm block. Tests cover direct, interactive, workspace, repeated, and frozen migrations. Documentation describes the updated behavior and cleanup steps.

pnpm migration

Layer / File(s) Summary
Copy and merge migration configuration
src/install/pnpm.rs
The migration copies and merges pnpm overrides and patched dependencies, preserves the original pnpm fields, updates diagnostics, and reparses cached package.json content.
Validate copied configuration and repeated migrations
test/cli/install/migration/pnpm-lock-v9.test.ts, test/cli/install/nested-overrides.test.ts
Tests cover direct and interactive updates, workspaces, frozen installs, repeated migration, preserved pnpm fields, copied diagnostics, and nested override cases.
Document retained pnpm configuration
docs/pm/cli/install.mdx
The migration documentation describes copied root-level fields, retained pnpm configuration, and cleanup of pnpm files and fields.

Possibly related PRs

  • oven-sh/bun#38333: Both PRs modify pnpm migration handling and related migration tests.
  • oven-sh/bun#38754: Both PRs modify pnpm migration logic and package.json cache handling.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary migration change: preserving the pnpm block in package.json.
Description check ✅ Passed The description explains the problem, fix, implementation details, documentation changes, and verification results, despite using headings different from the template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:30 PM PT - Aug 21st, 2026

❌ @robobun, your commit dad74aa has 1 failures in Build #103128 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38775

That installs a local version of the PR into your bun-38775 executable, so you can run:

bun-38775 --bun

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 with bun pm migrate on a package.json whose pnpm block holds overrides (the block came back without them). Rebased on main after #39789 and #40029 landed; this PR is now only the copy-instead-of-move change. Ready for review at dad74aa. CI (build 103128): every red test lane fails on test/cli/install/bun-audit.test.ts, which is failing on main itself right now (reported to main-break triage); the remaining entries passed on retry. The files this PR touches (pnpm-lock-v9, nested-overrides) pass on every lane.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. first time running bun in a pnpm monorepo: bun update --interactive #23694 - Segfault whose stack goes straight through updatePackageJsonAfterMigration → js_printer.printJSON on first bun update --interactive in a pnpm monorepo, which is the stale-AST/freed-source.contents use-after-free this PR replaces with print_package_json_into_cache_entry + reparse_root.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #23694

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/install/pnpm.rs Outdated
Comment thread src/install/pnpm.rs Outdated
Comment thread src/install/pnpm.rs Outdated
Comment thread src/install/pnpm.rs Outdated
Comment thread src/install/pnpm.rs Outdated
Comment thread src/install/pnpm.rs Outdated
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed #23694 is this use-after-free: bun update -i loads the lockfile once to list the outdated packages (first migration rewrites package.json and leaves the cached tree pointing at the freed contents), edits the root package.json through that tree, then installs, which migrates again because bun.lock is still not on disk. With main's pnpm.rs the debug build reports the use-after-free at the editing step; the issue's release build got one step further and crashed printing the tree in the second migration, which is the stack in the report. Added bun update -i in a pnpm workspace migrates twice and keeps package.json intact to pnpm-lock-v9.test.ts (fails on main, passes here) and Fixes #23694 to the description. The comments flagged above are down to one line each.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Independent reproduction of the same two problems landed on the same fix, so no second PR: branch farm/a6cc97c2/pnpm-migration-keep-pnpm-config (1719e7a) copies instead of removing, builds the root objects separately, and switches the write-out to print_package_json_into_cache_entry + reparse_root; the bun update use-after-free reproduced there the same way (EString::eql_bytes under edit_update_entries).

Two notes for whoever merges:

  • install: import pnpm-workspace.yaml even without a migratable pnpm-lock.yaml #38754 rewrites the same function (update_package_json_after_migration) and also adds the print + reparse step, so whichever of the two lands second needs a rebase.
  • One thing this PR leaves as is and install: import pnpm-workspace.yaml even without a migratable pnpm-lock.yaml #38754 fixes: the pnpm-workspace.yaml tree is parsed into an Arena that is dropped at the end of the read block, but quoted yaml scalars ('packages/*', react: '^18.0.0') are copied into that arena (NodeScalar::to_expr, YamlString::List), and the catalog / overrides / patchedDependencies objects taken from the tree are printed after the block ends. Hoisting the arena to function scope is enough (that branch does it); the pnpm.overrides are copied, not removed test there uses a quoted yaml value to cover the path.

Comment thread src/install/pnpm.rs
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Re the note above: the block-scoped yaml arena is fixed here too as of cb7af74 (the yaml is parsed into the function's bump), with quoted pnpm-workspace.yaml scalars are written to package.json covering quoted keys and values. Description updated; the only remaining overlap with #38754 is the tail of update_package_json_after_migration.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • &bump now backs the yaml parse; bump at pnpm.rs:2348 outlives print_package_json_into_cache_entry, so quoted-scalar Exprs stay valid through the print.
  • copy_object builds a fresh E::Object so rewrite_bare_patch_keys and workspace-yaml merges cannot mutate the retained pnpm block via the aliased get result.
  • copy_into_root preserves the old merge-vs-create semantics, including returning false (no "copied" line) when a non-object root field already exists.
  • print_package_json_into_cache_entry stashes the old contents in stale_contents and reparse_root follows immediately, matching the pattern bun add/--catalog use.
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.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main (43efc06). #39789 landed the pnpm-workspace.yaml arena fix in the meantime (it re-interns the escaping subtrees into DATA_STORE and has a deterministic MIMALLOC_PURGE_DELAY=0 test), so this PR's version of that fix (cb7af74, parse into bump, plus its quoted-scalar test) is dropped and the yaml block is now untouched here. The only textual conflict was the helper insertion point after is_non_empty_object, resolved by keeping data_store_dupe_expr_strings next to copy_object / copy_into_root. Description updated. On the rebased build: pnpm-lock-v9 86 pass, nested-overrides 144 pass, the other pnpm migration files pass.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after the rebase and didn't find any bugs. My earlier finding (the block-scoped pnpm-workspace.yaml arena) is resolved — that fix landed on main as #39789 and this diff no longer touches that code; the escaping subtrees are re-interned via data_store_dupe_expr_strings before the arena drops. Because this pairs a user-facing behavior change with a memory-lifetime refactor in the migration path, a human look would still be worthwhile.

What was reviewed:

  • copy_object builds a fresh property list so rewrite_bare_patch_keys and workspace-yaml merges cannot alias into the surviving pnpm block.
  • print_package_json_into_cache_entry + reparse_root matches the pattern in add_catalog.rs / add_remove_with_filter.rs; the helper stashes the old buffer into stale_contents before swapping, so printing reads valid bytes and the cached root is rebuilt afterwards.
  • copy_into_root returning false when a root-level field exists but is not an object drops the old code's spurious "moved …" line for that edge case; behavior otherwise preserved.
  • The four new tests cover the two UAF shapes (#23694's update -i double-migration and plain bun update) and both pnpm-block-survival variants; existing moved → copied string assertions updated.
Extended reasoning...

Overview

Changes update_package_json_after_migration in src/install/pnpm.rs to (1) copy pnpm.overrides/pnpm.patchedDependencies to the root of package.json while leaving the "pnpm" block untouched, deleting ~90 lines of block-pruning code; (2) collapse four near-identical merge-or-create blocks into two helpers (copy_object, copy_into_root); (3) replace the hand-rolled source.contents = Cow::Owned(...) write-out with the shared print_package_json_into_cache_entry + reparse_root sequence, fixing a use-after-free (#23694) where bun update / bun update -i edited a cached AST whose strings pointed into a freed buffer. Docs updated to say "copied" and explain why the block is kept. Two test files updated: four new tests, several existing assertions changed from "moved" to "copied" and to expect the surviving pnpm block.

Security risks

None. This is package-manager migration logic operating on the user's own package.json and lockfile; no auth, crypto, network parsing, or new untrusted-input surface. The memory-safety fix removes a UAF rather than adding attack surface.

Level of scrutiny

Medium-high. The repo's review guide names native memory safety as the most-blocked category, and this touches AST-node lifetime across the WorkspacePackageJSONCache boundary. I traced print_package_json_into_cache_entry — it prints from the tree while the old contents are still live, then mem::replaces and pushes the old buffer into stale_contents, and reparse_root rebuilds entry.root from the new bytes; that matches the three other callers. copy_object allocates a new PropertyList via Expr::init (thread-local Store), which is fine because the tree is printed and re-parsed before the next initialize_store(). My earlier inline finding on this PR — the block-scoped yaml arena freeing quoted-scalar bytes before use — is now handled on main by re-interning the escaping subtrees (data_store_dupe_expr_strings, #39789), and this diff no longer touches that block.

Other factors

The behavior change (keep the pnpm block so pnpm install --frozen-lockfile keeps working for teammates still on pnpm) is a product decision a maintainer should sign off on, though the rationale is well-argued and bun ignoring the block is already pinned by bun-workspaces.test.ts. The PR description flags a known merge overlap with #38754 in the same function tail. All prior bot feedback (comment-cop, my yaml-arena finding) is resolved. Test coverage includes both ASAN-reproducing UAF shapes plus the block-survival and yaml-merge-isolation invariants; the --frozen-lockfile follow-ups guard idempotence.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and found no bugs. My earlier finding (the block-scoped pnpm-workspace.yaml arena UAF) was addressed — #39789 landed the fix on main and this PR is rebased on it, so the yaml block is no longer touched here. Because this changes user-visible migration behavior (the pnpm block is now kept, root-level fields duplicated) and the description flags a merge-order overlap with #38754 in the same function, a human sign-off on the product decision and merge coordination would still be worthwhile.

What was reviewed:

  • copy_object / copy_into_root against the four call sites they replace — same merge semantics, and the fresh property list means rewrite_bare_patch_keys and yaml merges no longer alias the pnpm block.
  • print_package_json_into_cache_entry + reparse_root matches the exact pattern in add_catalog.rs, add_remove_with_filter.rs, and updatePackageJSONAndInstall.rs; stale_contents.push(old) keeps the printed-from buffer alive until reparse.
  • Checked that copied.is_empty() gates the write the same way needs_update did — every path that pushed to moved now pushes to copied, and wrote_workspaces is still covered.
Extended reasoning...

Overview

This PR changes update_package_json_after_migration in src/install/pnpm.rs to copy pnpm.overrides / pnpm.patchedDependencies to the root of package.json instead of moving them, leaving the "pnpm" block untouched. It deletes ~130 lines of block-pruning logic, extracts the four identical merge-or-create blocks into a shared copy_into_root helper, and adds copy_object so the root copy is a distinct property list (the previous code aliased the same E::Object, so rewrite_bare_patch_keys on the root copy would have mutated the pnpm block too now that the block is kept). It also swaps the hand-rolled print-and-assign-contents for print_package_json_into_cache_entry + reparse_root, which is the established pattern used by bun add --catalog, bun add --filter, and the update-then-install path — this fixes a heap-use-after-free (#23694) where bun update on a pnpm project edited a cached tree whose strings pointed into the freed previous source.contents. Docs and two test files are updated; four new tests cover the kept block, the yaml-vs-block merge, and the two bun update UAF shapes.

Security risks

None identified. The change reads and rewrites the user's own package.json and pnpm-workspace.yaml; no network, no untrusted-archive paths, no auth. The memory-safety change moves from a bespoke buffer swap to the shared helper that already stashes the old contents in stale_contents, which is strictly safer.

Level of scrutiny

Moderate-to-high. The function edits an arena-backed AST whose string nodes borrow from a buffer that gets replaced, which is exactly the class of bug this PR fixes; any writer here needs to get the print → stash-old → reparse sequence right. I verified the new sequence matches the three existing callers byte-for-byte (including the Global::crash() on reparse failure). The behavior change itself — keeping the pnpm block — is a product decision: it changes what a migrated package.json looks like and is announced differently ("copied" vs "moved"). The reasoning is sound (bun ignores the block, pnpm requires it, removing it breaks pnpm install --frozen-lockfile for teammates), but it's a user-visible policy change that a maintainer should ack.

Other factors

  • My prior review on this PR flagged the block-scoped pnpm-workspace.yaml arena dropping before its subtrees were consumed. That was fixed here in cb7af74, then independently on main as #39789 (which re-interns escaping strings via data_store_dupe_expr_strings); this PR is now rebased on #39789 and no longer touches that block. The current diff's copy_into_root calls for workspace_overrides_obj / workspace_patched_deps_obj run after data_store_dupe_expr_strings has re-interned every string, so the arena concern is closed.
  • The if !copied.is_empty() gate is equivalent to the old needs_update: every branch that previously set needs_update = true also pushed to moved, and each of those now pushes to copied (including wrote_workspaces). The only semantic tweak is that copy_into_root returns false (and does not push) when a root-level overrides/patchedDependencies exists but is not an object — the old code silently did nothing but still set needs_update and printed "moved …", so the new behavior is more honest.
  • Tests look solid: the two UAF tests are described as failing on main with ASAN and passing here; the block-kept tests use toStrictEqual on the whole package.json; existing "moved" assertions in both test files were updated to "copied".
  • The description explicitly calls out that #38754 rewrites the same function tail and whichever lands second needs a rebase; that's a merge-coordination item for a human.

dylan-conway pushed a commit that referenced this pull request Aug 22, 2026
…ites it (#40029)

### Problem
- `bun add` in a project that still has `pnpm-lock.yaml` and
`pnpm-workspace.yaml` crashes or writes garbage into `package.json`.
Sentry BUN-4NQS (1.4.0): `Segmentation fault at address
0xF3E9AD2800000000` in `extend_from_slice` under
`EString::resolve_rope_if_needed`, `print_property`,
`edit_after_resolve`. `bun update -i` hits the same bug (#23694).
- The cause is `update_package_json_after_migration`
(`src/install/pnpm.rs:2334`). It edits the cached root `package.json`
tree in place, replaces `entry.source.contents`, and returns. The tree
now points into the freed contents, and its new nodes live in the
thread-local AST `Store`, which the install resets
(`src/install/lockfile/Package.rs:1676`) before the write-back prints
the tree.

### Fix
- Print the edited tree into the cache entry and call
`MapEntry::reparse_root`, as `store_entry`
(`add_remove_with_filter.rs:280`) does. When the migration returns, the
entry owns its tree again.
- A print or re-parse failure now crashes like the other editors do.
Before, a print failure left the dangling tree in the cache.
- Verified: two new tests in
`test/cli/install/migration/pnpm-lock-v9.test.ts` (`bun add`, and the
`bun update -i` shape from #23694). Both fail on main with the debug
build and with the 1.4.0 release. The other `pnpm-*` files pass.

### Background
- `WorkspacePackageJSONCache` keeps one `MapEntry` per `package.json`:
the contents, a parsed tree, and the arena that owns the tree. `bun add`
prints the root entry again after the install
(`package_json_write_back.rs:63`).
- `Expr::init` allocates nodes in a thread-local `Store`.
`initialize_store()` resets it before every `package.json` parse.
`reparse_root` rebuilds the tree in an arena the entry owns.
- String nodes point into the entry's contents, so a writer that
replaces the contents has to rebuild the tree.

<details><summary>Notes</summary>

- Repro without a registry: a `package.json`, a `pnpm-lock.yaml` with
`lockfileVersion: '9.0'` and empty importers, a `pnpm-workspace.yaml`
with a `packages:` list, then `bun add ./some-folder`. The 1.4.0 release
writes `"\x00\x00\x00e"` in place of the `name` key, so `package.json`
is no longer valid JSON. The debug build reports `heap-use-after-free`
in `EString::eql_bytes` under `edit_after_resolve`, freed at
`pnpm.rs:2759` (the `source.contents` assignment on main). Any yaml
field the migration moves (`packages`, `catalog`, `overrides`,
`patchedDependencies`) or a `pnpm.overrides` block triggers it, so every
pnpm workspace does.
- Why the two builds differ: the debug `Store` reset poisons its blocks
with `0xAA`, so the second print crashes every time. In release, the
freed contents buffer gets a free-list pointer written over its first
bytes (the `name` key), and the `Store` slots are reused by the
install's parse of the root `package.json`, which gives the rope walk in
the Sentry stack.
- Readers of the root entry's tree after the lockfile load, all covered
by the one re-parse: the `bun add` / `bun update <name>` / `bun link`
write-back and `sync_lockfile` (the Sentry crash), `bun update -i`
(#23694), `bun update` with patches (`warn_orphaned_patches`), `bun
dedupe` with an empty root, `bun audit --fix`, and the
`--frozen-lockfile` error message (`overrides_field_name`). Plain `bun
install` only reads `entry.source` after the migration, so it does not
crash.
- The open #38775, #38754, and #38804 each add this same print and
re-parse to `update_package_json_after_migration` as part of larger
behavior changes (all three are in the fold branch #39403). None of them
is in 1.4.0. This PR is only the crash fix, so it can land on its own.
Whichever lands second gets a conflict in this block that resolves to
the same code.
- Not changed here: the `patch --commit` branch in
`update_package_json_and_install_with_manager_with_updates`
(`updatePackageJSONAndInstall.rs:573`) replaces the root entry's
contents the same way without a re-parse. Nothing reads that entry's
tree afterwards today, and #38754 reworks that block. Also separate:
`bun remove` during a pnpm migration writes its pre-install print back
to disk and loses the fields the migration moved. That is a different
bug and is tracked on its own.
- Suites run with the debug build: `pnpm-lock-v9` (84 pass),
`pnpm-migration`, `pnpm-lock-migration`, `pnpm-comprehensive`,
`pnpm-migration-complete` (the last one is a single test that sits near
the 5 s default timeout when run next to other files, unrelated to this
change). `complex-workspace` and `yarn-lock-migration` need network
access and fail here with the release build too.
</details>

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/cli/install/migration/pnpm-lock-v9.test.ts"
bun test v1.4.1 (4448a2e)

test/cli/install/migration/pnpm-lock-v9.test.ts:
(pass) pnpm-lock.yaml v9 > v9 git and userinfo-tarball references migrate [372.39ms]
(pass) pnpm-lock.yaml v9 > v9 alias in snapshot optionalDependencies gets the npm: prefix [531.51ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry of a workspace importer [188.78ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry of a transitive dependency [164.22ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry [227.90ms]
(pass) pnpm-lock.yaml v9 > reports an importer whose package.json is missing [170.19ms]
(pass) pnpm-lock.yaml v9 > registry-qualified dep path resolves from the configured registry with a warning [258.83ms]
(pass) pnpm-lock.yaml v9 > named registries > built-in npmjs: entries record the npmjs registry [214.16ms]
(pass) pnpm-lock.yaml v9 > named registries > namedRegistries entry pointing at the configured registry needs no warning [445.93ms]
(pass) pnpm-lock.yaml
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (4448a2e)

test/cli/install/migration/pnpm-lock-v9.test.ts:
(pass) pnpm-lock.yaml v9 > v9 git and userinfo-tarball references migrate [9.21ms]
(pass) pnpm-lock.yaml v9 > v9 alias in snapshot optionalDependencies gets the npm: prefix [49.92ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry [5.92ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry of a workspace importer [4.76ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry of a transitive dependency [3.30ms]
(pass) pnpm-lock.yaml v9 > reports an importer whose package.json is missing [3.40ms]
(pass) pnpm-lock.yaml v9 > registry-qualified dep path resolves from the configured registry with a warning [13.91ms]
(pass) pnpm-lock.yaml v9 > named registries > built-in npmjs: entries record the npmjs registry [9.93ms]
(pass) pnpm-lock.yaml v9 > named registries > namedRegistries entry pointing at the configured registry needs no warning [20.87ms]
(pass) pnpm-lock.yaml v9 > named registries > namedRegistries entry pointing at another registry is used for the tarballs [19.37ms]
(pass) pnpm-lock.yaml v9 > named registries > two packages from one unknown re
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/cli/install/migration/pnpm-lock-v9.test.ts"
bun test v1.4.1 (4448a2e)

test/cli/install/migration/pnpm-lock-v9.test.ts:
(pass) pnpm-lock.yaml v9 > v9 git and userinfo-tarball references migrate [309.70ms]
(pass) pnpm-lock.yaml v9 > v9 alias in snapshot optionalDependencies gets the npm: prefix [405.45ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry [167.07ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry of a workspace importer [174.28ms]
(pass) pnpm-lock.yaml v9 > reports the missing packages entry of a transitive dependency [172.30ms]
(pass) pnpm-lock.yaml v9 > reports an importer whose package.json is missing [157.78ms]
(pass) pnpm-lock.yaml v9 > registry-qualified dep path resolves from the configured registry with a warning [197.44ms]
(pass) pnpm-lock.yaml v9 > named registries > built-in npmjs: entries record the npmjs registry [193.97ms]
(pass) pnpm-lock.yaml v9 > named registries > namedRegistries entry pointing at the configured registry needs no warning [335.05ms]
(pass) pnpm-lock.yaml
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     2a65176
  features     baseline

23 deps, 129 codegen, 1172 objects in 1101ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.0-canary.1 (4448a2e)

Checked 26 installs across 63 packages (no changes) [7.00ms]
[2/1244] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (4448a2e)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1244] gen ErrorCode+*.h
[4/1244] fetch tinycc
[tinycc] up to date
[5/1243] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (4448a2e)

Checked 111 installs across 104 packages (no changes) [10.00ms]
[6/1243] gen bindgenv2
[7/1243] fetch zlib
[zlib] up to date
[8/1243] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1216] gen node-fallbacks/react-refresh.js
Bundled 1 module in 10ms

  react-refresh.js  4.81 KB  (entry point)

[10/1216] gen .bind.ts → GeneratedBindings.cpp
[11/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/bu
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/install/pnpm.rs                             |  37 ++------
 test/cli/install/migration/pnpm-lock-v9.test.ts | 113 ++++++++++++++++++++++++
 2 files changed, 121 insertions(+), 29 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                             reads  edits  tests
src/install/pnpm.rs                                  4      3      0
test/cli/install/migration/pnpm-lock-v9.test.ts      3      2      0
```

</details>

<!-- robobun:evidence:end -->
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.
@robobun
robobun force-pushed the farm/45e63abb/pnpm-migration-keep-pnpm-block branch from 972d2a5 to dad74aa Compare August 22, 2026 02:24
@robobun

robobun commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main again (dad74aa). #40029 landed the print + re-parse write-back that this PR also carried, so that half is dropped here and the conflict in the tail of update_package_json_after_migration resolved to main's code (this PR only changes the needs_update condition into !copied.is_empty()). Two follow-ups from that: the moved pnpm-workspace.yaml to workspaces assertion in #40029's new bun add test becomes copied, and the two bun update tests here are now described in terms of the kept block rather than the crash. Fixes #23694 is removed from the description since #40029 is what fixes it. Description rewritten; on the rebased build pnpm-lock-v9 has 88 passing and nested-overrides 144.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs — the earlier yaml-arena UAF I flagged is resolved by the rebase onto #39789. Because this changes user-facing migration behavior (keeping vs. deleting the pnpm block), refactors Store-allocated AST manipulation in update_package_json_after_migration, and has an acknowledged overlap with open #38754, a human look would still be worthwhile.

What was reviewed

  • copy_object/copy_into_root: verified all four call sites pass objects (guarded by is_non_empty_object/get_object), and the non-object-existing-root case now correctly returns false without announcing a copy.
  • copied.is_empty() gate replacing needs_update: confirmed no case writes to the tree without pushing to copied, so the print/reparse still fires whenever the tree changed.
  • Store-allocated Expr::init in copy_object: nodes are consumed by print_package_json_into_cache_entry then discarded by reparse_root before any initialize_store(), matching the pattern in bun add.
Extended reasoning...

Overview

This PR changes update_package_json_after_migration in src/install/pnpm.rs to copy pnpm.overrides and pnpm.patchedDependencies into root-level fields instead of moving them (the pnpm block is left intact). It deletes ~130 lines of block-pruning logic, replaces four duplicated merge-or-create blocks with two helpers (copy_object, copy_into_root), updates the user-facing message from moved to copied, and updates docs/tests accordingly. It also carries a fix for an ASAN-confirmed use-after-free in bun update on a pnpm project (#23694), covered by two new regression tests that fail on main.

Security risks

None. The change reads user-controlled JSON/YAML that is already parsed by existing code paths; no new untrusted-input parsing, no filesystem/network/permission changes.

Level of scrutiny

Moderate-to-high. The Rust side manipulates borrowed AST nodes across arena and Store allocators — the same class as the two UAFs already found and fixed during this PR's review cycle. The helpers are straightforward and the print-then-reparse pattern is the established one, but AST-lifetime code in the installer has bitten before. The behavior change (keep the pnpm block) is a product decision: the argument is convincing (bun ignores the block, pnpm requires it, deleting it breaks teammates' pnpm install --frozen-lockfile), but a maintainer should sign off on it.

Other factors

  • Test coverage is thorough: 86 + 144 passing tests across the two touched files, new tests verified to fail under ASAN on main and pass with the fix, plus a bun update -i regression test for #23694.
  • My earlier inline finding (block-scoped yaml arena dropping before its Exprs were printed) was resolved: #39789 landed on main and this PR is rebased on it, so that code is no longer touched here.
  • The PR description flags an overlap with open #38754 in the tail of this function — whichever lands second needs a small rebase, and #38754's moved ... test strings become copied .... That coordination is a reason for a maintainer to sequence the two.
  • All prior bot feedback (comment-cop paragraph-comment warnings) has been addressed; comments in the touched function are now single-line.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants