mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 18:35:18 -04:00
fix: contain hoisted dependency aliases (GHSA-fr4h-3cph-29xv) (#12343)
* fix: contain hoisted dependency aliases (GHSA-fr4h-3cph-29xv) The `nodeLinker: hoisted` install restores its dependency graph straight from the lockfile via `lockfileToHoistedDepGraph`, which joins each dependency alias under a `node_modules` directory and imports the package files there. On a frozen / up-to-date lockfile, resolution is skipped entirely, so the alias validation added for the resolution path never runs. A crafted lockfile alias such as `../../../escape` could therefore escape the install root, and reserved aliases such as `.bin`, `.pnpm`, or `node_modules` could overwrite pnpm-owned layout. Validate every alias at the hoisted-graph directory sink. The shared `safeJoinModulesDir` helper now rejects aliases that are not valid npm package names (path-traversal, absolute, and reserved names) in addition to its containment check, and the hoisted graph routes its `dep.name` sink through it. Pacquet mirrors the boundary: `safe_join_modules_dir` validates the hoister's `dep.0.name` before adding the graph node or recursing, reusing the same dependency-name rule it already applies to direct-dependency aliases. Both stacks surface `ERR_PNPM_INVALID_DEPENDENCY_NAME`. --- Written by an agent (Claude Code, claude-fable-5). * fix: reject invalid dependency aliases at the lockfile verification gate Add an always-on, policy-independent structural check to verifyLockfileResolutions that rejects any importer or package-snapshot dependency alias that is not a valid npm package name. A dependency alias becomes a `node_modules/<alias>` directory at link time, so an alias with path-traversal segments or a reserved name (`.bin`, `.pnpm`, `node_modules`) could escape the install root or overwrite pnpm-owned layout. This complements the linker-sink guards: the verifier runs before any fetch or filesystem work and covers every node linker at once, while the sink guards still protect the `trustLockfile` path the verifier skips. The check runs before the cache lookup so a record written by a version that predates the rule cannot fast-path around it, and before the `packages` guard so a tampered importer alias is caught even when nothing is installed. `isValidDependencyAlias` is now exported from `@pnpm/installing.deps-resolver` and reused here. Pacquet mirrors the gate in its lockfile-verification crate with a matching `ERR_PNPM_INVALID_DEPENDENCY_NAME` verdict. --- Written by an agent (Claude Code, claude-fable-5). * docs(package-manager): drop redundant explicit intra-doc link target `is_valid_dependency_alias` is in scope via `use`, so the bare intra-doc link resolves on its own. The explicit path target tripped `rustdoc::redundant-explicit-links` under the CI Doc job's `cargo doc --document-private-items` (the local pre-push hook runs `cargo doc` without that flag, so it didn't surface). --- Written by an agent (Claude Code, claude-fable-5). * refactor(lockfile-verification): fold the alias check into the single candidate pass The dependency-alias check ran as its own full traversal of the lockfile in addition to collectCandidates' existing pass over every package snapshot. Fold it into that pass instead: collectCandidates now also validates each importer and snapshot dependency alias and returns the invalid ones alongside the resolution-shape violations, so the lockfile is walked once per verification rather than twice. Because collectCandidates runs after the verification-cache lookup, the alias check is now covered by the cache the same way the resolution-shape check is: a new dependencyAliasCheck cache identity makes a record written before this rule existed fail canTrustPastCheck, forcing a re-verification. The shared helper is renamed withOfflineCheckCacheIdentities and appends both offline-structural-check identities. No behavior change for valid lockfiles; the same ERR_PNPM_INVALID_DEPENDENCY_NAME is thrown for invalid aliases. Mirrored in pacquet's lockfile-verification crate. --- Written by an agent (Claude Code, claude-fable-5). * refactor: declare pushInvalidAliases after its caller, trim duplicated comments Move `pushInvalidAliases` below `collectCandidates`, following the repo's declare-after-use convention. Collapse the repeated "an alias becomes a node_modules directory, so a traversal/reserved name escapes or overwrites layout" explanation that was copied across the verifier, the hoisted-graph error, and the pacquet mirror down to a single reference each — the full rationale lives once in the validating sink (`safeJoinModulesDir` / `safe_join_modules_dir`) and the user-facing error hints. --- Written by an agent (Claude Code, claude-fable-5).
This commit is contained in:
14
.changeset/contain-hoisted-dependency-aliases.md
Normal file
14
.changeset/contain-hoisted-dependency-aliases.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
"@pnpm/fs.symlink-dependency": patch
|
||||
"@pnpm/installing.deps-resolver": patch
|
||||
"@pnpm/installing.deps-installer": patch
|
||||
"@pnpm/installing.deps-restorer": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Reject path-traversal and reserved dependency aliases (such as `../../../escape`, `.bin`, `.pnpm`, or `node_modules`) that come from a lockfile rather than a freshly resolved manifest. A crafted lockfile alias could otherwise be joined directly under a hoisted `node_modules` directory, letting package files be written outside the intended install root or overwrite pnpm-owned layout.
|
||||
|
||||
The fix adds two layers:
|
||||
|
||||
- The `nodeLinker: hoisted` graph builder now validates each alias at the directory sink (`safeJoinModulesDir`), matching the validation pnpm already performs when resolving aliases from manifests.
|
||||
- The lockfile verification gate (`verifyLockfileResolutions`) now runs an always-on, policy-independent check that rejects any importer or snapshot dependency alias that is not a valid package name, failing the install early — before any fetch or filesystem work — for every node linker at once.
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3904,6 +3904,7 @@ dependencies = [
|
||||
"pacquet-diagnostics",
|
||||
"pacquet-lockfile",
|
||||
"pacquet-reporter",
|
||||
"pacquet-resolving-parse-wanted-dependency",
|
||||
"pacquet-resolving-resolver-base",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"dependencies": {
|
||||
"@pnpm/core-loggers": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"symlink-dir": "catalog:"
|
||||
"symlink-dir": "catalog:",
|
||||
"validate-npm-package-name": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pnpm/logger": "catalog:"
|
||||
@@ -46,7 +47,8 @@
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/fs.symlink-dependency": "workspace:*",
|
||||
"@pnpm/logger": "workspace:*",
|
||||
"@pnpm/prepare": "workspace:*"
|
||||
"@pnpm/prepare": "workspace:*",
|
||||
"@types/validate-npm-package-name": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { symlinkDir, symlinkDirSync } from 'symlink-dir'
|
||||
|
||||
import { safeJoinModulesDir } from './safeJoinModulesDir.js'
|
||||
|
||||
export { safeJoinModulesDir } from './safeJoinModulesDir.js'
|
||||
export { symlinkDirectRootDependency } from './symlinkDirectRootDependency.js'
|
||||
|
||||
export async function symlinkDependency (
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import path from 'node:path'
|
||||
|
||||
// `path.join(modulesDir, alias)` paired with a containment check, so a
|
||||
// caller can't accidentally use the joined path without verifying that
|
||||
// it lives inside `modulesDir`. Earlier passes reject path-traversal
|
||||
// aliases at manifest-read time, but this layer also runs for paths
|
||||
// reconstructed from lockfiles and snapshots, so the check stays here
|
||||
// as a final guarantee.
|
||||
import validateNpmPackageName from 'validate-npm-package-name'
|
||||
|
||||
// Joins `modulesDir` with a dependency alias and guarantees the result
|
||||
// stays a direct child of `modulesDir`. The alias becomes a directory
|
||||
// name inside `node_modules`, so it must be a valid npm package name: a
|
||||
// single `name` or `@scope/name` of URL-friendly characters with no
|
||||
// leading `.` or `_`, and not a reserved name. That rejects
|
||||
// path-traversal (`../x`), absolute, and pnpm-owned aliases (`.bin`,
|
||||
// `.pnpm`, `node_modules`) before they can escape `modulesDir` or
|
||||
// overwrite pnpm's own layout. The containment check is kept as a
|
||||
// belt-and-suspenders guard for any platform-specific join behavior the
|
||||
// name check might not anticipate.
|
||||
//
|
||||
// Earlier passes reject such aliases at manifest-read and resolution
|
||||
// time, but this layer also runs for paths reconstructed from lockfiles
|
||||
// and snapshots, so the check stays here as a final guarantee.
|
||||
export function safeJoinModulesDir (modulesDir: string, alias: string): string {
|
||||
if (!validateNpmPackageName(alias).validForOldPackages) {
|
||||
throw invalidDependencyNameError(modulesDir, alias)
|
||||
}
|
||||
const link = path.join(modulesDir, alias)
|
||||
const resolvedDir = path.resolve(modulesDir)
|
||||
const resolvedLink = path.resolve(link)
|
||||
if (resolvedLink === resolvedDir || !resolvedLink.startsWith(resolvedDir + path.sep)) {
|
||||
const error = new Error(`Refusing to symlink dependency outside ${modulesDir}: alias ${JSON.stringify(alias)} resolves to ${resolvedLink}`) as Error & { code: string }
|
||||
error.code = 'ERR_PNPM_INVALID_DEPENDENCY_NAME'
|
||||
throw error
|
||||
throw invalidDependencyNameError(modulesDir, alias, resolvedLink)
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
function invalidDependencyNameError (modulesDir: string, alias: string, resolvedLink?: string): Error & { code: string } {
|
||||
const detail = resolvedLink ? ` (it resolves to ${resolvedLink})` : ''
|
||||
const error = new Error(`Refusing to place a dependency under ${modulesDir} with the invalid alias ${JSON.stringify(alias)}${detail}`) as Error & { code: string }
|
||||
error.code = 'ERR_PNPM_INVALID_DEPENDENCY_NAME'
|
||||
return error
|
||||
}
|
||||
|
||||
@@ -5,7 +5,18 @@ import { expect, test } from '@jest/globals'
|
||||
import { symlinkDependency, symlinkDependencySync, symlinkDirectRootDependency } from '@pnpm/fs.symlink-dependency'
|
||||
import { tempDir } from '@pnpm/prepare'
|
||||
|
||||
const escapeAliases = ['@x/../../../etc', '../sibling', '', '.']
|
||||
const escapeAliases = [
|
||||
'@x/../../../etc',
|
||||
'../sibling',
|
||||
'',
|
||||
'.',
|
||||
// Reserved names that resolve *inside* `node_modules` but would
|
||||
// overwrite pnpm-owned layout, so the containment check alone can't
|
||||
// catch them.
|
||||
'.bin',
|
||||
'.pnpm',
|
||||
'node_modules',
|
||||
]
|
||||
|
||||
test.each(escapeAliases)('symlinkDependency refuses alias %p', async (alias) => {
|
||||
const tmp = tempDir(false)
|
||||
@@ -34,3 +45,15 @@ test.each(escapeAliases)('symlinkDirectRootDependency refuses alias %p', async (
|
||||
prefix: '',
|
||||
})).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
|
||||
})
|
||||
|
||||
const validAliases = ['foo', '@scope/name', 'foo.bar']
|
||||
|
||||
test.each(validAliases)('symlinkDependency accepts valid alias %p', async (alias) => {
|
||||
const tmp = tempDir(false)
|
||||
const destModulesDir = path.join(tmp, 'node_modules')
|
||||
fs.mkdirSync(destModulesDir)
|
||||
const dep = path.join(tmp, 'dep')
|
||||
fs.mkdirSync(dep)
|
||||
await expect(symlinkDependency(dep, destModulesDir, alias)).resolves.toBeDefined()
|
||||
expect(fs.existsSync(path.join(destModulesDir, alias))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { hashObject } from '@pnpm/crypto.object-hasher'
|
||||
import type { LockfileObject } from '@pnpm/lockfile.fs'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
|
||||
import { withResolutionShapeCacheIdentity } from './verifyLockfileResolutions.js'
|
||||
import { withOfflineCheckCacheIdentities } from './verifyLockfileResolutions.js'
|
||||
import { recordVerification } from './verifyLockfileResolutionsCache.js'
|
||||
|
||||
export interface RecordLockfileVerifiedOptions {
|
||||
@@ -31,7 +31,7 @@ export function recordLockfileVerified (opts: RecordLockfileVerifiedOptions): vo
|
||||
if (!opts.lockfile.packages) return
|
||||
recordVerification(opts.cacheDir, {
|
||||
lockfilePath: opts.lockfilePath,
|
||||
verifiers: withResolutionShapeCacheIdentity(opts.resolutionVerifiers),
|
||||
verifiers: withOfflineCheckCacheIdentities(opts.resolutionVerifiers),
|
||||
hashLockfile: () => hashObject(opts.lockfile),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lockfileVerificationLogger } from '@pnpm/core-loggers'
|
||||
import { hashObject } from '@pnpm/crypto.object-hasher'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { isValidDependencyAlias } from '@pnpm/installing.deps-resolver'
|
||||
import type { LockfileObject } from '@pnpm/lockfile.fs'
|
||||
import { isGitHostedTarballUrl, nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
|
||||
import type {
|
||||
@@ -34,21 +35,32 @@ const DEFAULT_CONCURRENCY = 64
|
||||
|
||||
export const RESOLUTION_SHAPE_MISMATCH_VIOLATION_CODE = 'RESOLUTION_SHAPE_MISMATCH'
|
||||
|
||||
// Same code the sink-level guards (`safeJoinModulesDir`) throw.
|
||||
export const INVALID_DEPENDENCY_ALIAS_CODE = 'INVALID_DEPENDENCY_NAME'
|
||||
|
||||
const RESOLUTION_SHAPE_CACHE_IDENTITY: VerifierCacheIdentity = {
|
||||
policy: { resolutionShapeCheck: true },
|
||||
canTrustPastCheck: (cached) => cached.resolutionShapeCheck === true,
|
||||
}
|
||||
|
||||
const DEPENDENCY_ALIAS_CACHE_IDENTITY: VerifierCacheIdentity = {
|
||||
policy: { dependencyAliasCheck: true },
|
||||
canTrustPastCheck: (cached) => cached.dependencyAliasCheck === true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Every verifier list that flows into the verification cache must carry
|
||||
* the resolution-shape identity, so records written before the shape rule
|
||||
* existed cannot stat-fast-path around it. Used by the gate itself and by
|
||||
* {@link recordLockfileVerified}, whose freshly-resolved lockfile satisfies
|
||||
* the shape invariant by construction (the writer derives every key from
|
||||
* the resolution it just produced).
|
||||
* the always-on offline structural checks' identities, so a record
|
||||
* written before one of those rules existed cannot stat-fast-path around
|
||||
* it — its missing flag fails `canTrustPastCheck`, forcing a
|
||||
* re-verification that runs the new check. Used by the gate itself and by
|
||||
* {@link recordLockfileVerified}, whose freshly-resolved lockfile
|
||||
* satisfies these invariants by construction (the resolver validates
|
||||
* aliases at manifest-read time and derives every resolution key from the
|
||||
* resolution it just produced).
|
||||
*/
|
||||
export function withResolutionShapeCacheIdentity (verifiers: readonly VerifierCacheIdentity[]): VerifierCacheIdentity[] {
|
||||
return [...verifiers, RESOLUTION_SHAPE_CACHE_IDENTITY]
|
||||
export function withOfflineCheckCacheIdentities (verifiers: readonly VerifierCacheIdentity[]): VerifierCacheIdentity[] {
|
||||
return [...verifiers, RESOLUTION_SHAPE_CACHE_IDENTITY, DEPENDENCY_ALIAS_CACHE_IDENTITY]
|
||||
}
|
||||
|
||||
export interface VerifyLockfileResolutionsOptions {
|
||||
@@ -108,7 +120,7 @@ export async function verifyLockfileResolutions (
|
||||
? { cacheDir: options.cacheDir, lockfilePath: options.lockfilePath }
|
||||
: undefined
|
||||
|
||||
const cacheVerifiers = withResolutionShapeCacheIdentity(verifiers)
|
||||
const cacheVerifiers = withOfflineCheckCacheIdentities(verifiers)
|
||||
|
||||
// Cache lookup runs before any registry I/O — the fast path is a
|
||||
// single stat() of the lockfile when the previous install already
|
||||
@@ -155,7 +167,10 @@ export async function verifyLockfileResolutions (
|
||||
// A degenerate lockfile where every snapshot fails the
|
||||
// name/version extraction (so candidates is empty) skips emission
|
||||
// entirely — no work, no noise.
|
||||
const { candidates, shapeViolations } = collectCandidates(lockfile)
|
||||
const { candidates, shapeViolations, invalidAliases } = collectCandidates(lockfile)
|
||||
if (invalidAliases.length > 0) {
|
||||
throw buildInvalidAliasError(invalidAliases)
|
||||
}
|
||||
if (shapeViolations.length > 0) {
|
||||
throw buildVerificationError(shapeViolations)
|
||||
}
|
||||
@@ -208,6 +223,24 @@ export async function verifyLockfileResolutions (
|
||||
}
|
||||
}
|
||||
|
||||
function buildInvalidAliasError (aliases: string[]): PnpmError {
|
||||
const sorted = [...aliases].sort()
|
||||
const visible = sorted.slice(0, MAX_VIOLATIONS_TO_PRINT)
|
||||
const omitted = sorted.length - visible.length
|
||||
const breakdown = visible.map((alias) => ` ${JSON.stringify(alias)}`).join('\n')
|
||||
const details = omitted > 0 ? `${breakdown}\n …and ${omitted} more` : breakdown
|
||||
const plural = aliases.length === 1 ? 'alias' : 'aliases'
|
||||
return new PnpmError(
|
||||
INVALID_DEPENDENCY_ALIAS_CODE,
|
||||
`The lockfile contains ${aliases.length} dependency ${plural} that are not valid package names:\n${details}`,
|
||||
{
|
||||
hint: 'A dependency alias becomes a directory under node_modules, so it must be a valid npm package name — a single `name` or `@scope/name` with no leading `.` or `_`, and not a reserved name such as `node_modules`. ' +
|
||||
'An alias containing path-traversal segments or a reserved name such as `.bin` or `.pnpm` could make an install write outside the intended directory or overwrite pnpm-owned layout. ' +
|
||||
'This usually means the lockfile was tampered with — inspect recent changes to pnpm-lock.yaml before trusting it.',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function buildVerificationError (violations: ResolutionPolicyViolation[]): PnpmError {
|
||||
// Stable order so the error output is deterministic.
|
||||
violations.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`))
|
||||
@@ -329,10 +362,20 @@ interface Candidate {
|
||||
// skips the tarball/policy checks, so if the wrong shape wins the dedup the
|
||||
// surviving entry is verified under the wrong rules and the real one is
|
||||
// never checked.
|
||||
function collectCandidates (lockfile: LockfileObject): { candidates: Map<string, Candidate>, shapeViolations: ResolutionPolicyViolation[] } {
|
||||
function collectCandidates (lockfile: LockfileObject): { candidates: Map<string, Candidate>, shapeViolations: ResolutionPolicyViolation[], invalidAliases: string[] } {
|
||||
const candidates = new Map<string, Candidate>()
|
||||
const shapeViolations: ResolutionPolicyViolation[] = []
|
||||
// The importer alias maps are the one source not reached by the
|
||||
// package loop below, so they're scanned here.
|
||||
const invalidAliases = new Set<string>()
|
||||
for (const importer of Object.values(lockfile.importers ?? {})) {
|
||||
pushInvalidAliases(importer.dependencies, invalidAliases)
|
||||
pushInvalidAliases(importer.devDependencies, invalidAliases)
|
||||
pushInvalidAliases(importer.optionalDependencies, invalidAliases)
|
||||
}
|
||||
for (const [depPath, snapshot] of Object.entries(lockfile.packages ?? {})) {
|
||||
pushInvalidAliases(snapshot.dependencies, invalidAliases)
|
||||
pushInvalidAliases(snapshot.optionalDependencies, invalidAliases)
|
||||
const { name, version, nonSemverVersion } = nameVerFromPkgSnapshot(depPath as DepPath, snapshot)
|
||||
if (!name || !version) continue
|
||||
// A registry-style depPath (name@semver) must be backed by a
|
||||
@@ -357,7 +400,20 @@ function collectCandidates (lockfile: LockfileObject): { candidates: Map<string,
|
||||
resolution: snapshot.resolution as Resolution,
|
||||
})
|
||||
}
|
||||
return { candidates, shapeViolations }
|
||||
return { candidates, shapeViolations, invalidAliases: Array.from(invalidAliases) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add every key of `deps` that is not a valid {@link isValidDependencyAlias}
|
||||
* to `invalid`. Only pass maps whose keys become `node_modules/<alias>`
|
||||
* directories — not `overrides` (`foo>bar` selectors) or
|
||||
* `patchedDependencies` (`name@version` keys).
|
||||
*/
|
||||
function pushInvalidAliases (deps: Record<string, string> | undefined, invalid: Set<string>): void {
|
||||
if (deps == null) return
|
||||
for (const alias of Object.keys(deps)) {
|
||||
if (!isValidDependencyAlias(alias)) invalid.add(alias)
|
||||
}
|
||||
}
|
||||
|
||||
async function iterateLockfileViolations (
|
||||
|
||||
@@ -480,3 +480,57 @@ test('rejects a registry-style depPath whose git-host tarball varies the host ca
|
||||
code: 'ERR_PNPM_RESOLUTION_SHAPE_MISMATCH',
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
'../../../escape',
|
||||
'@scope/../../escape',
|
||||
'.bin',
|
||||
'.pnpm',
|
||||
'node_modules',
|
||||
])('rejects an importer dependency alias %p, even with no verifiers', async (alias) => {
|
||||
const lockfile = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
specifiers: { [alias]: '1.0.0' },
|
||||
dependencies: { [alias]: '1.0.0' },
|
||||
},
|
||||
},
|
||||
packages: {
|
||||
'real@1.0.0': { resolution: tarballResolution() },
|
||||
},
|
||||
} as unknown as LockfileObject
|
||||
await expect(verifyLockfileResolutions(lockfile, [])).rejects.toMatchObject({
|
||||
code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME',
|
||||
message: expect.stringMatching(/not valid package names/),
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects an invalid alias nested in a package snapshot, even with no verifiers', async () => {
|
||||
const lockfile = makeLockfile({
|
||||
'real@1.0.0': {
|
||||
resolution: tarballResolution(),
|
||||
dependencies: { '../../../escape': '1.0.0' },
|
||||
} as never,
|
||||
})
|
||||
await expect(verifyLockfileResolutions(lockfile, [])).rejects.toMatchObject({
|
||||
code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME',
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts valid scoped and unscoped dependency aliases', async () => {
|
||||
const lockfile = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
specifiers: { foo: '1.0.0', '@scope/bar': '1.0.0' },
|
||||
dependencies: { foo: '1.0.0', '@scope/bar': '1.0.0' },
|
||||
},
|
||||
},
|
||||
packages: {
|
||||
'foo@1.0.0': { resolution: tarballResolution() },
|
||||
'@scope/bar@1.0.0': { resolution: tarballResolution() },
|
||||
},
|
||||
} as unknown as LockfileObject
|
||||
await expect(verifyLockfileResolutions(lockfile, [])).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -70,6 +70,7 @@ export {
|
||||
type UpdateMatchingFunction,
|
||||
type WantedDependency,
|
||||
}
|
||||
export { assertValidDependencyAliases, isValidDependencyAlias } from './validateDependencyAlias.js'
|
||||
|
||||
interface ProjectToLink {
|
||||
binsDir: string
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
LockfileToDepGraphResult,
|
||||
} from '@pnpm/deps.graph-builder'
|
||||
import * as dp from '@pnpm/deps.path'
|
||||
import { safeJoinModulesDir } from '@pnpm/fs.symlink-dependency'
|
||||
import { hoist, type HoisterResult, type HoistingLimits } from '@pnpm/installing.linking.real-hoist'
|
||||
import type { IncludedDependencies } from '@pnpm/installing.modules-yaml'
|
||||
import type {
|
||||
@@ -218,7 +219,7 @@ async function fetchDeps (
|
||||
return
|
||||
}
|
||||
|
||||
const dir = path.join(modules, dep.name)
|
||||
const dir = safeJoinModulesDir(modules, dep.name)
|
||||
const depLocation = path.relative(opts.lockfileDir, dir)
|
||||
const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, opts.registries)
|
||||
let fetchResponse!: ReturnType<FetchPackageToStoreFunction>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/// <reference path="../../../__typings__/index.d.ts" />
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { expect, test } from '@jest/globals'
|
||||
import type { LockfileObject } from '@pnpm/lockfile.fs'
|
||||
import { tempDir } from '@pnpm/prepare'
|
||||
|
||||
import { lockfileToHoistedDepGraph } from '../src/lockfileToHoistedDepGraph.js'
|
||||
|
||||
// A crafted lockfile whose dependency *alias* (the key pnpm turns into a
|
||||
// `node_modules/<alias>` directory) is a path-traversal or reserved name,
|
||||
// pointing at an otherwise ordinary package snapshot. The `nodeLinker:
|
||||
// hoisted` restore path reads aliases straight from the lockfile, so this
|
||||
// is the shape an attacker who can ship a lockfile would use to escape
|
||||
// `node_modules` or overwrite pnpm-owned layout (`.bin` / `.pnpm`).
|
||||
function craftedLockfile (alias: string): LockfileObject {
|
||||
return {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: { [alias]: '1.0.0' },
|
||||
specifiers: { [alias]: '1.0.0' },
|
||||
},
|
||||
},
|
||||
packages: {
|
||||
[`${alias}@1.0.0`]: {
|
||||
resolution: { integrity: 'sha512-deadbeef' },
|
||||
},
|
||||
},
|
||||
} as unknown as LockfileObject
|
||||
}
|
||||
|
||||
// `force: true` skips the installability check so the walk reaches the
|
||||
// alias sink directly; the store controller throws if touched, proving
|
||||
// the alias is rejected before any fetch or filesystem work.
|
||||
function hoistedOpts (lockfileDir: string): Parameters<typeof lockfileToHoistedDepGraph>[2] {
|
||||
const unreachable = (name: string) => () => {
|
||||
throw new Error(`${name} must not be reached for a rejected alias`)
|
||||
}
|
||||
return {
|
||||
autoInstallPeers: false,
|
||||
engineStrict: false,
|
||||
force: true,
|
||||
importerIds: ['.'],
|
||||
include: { dependencies: true, devDependencies: true, optionalDependencies: true },
|
||||
ignoreScripts: false,
|
||||
lockfileDir,
|
||||
nodeVersion: process.version,
|
||||
pnpmVersion: '0.0.0',
|
||||
registries: { default: 'http://localhost/' },
|
||||
sideEffectsCacheRead: false,
|
||||
skipped: new Set<string>(),
|
||||
storeController: {
|
||||
fetchPackage: unreachable('fetchPackage'),
|
||||
getFilesIndexFilePath: unreachable('getFilesIndexFilePath'),
|
||||
},
|
||||
storeDir: path.join(lockfileDir, 'store'),
|
||||
virtualStoreDir: path.join(lockfileDir, 'node_modules', '.pnpm'),
|
||||
} as unknown as Parameters<typeof lockfileToHoistedDepGraph>[2]
|
||||
}
|
||||
|
||||
test.each([
|
||||
'../../../escape',
|
||||
'@scope/../../escape',
|
||||
'.bin',
|
||||
'.pnpm',
|
||||
'node_modules',
|
||||
])('lockfileToHoistedDepGraph rejects hoisted alias %p', async (alias) => {
|
||||
const dir = tempDir(false)
|
||||
await expect(
|
||||
lockfileToHoistedDepGraph(craftedLockfile(alias), null, hoistedOpts(dir))
|
||||
).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
|
||||
})
|
||||
|
||||
test('lockfileToHoistedDepGraph does not create a file outside node_modules for a traversal alias', async () => {
|
||||
const dir = tempDir(false)
|
||||
const escaped = path.join(dir, 'node_modules', '..', '..', '..', 'escape')
|
||||
await expect(
|
||||
lockfileToHoistedDepGraph(craftedLockfile('../../../escape'), null, hoistedOpts(dir))
|
||||
).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
|
||||
expect(fs.existsSync(escaped)).toBe(false)
|
||||
})
|
||||
@@ -11,10 +11,11 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-diagnostics = { workspace = true }
|
||||
pacquet-lockfile = { workspace = true }
|
||||
pacquet-reporter = { workspace = true }
|
||||
pacquet-resolving-resolver-base = { workspace = true }
|
||||
pacquet-diagnostics = { workspace = true }
|
||||
pacquet-lockfile = { workspace = true }
|
||||
pacquet-reporter = { workspace = true }
|
||||
pacquet-resolving-parse-wanted-dependency = { workspace = true }
|
||||
pacquet-resolving-resolver-base = { workspace = true }
|
||||
|
||||
chrono = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
|
||||
@@ -31,6 +31,13 @@ run \"pnpm clean --lockfile\" and then \"pnpm install\" to rebuild from \
|
||||
a fresh resolution. Alternatively, relax the policy that flagged \
|
||||
them.";
|
||||
|
||||
const INVALID_ALIAS_HINT: &str = "A dependency alias becomes a directory under node_modules, \
|
||||
so it must be a valid npm package name — a single `name` or `@scope/name` with no leading \
|
||||
`.` or `_`, and not a reserved name such as `node_modules`. An alias containing path-traversal \
|
||||
segments or a reserved name such as `.bin` or `.pnpm` could make an install write outside the \
|
||||
intended directory or overwrite pnpm-owned layout. This usually means the lockfile was tampered \
|
||||
with — inspect recent changes to pnpm-lock.yaml before trusting it.";
|
||||
|
||||
/// One verifier rejection rendered for the error breakdown.
|
||||
/// Internal-only data shape — the runner builds these from
|
||||
/// `ResolutionPolicyViolation` after sorting.
|
||||
@@ -91,9 +98,46 @@ pub enum VerifyError {
|
||||
count: usize,
|
||||
breakdown: String,
|
||||
},
|
||||
|
||||
/// One or more dependency aliases in the lockfile are not valid npm
|
||||
/// package names. Surfaces `ERR_PNPM_INVALID_DEPENDENCY_NAME`, the
|
||||
/// same code the sink-level guards raise.
|
||||
#[display("{count} dependency {plural} in the lockfile {verb} not valid package names:\n{breakdown}", plural = if *count == 1 { "alias" } else { "aliases" }, verb = if *count == 1 { "is" } else { "are" })]
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_DEPENDENCY_NAME), help("{INVALID_ALIAS_HINT}"))]
|
||||
InvalidDependencyAlias {
|
||||
#[error(not(source))]
|
||||
count: usize,
|
||||
breakdown: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl VerifyError {
|
||||
/// Build the [`VerifyError::InvalidDependencyAlias`] variant from a
|
||||
/// list of offending aliases. Sorts for determinism and caps the
|
||||
/// printed breakdown at `MAX_VIOLATIONS_TO_PRINT`. Empty input is
|
||||
/// a logic error — callers must check before constructing.
|
||||
#[must_use]
|
||||
pub fn invalid_dependency_aliases(aliases: &[String]) -> Self {
|
||||
debug_assert!(!aliases.is_empty(), "no invalid aliases → no error");
|
||||
let mut sorted: Vec<&String> = aliases.iter().collect();
|
||||
sorted.sort();
|
||||
let count = sorted.len();
|
||||
let visible_count = count.min(MAX_VIOLATIONS_TO_PRINT);
|
||||
let omitted = count.saturating_sub(visible_count);
|
||||
|
||||
let mut breakdown = String::new();
|
||||
for alias in sorted.iter().take(visible_count) {
|
||||
writeln!(breakdown, " {alias:?}").unwrap();
|
||||
}
|
||||
if omitted > 0 {
|
||||
write!(breakdown, " …and {omitted} more").unwrap();
|
||||
} else if breakdown.ends_with('\n') {
|
||||
breakdown.pop();
|
||||
}
|
||||
|
||||
VerifyError::InvalidDependencyAlias { count, breakdown }
|
||||
}
|
||||
|
||||
/// Build the appropriate variant from a list of rendered
|
||||
/// violations. The list is **already sorted** by `name@version`
|
||||
/// (the runner sorts before calling). Empty input is a logic
|
||||
|
||||
@@ -22,7 +22,7 @@ use pacquet_resolving_resolver_base::ResolutionVerifier;
|
||||
|
||||
use crate::{
|
||||
cache::record_verification, hash_lockfile,
|
||||
verify_lockfile_resolutions::with_resolution_shape_cache_identity,
|
||||
verify_lockfile_resolutions::with_offline_check_cache_identities,
|
||||
};
|
||||
|
||||
/// Persist the post-resolution lockfile as already-verified.
|
||||
@@ -49,7 +49,7 @@ pub fn record_lockfile_verified(
|
||||
record_verification(
|
||||
cache_dir,
|
||||
lockfile_path,
|
||||
&with_resolution_shape_cache_identity(verifiers),
|
||||
&with_offline_check_cache_identities(verifiers),
|
||||
|| hash_lockfile(lockfile),
|
||||
crate::cache::CachePrecomputed::default(),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ use pacquet_lockfile::{Lockfile, LockfileResolution, PkgName, is_git_hosted_tarb
|
||||
use pacquet_reporter::{
|
||||
LockfileVerificationLog, LockfileVerificationMessage, LogEvent, LogLevel, Reporter,
|
||||
};
|
||||
use pacquet_resolving_parse_wanted_dependency::is_valid_old_npm_package_name;
|
||||
use pacquet_resolving_resolver_base::{
|
||||
ResolutionPolicyViolation, ResolutionVerification, ResolutionVerifier, VerifyCtx, VerifyFuture,
|
||||
};
|
||||
@@ -95,7 +96,7 @@ pub async fn verify_lockfile_resolutions<Reporter: self::Reporter>(
|
||||
// logic via the same code path).
|
||||
let cache_inputs = opts.cache_dir.zip(opts.lockfile_path);
|
||||
|
||||
let cache_verifiers = with_resolution_shape_cache_identity(verifiers);
|
||||
let cache_verifiers = with_offline_check_cache_identities(verifiers);
|
||||
|
||||
// Memoised content hash. Used by both the lookup (when the
|
||||
// stat-shortcut doesn't apply) and the recorder (after the
|
||||
@@ -140,7 +141,10 @@ pub async fn verify_lockfile_resolutions<Reporter: self::Reporter>(
|
||||
cache_precomputed = result.precomputed;
|
||||
}
|
||||
|
||||
let (candidates, shape_violations) = collect_candidates(lockfile);
|
||||
let (candidates, shape_violations, invalid_aliases) = collect_candidates(lockfile);
|
||||
if !invalid_aliases.is_empty() {
|
||||
return Err(VerifyError::invalid_dependency_aliases(&invalid_aliases));
|
||||
}
|
||||
if !shape_violations.is_empty() {
|
||||
return Err(build_verification_error(shape_violations));
|
||||
}
|
||||
@@ -212,44 +216,59 @@ pub async fn collect_resolution_policy_violations(
|
||||
if verifiers.is_empty() || lockfile.packages.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Shape violations are deliberately not collected here: they are
|
||||
// hard tampering failures, not policy picks a caller may
|
||||
// auto-exclude.
|
||||
let (candidates, _shape_violations) = collect_candidates(lockfile);
|
||||
// Shape violations and invalid aliases are deliberately not
|
||||
// collected here: they are hard tampering failures, not policy
|
||||
// picks a caller may auto-exclude.
|
||||
let (candidates, _shape_violations, _invalid_aliases) = collect_candidates(lockfile);
|
||||
run_fan_out(candidates, verifiers, concurrency).await
|
||||
}
|
||||
|
||||
pub const RESOLUTION_SHAPE_MISMATCH_VIOLATION_CODE: &str = "RESOLUTION_SHAPE_MISMATCH";
|
||||
|
||||
/// Cache-key participant for the offline resolution-shape pass: a
|
||||
/// record written before the rule existed lacks the flag, so
|
||||
/// `can_trust_past_check` rejects it and forces a re-verification.
|
||||
/// `verify` is never invoked — the identity is appended only to the
|
||||
/// verifier lists handed to the cache lookup and recorder.
|
||||
struct ResolutionShapeCacheIdentity {
|
||||
/// Cache-key participant for an always-on offline structural check
|
||||
/// (resolution-shape, dependency-alias): a record written before the
|
||||
/// check's rule existed lacks its `flag`, so `can_trust_past_check`
|
||||
/// rejects it and forces a re-verification. `verify` is never invoked —
|
||||
/// the identity is appended only to the verifier lists handed to the
|
||||
/// cache lookup and recorder.
|
||||
struct OfflineCheckCacheIdentity {
|
||||
policy: serde_json::Map<String, serde_json::Value>,
|
||||
flag: &'static str,
|
||||
}
|
||||
|
||||
fn resolution_shape_cache_identity() -> Arc<dyn ResolutionVerifier> {
|
||||
let mut policy = serde_json::Map::new();
|
||||
policy.insert("resolutionShapeCheck".to_string(), serde_json::Value::Bool(true));
|
||||
Arc::new(ResolutionShapeCacheIdentity { policy })
|
||||
Arc::new(OfflineCheckCacheIdentity { policy, flag: "resolutionShapeCheck" })
|
||||
}
|
||||
|
||||
fn dependency_alias_cache_identity() -> Arc<dyn ResolutionVerifier> {
|
||||
let mut policy = serde_json::Map::new();
|
||||
policy.insert("dependencyAliasCheck".to_string(), serde_json::Value::Bool(true));
|
||||
Arc::new(OfflineCheckCacheIdentity { policy, flag: "dependencyAliasCheck" })
|
||||
}
|
||||
|
||||
/// Every verifier list that flows into the verification cache must
|
||||
/// carry the resolution-shape identity, so records written before the
|
||||
/// shape rule existed cannot stat-fast-path around it. Used by the
|
||||
/// gate itself and by [`crate::record_lockfile_verified()`], whose
|
||||
/// freshly-resolved lockfile satisfies the shape invariant by
|
||||
/// construction (the writer derives every key from the resolution it
|
||||
/// just produced).
|
||||
pub(crate) fn with_resolution_shape_cache_identity(
|
||||
/// carry the always-on offline structural checks' identities, so a
|
||||
/// record written before one of those rules existed cannot
|
||||
/// stat-fast-path around it — its missing flag fails
|
||||
/// `can_trust_past_check`, forcing a re-verification that runs the new
|
||||
/// check. Used by the gate itself and by
|
||||
/// [`crate::record_lockfile_verified()`], whose freshly-resolved
|
||||
/// lockfile satisfies these invariants by construction (the resolver
|
||||
/// validates aliases at manifest-read time and derives every resolution
|
||||
/// key from the resolution it just produced).
|
||||
pub(crate) fn with_offline_check_cache_identities(
|
||||
verifiers: &[Arc<dyn ResolutionVerifier>],
|
||||
) -> Vec<Arc<dyn ResolutionVerifier>> {
|
||||
verifiers.iter().cloned().chain(std::iter::once(resolution_shape_cache_identity())).collect()
|
||||
verifiers
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain([resolution_shape_cache_identity(), dependency_alias_cache_identity()])
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ResolutionVerifier for ResolutionShapeCacheIdentity {
|
||||
impl ResolutionVerifier for OfflineCheckCacheIdentity {
|
||||
fn verify<'a>(
|
||||
&'a self,
|
||||
_resolution: &'a LockfileResolution,
|
||||
@@ -263,7 +282,7 @@ impl ResolutionVerifier for ResolutionShapeCacheIdentity {
|
||||
}
|
||||
|
||||
fn can_trust_past_check(&self, cached: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
cached.get("resolutionShapeCheck") == Some(&serde_json::Value::Bool(true))
|
||||
cached.get(self.flag) == Some(&serde_json::Value::Bool(true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +323,23 @@ fn is_http_tarball_url(url: &str) -> bool {
|
||||
lower.starts_with("https://") || lower.starts_with("http://")
|
||||
}
|
||||
|
||||
/// Add every alias in `aliases` that fails
|
||||
/// `is_valid_old_npm_package_name` (the `validForOldPackages` rule
|
||||
/// pnpm's `isValidDependencyAlias` applies) to `invalid`. Only pass maps
|
||||
/// whose keys become `node_modules/<alias>` directories — not
|
||||
/// `overrides`, `patched_dependencies`, or peer dependencies.
|
||||
fn push_invalid_aliases<'alias>(
|
||||
aliases: impl Iterator<Item = &'alias PkgName>,
|
||||
invalid: &mut std::collections::BTreeSet<String>,
|
||||
) {
|
||||
for alias in aliases {
|
||||
let alias = alias.to_string();
|
||||
if !is_valid_old_npm_package_name(&alias) {
|
||||
invalid.insert(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `(name, version, resolution)` tuple deduplicated from
|
||||
/// `lockfile.packages`. Mirrors upstream's inline `Candidate`
|
||||
/// interface.
|
||||
@@ -323,10 +359,36 @@ struct Candidate {
|
||||
/// `BTreeMap` over a serialized key gives deterministic iteration
|
||||
/// order for tests; the fan-out runs across the value iter so order
|
||||
/// doesn't affect correctness, only the reproducibility of failures.
|
||||
fn collect_candidates(lockfile: &Lockfile) -> (Vec<Candidate>, Vec<ResolutionPolicyViolation>) {
|
||||
fn collect_candidates(
|
||||
lockfile: &Lockfile,
|
||||
) -> (Vec<Candidate>, Vec<ResolutionPolicyViolation>, Vec<String>) {
|
||||
let Some(packages) = lockfile.packages.as_ref() else {
|
||||
return (Vec::new(), Vec::new());
|
||||
return (Vec::new(), Vec::new(), Vec::new());
|
||||
};
|
||||
// Pacquet keeps the alias-bearing maps in `importers` / `snapshots`,
|
||||
// separate from the `packages` metadata the loop below walks, so
|
||||
// they're scanned here in the same pass.
|
||||
let mut invalid_aliases: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
for importer in lockfile.importers.values() {
|
||||
for deps in
|
||||
[&importer.dependencies, &importer.dev_dependencies, &importer.optional_dependencies]
|
||||
{
|
||||
push_invalid_aliases(
|
||||
deps.iter().flatten().map(|(alias, _)| alias),
|
||||
&mut invalid_aliases,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(snapshots) = lockfile.snapshots.as_ref() {
|
||||
for snapshot in snapshots.values() {
|
||||
for deps in [&snapshot.dependencies, &snapshot.optional_dependencies] {
|
||||
push_invalid_aliases(
|
||||
deps.iter().flatten().map(|(alias, _)| alias),
|
||||
&mut invalid_aliases,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut deduped: BTreeMap<String, Candidate> = BTreeMap::new();
|
||||
let mut shape_violations = Vec::new();
|
||||
for (key, metadata) in packages {
|
||||
@@ -366,7 +428,7 @@ fn collect_candidates(lockfile: &Lockfile) -> (Vec<Candidate>, Vec<ResolutionPol
|
||||
resolution: metadata.resolution.clone(),
|
||||
});
|
||||
}
|
||||
(deduped.into_values().collect(), shape_violations)
|
||||
(deduped.into_values().collect(), shape_violations, invalid_aliases.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Run every active verifier against every candidate with a
|
||||
|
||||
@@ -805,3 +805,99 @@ snapshots:
|
||||
.expect_err("uppercased git-host tarball must be rejected");
|
||||
assert!(matches!(err, VerifyError::ResolutionShapeMismatch { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_importer_alias_even_with_no_verifiers() {
|
||||
for alias in ["../../../escape", "@scope/../../escape", ".bin", ".pnpm", "node_modules"] {
|
||||
let yaml = format!(
|
||||
"lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n '{alias}':\n specifier: ^1.0.0\n version: 1.0.0\n\npackages:\n\n real@1.0.0:\n resolution: {{integrity: sha512-deadbeef}}\n\nsnapshots:\n\n real@1.0.0: {{}}\n",
|
||||
);
|
||||
let lockfile = parse(&yaml);
|
||||
let err = verify_lockfile_resolutions::<SilentReporter>(
|
||||
&lockfile,
|
||||
&[],
|
||||
&VerifyLockfileResolutionsOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid importer alias must be rejected");
|
||||
let VerifyError::InvalidDependencyAlias { count, breakdown } = err else {
|
||||
panic!("expected InvalidDependencyAlias for {alias:?}, got {err:?}");
|
||||
};
|
||||
assert_eq!(count, 1);
|
||||
assert!(breakdown.contains(alias), "breakdown {breakdown:?} should mention {alias:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_alias_nested_in_snapshot() {
|
||||
let lockfile = parse(
|
||||
"lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
real:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
|
||||
packages:
|
||||
|
||||
real@1.0.0:
|
||||
resolution: {integrity: sha512-deadbeef}
|
||||
|
||||
snapshots:
|
||||
|
||||
real@1.0.0:
|
||||
dependencies:
|
||||
'../../../escape': 1.0.0
|
||||
",
|
||||
);
|
||||
let err = verify_lockfile_resolutions::<SilentReporter>(
|
||||
&lockfile,
|
||||
&[],
|
||||
&VerifyLockfileResolutionsOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid alias nested in a snapshot must be rejected");
|
||||
assert!(matches!(err, VerifyError::InvalidDependencyAlias { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_valid_scoped_and_unscoped_aliases() {
|
||||
let lockfile = parse(
|
||||
"lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
foo:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
'@scope/bar':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
|
||||
packages:
|
||||
|
||||
foo@1.0.0:
|
||||
resolution: {integrity: sha512-deadbeef}
|
||||
|
||||
'@scope/bar@1.0.0':
|
||||
resolution: {integrity: sha512-deadbeef}
|
||||
|
||||
snapshots:
|
||||
|
||||
foo@1.0.0: {}
|
||||
'@scope/bar@1.0.0': {}
|
||||
",
|
||||
);
|
||||
verify_lockfile_resolutions::<SilentReporter>(
|
||||
&lockfile,
|
||||
&[],
|
||||
&VerifyLockfileResolutionsOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("valid scoped and unscoped aliases pass");
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! to nest. Hoisting decisions are made at directory granularity,
|
||||
//! not depPath granularity.
|
||||
|
||||
use crate::safe_join_modules_dir::{InvalidDependencyAliasError, safe_join_modules_dir};
|
||||
use derive_more::{Display, Error, From};
|
||||
use indexmap::IndexSet;
|
||||
use miette::Diagnostic;
|
||||
@@ -325,6 +326,11 @@ pub enum HoistedDepGraphError {
|
||||
#[display("{_0}")]
|
||||
#[diagnostic(transparent)]
|
||||
Installability(#[error(source)] Box<InstallabilityError>),
|
||||
/// A hoisted node's alias was rejected by `safe_join_modules_dir`
|
||||
/// before the join. Surfaces `ERR_PNPM_INVALID_DEPENDENCY_NAME`.
|
||||
#[display("{_0}")]
|
||||
#[diagnostic(transparent)]
|
||||
InvalidDependencyAlias(#[error(source)] InvalidDependencyAliasError),
|
||||
}
|
||||
|
||||
/// Build a directory-keyed [`LockfileToDepGraphResult`] from a
|
||||
@@ -690,7 +696,7 @@ fn walk_deps(
|
||||
}
|
||||
}
|
||||
|
||||
let dir = modules.join(&dep.0.name);
|
||||
let dir = safe_join_modules_dir(modules, &dep.0.name)?;
|
||||
let dep_location = path_relative_to_lockfile_dir(&dir, state.lockfile_dir);
|
||||
|
||||
// Insert *before* recursing — mirrors upstream's
|
||||
|
||||
@@ -1061,3 +1061,36 @@ fn walker_forwards_external_dependencies_to_hoister() {
|
||||
"root direct deps drop the externalised alias",
|
||||
);
|
||||
}
|
||||
|
||||
/// A crafted lockfile whose dependency alias is a path-traversal
|
||||
/// (`../../../escape`) or a reserved name (`.bin`, `.pnpm`,
|
||||
/// `node_modules`) is rejected at the hoisted graph sink before the
|
||||
/// node is inserted or the walker recurses. `PkgName::parse` is
|
||||
/// permissive enough to carry such an alias straight out of a
|
||||
/// deserialized lockfile, so this is the boundary that stops it.
|
||||
/// Mirrors pnpm's `ERR_PNPM_INVALID_DEPENDENCY_NAME`; `force: true`
|
||||
/// skips installability so the walk reaches the alias sink directly.
|
||||
#[test]
|
||||
fn walker_rejects_invalid_hoisted_alias() {
|
||||
for alias in ["../../../escape", "@scope/../../escape", ".bin", ".pnpm", "node_modules"] {
|
||||
let mut root_deps = ResolvedDependencyMap::new();
|
||||
root_deps.insert(pkg_name(alias), resolved_dep("1.0.0"));
|
||||
|
||||
let mut packages = HashMap::new();
|
||||
packages.insert(dep_key(alias, "1.0.0"), metadata_stub());
|
||||
|
||||
let mut snapshots = HashMap::new();
|
||||
snapshots.insert(dep_key(alias, "1.0.0"), SnapshotEntry::default());
|
||||
|
||||
let lockfile = lockfile_with(root_deps, packages, snapshots);
|
||||
let opts = LockfileToHoistedDepGraphOptions { force: true, ..host_aware_opts() };
|
||||
let err = lockfile_to_hoisted_dep_graph(&lockfile, None, &opts)
|
||||
.expect_err("invalid alias must be rejected");
|
||||
match err {
|
||||
HoistedDepGraphError::InvalidDependencyAlias(inner) => {
|
||||
assert_eq!(inner.alias, alias);
|
||||
}
|
||||
other => panic!("expected InvalidDependencyAlias error for {alias:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod prefetching_resolver;
|
||||
mod remove;
|
||||
mod resolution_observer;
|
||||
mod retry_config;
|
||||
mod safe_join_modules_dir;
|
||||
mod store_init;
|
||||
mod symlink_direct_dependencies;
|
||||
mod symlink_package;
|
||||
|
||||
47
pacquet/crates/package-manager/src/safe_join_modules_dir.rs
Normal file
47
pacquet/crates/package-manager/src/safe_join_modules_dir.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! Join a `node_modules` directory with a dependency alias and reject
|
||||
//! aliases that aren't valid npm package names before the join. The
|
||||
//! alias becomes a directory name inside `node_modules`, so a
|
||||
//! path-traversal alias (`../../../escape`) would escape the directory
|
||||
//! and a reserved alias (`.bin`, `.pnpm`, `node_modules`) would
|
||||
//! overwrite pnpm-owned layout. Mirrors pnpm's
|
||||
//! [`safeJoinModulesDir`](https://github.com/pnpm/pnpm/blob/main/fs/symlink-dependency/src/safeJoinModulesDir.ts)
|
||||
//! and routes through the same [`is_valid_dependency_alias`]
|
||||
//! check pacquet applies to direct-dependency aliases at resolution
|
||||
//! time, so the hoisted restore path enforces the boundary the
|
||||
//! resolution path already enforces.
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_resolving_deps_resolver::is_valid_dependency_alias;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A dependency alias that would escape `modules` or collide with
|
||||
/// pnpm's own `node_modules` layout. Surfaces pnpm's
|
||||
/// `ERR_PNPM_INVALID_DEPENDENCY_NAME`.
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
#[display("Refusing to place a dependency under {} with the invalid alias {alias:?}", modules.display())]
|
||||
#[diagnostic(code(INVALID_DEPENDENCY_NAME))]
|
||||
pub struct InvalidDependencyAliasError {
|
||||
pub modules: PathBuf,
|
||||
#[error(not(source))]
|
||||
pub alias: String,
|
||||
}
|
||||
|
||||
/// `modules.join(alias)` guarded by a package-name validity check.
|
||||
/// Returns [`InvalidDependencyAliasError`] when `alias` is not a valid
|
||||
/// npm package name (path-traversal, absolute, or reserved).
|
||||
pub fn safe_join_modules_dir(
|
||||
modules: &Path,
|
||||
alias: &str,
|
||||
) -> Result<PathBuf, InvalidDependencyAliasError> {
|
||||
if !is_valid_dependency_alias(alias) {
|
||||
return Err(InvalidDependencyAliasError {
|
||||
modules: modules.to_path_buf(),
|
||||
alias: alias.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(modules.join(alias))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,34 @@
|
||||
use super::safe_join_modules_dir;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_aliases() {
|
||||
let modules = Path::new("/project/node_modules");
|
||||
for alias in ["foo", "@scope/name", "foo.bar"] {
|
||||
let joined =
|
||||
safe_join_modules_dir(modules, alias).expect("valid alias should join cleanly");
|
||||
assert_eq!(joined, modules.join(alias));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_aliases() {
|
||||
let modules = Path::new("/project/node_modules");
|
||||
for alias in ["../../../escape", "@scope/../../escape"] {
|
||||
let err = safe_join_modules_dir(modules, alias)
|
||||
.expect_err("traversal alias must be rejected before the join");
|
||||
assert_eq!(err.alias, alias);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved_aliases() {
|
||||
let modules = Path::new("/project/node_modules");
|
||||
// These resolve *inside* `node_modules` but collide with pnpm-owned
|
||||
// layout, so a containment check alone could not catch them.
|
||||
for alias in [".bin", ".pnpm", "node_modules"] {
|
||||
let err = safe_join_modules_dir(modules, alias)
|
||||
.expect_err("reserved alias must be rejected before the join");
|
||||
assert_eq!(err.alias, alias);
|
||||
}
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -4936,6 +4936,9 @@ importers:
|
||||
symlink-dir:
|
||||
specifier: 'catalog:'
|
||||
version: 10.0.1
|
||||
validate-npm-package-name:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.2
|
||||
devDependencies:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
@@ -4949,6 +4952,9 @@ importers:
|
||||
'@pnpm/prepare':
|
||||
specifier: workspace:*
|
||||
version: link:../../__utils__/prepare
|
||||
'@types/validate-npm-package-name':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.2
|
||||
|
||||
global/commands:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user