fix: fail by default when a tarball does not match the locked integrity (#11985)

Backport of #11968 to release/10. Treats tarball-integrity mismatches
against the lockfile as a hard failure by default; `--update-checksums`
is the only opt-in. `--force` and `pnpm update` deliberately do not
bypass the integrity check.

🤖 Written by an agent (Claude Code, claude-opus-4-7).
This commit is contained in:
Zoltan Kochan
2026-05-27 14:25:11 +02:00
committed by GitHub
parent 19f7df1461
commit 6c0f2940c3
13 changed files with 80 additions and 52 deletions

View File

@@ -0,0 +1,14 @@
---
"@pnpm/core": minor
"@pnpm/plugin-commands-installation": minor
"@pnpm/worker": patch
"pnpm": minor
---
Treat tarball-integrity mismatches against the lockfile as a hard failure by default. Previously, `pnpm install` (non-frozen) would log `ERR_PNPM_TARBALL_INTEGRITY`, silently re-resolve from the registry, and overwrite the locked integrity — which meant a compromised registry, proxy, or republished version could substitute attacker-controlled content on a clean machine even though the project shipped a committed lockfile.
`pnpm install` now exits with `ERR_PNPM_TARBALL_INTEGRITY` and a hint pointing at the new opt-in flag.
The only opt-in is **`pnpm install --update-checksums`** — narrowly scoped to refreshing the locked integrity values from what the registry currently serves. Mirrors yarn's flag of the same name. A warning still prints when the bypass takes effect so the operation is auditable.
`--force` and `pnpm update` deliberately do **not** bypass the integrity check. They are routine refresh operations; silently overwriting a locked integrity in those flows would erase the protection a committed lockfile is supposed to provide. `--frozen-lockfile` behavior is unchanged. `--fix-lockfile` keeps its documented purpose (filling in missing lockfile entries) and is also not a bypass.

View File

@@ -47,6 +47,7 @@ export interface StrictInstallOptions {
lockfileOnly: boolean
forceFullResolution: boolean
fixLockfile: boolean
updateChecksums: boolean
dedupe: boolean
ignoreCompatibilityDb: boolean
ignoreDepScripts: boolean
@@ -226,6 +227,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
},
lockfileDir: opts.lockfileDir ?? opts.dir ?? process.cwd(),
lockfileOnly: false,
updateChecksums: false,
nodeVersion: opts.nodeVersion,
nodeLinker: 'isolated',
overrides: {},

View File

@@ -449,6 +449,7 @@ export async function mutateModules (
const upToDateLockfileMajorVersion = ctx.wantedLockfile.lockfileVersion.toString().startsWith(`${LOCKFILE_MAJOR_VERSION}.`)
let needsFullResolution = outdatedLockfileSettings ||
opts.fixLockfile ||
opts.updateChecksums ||
!upToDateLockfileMajorVersion ||
opts.forceFullResolution
if (needsFullResolution) {
@@ -844,21 +845,16 @@ Note that in CI environments, this setting is enabled by default.`,
ignoredBuilds,
}
} catch (error: any) { // eslint-disable-line
const isIntegrityError = BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code)
if (
frozenLockfile ||
(
error.code !== 'ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY' &&
!BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code)
!isIntegrityError
) ||
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile)
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile) ||
(isIntegrityError && !opts.updateChecksums)
) throw error
if (BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code)) {
needsFullResolution = true
// Ideally, we would not update but currently there is no other way to redownload the integrity of the package
for (const project of projects) {
(project as InstallMutationOptions).update = true
}
}
// A broken lockfile may be caused by a badly resolved Git conflict
logger.warn({
error,
@@ -1201,6 +1197,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
force: opts.force,
forceFullResolution,
updateChecksums: opts.updateChecksums,
ignoreScripts: opts.ignoreScripts,
hooks: {
readPackage: opts.readPackageHook,
@@ -1684,19 +1681,16 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => {
} catch (error: any) { // eslint-disable-line
if (
!BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code) ||
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile)
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile) ||
!opts.updateChecksums
) throw error
opts.needsFullResolution = true
// Ideally, we would not update but currently there is no other way to redownload the integrity of the package
for (const project of projects) {
(project as InstallMutationOptions).update = true
}
logger.warn({
error,
message: error.message,
prefix: ctx.lockfileDir,
})
logger.error(new PnpmError(error.code, 'The lockfile is broken! A full installation will be performed in an attempt to fix it.'))
logger.error(new PnpmError(error.code, 'Refreshing the locked integrity from the registry as requested by --update-checksums. A full installation will be performed.'))
return _installInContext(projects, ctx, opts)
} finally {
await opts.storeController.close()

View File

@@ -12,7 +12,7 @@ import {
import { sync as writeYamlFile } from 'write-yaml-file'
import { testDefaults } from './utils/index.js'
test('installation breaks if the lockfile contains the wrong checksum', async () => {
test('installation fails by default if the lockfile contains a wrong checksum, but --update-checksums recovers', async () => {
await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.0.0', distTag: 'latest' })
const project = prepareEmpty()
@@ -36,11 +36,17 @@ test('installation breaks if the lockfile contains the wrong checksum', async ()
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({ frozenLockfile: true }, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum for/)
await expect(mutateModulesInSingleProject({
manifest,
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({}, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum for/)
await mutateModulesInSingleProject({
manifest,
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({}, { retry: { retries: 0 } }))
}, testDefaults({ updateChecksums: true }, { retry: { retries: 0 } }))
expect(project.readLockfile()).toStrictEqual(correctLockfile)
@@ -49,16 +55,15 @@ test('installation breaks if the lockfile contains the wrong checksum', async ()
rimraf('node_modules')
await mutateModulesInSingleProject({
// --force is NOT an opt-in: it should still fail.
await expect(mutateModulesInSingleProject({
manifest,
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({ preferFrozenLockfile: false }, { retry: { retries: 0 } }))
expect(project.readLockfile()).toStrictEqual(correctLockfile)
}, testDefaults({ force: true }, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum for/)
})
test('installation breaks if the lockfile contains the wrong checksum and the store is clean', async () => {
test('installation fails by default if the lockfile contains the wrong checksum and the store is clean', async () => {
await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.0.0', distTag: 'latest' })
const project = prepareEmpty()
@@ -83,37 +88,20 @@ test('installation breaks if the lockfile contains the wrong checksum and the st
}, testDefaults({ frozenLockfile: true }, { retry: { retries: 0 } }))
).rejects.toThrowError(/Got unexpected checksum/)
await expect(mutateModulesInSingleProject({
manifest,
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({}, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum/)
await mutateModulesInSingleProject({
manifest,
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({}, { retry: { retries: 0 } }))
}, testDefaults({ updateChecksums: true }, { retry: { retries: 0 } }))
{
const lockfile = project.readLockfile()
expect((lockfile.packages['@pnpm.e2e/pkg-with-1-dep@100.0.0'].resolution as TarballResolution).integrity).toBe(correctIntegrity)
}
// Breaking the lockfile again
writeYamlFile(WANTED_LOCKFILE, corruptedLockfile, { lineWidth: 1000 })
rimraf('node_modules')
const reporter = jest.fn()
await mutateModulesInSingleProject({
manifest,
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({ preferFrozenLockfile: false, reporter }, { retry: { retries: 0 } }))
expect(reporter).toHaveBeenCalledWith(expect.objectContaining({
level: 'warn',
name: 'pnpm',
prefix: process.cwd(),
message: expect.stringMatching(/Got unexpected checksum/),
}))
{
const lockfile = project.readLockfile()
expect((lockfile.packages['@pnpm.e2e/pkg-with-1-dep@100.0.0'].resolution as TarballResolution).integrity).toBe(correctIntegrity)
}
})

View File

@@ -186,7 +186,7 @@ async function resolveAndFetch (
let alias: string | undefined
let resolution = options.currentPkg?.resolution as Resolution
let pkgId = options.currentPkg?.id
const skipResolution = resolution && !options.update
const skipResolution = resolution && !options.update && !options.updateChecksums
let forceFetch = false
let updated = false
let resolvedVia: string | undefined
@@ -225,6 +225,7 @@ async function resolveAndFetch (
projectDir: options.projectDir,
workspacePackages: options.workspacePackages,
update: options.update,
updateChecksums: options.updateChecksums,
injectWorkspacePackages: options.injectWorkspacePackages,
calcSpecifier: options.calcSpecifier,
pinnedVersion: options.pinnedVersion,

View File

@@ -81,6 +81,7 @@ export const cliOptionsTypes = (): Record<string, unknown> => ({
...rcOptionsTypes(),
...pick(['force'], allTypes),
'fix-lockfile': Boolean,
'update-checksums': Boolean,
'resolution-only': Boolean,
recursive: Boolean,
})
@@ -154,6 +155,10 @@ For options that may be used with `-r`, see "pnpm help recursive"',
description: 'Fix broken lockfile entries automatically',
name: '--fix-lockfile',
},
{
description: 'Refresh integrity checksums recorded in the lockfile from the registry',
name: '--update-checksums',
},
{
description: 'Merge lockfiles were generated on git branch',
name: '--merge-git-branch-lockfiles',
@@ -344,6 +349,7 @@ export type InstallCommandOptions = Pick<Config,
original: string[]
}
fixLockfile?: boolean
updateChecksums?: boolean
frozenLockfileIfExists?: boolean
useBetaCli?: boolean
pruneDirectDependencies?: boolean

View File

@@ -155,6 +155,7 @@ export interface ResolutionContext {
defaultTag: string
dryRun: boolean
forceFullResolution: boolean
updateChecksums?: boolean
ignoreScripts?: boolean
resolvedPkgsById: ResolvedPkgsById
resolvePeersFromWorkspaceRoot?: boolean
@@ -866,6 +867,7 @@ async function resolveDependenciesOfDependency (
proceed: extendedWantedDep.proceed || updateShouldContinue || ctx.updatedSet.size > 0,
publishedBy: options.publishedBy,
update: update ? options.updateToLatest ? 'latest' : 'compatible' : false,
updateChecksums: ctx.updateChecksums,
updateDepth,
updateRequested,
supportedArchitectures: options.supportedArchitectures,
@@ -1254,6 +1256,7 @@ interface ResolveDependencyOptions {
publishedBy?: Date
pickLowestVersion?: boolean
update: false | 'compatible' | 'latest'
updateChecksums?: boolean
updateDepth: number
/**
* Whether or not an update is requested based on filter conditions (such as
@@ -1354,6 +1357,7 @@ async function resolveDependency (
trustPolicyExclude: ctx.trustPolicyExclude,
trustPolicyIgnoreAfter: ctx.trustPolicyIgnoreAfter,
update: options.update,
updateChecksums: options.updateChecksums,
workspacePackages: ctx.workspacePackages,
supportedArchitectures: options.supportedArchitectures,
onFetchError: (err: any) => { // eslint-disable-line

View File

@@ -116,6 +116,7 @@ export interface ResolveDependenciesOptions {
engineStrict: boolean
force: boolean
forceFullResolution: boolean
updateChecksums?: boolean
ignoreScripts?: boolean
hooks: {
readPackage?: ReadPackageHook
@@ -180,6 +181,7 @@ export async function resolveDependencyTree<T> (
engineStrict: opts.engineStrict,
force: opts.force,
forceFullResolution: opts.forceFullResolution,
updateChecksums: opts.updateChecksums,
ignoreScripts: opts.ignoreScripts,
injectWorkspacePackages: opts.injectWorkspacePackages,
linkWorkspacePackagesDepth: opts.linkWorkspacePackagesDepth ?? -1,

View File

@@ -219,6 +219,7 @@ export type ResolveFromNpmOptions = {
preferredVersions?: PreferredVersions
preferWorkspacePackages?: boolean
update?: false | 'compatible' | 'latest'
updateChecksums?: boolean
injectWorkspacePackages?: boolean
calcSpecifier?: boolean
pinnedVersion?: PinnedVersion
@@ -275,6 +276,7 @@ async function resolveNpm (
preferredVersionSelectors: opts.preferredVersions?.[spec.name],
registry,
updateToLatest: opts.update === 'latest',
updateChecksums: opts.updateChecksums,
optional: wantedDependency.optional,
})
} catch (err: any) { // eslint-disable-line
@@ -417,6 +419,7 @@ async function resolveJsr (
preferredVersionSelectors: opts.preferredVersions?.[spec.name],
registry,
updateToLatest: opts.update === 'latest',
updateChecksums: opts.updateChecksums,
})
if (pickedPackage == null) {

View File

@@ -69,6 +69,15 @@ export interface PickPackageOptions extends PickPackageFromMetaOptions {
dryRun: boolean
updateToLatest?: boolean
optional?: boolean
/**
* When true, skip the on-disk exact-version cache fast path so a
* stale on-disk packument can't satisfy the call without a
* conditional registry request. The in-memory cache is left alone:
* its entries can only be populated by this install's own fresh
* network fetches, so they're authoritative for second-and-onward
* lookups within the same install.
*/
updateChecksums?: boolean
}
const pickPackageFromMetaUsingTimeStrict = pickPackageFromMeta.bind(null, pickVersionByVersionRange)
@@ -189,7 +198,7 @@ export async function pickPackage (
}
}
if (!opts.updateToLatest && spec.type === 'version') {
if (!opts.updateToLatest && !opts.updateChecksums && spec.type === 'version') {
metaCachedInStore = metaCachedInStore ?? await limit(async () => loadMeta(pkgMirror))
// use the cached meta only if it has the required package version
// otherwise it is probably out of date

View File

@@ -129,6 +129,7 @@ export interface ResolveOptions {
preferWorkspacePackages?: boolean
workspacePackages?: WorkspacePackages
update?: false | 'compatible' | 'latest'
updateChecksums?: boolean
injectWorkspacePackages?: boolean
calcSpecifier?: boolean
pinnedVersion?: PinnedVersion

View File

@@ -134,6 +134,7 @@ export interface RequestPackageOptions {
sideEffectsCache?: boolean
skipFetch?: boolean
update?: false | 'compatible' | 'latest'
updateChecksums?: boolean
workspacePackages?: WorkspacePackages
forceResolve?: boolean
supportedArchitectures?: SupportedArchitectures

View File

@@ -122,11 +122,14 @@ export class TarballIntegrityError extends PnpmError {
`Got unexpected checksum for "${opts.url}". Wanted "${opts.expected}". Got "${opts.found}".`,
{
attempts: opts.attempts,
hint: `This error may happen when a package is republished to the registry with the same version.
In this case, the metadata in the local pnpm cache will contain the old integrity checksum.
hint: `The downloaded tarball does not match the integrity recorded in the lockfile. pnpm will not silently overwrite the locked integrity — that would defeat the lockfile's protection if a registry or proxy is serving tampered content.
If you think that this is the case, then run "pnpm store prune" and rerun the command that failed.
"pnpm store prune" will remove your local metadata cache.`,
If you trust the new content (legitimate republish, or stale local metadata cache):
- Run "pnpm store prune" and retry, in case only the metadata cache is out of date.
- Run "pnpm install --update-checksums" to refresh the locked integrity from the registry.
If you did not expect this package to change, treat it as a potential supply-chain issue and verify the new content before re-running with --update-checksums.`,
}
)
this.found = opts.found