mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-23 14:13:09 -04:00
* chore: upgrade @typescript/native-preview to 7.0.0-dev.20260421.2
- Add explicit `types: ["node"]` to the shared tsconfig because tsgo
20260421 no longer auto-acquires `@types/*` from `node_modules`.
- Refactor test files to explicitly import jest globals (`describe`,
`it`, `test`, `expect`, `beforeEach`, etc.) from `@jest/globals`
instead of relying on `@types/jest` ambient declarations. Under the
new tsgo build, `import { jest } from '@jest/globals'` shadows the
ambient `jest` namespace, breaking `@types/jest`'s `declare var
describe: jest.Describe;` globals.
- Add `@jest/globals` to each package's devDependencies where tests
now import from it, and add `@types/node` to packages that need it
but were relying on hoisted resolution.
- Replace `fail()` calls with `throw new Error(...)` since `fail` is
no longer globally available.
* chore: fix remaining tsgo type-strictness errors
- Strip `as <PnpmType>` casts on objects passed to toMatchObject /
toStrictEqual / toEqual; @jest/globals rejects the typed objects
(which include AsymmetricMatchers) vs. the repo-specific type.
- Type `jest.fn<...>()` explicitly where the mock's signature matters
for toHaveBeenCalledWith.
- Replace `beforeEach(() => X)` with `beforeEach(() => { X })` so the
return value is void, as the stricter jest typing requires.
- Use `expect.objectContaining({...})` in one place where the full
expected object triggered stricter type resolution.
- Cast `prompt.mock.calls` arg through `as unknown as Record<...>[]`
for patch.test.ts's nested-array matchers.
- Fix off-by-one `<reference path>` in pnpm/test/getConfig.test.ts
that only surfaced now.
- Move `@jest/globals` from devDependencies to dependencies in the
two `__utils__` packages that import it from `src/`.
- Clean up unused imports from the @jest/globals migration.
* chore: address Copilot review on #11332
- Move misplaced `@jest/globals` imports to the top import block in
checkEngine, run.ts, and workspace/root-finder tests where the
script dropped them below executable code.
- Replace `try { await x(); throw new Error('should have thrown') } catch`
in bins/linker, lockfile/fs, and resolving/local-resolver tests with
`await expect(x()).rejects.toMatchObject({...})`. The old pattern
swallowed an unrelated `throw` if the under-test call silently
succeeded, which would fail on the catch-block assertion with a
misleading message.
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
import { describe, expect, it, test } from '@jest/globals'
|
|
import { fixtures } from '@pnpm/test-fixtures'
|
|
import { symlinkDir } from 'symlink-dir'
|
|
import { temporaryDirectory } from 'tempy'
|
|
|
|
import {
|
|
checkPkgFilesIntegrity,
|
|
createCafs,
|
|
getFilePathByModeInCafs,
|
|
} from '../src/index.js'
|
|
import { parseTarball } from '../src/parseTarball.js'
|
|
|
|
const f = fixtures(import.meta.dirname)
|
|
|
|
describe('cafs', () => {
|
|
it('unpack', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest)
|
|
const { filesIndex } = cafs.addFilesFromTarball(
|
|
fs.readFileSync(f.find('node-gyp-6.1.0.tgz'))
|
|
)
|
|
expect(filesIndex.size).toBe(121)
|
|
const pkgFile = filesIndex.get('package.json')
|
|
expect(pkgFile!.size).toBe(1121)
|
|
expect(pkgFile!.mode).toBe(420)
|
|
expect(typeof pkgFile!.checkedAt).toBe('number')
|
|
expect(pkgFile!.digest).toBe('f310afae50bb5b74e5c17c5eb6fe426538b9deccd88664fbb66a5717fb6d36d86d4d1f530bb63b58914f9894e81da490e2e39bb99c8e01174e258358b9349b5c')
|
|
})
|
|
|
|
it('addFilesFromTarball honors a per-call ignore predicate', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest)
|
|
const tarball = fs.readFileSync(f.find('node-gyp-6.1.0.tgz'))
|
|
const baseline = cafs.addFilesFromTarball(tarball)
|
|
const filtered = cafs.addFilesFromTarball(tarball, false, (name) => name === 'package.json')
|
|
expect(filtered.filesIndex.has('package.json')).toBe(false)
|
|
expect(filtered.filesIndex.size).toBe(baseline.filesIndex.size - 1)
|
|
})
|
|
|
|
it('addFilesFromTarball combines cafs-level ignoreFile with per-call ignore', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest, { ignoreFile: (name) => name === 'package.json' })
|
|
const tarball = fs.readFileSync(f.find('node-gyp-6.1.0.tgz'))
|
|
const { filesIndex } = cafs.addFilesFromTarball(tarball, false, (name) => name === 'README.md')
|
|
expect(filesIndex.has('package.json')).toBe(false)
|
|
expect(filesIndex.has('README.md')).toBe(false)
|
|
})
|
|
|
|
it('replaces an already existing file, if the integrity of it was broken', () => {
|
|
const storeDir = temporaryDirectory()
|
|
const srcDir = path.join(import.meta.dirname, 'fixtures/one-file')
|
|
const addFiles = () => createCafs(storeDir).addFilesFromDir(srcDir)
|
|
|
|
let addFilesResult = addFiles()
|
|
|
|
// Modifying the file in the store
|
|
const { digest } = addFilesResult.filesIndex.get('foo.txt')!
|
|
const filePath = getFilePathByModeInCafs(storeDir, digest, 420)
|
|
fs.appendFileSync(filePath, 'bar')
|
|
|
|
addFilesResult = addFiles()
|
|
expect(fs.readFileSync(filePath, 'utf8')).toBe('foo\n')
|
|
expect(addFilesResult.manifest).toBeUndefined()
|
|
})
|
|
|
|
it('ignores broken symlinks when traversing subdirectories', () => {
|
|
const storeDir = temporaryDirectory()
|
|
const srcDir = path.join(import.meta.dirname, 'fixtures/broken-symlink')
|
|
const addFiles = () => createCafs(storeDir).addFilesFromDir(srcDir)
|
|
|
|
const { filesIndex } = addFiles()
|
|
expect(filesIndex.get('subdir/should-exist.txt')).toBeDefined()
|
|
})
|
|
|
|
it('symlinks are resolved and added as regular files', async () => {
|
|
const storeDir = temporaryDirectory()
|
|
const srcDir = temporaryDirectory()
|
|
const filePath = path.join(srcDir, 'index.js')
|
|
const symlinkPath = path.join(srcDir, 'symlink.js')
|
|
fs.writeFileSync(filePath, '// comment', 'utf8')
|
|
fs.symlinkSync(filePath, symlinkPath)
|
|
fs.mkdirSync(path.join(srcDir, 'lib'))
|
|
fs.writeFileSync(path.join(srcDir, 'lib/index.js'), '// comment 2', 'utf8')
|
|
await symlinkDir(path.join(srcDir, 'lib'), path.join(srcDir, 'lib-symlink'))
|
|
|
|
const { filesIndex } = createCafs(storeDir).addFilesFromDir(srcDir)
|
|
expect(filesIndex.get('symlink.js')).toBeDefined()
|
|
expect(filesIndex.get('symlink.js')).toStrictEqual(filesIndex.get('index.js'))
|
|
expect(filesIndex.get('lib/index.js')).toBeDefined()
|
|
expect(filesIndex.get('lib/index.js')).toStrictEqual(filesIndex.get('lib-symlink/index.js'))
|
|
})
|
|
|
|
// Security test: symlinks pointing outside the package root should be rejected
|
|
// This prevents file: and git: dependencies from leaking local data via malicious symlinks
|
|
it('rejects symlinks pointing outside the package directory', () => {
|
|
const storeDir = temporaryDirectory()
|
|
const srcDir = temporaryDirectory()
|
|
|
|
// Create a legitimate file inside the package
|
|
fs.writeFileSync(path.join(srcDir, 'legit.txt'), 'legitimate content')
|
|
|
|
// Create a file outside the package that a malicious symlink tries to leak
|
|
const outsideDir = temporaryDirectory()
|
|
const secretFile = path.join(outsideDir, 'secret.txt')
|
|
fs.writeFileSync(secretFile, 'secret content')
|
|
|
|
// Create a symlink pointing to the file outside the package
|
|
fs.symlinkSync(secretFile, path.join(srcDir, 'leak.txt'))
|
|
|
|
const { filesIndex } = createCafs(storeDir).addFilesFromDir(srcDir)
|
|
|
|
// The legitimate file should be included
|
|
expect(filesIndex.get('legit.txt')).toBeDefined()
|
|
|
|
// The symlink pointing outside should be skipped (security fix)
|
|
expect(filesIndex.get('leak.txt')).toBeUndefined()
|
|
})
|
|
|
|
// Security test: symlinked directories pointing outside the package should be rejected
|
|
it('rejects symlinked directories pointing outside the package', () => {
|
|
const storeDir = temporaryDirectory()
|
|
const srcDir = temporaryDirectory()
|
|
|
|
// Create a legitimate file inside the package
|
|
fs.writeFileSync(path.join(srcDir, 'legit.txt'), 'legitimate content')
|
|
|
|
// Create a directory with secret files outside the package
|
|
const outsideDir = temporaryDirectory()
|
|
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret content')
|
|
|
|
// Create a symlink to the outside directory
|
|
fs.symlinkSync(outsideDir, path.join(srcDir, 'leak-dir'))
|
|
|
|
const { filesIndex } = createCafs(storeDir).addFilesFromDir(srcDir)
|
|
|
|
// The legitimate file should be included
|
|
expect(filesIndex.get('legit.txt')).toBeDefined()
|
|
|
|
// Files from the symlinked directory pointing outside should NOT be included
|
|
expect(filesIndex.get('leak-dir/secret.txt')).toBeUndefined()
|
|
})
|
|
|
|
// Symlinked node_modules at the root should be skipped just like regular node_modules
|
|
it('skips symlinked node_modules directory at root', () => {
|
|
const storeDir = temporaryDirectory()
|
|
const srcDir = temporaryDirectory()
|
|
|
|
// Create a legitimate file inside the package
|
|
fs.writeFileSync(path.join(srcDir, 'index.js'), '// code')
|
|
|
|
// Create a target directory for the symlink (inside the package to pass containment check)
|
|
const targetDir = path.join(srcDir, '.deps')
|
|
fs.mkdirSync(targetDir)
|
|
fs.writeFileSync(path.join(targetDir, 'dep.js'), '// dep')
|
|
|
|
// Create a symlinked node_modules directory at the root
|
|
fs.symlinkSync(targetDir, path.join(srcDir, 'node_modules'))
|
|
|
|
const { filesIndex } = createCafs(storeDir).addFilesFromDir(srcDir)
|
|
|
|
// The legitimate file should be included
|
|
expect(filesIndex.get('index.js')).toBeDefined()
|
|
// The target files under .deps should be included
|
|
expect(filesIndex.get('.deps/dep.js')).toBeDefined()
|
|
|
|
// Files from symlinked node_modules at root should NOT be included
|
|
expect(filesIndex.get('node_modules/dep.js')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('checkPkgFilesIntegrity()', () => {
|
|
it("doesn't fail if file was removed from the store", () => {
|
|
const storeDir = temporaryDirectory()
|
|
expect(checkPkgFilesIntegrity(storeDir, {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['foo', {
|
|
digest: 'f310afae50bb5b74e5c17c5eb6fe426538b9deccd88664fbb66a5717fb6d36d86d4d1f530bb63b58914f9894e81da490e2e39bb99c8e01174e258358b9349b5c',
|
|
mode: 420,
|
|
size: 10,
|
|
}],
|
|
]),
|
|
}).passed).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
test('file names are normalized when unpacking a tarball', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest)
|
|
const { filesIndex } = cafs.addFilesFromTarball(
|
|
fs.readFileSync(f.find('colorize-semver-diff.tgz'))
|
|
)
|
|
expect(Array.from(filesIndex.keys()).sort()).toStrictEqual([
|
|
'LICENSE',
|
|
'README.md',
|
|
'lib/index.d.ts',
|
|
'lib/index.js',
|
|
'package.json',
|
|
])
|
|
})
|
|
|
|
test('broken magic in tarball headers is handled gracefully', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest)
|
|
cafs.addFilesFromTarball(
|
|
fs.readFileSync(f.find('jquery.dirtyforms-2.0.0.tgz'))
|
|
)
|
|
})
|
|
|
|
test('unpack an older version of tar that prefixes with spaces', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest)
|
|
const { filesIndex } = cafs.addFilesFromTarball(
|
|
fs.readFileSync(f.find('parsers-3.0.0-rc.48.1.tgz'))
|
|
)
|
|
expect(Array.from(filesIndex.keys()).sort()).toStrictEqual([
|
|
'lib/grammars/resolution.d.ts',
|
|
'lib/grammars/resolution.js',
|
|
'lib/grammars/resolution.pegjs',
|
|
'lib/grammars/shell.d.ts',
|
|
'lib/grammars/shell.js',
|
|
'lib/grammars/shell.pegjs',
|
|
'lib/grammars/syml.d.ts',
|
|
'lib/grammars/syml.js',
|
|
'lib/grammars/syml.pegjs',
|
|
'lib/index.d.ts',
|
|
'lib/index.js',
|
|
'lib/resolution.d.ts',
|
|
'lib/resolution.js',
|
|
'lib/shell.d.ts',
|
|
'lib/shell.js',
|
|
'lib/syml.d.ts',
|
|
'lib/syml.js',
|
|
'package.json',
|
|
])
|
|
})
|
|
|
|
test('unpack a tarball that contains hard links', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest)
|
|
const { filesIndex } = cafs.addFilesFromTarball(
|
|
fs.readFileSync(f.find('vue.examples.todomvc.todo-store-0.0.1.tgz'))
|
|
)
|
|
expect(filesIndex.size).toBeGreaterThan(0)
|
|
})
|
|
|
|
// Regression test for Windows path traversal vulnerability
|
|
// A malicious tarball entry like "foo\..\..\..\.npmrc" should have its path normalized
|
|
test('path traversal with backslashes is blocked (Windows security fix)', () => {
|
|
// Create a minimal valid tarball with a malicious filename
|
|
const tarBuffer = createTarballWithEntry('foo\\..\\..\\..\\malicious.txt', 'evil content')
|
|
|
|
const result = parseTarball(tarBuffer)
|
|
const fileNames = Array.from(result.files.keys())
|
|
|
|
// The path should be normalized - no ".." segments and no path traversal
|
|
for (const fileName of fileNames) {
|
|
expect(fileName).not.toContain('..')
|
|
expect(fileName).not.toContain('\\')
|
|
}
|
|
})
|
|
|
|
// Helper to create a minimal tarball buffer with a single entry
|
|
function createTarballWithEntry (fileName: string, content: string): Buffer {
|
|
const contentBytes = Buffer.from(content, 'utf8')
|
|
|
|
// Create a 512-byte header
|
|
const header = Buffer.alloc(512, 0)
|
|
|
|
// File name at offset 0 (max 100 chars)
|
|
const nameToWrite = `package/${fileName}`
|
|
header.write(nameToWrite, 0, Math.min(nameToWrite.length, 100), 'utf8')
|
|
|
|
// File mode at offset 100 (octal, 8 bytes) - 0644
|
|
header.write('0000644\0', 100, 8, 'utf8')
|
|
|
|
// UID at offset 108 (octal, 8 bytes)
|
|
header.write('0000000\0', 108, 8, 'utf8')
|
|
|
|
// GID at offset 116 (octal, 8 bytes)
|
|
header.write('0000000\0', 116, 8, 'utf8')
|
|
|
|
// File size at offset 124 (octal, 12 bytes)
|
|
const sizeOctal = contentBytes.length.toString(8).padStart(11, '0')
|
|
header.write(sizeOctal + '\0', 124, 12, 'utf8')
|
|
|
|
// Mtime at offset 136 (octal, 12 bytes)
|
|
header.write('00000000000\0', 136, 12, 'utf8')
|
|
|
|
// File type at offset 156 ('0' for regular file)
|
|
header[156] = '0'.charCodeAt(0)
|
|
|
|
// USTAR indicator at offset 257
|
|
header.write('ustar\0', 257, 6, 'utf8')
|
|
header.write('00', 263, 2, 'utf8')
|
|
|
|
// Compute checksum (offset 148, 8 bytes) - sum of all header bytes treating checksum field as spaces
|
|
// First, fill checksum field with spaces
|
|
header.fill(' ', 148, 156)
|
|
let checksum = 0
|
|
for (let i = 0; i < 512; i++) {
|
|
checksum += header[i]
|
|
}
|
|
const checksumOctal = checksum.toString(8).padStart(6, '0')
|
|
header.write(checksumOctal + '\0 ', 148, 8, 'utf8')
|
|
|
|
// Content block (padded to 512 bytes)
|
|
const contentBlock = Buffer.alloc(512, 0)
|
|
contentBytes.copy(contentBlock)
|
|
|
|
// End-of-archive marker (two 512-byte blocks of zeros)
|
|
const endMarker = Buffer.alloc(1024, 0)
|
|
|
|
return Buffer.concat([header, contentBlock, endMarker])
|
|
}
|
|
|
|
// Related issue: https://github.com/pnpm/pnpm/issues/7120
|
|
test('unpack should not fail when the tarball format seems to be not USTAR or GNU TAR', () => {
|
|
const dest = temporaryDirectory()
|
|
const cafs = createCafs(dest)
|
|
const { filesIndex } = cafs.addFilesFromTarball(
|
|
fs.readFileSync(f.find('devextreme-17.1.6.tgz'))
|
|
)
|
|
expect(filesIndex.size).toBeGreaterThan(0)
|
|
})
|