Files
pnpm/pnpm11/worker/test/tarballFile.test.ts
Juan Manuel Martinez OrtizandZoltan Kochan ae349ec502 feat: cap the unpacked size of package archives with maxTarballUnpackedSize in v11 and for zips (#17014)
A gzip bomb inflates a kilobyte-sized tarball into hundreds of gigabytes
during extraction. pnpm v12 bounds what an archive may unpack to with
maxTarballUnpackedSize since pnpm/pnpm#17054; pnpm v11 had no such
bound, so an install could still exhaust the disk.

pnpm v11 now adds up the sizes that the regular-file entries of a
package archive declare in their tar headers and fails the extraction
with ERR_PNPM_TARBALL_TOO_LARGE once the total passes
maxTarballUnpackedSize (default 4 GiB). Header sizes stand in for
written bytes: the tar parser never yields more payload than a header
declares, so their sum bounds what can reach the store, and checking it
before the payload is read keeps the crossing entry out of the store.
Entries the ignore filter drops count as well. The error is
deterministic for a given archive, so the fetch retry loop does not
retry it. The zip archives that runtimes are installed from are held to
the same limit. The setting name, default, error code, message and hint
match pnpm v12.

pnpm v12's zip extractor bounded each entry by its central-directory
size but not their sum. Every stored zip entry now counts against the
payload ceiling of the TarLimits that tarballs use.

The "Added in" line in the v11 docs assumes the next release is 11.28.7.

Completes https://github.com/pnpm/tasks/issues/81.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-10-18 19:05:42 +02:00

98 lines
3.7 KiB
TypeScript

import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { gzipSync } from 'node:zlib'
import { expect, test } from '@jest/globals'
import { addTarballToStore } from '../src/addToStore.js'
function createSource (): { temporaryDirectory: string, tarballFile: string, storeDir: string, integrity: string } {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-tarball-file-'))
const tarballFile = path.join(temporaryDirectory, 'archive.tgz')
const data = gzipSync(Buffer.alloc(1024))
fs.writeFileSync(tarballFile, data)
return {
temporaryDirectory,
tarballFile,
storeDir: path.join(temporaryDirectory, 'store'),
integrity: `sha512-${crypto.hash('sha512', data, 'base64')}`,
}
}
test('file-backed tarballs are verified before extraction', async () => {
const source = createSource()
try {
const result = await addTarballToStore({
type: 'extract',
tarballFile: source.tarballFile,
storeDir: source.storeDir,
filesIndexFile: 'test',
integrity: `sha512-${crypto.hash('sha512', 'wrong', 'base64')}`,
maxTarballUnpackedSize: 4294967296,
})
expect(result).toMatchObject({ status: 'error', error: { type: 'integrity_validation_failed', found: source.integrity } })
expect(fs.existsSync(source.storeDir)).toBe(false)
} finally {
fs.rmSync(source.temporaryDirectory, { recursive: true, force: true })
}
})
test.each([false, true])('file-backed extraction retains the tarball integrity (pinned: %s)', async (pinned) => {
const source = createSource()
try {
const result = await addTarballToStore({
type: 'extract',
tarballFile: source.tarballFile,
storeDir: source.storeDir,
filesIndexFile: 'test',
pkgId: 'test@1.0.0',
integrity: pinned ? source.integrity : undefined,
maxTarballUnpackedSize: 4294967296,
})
expect(result).toMatchObject({ status: 'success', value: { integrity: source.integrity } })
expect('indexWrites' in result && result.indexWrites).toHaveLength(pinned ? 1 : 2)
} finally {
fs.rmSync(source.temporaryDirectory, { recursive: true, force: true })
}
})
function createTarEntry (name: string, content: string): Buffer {
const header = Buffer.alloc(512)
header.write(name, 0)
header.write('0000644\0', 100)
header.write(content.length.toString(8).padStart(11, '0') + '\0', 124)
header.fill(' ', 148, 156)
header[156] = '0'.charCodeAt(0)
header.write(`${header.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, '0')}\0 `, 148)
const body = Buffer.alloc(Math.ceil(content.length / 512) * 512)
body.write(content)
return Buffer.concat([header, body])
}
test.each([false, true])('an archive that unpacks past maxTarballUnpackedSize is rejected with a hint (file-backed: %s)', async (fileBacked) => {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-tarball-limit-'))
try {
const buffer = gzipSync(Buffer.concat([
createTarEntry('package/package.json', '{"name":"limited","version":"1.0.0"}'),
createTarEntry('package/index.js', 'x'.repeat(100)),
Buffer.alloc(1024),
]))
const tarballFile = path.join(temporaryDirectory, 'archive.tgz')
fs.writeFileSync(tarballFile, buffer)
await expect(addTarballToStore({
type: 'extract',
...(fileBacked ? { tarballFile } : { buffer }),
storeDir: path.join(temporaryDirectory, 'store'),
filesIndexFile: 'test',
maxTarballUnpackedSize: 100,
})).rejects.toMatchObject({
code: 'ERR_PNPM_TARBALL_TOO_LARGE',
hint: 'If the package is legitimately this large, raise the maxTarballUnpackedSize setting.',
})
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
}
})