Files
pnpm/store/cafs/test/index.ts
Zoltan Kochan 0d88df854f chore: update all dependencies to latest versions (#11032)
* chore: update all dependencies to latest versions

Update all outdated dependencies across the monorepo catalog and fix
breaking changes from major version bumps.

Notable updates:
- ESLint 9 → 10 (fix custom rule API, disable new no-useless-assignment)
- @stylistic/eslint-plugin 4 → 5 (auto-fixed indent changes)
- @cyclonedx/cyclonedx-library 9 → 10 (adapt to removed SPDX API)
- esbuild 0.25 → 0.27
- TypeScript 5.9.2 → 5.9.3
- Various @types packages, test utilities, and build tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update unified/remark/mdast imports for v11/v4 API changes

Update imports in get-release-text for the new ESM named exports:
- mdast-util-to-string: default → { toString }
- unified: default → { unified }

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve typecheck errors from dependency updates

- isexe v4: use named import { sync } instead of default export
- remark-parse/remark-stringify v11: add vfile as packageExtension
  dependency so TypeScript can resolve type declarations
- get-release-text: remove unused @ts-expect-error directives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert runtime dependency major version bumps

Revert major version bumps for runtime dependencies that are bundled
into pnpm to fix test failures where pnpm add silently fails:
- bin-links: keep ^5.0.0 (was ^6.0.0)
- cli-truncate: keep ^4.0.0 (was ^5.2.0)
- delay: keep ^6.0.0 (was ^7.0.0)
- filenamify: keep ^6.0.0 (was ^7.0.1)
- find-up: keep ^7.0.0 (was ^8.0.0)
- isexe: keep 2.0.0 (was 4.0.0)
- normalize-newline: keep 4.1.0 (was 5.0.0)
- p-queue: keep ^8.1.0 (was ^9.1.0)
- ps-list: keep ^8.1.1 (was ^9.0.0)
- string-length: keep ^6.0.0 (was ^7.0.1)
- symlink-dir: keep ^7.0.0 (was ^9.0.0)
- terminal-link: keep ^4.0.0 (was ^5.0.0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore runtime dependency major version bumps

Re-apply all runtime dependency major version bumps that were
previously reverted. All packages maintain their default exports
except isexe v4 which needs named imports.

Updated runtime deps:
- bin-links: ^5.0.0 → ^6.0.0
- cli-truncate: ^4.0.0 → ^5.2.0
- delay: ^6.0.0 → ^7.0.0
- filenamify: ^6.0.0 → ^7.0.1
- find-up: ^7.0.0 → ^8.0.0
- isexe: 2.0.0 → 4.0.0 (fix: use named import { sync })
- normalize-newline: 4.1.0 → 5.0.0
- p-queue: ^8.1.0 → ^9.1.0
- ps-list: ^8.1.1 → ^9.0.0
- string-length: ^6.0.0 → ^7.0.1
- symlink-dir: ^7.0.0 → ^9.0.0
- terminal-link: ^4.0.0 → ^5.0.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert tempy to 3.0.0 to fix bundle hang

tempy 3.2.0 pulls in temp-dir 3.0.0 which uses async fs.realpath()
inside its module init. When bundled by esbuild into the __esm lazy
init pattern, this causes a deadlock during module initialization,
making the pnpm binary hang silently on startup.

Keeping tempy at 3.0.0 which uses temp-dir 2.x (sync fs.realpathSync).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add comment explaining why tempy cannot be upgraded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert nock to 13.3.4 for node-fetch compatibility

nock 14 changed its HTTP interception mechanism in a way that doesn't
properly intercept node-fetch requests, causing audit tests to hang
waiting for responses that are never intercepted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add comment explaining why nock cannot be upgraded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update symlink-dir imports for v10 ESM named exports

symlink-dir v10 removed the default export and switched to named
exports: { symlinkDir, symlinkDirSync }.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert @typescript/native-preview to working version

Newer tsgo dev builds (>= 20260318) have a regression where
@types/node cannot be resolved, breaking all node built-in types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: vulnerabilities

* fix: align comment indentation in runLifecycleHook

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pin msgpackr to 1.11.8 for TypeScript 5.9 compatibility

msgpackr 1.11.9 has broken type definitions that use Iterable/Iterator
without required type arguments, causing compile errors with TS 5.9.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:28:53 +01:00

309 lines
11 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
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('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)
})