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.
195 lines
7.4 KiB
TypeScript
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',
|
|
}))
|
|
})
|