Files
pnpm/pnpm11/fetching/binary-fetcher/test/index.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

682 lines
25 KiB
TypeScript

/// <reference path="../../../__typings__/index.d.ts"/>
import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from '@jest/globals'
import { PnpmError } from '@pnpm/error'
import { createBinaryFetcher, downloadAndUnpackZip } from '@pnpm/fetching.binary-fetcher'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import AdmZip from 'adm-zip'
import ssri from 'ssri'
import { temporaryDirectory } from 'tempy'
// Mock fetch function that returns a ZIP buffer and simulates FetchFromRegistry
function createMockFetch (zipBuffer: Buffer): FetchFromRegistry {
return (() => Promise.resolve({
body: (async function * () {
yield zipBuffer
})(),
})) as unknown as FetchFromRegistry
}
describe('extractZipToTarget security', () => {
describe('prefix path traversal (Attack Vector 2)', () => {
it('should reject prefix with ../ path traversal', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('#!/bin/sh\necho "node"'))
const zipBuffer = zip.toBuffer()
// Use real integrity so the check passes and we reach path traversal validation
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await expect(
downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '../../evil',
},
targetDir
)
).rejects.toThrow(PnpmError)
await expect(
downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '../../evil',
},
targetDir
)
).rejects.toMatchObject({
code: 'ERR_PNPM_PATH_TRAVERSAL',
})
})
it('should reject absolute path prefix', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('#!/bin/sh\necho "node"'))
const zipBuffer = zip.toBuffer()
// Use real integrity so the check passes and we reach path traversal validation
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await expect(
downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '/tmp/evil',
},
targetDir
)
).rejects.toMatchObject({
code: 'ERR_PNPM_PATH_TRAVERSAL',
})
})
})
describe('ZIP entry path traversal (Attack Vector 1)', () => {
it('should reject ZIP entries with ../ path traversal', async () => {
const targetDir = temporaryDirectory()
// Load fixture ZIP that has a raw malicious entry path
const zipBuffer = fs.readFileSync(path.join(import.meta.dirname, 'fixtures/path-traversal.zip'))
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await expect(
downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '',
},
targetDir
)
).rejects.toMatchObject({
code: 'ERR_PNPM_PATH_TRAVERSAL',
})
const parentDir = path.dirname(targetDir)
expect(fs.existsSync(path.join(parentDir, '.npmrc'))).toBe(false)
})
it('should reject ZIP entries with absolute paths', async () => {
const targetDir = temporaryDirectory()
// Load fixture ZIP that has a raw malicious absolute path entry
const zipBuffer = fs.readFileSync(path.join(import.meta.dirname, 'fixtures/absolute-path.zip'))
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await expect(
downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '',
},
targetDir
)
).rejects.toMatchObject({
code: 'ERR_PNPM_PATH_TRAVERSAL',
})
})
// Windows-specific: backslash is a path separator only on Windows
// On Unix, backslash is a valid filename character, so this test only runs on Windows
const isWindows = process.platform === 'win32'
const windowsTest = isWindows ? it : it.skip
windowsTest('should reject ZIP entries with backslash path traversal on Windows', async () => {
const targetDir = temporaryDirectory()
// Load fixture ZIP with Windows-style backslash path traversal
const zipBuffer = fs.readFileSync(path.join(import.meta.dirname, 'fixtures/backslash-traversal.zip'))
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await expect(
downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '',
},
targetDir
)
).rejects.toMatchObject({
code: 'ERR_PNPM_PATH_TRAVERSAL',
})
})
})
describe('destination symlink following', () => {
it('does not write through a symlink planted next to the target directory', async () => {
// Extraction must not happen at a path an attacker sharing the store can predict.
const parentDir = temporaryDirectory()
const targetDir = path.join(parentDir, 'target')
fs.mkdirSync(targetDir)
const outsideDir = path.join(parentDir, 'outside')
fs.mkdirSync(outsideDir)
fs.writeFileSync(path.join(outsideDir, 'node'), 'original')
fs.mkdirSync(path.join(parentDir, 'node-v20.0.0'))
// 'junction' keeps this working on Windows, where symlinking needs privileges.
fs.symlinkSync(outsideDir, path.join(parentDir, 'node-v20.0.0', 'bin'), 'junction')
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('overwritten'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
await downloadAndUnpackZip(
createMockFetch(zipBuffer),
{
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
},
targetDir
)
expect(fs.readFileSync(path.join(outsideDir, 'node'), 'utf8')).toBe('original')
expect(fs.readFileSync(path.join(targetDir, 'bin', 'node'), 'utf8')).toBe('overwritten')
})
it('leaves no extraction directory behind when extraction fails', async () => {
const parentDir = temporaryDirectory()
const targetDir = path.join(parentDir, 'target')
fs.mkdirSync(targetDir)
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('binary'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
await expect(
downloadAndUnpackZip(
createMockFetch(zipBuffer),
{
url: 'https://example.com/node.zip',
integrity,
// Rejected by validatePathSecurity after the extraction directory exists.
basename: '../evil',
},
targetDir
)
).rejects.toMatchObject({ code: 'ERR_PNPM_PATH_TRAVERSAL' })
expect(fs.readdirSync(parentDir)).toStrictEqual(['target'])
})
it('leaves no extraction directory behind in the store', async () => {
const parentDir = temporaryDirectory()
const targetDir = path.join(parentDir, 'target')
fs.mkdirSync(targetDir)
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('binary'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
await downloadAndUnpackZip(
createMockFetch(zipBuffer),
{
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
},
targetDir
)
expect(fs.readdirSync(parentDir)).toStrictEqual(['target'])
})
})
describe('legitimate ZIP extraction', () => {
it('should successfully extract a normal ZIP file', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('#!/bin/sh\necho "node"'))
zip.addFile('node-v20.0.0/README.md', Buffer.from('# Node.js'))
const zipBuffer = zip.toBuffer()
// Create a mock fetch that also passes integrity check by using the actual buffer
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
},
targetDir
)
expect(fs.existsSync(path.join(targetDir, 'bin/node'))).toBe(true)
expect(fs.existsSync(path.join(targetDir, 'README.md'))).toBe(true)
})
it('should authenticate the ZIP download', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('binary'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
const fetch = (_url: string, opts?: { authHeaderValue?: string }) => {
expect(opts?.authHeaderValue).toBe('Bearer mirror-token')
return Promise.resolve({
body: (async function * () {
yield zipBuffer
})(),
})
}
await downloadAndUnpackZip(fetch as never, {
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
authHeaderValue: 'Bearer mirror-token',
}, targetDir)
})
it('should handle empty basename correctly', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('bin/node', Buffer.from('#!/bin/sh\necho "node"'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '',
},
targetDir
)
expect(fs.existsSync(path.join(targetDir, 'bin/node'))).toBe(true)
})
it('skips entries matching ignoreEntry regex (basename stripped)', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('node-v20.0.0/node.exe', Buffer.from('binary'))
zip.addFile('node-v20.0.0/npm', Buffer.from('npm shim'))
zip.addFile('node-v20.0.0/npm.cmd', Buffer.from('npm cmd'))
zip.addFile('node-v20.0.0/node_modules/npm/package.json', Buffer.from('{}'))
zip.addFile('node-v20.0.0/node_modules/corepack/package.json', Buffer.from('{}'))
zip.addFile('node-v20.0.0/node_modules/keep-me/index.js', Buffer.from('kept'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
ignoreEntry: /^(?:node_modules\/(?:npm|corepack)(?:\/|$)|npm(?:\.cmd)?$)/,
},
targetDir
)
expect(fs.existsSync(path.join(targetDir, 'node.exe'))).toBe(true)
expect(fs.existsSync(path.join(targetDir, 'node_modules/keep-me/index.js'))).toBe(true)
expect(fs.existsSync(path.join(targetDir, 'npm'))).toBe(false)
expect(fs.existsSync(path.join(targetDir, 'npm.cmd'))).toBe(false)
expect(fs.existsSync(path.join(targetDir, 'node_modules/npm'))).toBe(false)
expect(fs.existsSync(path.join(targetDir, 'node_modules/corepack'))).toBe(false)
})
it('skips entries matching ignoreEntry regex when basename is empty', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('bin/node', Buffer.from('#!/bin/sh\necho "node"'))
zip.addFile('bin/npm', Buffer.from('npm shim'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: '',
ignoreEntry: /^bin\/npm$/,
},
targetDir
)
expect(fs.existsSync(path.join(targetDir, 'bin/node'))).toBe(true)
expect(fs.existsSync(path.join(targetDir, 'bin/npm'))).toBe(false)
})
it('still honors ignoreEntry when the archive contains directory entries (regression for #11325)', async () => {
// Real Node.js Windows zips include directory entries in addition to file
// entries.
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('node-v20.0.0/', Buffer.alloc(0))
zip.addFile('node-v20.0.0/node.exe', Buffer.from('binary'))
zip.addFile('node-v20.0.0/node_modules/', Buffer.alloc(0))
zip.addFile('node-v20.0.0/node_modules/npm/', Buffer.alloc(0))
zip.addFile('node-v20.0.0/node_modules/npm/package.json', Buffer.from('{}'))
zip.addFile('node-v20.0.0/node_modules/corepack/', Buffer.alloc(0))
zip.addFile('node-v20.0.0/node_modules/corepack/package.json', Buffer.from('{}'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
ignoreEntry: /^node_modules\/(?:npm|corepack)(?:\/|$)/,
},
targetDir
)
expect(fs.existsSync(path.join(targetDir, 'node.exe'))).toBe(true)
expect(fs.existsSync(path.join(targetDir, 'node_modules/npm'))).toBe(false)
expect(fs.existsSync(path.join(targetDir, 'node_modules/corepack'))).toBe(false)
})
it('strips /g /y flags from ignoreEntry so .test() is not stateful across entries', async () => {
const targetDir = temporaryDirectory()
const zip = new AdmZip()
zip.addFile('node-v20.0.0/node.exe', Buffer.from('binary'))
zip.addFile('node-v20.0.0/npm', Buffer.from('npm shim 1'))
zip.addFile('node-v20.0.0/npx', Buffer.from('npx shim 2'))
zip.addFile('node-v20.0.0/corepack', Buffer.from('corepack 3'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(zipBuffer).toString()
const mockFetch = createMockFetch(zipBuffer)
await downloadAndUnpackZip(
mockFetch,
{
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
// Deliberately pass a /g regex — a stateful .test() would skip only
// every other matching entry. All three shims must still be dropped.
ignoreEntry: /^(?:npm|npx|corepack)$/g,
},
targetDir
)
expect(fs.existsSync(path.join(targetDir, 'node.exe'))).toBe(true)
expect(fs.existsSync(path.join(targetDir, 'npm'))).toBe(false)
expect(fs.existsSync(path.join(targetDir, 'npx'))).toBe(false)
expect(fs.existsSync(path.join(targetDir, 'corepack'))).toBe(false)
})
})
})
// A symlink to a file cannot be a junction, and creating one on Windows needs
// Developer Mode or elevation.
const itOnNonWindows = process.platform === 'win32' ? it.skip : it
// A symlink inside an extraction destination can redirect a ZIP entry to a file outside it.
describe('zip extraction over a symlink', () => {
async function extractOverSymlink (plantSymlink: (paths: { targetDir: string, outside: string }) => void): Promise<{ extraction: Promise<void>, outside: string, targetDir: string }> {
const dir = temporaryDirectory()
const outside = path.join(dir, 'outside')
fs.mkdirSync(outside)
fs.writeFileSync(path.join(outside, 'node'), 'original')
const targetDir = path.join(dir, 'target')
fs.mkdirSync(targetDir)
plantSymlink({ targetDir, outside })
const zip = new AdmZip()
zip.addFile('bin/node', Buffer.from('overwritten'))
const zipBuffer = zip.toBuffer()
const extraction = downloadAndUnpackZip(
createMockFetch(zipBuffer),
{ url: 'https://example.com/node.zip', integrity: ssri.fromData(zipBuffer).toString(), basename: '' },
targetDir
)
return { extraction, outside, targetDir }
}
it('refuses to extract through a symlinked parent directory', async () => {
const { extraction, outside } = await extractOverSymlink(({ targetDir, outside }) => {
fs.symlinkSync(outside, path.join(targetDir, 'bin'), 'junction')
})
await expect(extraction).rejects.toMatchObject({ code: 'ERR_PNPM_PATH_TRAVERSAL' })
expect(fs.readFileSync(path.join(outside, 'node'), 'utf8')).toBe('original')
})
itOnNonWindows('replaces a symlinked destination file instead of writing through it', async () => {
const { extraction, outside, targetDir } = await extractOverSymlink((paths) => {
fs.mkdirSync(path.join(paths.targetDir, 'bin'))
fs.symlinkSync(path.join(paths.outside, 'node'), path.join(paths.targetDir, 'bin', 'node'))
})
await extraction
expect(fs.readFileSync(path.join(outside, 'node'), 'utf8')).toBe('original')
expect(fs.lstatSync(path.join(targetDir, 'bin', 'node')).isSymbolicLink()).toBe(false)
expect(fs.readFileSync(path.join(targetDir, 'bin', 'node'), 'utf8')).toBe('overwritten')
})
})
describe('zip entry sizes', () => {
it('rejects an entry that inflates past the size the archive declares for it', async () => {
const zip = new AdmZip()
zip.addFile('bin/node', Buffer.alloc(64 * 1024, 'x'))
const zipBuffer = setDeclaredUncompressedSize(zip.toBuffer(), 1024)
const targetDir = temporaryDirectory()
await expect(downloadAndUnpackZip(
createMockFetch(zipBuffer),
{ url: 'https://example.com/node.zip', integrity: ssri.fromData(zipBuffer).toString(), basename: '' },
targetDir
)).rejects.toThrow('too many bytes in the stream')
})
})
describe('zip extraction target', () => {
it('creates a target directory that does not exist yet when basename is empty', async () => {
const zip = new AdmZip()
zip.addFile('bin/node', Buffer.from('binary'))
const zipBuffer = zip.toBuffer()
const targetDir = path.join(temporaryDirectory(), 'missing', 'target')
await downloadAndUnpackZip(
createMockFetch(zipBuffer),
{ url: 'https://example.com/node.zip', integrity: ssri.fromData(zipBuffer).toString(), basename: '' },
targetDir
)
expect(fs.readFileSync(path.join(targetDir, 'bin', 'node'), 'utf8')).toBe('binary')
})
})
describe('zip download integrity', () => {
it('rejects a download whose checksum does not match, leaving the target untouched', async () => {
const zip = new AdmZip()
zip.addFile('bin/node', Buffer.from('binary'))
const zipBuffer = zip.toBuffer()
const integrity = ssri.fromData(Buffer.from('something else')).toString()
const targetDir = temporaryDirectory()
await expect(downloadAndUnpackZip(
createMockFetch(zipBuffer),
{ url: 'https://example.com/node.zip', integrity, basename: '' },
targetDir
)).rejects.toMatchObject({
code: 'ERR_PNPM_TARBALL_INTEGRITY',
message: `Got unexpected checksum for "https://example.com/node.zip". Wanted "${integrity}". Got "${ssri.fromData(zipBuffer).toString()}".`,
})
expect(fs.readdirSync(targetDir)).toStrictEqual([])
})
})
/**
* Returns a copy of a single-entry zip whose entry declares `size` as its
* uncompressed size, in both its local file header and its central directory
* record. The input is left unchanged. The zip must start with the entry's
* local file header, and the expectations fail if either record is not where
* a single-entry zip has it.
*/
function setDeclaredUncompressedSize (zipBuffer: Buffer, size: number): Buffer {
const patched = Buffer.from(zipBuffer)
const LOCAL_FILE_HEADER = 0x04034b50
const CENTRAL_DIRECTORY_HEADER = 0x02014b50
expect(patched.readUInt32LE(0)).toBe(LOCAL_FILE_HEADER)
patched.writeUInt32LE(size, 22)
const centralDirectoryOffset = patched.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]))
expect(patched.readUInt32LE(centralDirectoryOffset)).toBe(CENTRAL_DIRECTORY_HEADER)
patched.writeUInt32LE(size, centralDirectoryOffset + 24)
return patched
}
describe('createBinaryFetcher', () => {
it.each([
['https://mirror.example/node.zip', 'Bearer mirror-token'],
['http://mirror.example/node.zip', undefined],
['http://127.attacker.example/node.zip', undefined],
['http://127.0.0.1/node.zip', 'Bearer mirror-token'],
])('selects secure Node.js mirror auth for %s', async (url, expectedAuthHeaderValue) => {
const fetch = ((_url: string, opts?: { authHeaderValue?: string }) => {
expect(opts?.authHeaderValue).toBe(expectedAuthHeaderValue)
throw new Error('stop after request inspection')
}) as never
const binaryFetcher = createBinaryFetcher({
fetch,
fetchFromRemoteTarball: fetch,
getAuthHeader: () => 'Bearer mirror-token',
storeIndex: {} as never,
}).binary
await expect(binaryFetcher({
tempDir: async () => temporaryDirectory(),
} as never, {
type: 'binary',
archive: 'zip',
bin: { node: 'node.exe' },
integrity: 'sha512-unused',
url,
}, {
filesIndexFile: 'unused',
lockfileDir: 'unused',
pkg: { name: 'node', version: '22.0.0' },
})).rejects.toThrow('stop after request inspection')
})
it('rejects an invalid archiveFilters regex at creation time', () => {
const noop = (() => {
throw new Error('should not be called')
}) as never
expect(() =>
createBinaryFetcher({
fetch: noop,
fetchFromRemoteTarball: noop,
storeIndex: noop,
archiveFilters: { node: '(' },
})
).toThrow(PnpmError)
expect(() =>
createBinaryFetcher({
fetch: noop,
fetchFromRemoteTarball: noop,
storeIndex: noop,
archiveFilters: { node: '(' },
})
).toThrow(/Invalid archive filter regex for "node"/)
})
it('snapshots archiveFilters so post-creation mutations cannot reintroduce invalid patterns', () => {
const noop = (() => {
throw new Error('should not be called')
}) as never
const filters: Record<string, string> = { node: '^ok$' }
// Must succeed — the pattern is valid at construction time.
expect(() =>
createBinaryFetcher({
fetch: noop,
fetchFromRemoteTarball: noop,
storeIndex: noop,
archiveFilters: filters,
})
).not.toThrow()
// Mutating the caller's object after construction must not affect the fetcher.
// There's no direct read back, but any mutation reaching the fetcher would throw
// on subsequent fetches; the snapshot guarantees it can't.
filters.node = '('
// Reconstructing with the broken pattern fails — demonstrating the original
// fetcher would have failed at construction if it had seen the broken pattern.
expect(() =>
createBinaryFetcher({
fetch: noop,
fetchFromRemoteTarball: noop,
storeIndex: noop,
archiveFilters: filters,
})
).toThrow(/Invalid archive filter regex for "node"/)
})
})
describe('maxTarballUnpackedSize', () => {
it('rejects a zip whose extracted entries total more than the limit', async () => {
await expect(unpack(99)).rejects.toMatchObject({
code: 'ERR_PNPM_TARBALL_TOO_LARGE',
message: 'The archive is larger than pnpm accepts: its files total more than 99 bytes',
hint: 'If the package is legitimately this large, raise the maxTarballUnpackedSize setting.',
})
})
it('extracts a zip whose extracted entries total exactly the limit, ignoring skipped and directory entries', async () => {
await expect(unpack(100)).resolves.toBeUndefined()
})
function createZip (): { zipBuffer: Buffer, integrity: string } {
const zip = new AdmZip()
zip.addFile('node-v20.0.0/bin/node', Buffer.from('a'.repeat(60)))
zip.addFile('node-v20.0.0/README.md', Buffer.from('b'.repeat(40)))
zip.addFile('node-v20.0.0/skipped.txt', Buffer.from('c'.repeat(500)))
zip.addFile('node-v20.0.0/dir/', Buffer.alloc(0))
const zipBuffer = zip.toBuffer()
return { zipBuffer, integrity: ssri.fromData(zipBuffer).toString() }
}
function unpack (maxTarballUnpackedSize: number): Promise<void> {
const { zipBuffer, integrity } = createZip()
return downloadAndUnpackZip(
createMockFetch(zipBuffer),
{
url: 'https://example.com/node.zip',
integrity,
basename: 'node-v20.0.0',
ignoreEntry: /^skipped\.txt$/,
maxTarballUnpackedSize,
},
temporaryDirectory()
)
}
})