fix(npm): discover pnpm virtual-store packages — transitive deps were invisible to apply and scan - #202
Merged
Mikola Lysenko (mikolalysenko) merged 4 commits intoAug 19, 2026
Conversation
… invisible to apply and scan Under pnpm's isolated linker a transitive-only dependency lives solely inside node_modules/.pnpm/<entry>/node_modules/<name>; the crawler skipped .pnpm as a hidden dir and never traversed symlinked packages, so apply reported package_not_installed for packages that were installed and runtime-loaded, and scan never sent them to the patch API. Confirmed empirically on pnpm 7, 8, 9, 10, 11, and 12-rc (2026-08-18 matrix). - find_by_purls: probe .pnpm store entries (real dirs only, root install wins via BFS order); entries whose dir name decodes as name@version are filtered against pending targets, undecodable names ride a conservative fallback so truncated/hashed dirs stay probeable. - crawl_all: inventory the virtual store after the root pass; identity re-reads are skipped for already-seen name@version entries, bundled deps inside store entries still walk. - One shared store-entry enumerator; scan helpers parameterized by a ScanPolicy bit instead of a parallel copy. - Multi-version installs of one package now individually discoverable. Tests: hand-built pnpm-shaped farm (transitive, multi-version, scoped, decoys, truncated-name fallback, decoder units) + a real-PATH-pnpm transitive apply e2e with CoW inode proofs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issues.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit bd4bd19. Configure here.
… .registry.* stores) The flat-entry store walk covered pnpm 6+ only. Two earlier layouts (both confirmed against captured real installs) still hid transitive-only deps — apply exited 0 claiming success with nothing written: - pnpm 4/5 (layoutVersion 3): entries nest by registry host — .pnpm/<registry-host>/<name>/<version>/node_modules/<name> — so the host child has no node_modules of its own and the flat walk found nothing behind it. - pnpm <=3 (layoutVersion <=2): the store is a hidden node_modules/.<registry-host> dir (no .pnpm at all), swallowed by the hidden-entry skip. list_pnpm_store_entries now descends a nested host (bounded: depth 3 = @scope/name/version, 16K-dir fan-out cap, symlinks never traversed) and synthesizes flat name@version entry names so the pending-name filter and identity_seen dedup treat nested and flat entries identically; node_modules/.registry.* dirs are recognized as legacy store roots for both the resolver and the scan pass (deliberately NOT any-hidden-dir — that would walk arbitrary tool caches). Root-install-wins ordering preserved. Tests: byte-accurate replicas of the captured pnpm-4 and pnpm-3 trees (decoy entries, hoist dirs, symlink-cycle bait) pinning find_by_purls + crawl_all for each layout, plus a REAL corepack pnpm@4.14.4 install leg proving offline apply patches a transitive dep in the genuine nested layout. Re-verified against the captured matrix trees: apply now exits 0 with the transitive patch applied on both real pnpm 4.14.4 and 3.8.1 projects that previously reported package_not_installed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed-only targets Adversarial review of the virtual-store discovery found two fail-opens: 1. Peer-variant duplicates — .pnpm/foo@1.0.0(react@17)/ and .pnpm/foo@1.0.0(react@18)/ are two real dirs holding the same name@version — resolved to ONE copy per purl: apply exited 0 claiming the CVE fixed while dependents through the second instantiation kept loading vulnerable bytes (the same single-instance fail-open the redirect rewriter eliminates on the lock side). The apply engine now fans out: find_pnpm_peer_variant_copies discovers every other physical store copy of the primary's name@version (flat + nested layouts, canonical-path dedup), the full verify+patch pipeline runs per copy — including when the primary is AlreadyPatched, healing pre-fix damage — and any copy failing fails the result. The shared write path fans out for rollback's restores too; CoW holds per copy. The dispatcher's purl-keyed contract is unchanged. 2. The resolver's pending-name store-entry filter hid targets that exist ONLY as a bundled dependency inside another package's store entry. find_by_purls now runs a second, unfiltered store pass for targets still unresolved after the filtered walk (common-case perf intact). Tests (all RED-verified against the pre-fix code): peer-variant twin apply+rollback with hardlinked-store inode purity asserted at BOTH canonical paths; AlreadyPatched-primary heals the lagging twin; bundled-only target resolves via the fallback. Verified end-to-end on real corepack pnpm 10.34.5 with a hard-linked crafted twin: both copies patched, global store pristine, rollback restores all copies. Known residuals (fail toward patched bytes, never vulnerable ones): rollback does not fan out deletion of patch-ADDED files, and an already-original primary early-returns before healing a still-patched twin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
enabled auto-merge (squash)
August 19, 2026 14:57
Review feedback: the campaign-added tests (pnpm virtual-store transitive
apply, pnpm4/pnpm3-era legs) called bare .unwrap() on fallible fixture
steps, so a CI failure named no step. All 26 fixture-step unwraps in the
new tests now carry .expect("…") messages in the file's established
style; long-standing tests are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wenxin Jiang (Wenxin-Jiang)
approved these changes
Aug 19, 2026
Mikola Lysenko (mikolalysenko)
deleted the
fix/pnpm-virtual-store-discovery
branch
August 19, 2026 15:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Problem
Under pnpm's isolated linker (the default on every pnpm major), a transitive-only dependency lives solely inside
node_modules/.pnpm/<entry>/node_modules/<name>— there is no importer-root symlink for it. The npm crawler skipped.pnpmas a hidden directory and never traversed symlinked packages, so:applyreportedpackage_not_installedfor packages that were physically installed and runtime-loaded (the classic minimist-behind-mkdirp CVE shape), exiting as if the project were clean;scannever included those packages in the patch-API batch query, so their patches could never surface.Confirmed empirically on pnpm 7.33.5, 8.15.9, 9.15.9, 10.34.5, 11.22.0, and 12.0.0-rc.7 (2026-08-18 e2e matrix, 18 legs, real corepack installs).
Fix
find_by_purlsnow probes.pnpmstore entries (real dirs only; BFS keeps root-install-wins). Store entries whose dir name decodes asname@versionare filtered against the pending-target set so large monorepo stores aren't walked wholesale; undecodable names (truncated/hash-suffixed dirs) ride a conservative fallback and stay probeable.crawl_allinventories the virtual store after the root pass; identity re-reads are skipped for already-seenname@versionentries while bundled deps inside store entries still walk.ScanPolicybit instead of a ~55-line parallel copy.The no-symlink-traversal policy is unchanged everywhere else;
.pnpmis the one documented exception because the virtual store is the only physical home of transitive deps under the isolated linker.Verification
+-escaped scope / v9 peer paren / legacy_suffix / non-package names → None).in_process_alternate_installers.rs: pnpm install of mkdirp@0.5.5 in two sibling projects sharing one hardlink-imported store; offline apply of a hand-staged minimist manifest must patch the canonical.pnpmpath (exact bytes + git-sha256), break the hardlink (inode diverges), and leave the sibling + global store byte-pristine.package_not_installed, scan batch body[mkdirp]only; after — apply exit 0 applied=1 with marker bytes at the canonical path, store clean, scan batch body includesminimist@1.2.8.e2e_safety_pnpm) re-run green on PATH pnpm 11 (4/4).🤖 Generated with Claude Code
Note
Medium Risk
Changes core npm package discovery and resolution paths used by apply and scan; behavior is well covered by new unit and e2e tests, but incorrect traversal could still miss packages or patch wrong paths.
Overview
Fixes a gap where pnpm isolated-linker transitive dependencies (only under
node_modules/.pnpm/…, often with no importer-root symlink) were skipped because.pnpmwas treated like other hidden dirs and store symlinks were not walked correctly. Apply could reportpackage_not_installed; scan could omit those packages from batch discovery.The npm crawler now defers a
.pnpmvirtual-store pass after the importernode_moduleswalk, with aScanPolicyseparating importer trees (record symlinks, defer store) from store-entry trees (real dirs only, skip redundantpackage.jsonwhen already seen).find_by_purlsenqueues store entries during BFS, filtering by pending package names viadecode_pnpm_store_entry_name(peer suffixes, scoped+escaping, conservativeNonefor truncated/git entries that stay probeable). Sharedlist_pnpm_store_entriescentralizes store layout rules.Coverage adds decoder unit tests, hand-built pnpm-farm e2e for resolve/scan/filter/bundled deps, and a real pnpm install test (
mkdirp→ transitiveminimist) asserting patch at the canonical store path and CoW (hardlink break) so a sibling project sharing the store stays pristine.Reviewed by Cursor Bugbot for commit bd4bd19. Configure here.