pnpm Lockfile Integrity Bypass Allows Remote Dynamic Dependencies
Description
pnpm is a package manager. Versions 10.26.2 and below store HTTP tarball dependencies (and git-hosted tarballs) in the lockfile without integrity hashes. This allows the remote server to serve different content on each install, even when a lockfile is committed. An attacker who publishes a package with an HTTP tarball dependency can serve different code to different users or CI/CD environments. The attack requires the victim to install a package that has an HTTP/git tarball in its dependency tree. The victim's lockfile provides no protection. This issue is fixed in version 10.26.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
pnpmnpm | < 10.26.0 | 10.26.0 |
Affected products
1Patches
10958027f88a9feat: enhance `store prune` to clean global virtual store (#10360)
16 files changed · +1038 −32
.changeset/global-virtual-store-prune.md+9 −0 added@@ -0,0 +1,9 @@ +--- +"@pnpm/package-store": minor +"@pnpm/get-context": minor +"pnpm": minor +--- + +Added project registry for global virtual store prune support. + +Projects using the store are now registered via symlinks in `{storeDir}/v10/projects/`. This enables `pnpm store prune` to track which packages are still in use by active projects and safely remove unused packages from the global virtual store.
.changeset/prune-global-virtual-store.md+14 −0 added@@ -0,0 +1,14 @@ +--- +"@pnpm/package-store": minor +"pnpm": minor +--- + +Added mark-and-sweep garbage collection for global virtual store. + +`pnpm store prune` now removes unused packages from the global virtual store's `links/` directory. The algorithm: + +1. Scans all registered projects for symlinks pointing to the store +2. Walks transitive dependencies to mark reachable packages +3. Removes any package directories not marked as reachable + +This includes support for workspace monorepos - all `node_modules` directories within a project (including those in workspace packages) are scanned.
pkg-manager/get-context/package.json+1 −0 modified@@ -36,6 +36,7 @@ "@pnpm/core-loggers": "workspace:*", "@pnpm/lockfile.fs": "workspace:*", "@pnpm/modules-yaml": "workspace:*", + "@pnpm/package-store": "workspace:*", "@pnpm/read-projects-context": "workspace:*", "@pnpm/resolver-base": "workspace:*", "@pnpm/types": "workspace:*",
pkg-manager/get-context/src/index.ts+7 −0 modified@@ -21,6 +21,7 @@ import { } from '@pnpm/types' import pathAbsolute from 'path-absolute' import clone from 'ramda/src/clone' +import { registerProject } from '@pnpm/package-store' import { readLockfiles } from './readLockfiles.js' /** @@ -123,6 +124,9 @@ export async function getContext ( await fs.mkdir(opts.storeDir, { recursive: true }) + // Register this project for store prune tracking + await registerProject(opts.storeDir, opts.lockfileDir) + for (const project of opts.allProjects) { packageManifestLogger.debug({ initial: project.manifest, @@ -293,6 +297,9 @@ export async function getContextForSingleImporter ( const virtualStoreDir = pathAbsolute(opts.virtualStoreDir ?? 'node_modules/.pnpm', opts.lockfileDir) await fs.mkdir(storeDir, { recursive: true }) + + // Register this project for store prune tracking + await registerProject(storeDir, opts.lockfileDir) const extraBinPaths = [ ...opts.extraBinPaths || [], ]
pkg-manager/get-context/tsconfig.json+3 −0 modified@@ -27,6 +27,9 @@ { "path": "../../resolving/resolver-base" }, + { + "path": "../../store/package-store" + }, { "path": "../modules-yaml" },
pnpm-lock.yaml+180 −29 modified@@ -1166,7 +1166,7 @@ importers: dependencies: '@pnpm/workspace.find-packages': specifier: 'catalog:' - version: 1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) + version: 1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) '@pnpm/workspace.read-manifest': specifier: 'catalog:' version: 1000.1.5 @@ -5183,6 +5183,9 @@ importers: '@pnpm/modules-yaml': specifier: workspace:* version: link:../modules-yaml + '@pnpm/package-store': + specifier: workspace:* + version: link:../../store/package-store '@pnpm/read-projects-context': specifier: workspace:* version: link:../read-projects-context @@ -8048,6 +8051,12 @@ importers: '@pnpm/create-cafs-store': specifier: workspace:* version: link:../create-cafs-store + '@pnpm/crypto.hash': + specifier: workspace:* + version: link:../../crypto/hash + '@pnpm/error': + specifier: workspace:* + version: link:../../packages/error '@pnpm/fetcher-base': specifier: workspace:* version: link:../../fetching/fetcher-base @@ -8072,6 +8081,9 @@ importers: '@zkochan/rimraf': specifier: 'catalog:' version: 3.0.2 + is-subdir: + specifier: 'catalog:' + version: 1.2.0 load-json-file: specifier: 'catalog:' version: 6.2.0 @@ -8081,6 +8093,9 @@ importers: ssri: specifier: 'catalog:' version: 10.0.5 + symlink-dir: + specifier: 'catalog:' + version: 6.0.5 devDependencies: '@pnpm/client': specifier: workspace:* @@ -8246,6 +8261,9 @@ importers: '@pnpm/logger': specifier: workspace:* version: link:../../packages/logger + '@pnpm/package-store': + specifier: workspace:* + version: link:../package-store '@pnpm/plugin-commands-script-runners': specifier: workspace:* version: link:../../exec/plugin-commands-script-runners @@ -9928,6 +9946,10 @@ packages: resolution: {integrity: sha512-BN7y+f4JHsixxq5uX1HYb791/CRJrIkGnH4EKN/vTgLWG7QyBzplyE8+gh1SfPGrcdefU10G+B1zMOkOiN/iwA==} engines: {node: '>=18.12'} + '@pnpm/cafs-types@1000.1.0': + resolution: {integrity: sha512-uUAnheFdWz+rwgDSr0MO8LH0M27j/ocj+KVXlGmmaAHyMKqIMRnuQZdAciAW7/Cb29WOfmPFm+U/aRtBjysE9g==} + engines: {node: '>=18.12'} + '@pnpm/catalogs.config@1000.0.2': resolution: {integrity: sha512-2GCYZwxmgw6w0bpB71VbbXapgIcSSFOF9vnS+YLyTdy8JaIYoag2XkhXP1cMu24THPRXeo/zKTyziEsqgr1u8w==} engines: {node: '>=18.12'} @@ -10002,6 +10024,12 @@ packages: peerDependencies: '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/core-loggers@1001.0.8': + resolution: {integrity: sha512-uQOhMKaym12a3Yk1vYhp6T1NecgS7YACex6VXYZaasmBq5D0iCIz/ZFgaDEWPsNPehKb8v9BJmElT1nHHsNWEQ==} + engines: {node: '>=18.12'} + peerDependencies: + '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/create-cafs-store@1000.0.14': resolution: {integrity: sha512-95OczT9/zsJea3Nm6x9ovUD04GoLKYHnSCKUhe5VFx7ckUxW8DUrFJynl94Tx+kdf7u7zXBIlwhhHJEplDaNTg==} engines: {node: '>=18.12'} @@ -10014,6 +10042,12 @@ packages: peerDependencies: '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/create-cafs-store@1000.0.27': + resolution: {integrity: sha512-52BHUOB0sWT8DGkL87FujFc/fxd0Gn8O+UciLMtDh9wB5jQyJMR07HI4N5JmEFlNvKyzCx57JPHFOQrxA6jYQw==} + engines: {node: '>=18.12'} + peerDependencies: + '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/crypto.hash@1000.1.1': resolution: {integrity: sha512-lb5kwXaOXdIW/4bkLLmtM9HEVRvp2eIvp+TrdawcPoaptgA/5f0/sRG0P52BF8dFqeNDj+1tGdqH89WQEqJnxA==} engines: {node: '>=18.12'} @@ -10066,6 +10100,10 @@ packages: resolution: {integrity: sha512-qAPTRskRWyG/2jOZH6WMNNUd4Px552AvrvjXPdesRmGFw3ht/Gv066LEYYuOZr8kOewD2AnYZwyXYsJ4gxHyKw==} engines: {node: '>=18.12'} + '@pnpm/exec.pkg-requires-build@1000.0.15': + resolution: {integrity: sha512-eDcGM6P/PmkCQQTlDWuMmhPjc734YchAnpe2mBd8QzaDlhIKzkg2eBYICg6VKcMr+YBGa6Cu7426ExY767ljYw==} + engines: {node: '>=18.12'} + '@pnpm/exec.pkg-requires-build@1000.0.8': resolution: {integrity: sha512-8Mx71nPcUEJpLVzl4k/+Yu5Mir8JLg4oWEImkMfLKd9orU/F7A5FIHTeLw4RAnK0MummjmXPwj8UMQgOxkq2eA==} engines: {node: '>=18.12'} @@ -10088,6 +10126,10 @@ packages: resolution: {integrity: sha512-hnVGfLGJtzSsysy1iqrOYYXZCxdlO7RZeRp28+j9Pq/HAchWvuK9kEo35kgE0upmegCTYuNuD3f0zDRCD5hZuQ==} engines: {node: '>=18.12'} + '@pnpm/fetcher-base@1001.2.0': + resolution: {integrity: sha512-6dCnVyIuSmJdPeqHxp0+cFKeb5rROvLQCO0OpR8iuH5HWpWsOPwu5mm4aOdmSjL+fCTrV90waMwlnIW7sXjmgg==} + engines: {node: '>=18.12'} + '@pnpm/fetching-types@1000.1.0': resolution: {integrity: sha512-0JFRtWH/6Pwsl9Q9CwxHpCxsoaaTr4cYbL4moMiVYnllg8yeJSU3V5S0gPsAlIdhHfjBVNfwMIM99pICzic33Q==} engines: {node: '>=18.12'} @@ -10112,6 +10154,12 @@ packages: peerDependencies: '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/fs.indexed-pkg-importer@1000.1.21': + resolution: {integrity: sha512-1AbbtJlKTshkYImHKVoSIh6QygnM+UkJCdpl7WFWgVjLgYRa8dMRP3Pq5yHv9t7e8ePjeELcJidl9/r0hGR6yQ==} + engines: {node: '>=18.12'} + peerDependencies: + '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/fs.indexed-pkg-importer@1000.1.8': resolution: {integrity: sha512-VwsjBhAyW+5TQO6Ndon1y8kyvSLQJyyWzwNRENQN+UkbrybsjNXHX1vcMV4Li/+pQ0VBYFVxYl/cx+EkU8H9hQ==} engines: {node: '>=18.12'} @@ -10364,6 +10412,10 @@ packages: resolution: {integrity: sha512-d/l84T+J26xVfl28HbfTNaZcpbzr+VH3CFI2RLFWxI8Y34pIphKbD18Zke6dQmv+g7JenT6FmE8YSRIfwAEjaw==} engines: {node: '>=18.12'} + '@pnpm/resolver-base@1005.3.3': + resolution: {integrity: sha512-mwh5nHZXP1iORD6bQLYAYp9WWma9kwHsK9tLWICLvrWPxw+RqRthKsp0NxYPR7nV1Yd1cEgAi1lWxgWNxjJ8wA==} + engines: {node: '>=18.12'} + '@pnpm/resolving.jsr-specifier-parser@1000.0.0': resolution: {integrity: sha512-/61cFu7EJcrXCJtqo9cjaEcuvtUYWOZQE0o7CrYL0S15lN7gSbUMD19y/n0eG4rBxyRJg3U+fnj43f+mvREL/A==} engines: {node: '>=18.12'} @@ -10400,6 +10452,10 @@ packages: resolution: {integrity: sha512-MHslDEKzoXj+rGA89smxVQC98vU9eFRZSBMfoQVNL/fKyigLDI4vLwxUhf6LiMZwO8wbr+Phi2TdcIY4gNeeAw==} engines: {node: '>=18.12'} + '@pnpm/store-controller-types@1004.4.1': + resolution: {integrity: sha512-eB6sHYQg+AcCfcYzm8iQLhJnQTJiC4vJVls1NBElj7XnPzU75L0hYhDZr3NL5knlIp1V91pBh//3D4L/IM4AOA==} + engines: {node: '>=18.12'} + '@pnpm/store-path@1000.0.2': resolution: {integrity: sha512-Ab2RJUnMb0ZP7rRTP9mr+KUSeoWjozNbd9gqC7ZYptHUlPohpVbjBY2xeppApw6GVzHLWPB3hIyXXz7qylnHuQ==} engines: {node: '>=18.12'} @@ -10412,12 +10468,22 @@ packages: resolution: {integrity: sha512-F/82ClolPdQIn3JP19LTpoi0FfzhjrkLLZjwDDFN6Kzy6QxRYIfIG3ame3eZEijAsPMWv7X9iIZVvG4NOSP2aA==} engines: {node: '>=18.12'} + '@pnpm/store.cafs@1000.1.0': + resolution: {integrity: sha512-WILVM1wwefg8PxX5ment/CLkCj1ibWYXhAe16v+0BRNI7NQ6vknqF0xD4TGG1gKOJmcbLZY4oJN7Cjwz8G4jmA==} + engines: {node: '>=18.12'} + '@pnpm/symlink-dependency@1000.0.14': resolution: {integrity: sha512-L1DZsBSGSo1zyDxuW6qmAo0tDMYnLi1I65dPwqUpjuolGuldh4mzalyYRaHUBgFyCRBwcYzKLEYGOsbF7lquMQ==} engines: {node: '>=18.12'} peerDependencies: '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/symlink-dependency@1000.0.16': + resolution: {integrity: sha512-xPvIQgGRF7PX6ZBP+E/2DMp5JxCd1Zd6w+Fiwiszgs1sDR5G00jUtHlg8PBVHW5SGUqOikGHiOsFDRfmhxo8TQ==} + engines: {node: '>=18.12'} + peerDependencies: + '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/tabtab@0.5.4': resolution: {integrity: sha512-bWLDlHsBlgKY/05wDN/V3ETcn5G2SV/SiA2ZmNvKGGlmVX4G5li7GRDhHcgYvHJHyJ8TUStqg2xtHmCs0UbAbg==} engines: {node: '>=18'} @@ -10448,6 +10514,10 @@ packages: resolution: {integrity: sha512-v5X09E6LkJFOOw9FgGITpAs7nQJtx6u3N0SNtyIC5mSeIC5SebMrrelpCz6QUTJvyXBEa1AWj2dZhYfLj59xhA==} engines: {node: '>=18.12'} + '@pnpm/types@1001.2.0': + resolution: {integrity: sha512-UIju+OadUVS0q5q/MbRAzMS5M9HZcZyT6evyrgPUH0DV9przkcW7/LH1Sj33Q2MpJO9Nzqw4b4w72x8mvtUAew==} + engines: {node: '>=18.12'} + '@pnpm/util.lex-comparator@3.0.2': resolution: {integrity: sha512-blFO4Ws97tWv/SNE6N39ZdGmZBrocXnBOfVp0ln4kELmns4pGPZizqyRtR8EjfOLMLstbmNCTReBoDvLz1isVg==} engines: {node: '>=18.12'} @@ -10463,6 +10533,12 @@ packages: peerDependencies: '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/worker@1000.6.0': + resolution: {integrity: sha512-DgWHMQld2TMwt5AYvvNLc3+8peaVLV+9quZSa3zU5QEUceaIRXV0lc2cQhaJCbaLOejbsitqoWNnTV+2fg5jGA==} + engines: {node: '>=18.12'} + peerDependencies: + '@pnpm/logger': '>=1001.0.0 <1002.0.0' + '@pnpm/workspace.find-packages@1000.0.25': resolution: {integrity: sha512-dKXeM46nSXKOzIIvofAhrcZqivxeJIqG27MX2nQoYYtccdJw6IBWozPqDJIPw0V3WLt9DAEQOqooEasbBmB5wg==} engines: {node: '>=18.12'} @@ -17080,6 +17156,8 @@ snapshots: '@pnpm/cafs-types@1000.0.0': {} + '@pnpm/cafs-types@1000.1.0': {} + '@pnpm/catalogs.config@1000.0.2': dependencies: '@pnpm/error': 1000.0.2 @@ -17113,19 +17191,19 @@ snapshots: - supports-color - typanion - '@pnpm/cli-utils@1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': + '@pnpm/cli-utils@1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': dependencies: '@pnpm/cli-meta': 1000.0.8 '@pnpm/config': 1003.1.1(@pnpm/logger@1001.0.0) - '@pnpm/config.deps-installer': 1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) + '@pnpm/config.deps-installer': 1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) '@pnpm/default-reporter': 1002.0.1(@pnpm/logger@1001.0.0) '@pnpm/error': 1000.0.2 '@pnpm/logger': 1001.0.0 '@pnpm/manifest-utils': 1001.0.1(@pnpm/logger@1001.0.0) '@pnpm/package-is-installable': 1000.0.10(@pnpm/logger@1001.0.0) '@pnpm/pnpmfile': 1001.2.2(@pnpm/logger@1001.0.0) '@pnpm/read-project-manifest': 1000.0.11 - '@pnpm/store-connection-manager': 1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) + '@pnpm/store-connection-manager': 1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) '@pnpm/types': 1000.6.0 chalk: 4.1.2 load-json-file: 6.2.0 @@ -17154,16 +17232,16 @@ snapshots: - supports-color - typanion - '@pnpm/client@1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': + '@pnpm/client@1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': dependencies: '@pnpm/default-resolver': 1002.0.2(@pnpm/logger@1001.0.0) '@pnpm/directory-fetcher': 1000.1.7(@pnpm/logger@1001.0.0) '@pnpm/fetch': 1000.2.2(@pnpm/logger@1001.0.0) '@pnpm/fetching-types': 1000.1.0 - '@pnpm/git-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) + '@pnpm/git-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) '@pnpm/network.auth-header': 1000.0.3 '@pnpm/resolver-base': 1003.0.1 - '@pnpm/tarball-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) + '@pnpm/tarball-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) '@pnpm/types': 1000.6.0 ramda: '@pnpm/ramda@0.28.1' transitivePeerDependencies: @@ -17206,7 +17284,7 @@ snapshots: - domexception - supports-color - '@pnpm/config.deps-installer@1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))': + '@pnpm/config.deps-installer@1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))': dependencies: '@pnpm/config.config-writer': 1000.0.5 '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0) @@ -17215,7 +17293,7 @@ snapshots: '@pnpm/logger': 1001.0.0 '@pnpm/network.auth-header': 1000.0.3 '@pnpm/npm-resolver': 1004.0.1(@pnpm/logger@1001.0.0) - '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) + '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) '@pnpm/parse-wanted-dependency': 1001.0.0 '@pnpm/pick-registry-for-package': 1000.0.8 '@pnpm/read-modules-dir': 1000.0.0 @@ -17278,6 +17356,11 @@ snapshots: '@pnpm/logger': 1001.0.0 '@pnpm/types': 1001.0.1 + '@pnpm/core-loggers@1001.0.8(@pnpm/logger@1001.0.0)': + dependencies: + '@pnpm/logger': 1001.0.0 + '@pnpm/types': 1001.2.0 + '@pnpm/create-cafs-store@1000.0.14(@pnpm/logger@1001.0.0)': dependencies: '@pnpm/exec.pkg-requires-build': 1000.0.8 @@ -17302,6 +17385,18 @@ snapshots: path-temp: 2.1.0 ramda: '@pnpm/ramda@0.28.1' + '@pnpm/create-cafs-store@1000.0.27(@pnpm/logger@1001.0.0)': + dependencies: + '@pnpm/exec.pkg-requires-build': 1000.0.15 + '@pnpm/fetcher-base': 1001.2.0 + '@pnpm/fs.indexed-pkg-importer': 1000.1.21(@pnpm/logger@1001.0.0) + '@pnpm/logger': 1001.0.0 + '@pnpm/store-controller-types': 1004.4.1 + '@pnpm/store.cafs': 1000.1.0 + mem: 8.1.1 + path-temp: 2.1.0 + ramda: '@pnpm/ramda@0.28.1' + '@pnpm/crypto.hash@1000.1.1': dependencies: '@pnpm/crypto.polyfill': 1000.1.0 @@ -17391,6 +17486,10 @@ snapshots: dependencies: '@pnpm/types': 1001.0.1 + '@pnpm/exec.pkg-requires-build@1000.0.15': + dependencies: + '@pnpm/types': 1001.2.0 + '@pnpm/exec.pkg-requires-build@1000.0.8': dependencies: '@pnpm/types': 1000.6.0 @@ -17426,6 +17525,12 @@ snapshots: '@pnpm/types': 1001.0.1 '@types/ssri': 7.1.5 + '@pnpm/fetcher-base@1001.2.0': + dependencies: + '@pnpm/resolver-base': 1005.3.3 + '@pnpm/types': 1001.2.0 + '@types/ssri': 7.1.5 + '@pnpm/fetching-types@1000.1.0': dependencies: '@zkochan/retry': 0.2.0 @@ -17468,6 +17573,21 @@ snapshots: rename-overwrite: 6.0.3 sanitize-filename: 1.6.3 + '@pnpm/fs.indexed-pkg-importer@1000.1.21(@pnpm/logger@1001.0.0)': + dependencies: + '@pnpm/core-loggers': 1001.0.8(@pnpm/logger@1001.0.0) + '@pnpm/graceful-fs': 1000.0.1 + '@pnpm/logger': 1001.0.0 + '@pnpm/store-controller-types': 1004.4.1 + '@reflink/reflink': 0.1.19 + '@zkochan/rimraf': 3.0.2 + fs-extra: 11.3.2 + make-empty-dir: 3.0.2 + p-limit: 3.1.0 + path-temp: 2.1.0 + rename-overwrite: 6.0.3 + sanitize-filename: 1.6.3 + '@pnpm/fs.indexed-pkg-importer@1000.1.8(@pnpm/logger@1001.0.0)': dependencies: '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0) @@ -17504,13 +17624,13 @@ snapshots: - supports-color - typanion - '@pnpm/git-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': + '@pnpm/git-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': dependencies: '@pnpm/fetcher-base': 1000.0.11 '@pnpm/fs.packlist': 2.0.0 '@pnpm/logger': 1001.0.0 '@pnpm/prepare-package': 1000.0.16(@pnpm/logger@1001.0.0)(typanion@3.14.0) - '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) + '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) '@zkochan/rimraf': 3.0.2 execa: safe-execa@0.1.2 transitivePeerDependencies: @@ -17833,7 +17953,7 @@ snapshots: semver: 7.7.2 ssri: 10.0.5 - '@pnpm/package-requester@1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))': + '@pnpm/package-requester@1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))': dependencies: '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0) '@pnpm/dependency-path': 1000.0.9 @@ -17848,7 +17968,7 @@ snapshots: '@pnpm/store-controller-types': 1003.0.2 '@pnpm/store.cafs': 1000.0.13 '@pnpm/types': 1000.6.0 - '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) + '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) p-defer: 3.0.0 p-limit: 3.1.0 p-queue: 6.6.2 @@ -17873,17 +17993,17 @@ snapshots: ramda: '@pnpm/ramda@0.28.1' ssri: 10.0.5 - '@pnpm/package-store@1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))': + '@pnpm/package-store@1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))': dependencies: '@pnpm/create-cafs-store': 1000.0.14(@pnpm/logger@1001.0.0) '@pnpm/fetcher-base': 1000.0.11 '@pnpm/logger': 1001.0.0 - '@pnpm/package-requester': 1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) + '@pnpm/package-requester': 1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) '@pnpm/resolver-base': 1003.0.1 '@pnpm/store-controller-types': 1003.0.2 '@pnpm/store.cafs': 1000.0.13 '@pnpm/types': 1000.6.0 - '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) + '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) '@zkochan/rimraf': 3.0.2 load-json-file: 6.2.0 ramda: '@pnpm/ramda@0.28.1' @@ -18005,6 +18125,10 @@ snapshots: dependencies: '@pnpm/types': 1001.0.1 + '@pnpm/resolver-base@1005.3.3': + dependencies: + '@pnpm/types': 1001.2.0 + '@pnpm/resolving.jsr-specifier-parser@1000.0.0': dependencies: '@pnpm/error': 1000.0.2 @@ -18049,14 +18173,14 @@ snapshots: - supports-color - typanion - '@pnpm/store-connection-manager@1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': + '@pnpm/store-connection-manager@1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': dependencies: '@pnpm/cli-meta': 1000.0.8 - '@pnpm/client': 1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) + '@pnpm/client': 1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) '@pnpm/config': 1003.1.1(@pnpm/logger@1001.0.0) '@pnpm/error': 1000.0.2 '@pnpm/logger': 1001.0.0 - '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) + '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)) '@pnpm/server': 1001.0.4(@pnpm/logger@1001.0.0) '@pnpm/store-path': 1000.0.2 '@zkochan/diable': 1.0.2 @@ -18080,6 +18204,12 @@ snapshots: '@pnpm/resolver-base': 1005.3.1 '@pnpm/types': 1001.0.1 + '@pnpm/store-controller-types@1004.4.1': + dependencies: + '@pnpm/fetcher-base': 1001.2.0 + '@pnpm/resolver-base': 1005.3.3 + '@pnpm/types': 1001.2.0 + '@pnpm/store-path@1000.0.2': dependencies: '@pnpm/constants': 1001.1.0 @@ -18115,13 +18245,32 @@ snapshots: ssri: 10.0.5 strip-bom: 4.0.0 + '@pnpm/store.cafs@1000.1.0': + dependencies: + '@pnpm/fetcher-base': 1001.2.0 + '@pnpm/graceful-fs': 1000.0.1 + '@pnpm/store-controller-types': 1004.4.1 + '@zkochan/rimraf': 3.0.2 + is-gzip: 2.0.0 + p-limit: 3.1.0 + rename-overwrite: 6.0.3 + ssri: 10.0.5 + strip-bom: 4.0.0 + '@pnpm/symlink-dependency@1000.0.14(@pnpm/logger@1001.0.0)': dependencies: '@pnpm/core-loggers': 1001.0.6(@pnpm/logger@1001.0.0) '@pnpm/logger': 1001.0.0 '@pnpm/types': 1001.0.1 symlink-dir: 6.0.5 + '@pnpm/symlink-dependency@1000.0.16(@pnpm/logger@1001.0.0)': + dependencies: + '@pnpm/core-loggers': 1001.0.8(@pnpm/logger@1001.0.0) + '@pnpm/logger': 1001.0.0 + '@pnpm/types': 1001.2.0 + symlink-dir: 6.0.5 + '@pnpm/tabtab@0.5.4': dependencies: debug: 4.4.1 @@ -18153,7 +18302,7 @@ snapshots: - supports-color - typanion - '@pnpm/tarball-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': + '@pnpm/tarball-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': dependencies: '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0) '@pnpm/error': 1000.0.2 @@ -18163,7 +18312,7 @@ snapshots: '@pnpm/graceful-fs': 1000.0.0 '@pnpm/logger': 1001.0.0 '@pnpm/prepare-package': 1000.0.16(@pnpm/logger@1001.0.0)(typanion@3.14.0) - '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) + '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30) '@zkochan/retry': 0.2.0 lodash.throttle: 4.1.1 p-map-values: 1.0.0 @@ -18192,6 +18341,8 @@ snapshots: '@pnpm/types@1001.0.1': {} + '@pnpm/types@1001.2.0': {} + '@pnpm/util.lex-comparator@3.0.2': {} '@pnpm/which@3.0.1': @@ -18217,18 +18368,18 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)': + '@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)': dependencies: - '@pnpm/cafs-types': 1000.0.0 - '@pnpm/create-cafs-store': 1000.0.24(@pnpm/logger@1001.0.0) + '@pnpm/cafs-types': 1000.1.0 + '@pnpm/create-cafs-store': 1000.0.27(@pnpm/logger@1001.0.0) '@pnpm/crypto.polyfill': 1000.1.0 '@pnpm/error': 1000.0.5 - '@pnpm/exec.pkg-requires-build': 1000.0.13 + '@pnpm/exec.pkg-requires-build': 1000.0.15 '@pnpm/fs.hard-link-dir': 1000.0.5(@pnpm/logger@1001.0.0) '@pnpm/graceful-fs': 1000.0.1 '@pnpm/logger': 1001.0.0 - '@pnpm/store.cafs': 1000.0.22 - '@pnpm/symlink-dependency': 1000.0.14(@pnpm/logger@1001.0.0) + '@pnpm/store.cafs': 1000.1.0 + '@pnpm/symlink-dependency': 1000.0.16(@pnpm/logger@1001.0.0) '@rushstack/worker-pool': 0.4.9(@types/node@22.15.30) is-windows: 1.0.2 load-json-file: 6.2.0 @@ -18250,9 +18401,9 @@ snapshots: - supports-color - typanion - '@pnpm/workspace.find-packages@1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': + '@pnpm/workspace.find-packages@1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)': dependencies: - '@pnpm/cli-utils': 1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) + '@pnpm/cli-utils': 1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0) '@pnpm/constants': 1001.1.0 '@pnpm/fs.find-packages': 1000.0.11 '@pnpm/logger': 1001.0.0
store/package-store/package.json+5 −1 modified@@ -45,16 +45,20 @@ }, "dependencies": { "@pnpm/create-cafs-store": "workspace:*", + "@pnpm/crypto.hash": "workspace:*", + "@pnpm/error": "workspace:*", "@pnpm/fetcher-base": "workspace:*", "@pnpm/package-requester": "workspace:*", "@pnpm/resolver-base": "workspace:*", "@pnpm/store-controller-types": "workspace:*", "@pnpm/store.cafs": "workspace:*", "@pnpm/types": "workspace:*", "@zkochan/rimraf": "catalog:", + "is-subdir": "catalog:", "load-json-file": "catalog:", "ramda": "catalog:", - "ssri": "catalog:" + "ssri": "catalog:", + "symlink-dir": "catalog:" }, "peerDependencies": { "@pnpm/logger": "catalog:",
store/package-store/src/index.ts+1 −0 modified@@ -1,3 +1,4 @@ export { createPackageStore, type CafsLocker, type CreatePackageStoreOptions } from './storeController/index.js' +export { registerProject, getRegisteredProjects } from './storeController/projectRegistry.js' export * from '@pnpm/store-controller-types'
store/package-store/src/storeController/projectRegistry.ts+94 −0 added@@ -0,0 +1,94 @@ +import { type Dirent, promises as fs } from 'fs' +import util from 'util' +import path from 'path' +import { createShortHash } from '@pnpm/crypto.hash' +import { PnpmError } from '@pnpm/error' +import { globalInfo } from '@pnpm/logger' +import symlinkDir from 'symlink-dir' + +const PROJECTS_DIR = 'projects' + +export function getProjectsRegistryDir (storeDir: string): string { + return path.join(storeDir, PROJECTS_DIR) +} + +/** + * Register a project as using the store. + * Creates a symlink in {storeDir}/projects/{hash} → {projectDir} + */ +export async function registerProject (storeDir: string, projectDir: string): Promise<void> { + const registryDir = getProjectsRegistryDir(storeDir) + await fs.mkdir(registryDir, { recursive: true }) + const linkPath = path.join(registryDir, createShortHash(projectDir)) + // symlink-dir handles the case where the symlink already exists + await symlinkDir(projectDir, linkPath) +} + +/** + * Get all registered projects that use the global virtual store. + * Cleans up stale entries (projects that no longer exist). + */ +export async function getRegisteredProjects (storeDir: string): Promise<string[]> { + const registryDir = getProjectsRegistryDir(storeDir) + let entries: Dirent[] + try { + entries = await fs.readdir(registryDir, { withFileTypes: true }) + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + return [] + } + throw err + } + + const projects: string[] = [] + await Promise.all(entries.map(async (entry) => { + if (entry.name.startsWith('.')) return + // We expect only symlinks (or junctions on Windows) in the registry + if (!entry.isSymbolicLink()) return + const linkPath = path.join(registryDir, entry.name) + + // Read the symlink target - if this fails, it's an invalid entry + let target: string + try { + target = await fs.readlink(linkPath) + } catch (err: unknown) { + // If the file is not a symlink (EINVAL) or doesn't exist (ENOENT), ignore it + if (util.types.isNativeError(err) && 'code' in err && (err.code === 'ENOENT' || err.code === 'EINVAL')) { + return + } + // For permission errors etc, inform the user + const message = util.types.isNativeError(err) ? err.message : String(err) + throw new PnpmError('PROJECT_REGISTRY_ENTRY_INACCESSIBLE', + `Cannot read project registry entry "${linkPath}": ${message}`, + { + hint: `To remove this project from the registry, delete the file at:\n ${linkPath}`, + } + ) + } + + const absoluteTarget = path.isAbsolute(target) ? target : path.resolve(path.dirname(linkPath), target) + + // Check if project still exists + try { + await fs.stat(absoluteTarget) + projects.push(absoluteTarget) + } catch (err: unknown) { + // Only clean up if project directory no longer exists + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + await fs.unlink(linkPath) + globalInfo(`Removed stale project registry entry: ${absoluteTarget}`) + return + } + // Can't access project - throw error to prevent incorrect pruning + const message = util.types.isNativeError(err) ? err.message : String(err) + throw new PnpmError('PROJECT_INACCESSIBLE', + `Cannot access registered project "${absoluteTarget}": ${message}`, + { + hint: `To remove this project from the registry, delete the symlink at:\n ${linkPath}`, + } + ) + } + })) + + return projects +}
store/package-store/src/storeController/pruneGlobalVirtualStore.ts+297 −0 added@@ -0,0 +1,297 @@ +import { type Dirent, promises as fs } from 'fs' +import util from 'util' +import path from 'path' +import crypto from 'crypto' +import { globalInfo } from '@pnpm/logger' +import rimraf from '@zkochan/rimraf' +import isSubdir from 'is-subdir' +import { getRegisteredProjects } from './projectRegistry.js' + +const LINKS_DIR = 'links' + +/** + * Prune unused packages from the global virtual store using mark-and-sweep: + * 1. Get all registered projects + * 2. Find all node_modules directories in each project (including workspace packages) + * 3. Walk symlinks from each node_modules to mark reachable packages + * 4. Remove any package directories that weren't marked as reachable + */ +export async function pruneGlobalVirtualStore (storeDir: string): Promise<void> { + const linksDir = path.join(storeDir, LINKS_DIR) + if (!await pathExists(linksDir)) { + return + } + + const projects = await getRegisteredProjects(storeDir) + if (projects.length === 0) { + globalInfo('No registered projects for global virtual store') + return + } + + globalInfo(`Checking ${projects.length} registered project(s) for global virtual store usage`) + + // Mark phase: collect all reachable package directories + const reachable = new Set<string>() + const visited = new Set<string>() // Track visited directories to prevent infinite loops + + // For each project, find all node_modules directories (root + workspace packages) + await Promise.all( + projects.map(async (projectDir) => { + const nodeModulesDirs = await findAllNodeModulesDirs(projectDir) + await Promise.all( + nodeModulesDirs.map((modulesDir) => + walkSymlinksToStore(modulesDir, linksDir, reachable, visited) + ) + ) + }) + ) + + // Sweep phase: remove unreachable packages + const unreachableCount = await removeUnreachablePackages(linksDir, reachable) + if (unreachableCount > 0) { + globalInfo(`Removed ${unreachableCount} package${unreachableCount === 1 ? '' : 's'} from global virtual store`) + } else { + globalInfo('No unused packages found in global virtual store') + } +} + +/** + * Find all node_modules directories within a project, including those + * in workspace packages. Does not descend into node_modules directories. + */ +async function findAllNodeModulesDirs (projectDir: string): Promise<string[]> { + const nodeModulesDirs: string[] = [] + + async function scan (dir: string): Promise<void> { + let entries: Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + + const subdirs: string[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const entryPath = path.join(dir, entry.name) + + if (entry.name === 'node_modules') { + nodeModulesDirs.push(entryPath) + // Don't descend into node_modules + } else if (!entry.name.startsWith('.')) { + // Collect directories to descend into (workspace packages, etc.) + // Skip hidden directories like .git, .pnpm + subdirs.push(entryPath) + } + } + + // Scan subdirectories concurrently + await Promise.all(subdirs.map((subdir) => scan(subdir))) + } + + await scan(projectDir) + return nodeModulesDirs +} + +/** + * Recursively walk symlinks from a directory, marking any that point + * into the global virtual store's links directory. + */ +async function walkSymlinksToStore ( + dir: string, + linksDir: string, + reachable: Set<string>, + visited: Set<string> +): Promise<void> { + // Prevent infinite loops from circular symlinks + const dirHash = await getRealPathHash(dir) + if (visited.has(dirHash)) { + return + } + visited.add(dirHash) + + let entries: Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + + await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dir, entry.name) + + if (entry.isSymbolicLink()) { + try { + const target = await fs.readlink(entryPath) + const absoluteTarget = path.isAbsolute(target) + ? target + : path.resolve(dir, target) + + // Check if this symlink points into the global virtual store + if (isSubdir(linksDir, absoluteTarget)) { + // Mark the package directory as reachable + // The path structure is: + // - Scoped: {linksDir}/{scope}/{pkgName}/{version}/{hash}/node_modules/{pkgName} + // - Unscoped: {linksDir}/@/{pkgName}/{version}/{hash}/node_modules/{pkgName} + // We want to mark the {hash} directory + const relPath = path.relative(linksDir, absoluteTarget) + const parts = relPath.split(path.sep) + // Find the hash directory (the one containing node_modules) + const nodeModulesIdx = parts.indexOf('node_modules') + if (nodeModulesIdx !== -1) { + // Store relative path like "@scope/pkg-a/1.0.0/hash123" or "@/pkg-a/1.0.0/hash123" + const relativePath = parts.slice(0, nodeModulesIdx).join(path.sep) + reachable.add(relativePath) + // Also walk into the package's node_modules for transitive deps + const pkgNodeModules = path.join(linksDir, relativePath, 'node_modules') + await walkSymlinksToStore(pkgNodeModules, linksDir, reachable, visited) + } + } + } catch { + // Ignore broken symlinks + } + } else if (entry.isDirectory() && entry.name !== '.pnpm') { + // Recurse into directories (but not .pnpm which is the local virtual store) + await walkSymlinksToStore(entryPath, linksDir, reachable, visited) + } + }) + ) +} + +/** + * Resolve symlinks and return a hash of the real path (for cycle detection) + */ +async function getRealPathHash (p: string): Promise<string> { + let realPath: string + try { + realPath = await fs.realpath(p) + } catch { + realPath = p + } + // Create a compact hash for in-memory use (base64url is shorter than hex that we use for file name hashes) + return crypto.createHash('sha256').update(realPath).digest('base64url') +} + +/** + * Remove package directories from the global virtual store that are not in the reachable set. + * Returns the count of removed packages. + * + * Directory structure is uniform 4-level: + * - Scoped: {linksDir}/{scope}/{pkgName}/{version}/{hash}/ + * - Unscoped: {linksDir}/@/{pkgName}/{version}/{hash}/ + */ +async function removeUnreachablePackages ( + linksDir: string, + reachable: Set<string> +): Promise<number> { + // First level is always a scope (either @scope or @ for unscoped packages) + const scopes = await getSubdirsSafely(linksDir) + let count = 0 + + await Promise.all( + scopes.map(async (scope) => { + const scopePath = path.join(linksDir, scope) + const pkgNames = await getSubdirsSafely(scopePath) + let removedPkgs = 0 + + await Promise.all( + pkgNames.map(async (pkgName) => { + const pkgDir = path.join(scopePath, pkgName) + const removedVersions = await removeUnreachableVersions( + pkgDir, + path.join(scope, pkgName), + reachable + ) + count += removedVersions.count + if (removedVersions.allRemoved) { + // Remove the package directory when all its versions are removed + await rimraf(pkgDir) + removedPkgs++ + } + }) + ) + + // If we removed all packages in scope, remove the scope directory + if (removedPkgs === pkgNames.length && pkgNames.length > 0) { + await rimraf(scopePath) + } + }) + ) + + return count +} + +/** + * Remove unreachable versions and hashes for a package. + * Returns the count of removed packages and whether all versions were removed. + */ +async function removeUnreachableVersions ( + pkgDir: string, + pkgPath: string, // relative path like "@/is-positive" or "@pnpm.e2e/romeo" + reachable: Set<string> +): Promise<{ count: number, allRemoved: boolean }> { + const versions = await getSubdirsSafely(pkgDir) + let count = 0 + let removedVersions = 0 + + await Promise.all( + versions.map(async (version) => { + const versionDir = path.join(pkgDir, version) + const hashes = await getSubdirsSafely(versionDir) + + // Remove unreachable hash directories + let removedHashes = 0 + await Promise.all( + hashes.map(async (hash) => { + const relativePath = path.join(pkgPath, version, hash) + if (!reachable.has(relativePath)) { + await rimraf(path.join(versionDir, hash)) + removedHashes++ + count++ + } + }) + ) + + // If we removed all hashes, remove the version directory + if (removedHashes === hashes.length && hashes.length > 0) { + await rimraf(versionDir) + removedVersions++ + } + }) + ) + + return { + count, + allRemoved: removedVersions === versions.length && versions.length > 0, + } +} + +async function pathExists (p: string): Promise<boolean> { + try { + await fs.stat(p) + return true + } catch { + return false + } +} + +async function getSubdirsSafely (dir: string): Promise<string[]> { + let entries: Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) as Dirent[] + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + return [] + } + throw err + } + const subdirs: string[] = [] + for (const entry of entries) { + if (entry.isDirectory()) { + subdirs.push(entry.name) + } + } + return subdirs +}
store/package-store/src/storeController/prune.ts+11 −1 modified@@ -6,6 +6,7 @@ import { globalInfo, globalWarn } from '@pnpm/logger' import rimraf from '@zkochan/rimraf' import loadJsonFile from 'load-json-file' import ssri from 'ssri' +import { pruneGlobalVirtualStore } from './pruneGlobalVirtualStore.js' const BIG_ONE = BigInt(1) as unknown @@ -15,7 +16,12 @@ export interface PruneOptions { } export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFiles?: boolean): Promise<void> { - const cafsDir = path.join(storeDir, 'files') + // 1. First, prune the global virtual store + // This must happen BEFORE pruning the CAS, because removing packages from + // the virtual store will reduce hard link counts on files in the CAS + await pruneGlobalVirtualStore(storeDir) + + // 2. Clean up metadata cache const metadataDirs = await getSubdirsSafely(cacheDir) await Promise.all(metadataDirs.map(async (metadataDir) => { if (!metadataDir.startsWith('metadata')) return @@ -29,6 +35,9 @@ export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFi })) await rimraf(path.join(storeDir, 'tmp')) globalInfo('Removed all cached metadata files') + + // 3. Prune the content-addressable store (CAS) + const cafsDir = path.join(storeDir, 'files') const pkgIndexFiles = [] as string[] const indexDir = path.join(storeDir, 'index') await Promise.all((await getSubdirsSafely(indexDir)).map(async (dir) => { @@ -72,6 +81,7 @@ export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFi })) globalInfo(`Removed ${fileCounter} file${fileCounter === 1 ? '' : 's'}`) + // 4. Clean up orphaned package index files let pkgCounter = 0 await Promise.all(pkgIndexFiles.map(async (pkgIndexFilePath) => { const { files: pkgFilesIndex } = await loadJsonFile<PackageFilesIndex>(pkgIndexFilePath)
store/package-store/test/projectRegistry.ts+110 −0 added@@ -0,0 +1,110 @@ +/// <reference path="../../../__typings__/index.d.ts"/> +import { promises as fs } from 'fs' +import path from 'path' +import { registerProject, getRegisteredProjects } from '@pnpm/package-store' +import tempy from 'tempy' + +describe('projectRegistry', () => { + describe('registerProject()', () => { + it('creates a symlink in the projects directory', async () => { + const storeDir = tempy.directory() + const projectDir = tempy.directory() + + await registerProject(storeDir, projectDir) + + // Check that projects directory was created + const projectsDir = path.join(storeDir, 'projects') + const entries = await fs.readdir(projectsDir) + expect(entries).toHaveLength(1) + + // Check that the symlink points to the project + const linkPath = path.join(projectsDir, entries[0]) + const target = await fs.readlink(linkPath) + expect(path.resolve(path.dirname(linkPath), target)).toBe(projectDir) + }) + + it('is idempotent - registering the same project twice works', async () => { + const storeDir = tempy.directory() + const projectDir = tempy.directory() + + await registerProject(storeDir, projectDir) + await registerProject(storeDir, projectDir) + + const projectsDir = path.join(storeDir, 'projects') + const entries = await fs.readdir(projectsDir) + expect(entries).toHaveLength(1) + }) + + it('registers multiple projects with different hashes', async () => { + const storeDir = tempy.directory() + const projectDir1 = tempy.directory() + const projectDir2 = tempy.directory() + + await registerProject(storeDir, projectDir1) + await registerProject(storeDir, projectDir2) + + const projectsDir = path.join(storeDir, 'projects') + const entries = await fs.readdir(projectsDir) + expect(entries).toHaveLength(2) + }) + }) + + describe('getRegisteredProjects()', () => { + it('returns empty array when no projects are registered', async () => { + const storeDir = tempy.directory() + + const projects = await getRegisteredProjects(storeDir) + expect(projects).toEqual([]) + }) + + it('returns registered project paths', async () => { + const storeDir = tempy.directory() + const projectDir1 = tempy.directory() + const projectDir2 = tempy.directory() + + await registerProject(storeDir, projectDir1) + await registerProject(storeDir, projectDir2) + + const projects = await getRegisteredProjects(storeDir) + expect(projects.sort()).toEqual([projectDir1, projectDir2].sort()) + }) + + it('cleans up stale entries for deleted projects', async () => { + const storeDir = tempy.directory() + const projectDir = tempy.directory() + + await registerProject(storeDir, projectDir) + + // Verify project is registered + let projects = await getRegisteredProjects(storeDir) + expect(projects).toEqual([projectDir]) + + // Delete the project directory + await fs.rm(projectDir, { recursive: true }) + + // getRegisteredProjects should clean up stale entry + projects = await getRegisteredProjects(storeDir) + expect(projects).toEqual([]) + + // Verify the symlink was removed + const projectsDir = path.join(storeDir, 'projects') + const entries = await fs.readdir(projectsDir) + expect(entries).toEqual([]) + }) + + it('handles mix of valid and stale entries', async () => { + const storeDir = tempy.directory() + const validProject = tempy.directory() + const staleProject = tempy.directory() + + await registerProject(storeDir, validProject) + await registerProject(storeDir, staleProject) + + // Delete one project + await fs.rm(staleProject, { recursive: true }) + + const projects = await getRegisteredProjects(storeDir) + expect(projects).toEqual([validProject]) + }) + }) +})
store/package-store/tsconfig.json+6 −0 modified@@ -12,9 +12,15 @@ { "path": "../../__utils__/prepare" }, + { + "path": "../../crypto/hash" + }, { "path": "../../fetching/fetcher-base" }, + { + "path": "../../packages/error" + }, { "path": "../../packages/logger" },
store/plugin-commands-store/package.json+2 −1 modified@@ -60,6 +60,7 @@ "@pnpm/constants": "workspace:*", "@pnpm/lockfile.fs": "workspace:*", "@pnpm/logger": "workspace:*", + "@pnpm/package-store": "workspace:*", "@pnpm/plugin-commands-script-runners": "workspace:*", "@pnpm/plugin-commands-store": "workspace:*", "@pnpm/prepare": "workspace:*", @@ -78,4 +79,4 @@ "jest": { "preset": "@pnpm/jest-config/with-registry" } -} +} \ No newline at end of file
store/plugin-commands-store/test/storePrune.ts+295 −0 modified@@ -418,3 +418,298 @@ test('prune removes cache directories that outlives dlx-cache-max-age', async () .sort() ) }) + +describe('global virtual store prune', () => { + test('prune removes unreferenced packages from global virtual store', async () => { + // Create project that installs a package with global virtual store enabled + prepare({ + dependencies: { + 'is-positive': '1.0.0', + }, + }) + const cacheDir = path.resolve('cache') + const storeDir = path.resolve('store') + + // Install with global virtual store enabled + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', // This is needed because enableGlobalVirtualStore is set to fails in CI + ]) + + // Verify the links directory was created + const linksDir = path.join(storeDir, STORE_VERSION, 'links') + expect(fs.existsSync(linksDir)).toBe(true) + + // Remove the dependency from package.json and reinstall + fs.writeFileSync('package.json', JSON.stringify({ dependencies: {} })) + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', + ]) + + // Run prune - should remove the now-unreferenced package + await store.handler({ + cacheDir, + dir: process.cwd(), + pnpmHomeDir: '', + rawConfig: { + registry: REGISTRY, + }, + registries: { default: REGISTRY }, + storeDir: path.join(storeDir, STORE_VERSION), + userConfig: {}, + dlxCacheMaxAge: Infinity, + virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, + }, ['prune']) + + // Verify: is-positive should no longer exist in links/@/ directory + const unscopedDir = path.join(linksDir, '@') + const entries = fs.existsSync(unscopedDir) ? fs.readdirSync(unscopedDir) : [] + expect(entries).not.toContain('is-positive') + }) + + test('prune keeps packages that are referenced by multiple projects', async () => { + const storeDir = path.resolve('shared-store') + const cacheDir = path.resolve('cache') + + // Create first project with is-positive + const project1Dir = path.resolve('project1') + fs.mkdirSync(project1Dir, { recursive: true }) + fs.writeFileSync(path.join(project1Dir, 'package.json'), JSON.stringify({ + dependencies: { 'is-positive': '1.0.0' }, + })) + + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', + ], { cwd: project1Dir }) + + // Create second project with the same dependency + const project2Dir = path.resolve('project2') + fs.mkdirSync(project2Dir, { recursive: true }) + fs.writeFileSync(path.join(project2Dir, 'package.json'), JSON.stringify({ + dependencies: { 'is-positive': '1.0.0' }, + })) + + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', + ], { cwd: project2Dir }) + + // Delete project1 + rimraf(project1Dir) + + // Verify package still exists in links/@/ directory + const linksDir = path.join(storeDir, STORE_VERSION, 'links') + const unscopedDir = path.join(linksDir, '@') + const beforePrune = fs.readdirSync(unscopedDir) + expect(beforePrune).toContain('is-positive') + + // Run prune + await store.handler({ + cacheDir, + dir: process.cwd(), + pnpmHomeDir: '', + rawConfig: { + registry: REGISTRY, + }, + registries: { default: REGISTRY }, + storeDir: path.join(storeDir, STORE_VERSION), + userConfig: {}, + dlxCacheMaxAge: Infinity, + virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, + }, ['prune']) + + // Package should still exist because project2 references it + const afterPrune = fs.readdirSync(unscopedDir) + expect(afterPrune).toContain('is-positive') + + rimraf(project2Dir) + }) + + test('prune removes packages when project using them is deleted', async () => { + const storeDir = path.resolve('orphan-store') + const cacheDir = path.resolve('cache') + + // Create first project with is-positive + const project1Dir = path.resolve('orphan-project1') + fs.mkdirSync(project1Dir, { recursive: true }) + fs.writeFileSync(path.join(project1Dir, 'package.json'), JSON.stringify({ + dependencies: { 'is-positive': '1.0.0' }, + })) + + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', + ], { cwd: project1Dir }) + + // Create second project with a different package (so it stays) + const project2Dir = path.resolve('orphan-project2') + fs.mkdirSync(project2Dir, { recursive: true }) + fs.writeFileSync(path.join(project2Dir, 'package.json'), JSON.stringify({ + dependencies: { 'is-negative': '1.0.0' }, + })) + + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', + ], { cwd: project2Dir }) + + // Verify both packages exist in links/@/ directory + const linksDir = path.join(storeDir, STORE_VERSION, 'links') + const unscopedDir = path.join(linksDir, '@') + expect(fs.existsSync(unscopedDir)).toBe(true) + const beforePrune = fs.readdirSync(unscopedDir) + expect(beforePrune).toContain('is-positive') + expect(beforePrune).toContain('is-negative') + + // Delete project1 (which uses is-positive) + rimraf(project1Dir) + + // Run prune + await store.handler({ + cacheDir, + dir: process.cwd(), + pnpmHomeDir: '', + rawConfig: { + registry: REGISTRY, + }, + registries: { default: REGISTRY }, + storeDir: path.join(storeDir, STORE_VERSION), + userConfig: {}, + dlxCacheMaxAge: Infinity, + virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, + }, ['prune']) + + // is-positive should be removed since project1 was deleted + const afterPrune = fs.readdirSync(unscopedDir) + expect(afterPrune).not.toContain('is-positive') + // is-negative should remain since project2 still exists + expect(afterPrune).toContain('is-negative') + + rimraf(project2Dir) + }) + + test('prune preserves transitive dependencies and removes isolated ones', async () => { + // Create project with three packages: + // - @pnpm.e2e/pkg-with-1-dep has transitive dep @pnpm.e2e/dep-of-pkg-with-1-dep + // - @pnpm.e2e/romeo has transitive dep @pnpm.e2e/romeo-dep + // - is-positive has no transitive deps + prepare({ + dependencies: { + '@pnpm.e2e/pkg-with-1-dep': '100.0.0', + '@pnpm.e2e/romeo': '1.0.0', + 'is-positive': '1.0.0', + }, + }) + + // Store should be OUTSIDE the project directory to avoid findAllNodeModulesDirs + // scanning the store's internal node_modules + const storeDir = path.resolve('..', 'transitive-store') + const cacheDir = path.resolve('..', 'cache') + + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', + ]) + + // Verify all packages exist in links directory + const linksDir = path.join(storeDir, STORE_VERSION, 'links') + + // Scoped packages are in links/@pnpm.e2e/pkg-name/ + const scopeDir = path.join(linksDir, '@pnpm.e2e') + const scopedPkgs = fs.readdirSync(scopeDir) + expect(scopedPkgs).toContain('pkg-with-1-dep') + expect(scopedPkgs).toContain('dep-of-pkg-with-1-dep') + expect(scopedPkgs).toContain('romeo') + expect(scopedPkgs).toContain('romeo-dep') + // Unscoped packages are in links/@/pkg-name/ (uniform 4-level depth) + const unscopedDir = path.join(linksDir, '@') + const unscopedPkgs = fs.readdirSync(unscopedDir) + expect(unscopedPkgs).toContain('is-positive') + + // Remove @pnpm.e2e/pkg-with-1-dep, keeping romeo and is-positive + fs.writeFileSync('package.json', JSON.stringify({ + dependencies: { + '@pnpm.e2e/romeo': '1.0.0', + 'is-positive': '1.0.0', + }, + })) + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`, + '--config.enableGlobalVirtualStore=true', + '--config.ci=false', + ]) + + // Run prune + await store.handler({ + cacheDir, + dir: process.cwd(), + pnpmHomeDir: '', + rawConfig: { + registry: REGISTRY, + }, + registries: { default: REGISTRY }, + storeDir: path.join(storeDir, STORE_VERSION), + userConfig: {}, + dlxCacheMaxAge: Infinity, + virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, + }, ['prune']) + + // Verify: + // - pkg-with-1-dep and its transitive dep-of-pkg-with-1-dep should be removed + // - romeo and its transitive romeo-dep should still exist + // - is-positive should still exist + const afterPruneScopes = fs.readdirSync(linksDir) + expect(afterPruneScopes).toContain('@') // unscoped packages scope + const unscopedAfterPrune = fs.readdirSync(unscopedDir) + expect(unscopedAfterPrune).toContain('is-positive') + + const scopedPkgsAfter = fs.readdirSync(scopeDir) + // pkg-with-1-dep and its transitive dep should be removed + expect(scopedPkgsAfter).not.toEqual(expect.arrayContaining([expect.stringContaining('pkg-with-1-dep')])) + expect(scopedPkgsAfter).not.toEqual(expect.arrayContaining([expect.stringContaining('dep-of-pkg-with-1-dep')])) + // romeo and its transitive dep should be preserved + expect(scopedPkgsAfter).toContain('romeo') + expect(scopedPkgsAfter).toContain('romeo-dep') + }) +})
store/plugin-commands-store/tsconfig.json+3 −0 modified@@ -57,6 +57,9 @@ { "path": "../cafs" }, + { + "path": "../package-store" + }, { "path": "../store-connection-manager" },
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-7vhp-vf5g-r2fwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-69263ghsaADVISORY
- github.com/pnpm/pnpm/commit/0958027f88a99ccefe7e9676cdebba393dfbdc85ghsax_refsource_MISCWEB
- github.com/pnpm/pnpm/security/advisories/GHSA-7vhp-vf5g-r2fwghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.