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.
285 lines
8.7 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
}
|