Files
pnpm/pnpm11/lockfile/utils/test/pkgSnapshotToResolution.ts
Zoltan Kochan 7b0d6873a6 feat: pin tar hashes with lockfile.integrityTarget and address revisions by tar digest (#17092)
lockfile.integrityTarget: tar makes the integrity of every package tarball
entry the hash of its uncompressed tar, recorded as settings.integrityTarget.
The lockfile then survives a registry recompressing its tarballs, switching
to tar resources, or being replaced by a mirror that does either. A lockfile
recorded under another target is stale: a frozen install fails with
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and any other install re-resolves with
update-checksums semantics so no hash of the old kind carries forward. The
setting is opt-in and the lockfile version is unchanged.

The tarball crate now reports both hashes of every extraction: the resource
after content decoding, and the tar a .tgz decodes to. A pin names one of
them (IntegrityKind::Resource / Tar), or either at resolution, where a locked
or published integrity may be of either kind (ResourceOrTar, sha512 only).
Under a tar pin every gzip member is decoded and nothing may follow the tar.
The resource pin keeps today's behaviour, extracting the first member and
tolerating trailing data, but then records no tar.

Store rows extracted from a .tgz record tarIntegrity and get an alias under
the tar key, in a table pnpm v11 never reads. Store lookups follow aliases,
so a tar-pinned frozen install is served offline by a store that .tgz
installs populated, and resolution takes a version's tar hash from its row
before downloading anything.

Registry revisions now always pin their tar, revision 0 included, and are
fetched with a tar pin, so the digest route can use any content coding.
Revision metadata must list revision 0 and the selected revision, and a
digest-route URL without a revision is rejected. pnpr keys its digest
references by tar hash on every registry format, serves the route as a
negotiated tar resource, and projects revision 0 explicitly. pnpm v11 cannot
extract tar resources, so it now rejects revisions with
ERR_PNPM_REGISTRY_REVISIONS_UNSUPPORTED.

Implements pnpm/rfcs text/0000-tar-integrity-for-every-source.md and
pnpm/rfcs#42.
2026-10-18 17:36:30 +02:00

195 lines
7.4 KiB
TypeScript

import { expect, test } from '@jest/globals'
import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries'
import { pkgSnapshotToResolution } from '@pnpm/lockfile.utils'
const GIT_TARBALL = 'https://codeload.github.com/foo/bar/tar.gz/0123456789abcdef0123456789abcdef01234567'
const LEGACY_GIT_TARBALL = 'https://codeload.github.com/kevva/is-negative/tar.gz/0123456789abcdef0123456789abcdef01234567'
const SHA512_INTEGRITY = `sha512-${'A'.repeat(86)}==`
test('pkgSnapshotToResolution() fails closed on a non-string tarball', () => {
// A tampered lockfile (YAML) could carry a non-string `tarball` that `new URL()` would
// string-coerce into an attacker-controlled URL.
expect(() => pkgSnapshotToResolution('foo@1.0.0', {
resolution: {
integrity: 'sha512-AAAA',
tarball: ['https://attacker.example/foo.tgz'],
},
} as never, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toThrow(
expect.objectContaining({ code: 'ERR_PNPM_INVALID_TARBALL_RESOLUTION' })
)
})
test('pkgSnapshotToResolution()', () => {
expect(pkgSnapshotToResolution('foo@1.0.0', {
resolution: {
integrity: 'AAAA',
},
}, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({
integrity: 'AAAA',
tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz',
})
expect(pkgSnapshotToResolution('@mycompany/mypackage@2.0.0', {
resolution: {
integrity: 'AAAA',
tarball: '@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz',
},
}, { registriesByScope: { default: 'https://registry.npmjs.org/', '@mycompany': 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local/' } })).toEqual({
integrity: 'AAAA',
tarball: 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local/@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz',
})
expect(pkgSnapshotToResolution('@mycompany/mypackage@2.0.0', {
resolution: {
integrity: 'AAAA',
tarball: '@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz',
},
}, { registriesByScope: { default: 'https://registry.npmjs.org/', '@mycompany': 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local' } })).toEqual({
integrity: 'AAAA',
tarball: 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local/@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz',
})
expect(pkgSnapshotToResolution('@cdn.sheetjs.com/xlsx-0.18.9/xlsx-0.18.9.tgz', {
resolution: {
integrity: 'sha512-CCCC',
tarball: 'https://cdn.sheetjs.com/xlsx-0.18.9/xlsx-0.18.9.tgz',
},
}, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({
integrity: 'sha512-CCCC',
tarball: 'https://cdn.sheetjs.com/xlsx-0.18.9/xlsx-0.18.9.tgz',
})
// Snapshot for a `file:` dependency whose resolution lacks the tarball
// field — the tarball should be recovered from the depPath.
expect(pkgSnapshotToResolution('test-package@file:test-package-1.0.0.tgz', {
resolution: {
integrity: 'sha512-AAAA',
},
version: '1.0.0',
}, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({
integrity: 'sha512-AAAA',
tarball: 'file:test-package-1.0.0.tgz',
})
})
test('pkgSnapshotToResolution() converts git-hosted and file: tarball snapshots', () => {
// The integrity requirement for registry tarballs is enforced by the npm
// resolver's lockfile verifier, not here; this pure conversion returns
// git-hosted (commit-anchored) and file: (local) tarballs as-is.
expect(pkgSnapshotToResolution('foo@https+++github.com+foo+bar', {
resolution: {
tarball: GIT_TARBALL,
gitHosted: true,
},
}, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({
tarball: GIT_TARBALL,
gitHosted: true,
})
expect(pkgSnapshotToResolution('is-negative@https+++codeload.github.com+kevva+is-negative+tar.gz+abc', {
resolution: {
tarball: LEGACY_GIT_TARBALL,
},
}, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({
tarball: LEGACY_GIT_TARBALL,
})
// `file:` tarballs are local files; the user already controls the
// bytes, and the install pipeline may write them without integrity.
expect(pkgSnapshotToResolution('local-pkg@file:local-pkg-1.0.0.tgz', {
resolution: {
tarball: 'file:local-pkg-1.0.0.tgz',
},
version: '1.0.0',
}, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({
tarball: 'file:local-pkg-1.0.0.tgz',
})
})
test('pkgSnapshotToResolution() reconstructs the tarball of a registry-qualified snapshot from its named registry', () => {
expect(pkgSnapshotToResolution('foo@work:1.0.0', {
resolution: {
integrity: 'sha512-AAAA',
},
}, {
registriesByScope: { default: 'https://registry.npmjs.org/' },
registriesByPrefix: normalizeRegistriesByPrefix({ work: 'https://npm.enterprise.example.com/' }),
})).toEqual({
integrity: 'sha512-AAAA',
tarball: 'https://npm.enterprise.example.com/foo/-/foo-1.0.0.tgz',
})
// The built-in gh alias needs no configuration.
expect(pkgSnapshotToResolution('@acme/private@gh:2.1.0', {
resolution: {
integrity: 'sha512-AAAA',
tarball: 'https://npm.pkg.github.com/download/@acme/private/2.1.0/abcdef',
},
}, {
registriesByScope: { default: 'https://registry.npmjs.org/' },
})).toEqual({
integrity: 'sha512-AAAA',
tarball: 'https://npm.pkg.github.com/download/@acme/private/2.1.0/abcdef',
})
})
test('pkgSnapshotToResolution() fails when a registry-qualified snapshot names an unknown alias', () => {
expect(() => pkgSnapshotToResolution('foo@work:1.0.0', {
resolution: {
integrity: 'sha512-AAAA',
},
}, {
registriesByScope: { default: 'https://registry.npmjs.org/' },
})).toThrow(
expect.objectContaining({ code: 'ERR_PNPM_MISSING_NAMED_REGISTRY' })
)
})
test('pkgSnapshotToResolution() rejects an alias that only exists on Object.prototype', () => {
// `constructor` matches the alias grammar and would resolve to a truthy
// inherited function on a plain object literal, sailing past the
// fail-closed check and reaching the tarball builder as a non-string.
for (const inherited of ['constructor', 'toString', 'valueOf']) {
expect(() => pkgSnapshotToResolution(`foo@${inherited}:1.0.0`, {
resolution: {
integrity: 'sha512-AAAA',
},
}, {
registriesByScope: { default: 'https://registry.npmjs.org/' },
})).toThrow(
expect.objectContaining({ code: 'ERR_PNPM_MISSING_NAMED_REGISTRY' })
)
}
})
test('pkgSnapshotToResolution() hydrates an integrity-only resolution from the canonical URL', () => {
expect(pkgSnapshotToResolution('foo@1.0.0', {
resolution: {
integrity: SHA512_INTEGRITY,
},
}, {
registriesByScope: { default: 'https://registry.npmjs.org/' },
})).toEqual({
integrity: SHA512_INTEGRITY,
tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz',
})
})
test.each([
{},
{ tarball: `https://registry.npmjs.org/-/tarballs/sha512/${'A'.repeat(86)}` },
{ tarball: 'file:../foo.tgz' },
])('pkgSnapshotToResolution() rejects a lockfile entry that pins a registry revision %#', (resolution) => {
expect(() => pkgSnapshotToResolution('foo@1.0.0', {
resolution: {
...resolution,
integrity: SHA512_INTEGRITY,
revision: 0,
} as never,
}, {
registriesByScope: { default: 'https://registry.npmjs.org/' },
})).toThrow(expect.objectContaining({
code: 'ERR_PNPM_REGISTRY_REVISIONS_UNSUPPORTED',
}))
})