Skip to content

Commit eba03e0

Browse files
authored
fix: detect reverted catalog entries on install (#12438)
* fix: detect reverted catalog entries on install After an update bumped a catalog entry in pnpm-workspace.yaml, the workspace state cache stored the pre-update catalog versions, so reverting the entry back to its original version was reported as "Already up to date" instead of reinstalling the previous version. Fold the catalogs written during the install into the catalogs recorded in the workspace state so a later install detects the reverted entry as outdated. Closes #12418 * fix: harden catalog merge against prototype pollution and entry loss Address review feedback on the catalog-merge helper: - mergeCatalogs now builds null-prototype records and copies entries with Object.defineProperty, so a catalog or dependency name like __proto__ (which can flow in from parsed pnpm-workspace.yaml) becomes an ordinary own property instead of corrupting the result's prototype. - The recursive per-project install path now accumulates updatedCatalogs with mergeCatalogs instead of a shallow Object.assign, so two projects updating different entries of the same catalog no longer clobber each other.
1 parent 2b81344 commit eba03e0

10 files changed

Lines changed: 201 additions & 14 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@pnpm/catalogs.config": patch
3+
"@pnpm/installing.commands": patch
4+
"pnpm": patch
5+
---
6+
7+
Fix `pnpm install` reporting "Already up to date" after a catalog entry in `pnpm-workspace.yaml` was reverted to a previous version. After an update modified a catalog, the workspace state cache stored the pre-update catalog versions, so reverting the entry back to its original version was not detected as an outdated state [#12418](https://github.com/pnpm/pnpm/issues/12418).

catalogs/config/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { getCatalogsFromWorkspaceManifest } from './getCatalogsFromWorkspaceManifest.js'
2+
export { mergeCatalogs } from './mergeCatalogs.js'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Catalog, Catalogs } from '@pnpm/catalogs.types'
2+
3+
/**
4+
* Deep-merges catalog definitions, later arguments taking precedence over
5+
* earlier ones at the individual entry level. Use it to fold the catalog
6+
* changes produced by an install (`updatedCatalogs`) back into the catalogs
7+
* read at startup, so the result reflects what was actually written to
8+
* `pnpm-workspace.yaml`.
9+
*
10+
* Catalog and dependency names originate from `pnpm-workspace.yaml`, so the
11+
* result is built from null-prototype records and entries are copied with
12+
* `Object.defineProperty`. A name like `__proto__` then becomes an ordinary
13+
* own property instead of mutating a prototype.
14+
*/
15+
export function mergeCatalogs (...catalogsList: Array<Catalogs | undefined>): Catalogs {
16+
const result = Object.create(null) as Record<string, Catalog>
17+
for (const catalogs of catalogsList) {
18+
if (catalogs == null) continue
19+
for (const catalogName of Object.keys(catalogs)) {
20+
const catalog = catalogs[catalogName]
21+
if (catalog == null) continue
22+
const target: Record<string, string | undefined> = result[catalogName] ?? Object.create(null)
23+
for (const dependencyName of Object.keys(catalog)) {
24+
Object.defineProperty(target, dependencyName, {
25+
value: catalog[dependencyName],
26+
writable: true,
27+
enumerable: true,
28+
configurable: true,
29+
})
30+
}
31+
Object.defineProperty(result, catalogName, {
32+
value: target,
33+
writable: true,
34+
enumerable: true,
35+
configurable: true,
36+
})
37+
}
38+
}
39+
return result
40+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect, test } from '@jest/globals'
2+
import { mergeCatalogs } from '@pnpm/catalogs.config'
3+
4+
test('returns an empty catalog when nothing is passed', () => {
5+
expect(mergeCatalogs()).toEqual({})
6+
expect(mergeCatalogs(undefined, undefined)).toEqual({})
7+
})
8+
9+
test('later entries override earlier ones at the dependency level', () => {
10+
expect(mergeCatalogs(
11+
{
12+
default: { foo: '1.0.0', bar: '1.0.0' },
13+
named: { baz: '1.0.0' },
14+
},
15+
{
16+
default: { foo: '2.0.0' },
17+
}
18+
)).toEqual({
19+
default: { foo: '2.0.0', bar: '1.0.0' },
20+
named: { baz: '1.0.0' },
21+
})
22+
})
23+
24+
test('adds catalog entries that did not exist in the base', () => {
25+
expect(mergeCatalogs(
26+
{ default: { foo: '1.0.0' } },
27+
{ default: { bar: '1.0.0' }, named: { baz: '1.0.0' } }
28+
)).toEqual({
29+
default: { foo: '1.0.0', bar: '1.0.0' },
30+
named: { baz: '1.0.0' },
31+
})
32+
})
33+
34+
test('skips nullish catalog arguments and nullish named catalogs', () => {
35+
expect(mergeCatalogs(
36+
undefined,
37+
{ default: { foo: '1.0.0' }, named: undefined }
38+
)).toEqual({
39+
default: { foo: '1.0.0' },
40+
})
41+
})
42+
43+
test('treats dangerous catalog and dependency names as ordinary own properties', () => {
44+
// Catalogs parsed from YAML/JSON can carry `__proto__` as an own property,
45+
// unlike an object literal where `{ __proto__: ... }` sets the prototype.
46+
const malicious = JSON.parse('{"__proto__":{"polluted":"yes"},"default":{"__proto__":"1.0.0","constructor":"2.0.0"}}')
47+
const merged = mergeCatalogs(malicious)
48+
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
49+
expect(Object.prototype.hasOwnProperty.call(merged, '__proto__')).toBe(true)
50+
expect(Object.prototype.hasOwnProperty.call(merged.default, '__proto__')).toBe(true)
51+
expect((merged.default as Record<string, unknown>).__proto__).toBe('1.0.0')
52+
expect((merged.default as Record<string, unknown>).constructor).toBe('2.0.0')
53+
})

installing/commands/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@inquirer/prompts": "catalog:",
3636
"@pnpm/building.after-install": "workspace:*",
3737
"@pnpm/building.policy": "workspace:*",
38+
"@pnpm/catalogs.config": "workspace:*",
3839
"@pnpm/catalogs.types": "workspace:*",
3940
"@pnpm/cli.command": "workspace:*",
4041
"@pnpm/cli.common-cli-options-help": "workspace:*",

installing/commands/src/installDeps.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import path from 'node:path'
22

33
import { buildProjects } from '@pnpm/building.after-install'
4+
import { mergeCatalogs } from '@pnpm/catalogs.config'
5+
import type { Catalogs } from '@pnpm/catalogs.types'
46
import type { CommandHandler } from '@pnpm/cli.command'
57
import {
68
readProjectManifestOnly,
@@ -426,7 +428,7 @@ export async function installDeps (
426428
if (!opts.lockfileOnly) {
427429
await updateWorkspaceState({
428430
allProjects,
429-
settings: opts,
431+
settings: withUpdatedCatalogs(opts, updatedCatalogs),
430432
workspaceDir: opts.workspaceDir ?? opts.lockfileDir ?? opts.dir,
431433
pnpmfiles: opts.pnpmfile,
432434
filteredInstall: allProjects.length !== Object.keys(opts.selectedProjectsGraph ?? {}).length,
@@ -485,7 +487,7 @@ export async function installDeps (
485487
selectedProjectsGraph,
486488
workspaceDir: opts.workspaceDir, // Otherwise TypeScript doesn't understand that is not undefined
487489
runPacquet,
488-
}, 'install')
490+
}, 'install', updatedCatalogs)
489491

490492
if (opts.ignoreScripts) return
491493

@@ -508,7 +510,7 @@ export async function installDeps (
508510
if (!opts.lockfileOnly) {
509511
await updateWorkspaceState({
510512
allProjects,
511-
settings: opts,
513+
settings: withUpdatedCatalogs(opts, updatedCatalogs),
512514
workspaceDir: opts.workspaceDir ?? opts.lockfileDir ?? opts.dir,
513515
pnpmfiles: opts.pnpmfile,
514516
filteredInstall: allProjects.length !== Object.keys(opts.selectedProjectsGraph ?? {}).length,
@@ -528,20 +530,36 @@ async function recursiveInstallThenUpdateWorkspaceState (
528530
allProjects: Project[],
529531
params: string[],
530532
opts: RecursiveOptions & WorkspaceStateSettings,
531-
cmdFullName: CommandFullName
533+
cmdFullName: CommandFullName,
534+
updatedCatalogs?: Catalogs
532535
): Promise<boolean | string> {
533536
const recursiveResult = await recursive(allProjects, params, opts, cmdFullName)
534537
if (!opts.lockfileOnly) {
535538
await updateWorkspaceState({
536539
allProjects,
537-
settings: opts,
540+
settings: withUpdatedCatalogs(opts, updatedCatalogs, recursiveResult.updatedCatalogs),
538541
workspaceDir: opts.workspaceDir,
539542
pnpmfiles: opts.pnpmfile,
540543
filteredInstall: allProjects.length !== Object.keys(opts.selectedProjectsGraph ?? {}).length,
541544
configDependencies: opts.configDependencies,
542545
})
543546
}
544-
return recursiveResult
547+
return recursiveResult.passed
548+
}
549+
550+
/**
551+
* Folds the catalog entries written to `pnpm-workspace.yaml` during this
552+
* install into the catalogs read at startup. The workspace state cache records
553+
* these so a later install detects when a catalog entry was reverted; without
554+
* this, the cache would keep the stale pre-install catalogs and report
555+
* "Already up to date" even though the manifest changed.
556+
*/
557+
function withUpdatedCatalogs<T extends { catalogs?: Catalogs }> (
558+
settings: T,
559+
...updatedCatalogs: Array<Catalogs | undefined>
560+
): T {
561+
if (updatedCatalogs.every((catalogs) => catalogs == null)) return settings
562+
return { ...settings, catalogs: mergeCatalogs(settings.catalogs, ...updatedCatalogs) }
545563
}
546564

547565
function severityStringToNumber (severity: VulnerabilitySeverity): number {

installing/commands/src/recursive.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { promises as fs } from 'node:fs'
22
import path from 'node:path'
33

4+
import { mergeCatalogs } from '@pnpm/catalogs.config'
45
import type { Catalogs } from '@pnpm/catalogs.types'
56
import type { CommandHandler } from '@pnpm/cli.command'
67
import {
@@ -144,21 +145,31 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
144145

145146
export type CommandFullName = 'install' | 'add' | 'remove' | 'update' | 'import'
146147

148+
export interface RecursiveResult {
149+
passed: boolean | string
150+
/**
151+
* Catalog entries written to `pnpm-workspace.yaml` during this install.
152+
* The caller folds these into the catalogs recorded in the workspace state
153+
* cache so that reverting a catalog entry is detected as an outdated state.
154+
*/
155+
updatedCatalogs?: Catalogs
156+
}
157+
147158
export async function recursive (
148159
allProjects: Project[],
149160
params: string[],
150161
opts: RecursiveOptions,
151162
cmdFullName: CommandFullName
152-
): Promise<boolean | string> {
163+
): Promise<RecursiveResult> {
153164
if (allProjects.length === 0) {
154165
// It might make sense to throw an exception in this case
155-
return false
166+
return { passed: false }
156167
}
157168

158169
const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package)
159170

160171
if (pkgs.length === 0) {
161-
return false
172+
return { passed: false }
162173
}
163174
const manifestsByPath = getManifestsByPath(allProjects)
164175

@@ -225,7 +236,7 @@ export async function recursive (
225236
const calculatedRepositoryRoot = await fs.realpath(calculateRepositoryRoot(opts.workspaceDir, importers.map(x => x.rootDir)))
226237
const isFromWorkspace = isSubdir.bind(null, calculatedRepositoryRoot)
227238
importers = await pFilter(importers, async ({ rootDirRealPath }) => isFromWorkspace(rootDirRealPath))
228-
if (importers.length === 0) return true
239+
if (importers.length === 0) return { passed: true }
229240
let mutation: 'install' | 'installSome' | 'uninstallSome'
230241
switch (cmdFullName) {
231242
case 'remove':
@@ -341,7 +352,7 @@ export async function recursive (
341352
await Promise.all(promises)
342353
}
343354
await handleIgnoredBuilds(opts, ignoredBuilds)
344-
return true
355+
return { passed: true, updatedCatalogs }
345356
}
346357

347358
const pkgPaths = (Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]).sort()
@@ -459,8 +470,10 @@ export async function recursive (
459470
if (opts.save !== false) {
460471
await writeProjectManifest(newManifest)
461472
if (newCatalogsAddition) {
462-
updatedCatalogs ??= {}
463-
Object.assign(updatedCatalogs, newCatalogsAddition)
473+
// Per-project additions are partial maps keyed by catalog name then
474+
// dependency. Merge at the dependency level so two projects updating
475+
// different entries of the same catalog don't clobber each other.
476+
updatedCatalogs = mergeCatalogs(updatedCatalogs, newCatalogsAddition)
464477
}
465478
}
466479
if (ignoredBuilds?.size) {
@@ -527,7 +540,7 @@ export async function recursive (
527540
'None of the specified packages were found in the dependencies of any of the projects.')
528541
}
529542

530-
return true
543+
return { passed: true, updatedCatalogs }
531544
}
532545

533546
function calculateRepositoryRoot (

installing/commands/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
{
2828
"path": "../../building/policy"
2929
},
30+
{
31+
"path": "../../catalogs/config"
32+
},
3033
{
3134
"path": "../../catalogs/types"
3235
},

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm/test/catalogUpToDate.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@jest/globals'
2+
import { prepare } from '@pnpm/prepare'
3+
import { addDistTag } from '@pnpm/testing.registry-mock'
4+
import type { ProjectManifest } from '@pnpm/types'
5+
import { writeYamlFileSync } from 'write-yaml-file'
6+
7+
import { execPnpm } from './utils/index.js'
8+
9+
test('reverting a catalog entry after updating it is detected as an outdated state (#12418)', async () => {
10+
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
11+
12+
const manifest: ProjectManifest = {
13+
name: 'test-catalog-up-to-date',
14+
version: '0.0.0',
15+
private: true,
16+
dependencies: {
17+
'@pnpm.e2e/foo': 'catalog:',
18+
},
19+
}
20+
const project = prepare(manifest)
21+
22+
writeYamlFileSync('pnpm-workspace.yaml', {
23+
packages: ['.'],
24+
catalog: {
25+
'@pnpm.e2e/foo': '100.0.0',
26+
},
27+
})
28+
29+
await execPnpm(['install'])
30+
expect(project.readCurrentLockfile().importers['.'].dependencies?.['@pnpm.e2e/foo'].version).toBe('100.0.0')
31+
32+
await execPnpm(['update', '@pnpm.e2e/foo', '--latest'])
33+
expect(project.readCurrentLockfile().importers['.'].dependencies?.['@pnpm.e2e/foo'].version).toBe('100.1.0')
34+
35+
// Restore the catalog entry to the version it had before the update. A
36+
// subsequent install must notice that the workspace state is no longer up to
37+
// date and reinstall the previous version instead of reporting
38+
// "Already up to date".
39+
writeYamlFileSync('pnpm-workspace.yaml', {
40+
packages: ['.'],
41+
catalog: {
42+
'@pnpm.e2e/foo': '100.0.0',
43+
},
44+
})
45+
46+
await execPnpm(['install'])
47+
expect(project.readCurrentLockfile().importers['.'].dependencies?.['@pnpm.e2e/foo'].version).toBe('100.0.0')
48+
})

0 commit comments

Comments
 (0)