Files
pnpm/pnpr/client/test/resolveViaPnprServer.test.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

285 lines
8.7 KiB
TypeScript

import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { expect, test } from '@jest/globals'
import { hashObjectNullableWithPrefix } from '@pnpm/crypto.object-hasher'
import { type PnprProject, resolveViaPnprServer, type ResolveViaPnprServerOptions } from '@pnpm/pnpr.client'
interface CapturedResolveRequest {
projects: Array<Record<string, unknown>>
resolutionMode?: string
patchedDependencies?: Record<string, string>
packageExtensions?: Record<string, unknown>
allowUnusedPatches?: boolean
}
const resolverSettingNames = ['autoInstallPeers', 'dedupePeers', 'excludeLinksFromLockfile'] as const
test('serializes name and version for the single-project compatibility options', async () => {
const options = {
name: 'app',
version: '1.2.3',
dependencies: {},
}
const request = await captureResolveRequest(options)
expect(request.projects).toEqual([{
dir: '.',
name: 'app',
version: '1.2.3',
dependencies: {},
}])
})
test('serializes name and version for every explicit project', async () => {
const projects: PnprProject[] = [
{
dir: 'packages/app',
name: 'app',
version: '1.0.0',
dependencies: { lib: 'workspace:*' },
},
{
dir: 'packages/lib',
name: 'lib',
version: '2.0.0',
dependencies: {},
},
]
const request = await captureResolveRequest({ projects })
expect(request.projects).toEqual(projects)
})
test('omits absent identity fields and current lockfile resolution settings', async () => {
const request = await captureResolveRequest({ dependencies: {} })
expect(Object.hasOwn(request.projects[0], 'name')).toBe(false)
expect(Object.hasOwn(request.projects[0], 'version')).toBe(false)
for (const setting of resolverSettingNames) {
expect(Object.hasOwn(request, setting)).toBe(false)
}
})
test.each(resolverSettingNames)('serializes %s independently', async (setting) => {
await Promise.all([true, false].map(async value => {
const request = await captureResolveRequest({ dependencies: {}, [setting]: value })
expect(request).toMatchObject({ [setting]: value })
for (const key of resolverSettingNames) {
expect(Object.hasOwn(request, key)).toBe(key === setting)
}
}))
})
// The mode decides which version every pick lands on, so a server resolving
// on the client's behalf has to be told it — omitted, it resolves `highest`
// and hands back a lockfile the client would never have written.
test.each(['time-based', 'lowest-direct', 'highest'] as const)('serializes resolutionMode %s', async (resolutionMode) => {
const request = await captureResolveRequest({ dependencies: {}, resolutionMode })
expect(request).toMatchObject({ resolutionMode })
})
test('omits resolutionMode when the caller has none', async () => {
const request = await captureResolveRequest({ dependencies: {} })
expect(Object.hasOwn(request, 'resolutionMode')).toBe(false)
})
test('serializes patch hashes and package extensions', async () => {
const request = await captureResolveRequest({
dependencies: {},
patchedDependencies: { 'foo@1.0.0': 'abc123' },
packageExtensions: {
'foo@1.0.0': { dependencies: { bar: '1.0.0' } },
},
allowUnusedPatches: true,
})
expect(request).toMatchObject({
patchedDependencies: { 'foo@1.0.0': 'abc123' },
packageExtensions: {
'foo@1.0.0': { dependencies: { bar: '1.0.0' } },
},
allowUnusedPatches: true,
})
})
test.each([
['omits', undefined],
['changes', { 'foo@1.0.0': 'different-hash' }],
] as const)('rejects a server that %s the requested patch metadata', async (_behavior, patchedDependencies) => {
await expect(captureResolveRequest({
dependencies: {},
patchedDependencies: { 'foo@1.0.0': 'abc123' },
}, {
lockfileVersion: '9.0',
importers: { '.': {} },
...(patchedDependencies == null ? {} : { patchedDependencies }),
})).rejects.toMatchObject({
code: 'ERR_PNPM_PNPR_TRANSFORM_METADATA_MISMATCH',
message: expect.stringContaining('returned patchedDependencies that do not match the request'),
})
})
test.each([
['omits', undefined],
['changes', 'sha256-different-checksum'],
] as const)('rejects a server that %s the requested package extension metadata', async (_behavior, packageExtensionsChecksum) => {
await expect(captureResolveRequest({
dependencies: {},
packageExtensions: {
'foo@1.0.0': { dependencies: { bar: '1.0.0' } },
},
}, {
lockfileVersion: '9.0',
importers: { '.': {} },
...(packageExtensionsChecksum == null ? {} : { packageExtensionsChecksum }),
})).rejects.toMatchObject({
code: 'ERR_PNPM_PNPR_TRANSFORM_METADATA_MISMATCH',
message: expect.stringContaining('returned packageExtensionsChecksum that does not match the request'),
})
})
test.each([
['omits', {}],
['changes', { publishDirectory: 'build' }],
] as const)('rejects a server that %s the requested publish directory', async (_behavior, importer) => {
await expect(captureResolveRequest({
projects: [{
dir: 'packages/lib',
name: 'lib',
publishConfig: { directory: 'dist' },
}],
}, {
lockfileVersion: '9.0',
importers: { 'packages/lib': importer },
})).rejects.toMatchObject({
code: 'ERR_PNPM_PNPR_PUBLISH_DIRECTORY_MISMATCH',
message: expect.stringContaining('instead of its publishConfig.directory'),
})
})
test('does not check a project that the server did not return', async () => {
const request = await captureResolveRequest({
projects: [{
dir: 'packages/lib',
name: 'lib',
publishConfig: { directory: 'dist' },
}],
}, {
lockfileVersion: '9.0',
// A partial install resolves a subset of the workspace, so a project the
// response leaves out carries no importer to compare against.
importers: {},
})
expect(request.projects).toEqual([{
dir: 'packages/lib',
name: 'lib',
publishConfig: { directory: 'dist' },
}])
})
test('does not check an unreturned project whose dir names an Object.prototype member', async () => {
const request = await captureResolveRequest({
projects: [{
dir: 'constructor',
name: 'lib',
publishConfig: { directory: 'dist' },
}],
}, {
lockfileVersion: '9.0',
importers: {},
})
expect(request.projects).toEqual([{
dir: 'constructor',
name: 'lib',
publishConfig: { directory: 'dist' },
}])
})
test('serializes publishConfig for the single-project compatibility options', async () => {
const request = await captureResolveRequest({
name: 'lib',
version: '1.2.3',
publishConfig: { directory: 'dist', linkDirectory: false },
dependencies: {},
}, {
lockfileVersion: '9.0',
importers: { '.': { publishDirectory: 'dist' } },
})
expect(request.projects).toEqual([{
dir: '.',
name: 'lib',
version: '1.2.3',
publishConfig: { directory: 'dist', linkDirectory: false },
dependencies: {},
}])
})
test('omits an absent publishConfig so servers that predate the field see the same body', async () => {
const request = await captureResolveRequest({ dependencies: {} })
expect(Object.hasOwn(request.projects[0], 'publishConfig')).toBe(false)
})
async function captureResolveRequest (
options: Omit<ResolveViaPnprServerOptions, 'registryUrl'>,
returnedLockfile?: Record<string, unknown>
): Promise<CapturedResolveRequest> {
let capturedRequest: CapturedResolveRequest | undefined
const server = http.createServer(async (request, response) => {
const chunks: Buffer[] = []
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
capturedRequest = JSON.parse(Buffer.concat(chunks).toString('utf8')) as CapturedResolveRequest
response.end(`${JSON.stringify({
type: 'done',
lockfile: returnedLockfile ?? {
lockfileVersion: '9.0',
importers: { '.': {} },
patchedDependencies: capturedRequest.patchedDependencies,
packageExtensionsChecksum: hashObjectNullableWithPrefix(capturedRequest.packageExtensions),
},
stats: { totalPackages: 0 },
})}\n`)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
try {
const { port } = server.address() as AddressInfo
await resolveViaPnprServer({
...options,
registryUrl: `http://127.0.0.1:${port}/`,
})
if (capturedRequest == null) {
throw new Error('The pnpr client did not send a resolve request')
}
return capturedRequest
} finally {
await closeServer(server)
}
}
async function closeServer (server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error == null) resolve()
else reject(error)
})
})
}