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.
275 lines
13 KiB
TypeScript
275 lines
13 KiB
TypeScript
import { describe, expect, test } from '@jest/globals'
|
|
|
|
import {
|
|
type NamedRegistryPackageSpec,
|
|
parseBareSpecifier,
|
|
parseNamedRegistrySpecifierToRegistryPackageSpec,
|
|
} from '../lib/parseBareSpecifier.js'
|
|
|
|
const GH_ALIASES: ReadonlySet<string> = new Set(['gh'])
|
|
const DEFAULT_TAG = 'latest'
|
|
const NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
|
|
describe('parseBareSpecifier', () => {
|
|
test('npm:<version_selector> falls back to the outer alias as the package name', () => {
|
|
// Mirrors the named-registry shape (`gh:^1.0.0` paired with `@acme/foo`),
|
|
// so `npm:^1.0.0` paired with `is-positive` resolves the outer alias.
|
|
expect(parseBareSpecifier('npm:^1.0.0', 'is-positive', DEFAULT_TAG, NPM_REGISTRY)).toMatchObject({
|
|
name: 'is-positive',
|
|
type: 'range',
|
|
})
|
|
expect(parseBareSpecifier('npm:1.0.0', '@acme/foo', DEFAULT_TAG, NPM_REGISTRY)).toMatchObject({
|
|
name: '@acme/foo',
|
|
type: 'version',
|
|
fetchSpec: '1.0.0',
|
|
})
|
|
})
|
|
|
|
test.each(['1.2.3+r0', '1.2.3+r01', '1.2.3+r9007199254740992', 'npm:foo@1.2.3+r42'])(
|
|
'rejects the registry revision selector %s',
|
|
(specifier) => {
|
|
expect(() => parseBareSpecifier(specifier, 'foo', DEFAULT_TAG, NPM_REGISTRY)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_REGISTRY_REVISIONS_UNSUPPORTED',
|
|
}))
|
|
}
|
|
)
|
|
|
|
test('leaves unrelated semver build metadata unchanged', () => {
|
|
expect(parseBareSpecifier('1.2.3+build.1', 'foo', DEFAULT_TAG, NPM_REGISTRY)).toStrictEqual({
|
|
name: 'foo',
|
|
fetchSpec: '1.2.3',
|
|
type: 'version',
|
|
})
|
|
})
|
|
|
|
// https://github.com/pnpm/pnpm/issues/14817
|
|
test.each([
|
|
'runtime:^22.18.0 || ^24.0.0',
|
|
'runtime:^22||^24',
|
|
'runtime:>=22.18.0 <25',
|
|
'runtime:rc/^23.0.0 || ^24.0.0',
|
|
'gh:^1.0.0 || ^2.0.0',
|
|
'jsr:^1.0.0 || ^2.0.0',
|
|
'foo/bar#semver:^1.0.0 || ^2.0.0',
|
|
])('declines the protocol-prefixed range union %s', (specifier) => {
|
|
expect(parseBareSpecifier(specifier, 'node', DEFAULT_TAG, NPM_REGISTRY)).toBeNull()
|
|
})
|
|
|
|
test('keeps the npm members of a range union whose later member has a protocol', () => {
|
|
// Merged peer ranges are joined with `||`.
|
|
expect(parseBareSpecifier('^1.0.0 || workspace:^2.0.0', 'foo', DEFAULT_TAG, NPM_REGISTRY)).toStrictEqual({
|
|
fetchSpec: '>=1.0.0 <2.0.0-0',
|
|
name: 'foo',
|
|
type: 'range',
|
|
})
|
|
})
|
|
|
|
test('parses a tarball URL under the registry', () => {
|
|
const url = 'https://registry.npmjs.org/@scope/foo/-/foo-1.0.0.tgz'
|
|
expect(parseBareSpecifier(url, undefined, DEFAULT_TAG, NPM_REGISTRY)).toStrictEqual({
|
|
fetchSpec: '1.0.0',
|
|
name: '@scope/foo',
|
|
normalizedBareSpecifier: url,
|
|
type: 'version',
|
|
})
|
|
})
|
|
|
|
test.each([
|
|
['https://example.com/artifactory/api/npm/npm-remote/', 'foo'],
|
|
['https://example.com/npm/', '@scope/foo'],
|
|
])('parses a tarball URL under the registry %s that has a path', (registry, name) => {
|
|
const url = `${registry}${name}/-/foo-1.0.0.tgz`
|
|
expect(parseBareSpecifier(url, undefined, DEFAULT_TAG, registry)).toStrictEqual({
|
|
fetchSpec: '1.0.0',
|
|
name,
|
|
normalizedBareSpecifier: url,
|
|
type: 'version',
|
|
})
|
|
})
|
|
|
|
test.each([
|
|
['https://example.com/npm', 'https://example.com/npm-other/foo/-/foo-1.0.0.tgz'],
|
|
[NPM_REGISTRY, 'https://registry.npmjs.org/foo/-/bar-1.0.0.tgz'],
|
|
[NPM_REGISTRY, 'https://registry.npmjs.org/%E0%A4%A/-/foo-1.0.0.tgz'],
|
|
[NPM_REGISTRY, 'https://registry.npmjs.org/foo%ZZ/-/foo%ZZ-1.0.0.tgz'],
|
|
[NPM_REGISTRY, 'https://registry.npmjs.org/foo%FF/-/foo%FF-1.0.0.tgz'],
|
|
])('declines the tarball URL %s %s', (registry, url) => {
|
|
expect(parseBareSpecifier(url, undefined, DEFAULT_TAG, registry)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('parseNamedRegistrySpecifierToRegistryPackageSpec', () => {
|
|
test('returns null on non-named-registry specifiers', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('latest', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('npm:foo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('npm:@foo/bar', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('jsr:@foo/bar', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('catalog:', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('workspace:*', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('does not intercept github: git shorthand (that scheme belongs to the git resolver)', () => {
|
|
// `hosted-git-info` / `npm-package-arg` own the `github:` scheme as a GitHub git repository shortcut.
|
|
// Even if a caller accidentally passed it in, it is not in the built-in `gh` alias set.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('github:owner/repo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('github:owner/repo#main', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('github:@acme/foo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('parses <alias>:<version_selector> when a scoped package alias is given', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:^1.0.0', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '>=1.0.0 <2.0.0-0',
|
|
type: 'range',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:1.0.0', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '1.0.0',
|
|
type: 'version',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:latest', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: 'latest',
|
|
type: 'tag',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('rejects a revision-addressed named-registry version', () => {
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:1.0.0+r2', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_REGISTRY_REVISIONS_UNSUPPORTED',
|
|
}))
|
|
})
|
|
|
|
test('parses <alias>:@<owner>/<name> and falls back to the default tag', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: 'latest',
|
|
type: 'tag',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('parses <alias>:@<owner>/<name>@<version_selector>', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '>=1.0.0 <2.0.0-0',
|
|
type: 'range',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '1.0.0',
|
|
type: 'version',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@beta', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: 'beta',
|
|
type: 'tag',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('preserves the original package name (no scope rewrite, unlike jsr)', () => {
|
|
// Named registries publish the package under its original name, unlike the JSR
|
|
// npm compatibility registry which remaps `@scope/name` to `@jsr/scope__name`.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)?.name).toBe('@acme/foo')
|
|
})
|
|
|
|
test('throws when the scope has no package name', () => {
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme@^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme', GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/', GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
})
|
|
|
|
test('throws when the scope is empty', () => {
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@/bar', GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
})
|
|
|
|
test('throws when the package name is a dot segment', () => {
|
|
for (const rawSpecifier of ['gh:.', 'gh:..', 'gh:@acme/.', 'gh:@acme/..']) {
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec(rawSpecifier, GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
}
|
|
})
|
|
|
|
test('throws when the package name contains path separators', () => {
|
|
for (const rawSpecifier of ['gh:@acme/../foo', 'gh:@acme/foo/bar', 'gh:@acme/foo\\bar', 'gh:@sco\\pe/foo', 'gh:foo/../bar', 'gh:foo\\bar']) {
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec(rawSpecifier, GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
}
|
|
})
|
|
|
|
test('does not claim <alias>:<version_selector> when no package alias is provided', () => {
|
|
// No alias means we cannot know the package name — we must not hijack such specifiers.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('falls back to the dependency alias for <alias>:<version_selector>, scoped or not', () => {
|
|
// Mirrors the `npm:^1.0.0` shape — works with both scoped and unscoped aliases.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:^1.0.0', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toMatchObject({
|
|
name: '@acme/foo',
|
|
registryName: 'gh',
|
|
})
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:^4.0.0', new Set(['work']), 'lodash', DEFAULT_TAG)).toMatchObject({
|
|
name: 'lodash',
|
|
registryName: 'work',
|
|
})
|
|
})
|
|
|
|
test('parses unscoped <alias>:<name>[@<version_selector>]', () => {
|
|
// Arbitrary named registries (vlt-style) accept unscoped names too,
|
|
// not just GitHub Packages-style scopes.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:lodash@^4.0.0', new Set(['work']), undefined, DEFAULT_TAG)).toMatchObject({
|
|
name: 'lodash',
|
|
fetchSpec: '>=4.0.0 <5.0.0-0',
|
|
type: 'range',
|
|
registryName: 'work',
|
|
})
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:lodash', new Set(['work']), undefined, DEFAULT_TAG)).toMatchObject({
|
|
name: 'lodash',
|
|
fetchSpec: 'latest',
|
|
type: 'tag',
|
|
registryName: 'work',
|
|
})
|
|
})
|
|
|
|
test('matches any alias in the configured set and reports it back to the caller', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:@acme/foo@^1.0.0', new Set(['gh', 'work']), undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '>=1.0.0 <2.0.0-0',
|
|
type: 'range',
|
|
registryName: 'work',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('returns null when the alias is not in the configured set', () => {
|
|
// Unrecognized prefixes must fall through so other resolvers (git, npm:, etc.) can try.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:@acme/foo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('includes the user-facing alias in error messages for user-defined aliases', () => {
|
|
// When `work:@acme` fails validation, the user's alias must appear in the error
|
|
// so they can find the offending specifier — not a generic `gh` reference.
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('work:@acme', new Set(['work']), undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
message: expect.stringContaining("'work:'"),
|
|
}))
|
|
})
|
|
})
|