Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
24fa6f8
fix: harden allowBuilds artifact approvals
zkochan Jun 9, 2026
6e13638
fix: dedupe allowBuilds identity logic and detect revoked artifact ap…
zkochan Jun 9, 2026
b4366f4
style: avoid a non-dictionary word in the log-drain test comment
zkochan Jun 9, 2026
7227a1d
test: approve the dlx git-hosted package by depPath
zkochan Jun 9, 2026
1dbfad8
fix: match rebuild depPath selectors with allowBuilds normalization
zkochan Jun 9, 2026
32422ac
refactor: identify packages by depPath in the AllowBuild policy
zkochan Jun 9, 2026
d86964d
style(package-manager): drop trailing comma in single-line assert
zkochan Jun 9, 2026
c194a8e
refactor: derive allowBuilds identity trust from the dependency path
zkochan Jun 9, 2026
429e434
fix: restore valid JSON in during-install tsconfig
zkochan Jun 9, 2026
b72ea3f
docs: include local directory artifacts in the allowBuilds changeset
zkochan Jun 9, 2026
1f896a9
refactor: fold the resolution-shape pass into the candidate walk
zkochan Jun 9, 2026
af4e687
refactor: normalize rebuild selectors once at the edges
zkochan Jun 10, 2026
3478af8
test: pin the GVS rebuild fallback for approvals granted after install
zkochan Jun 10, 2026
41f0b16
fix: carry the shape-check identity in fresh-resolve verification rec…
zkochan Jun 10, 2026
eca97f8
docs: link the GVS in-place-rebuild fallback to its tracking issue
zkochan Jun 10, 2026
f415118
fix: gate resolution-shape trust on the tarball URL, exempt custom re…
zkochan Jun 10, 2026
f38b7bd
fix: require http(s) registry tarballs; don't mangle scheme'd git repos
zkochan Jun 10, 2026
b085d5b
fix: match git-hosted tarball URLs case-insensitively
zkochan Jun 10, 2026
13ef6c8
test: reword case-insensitivity test to satisfy spellcheck
zkochan Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/tough-allow-builds-identities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@pnpm/building.after-install": patch
"@pnpm/building.during-install": patch
"@pnpm/building.policy": patch
"@pnpm/deps.graph-builder": patch
"@pnpm/deps.graph-hasher": patch
"@pnpm/exec.prepare-package": patch
"@pnpm/fetching.git-fetcher": patch
"@pnpm/fetching.tarball-fetcher": patch
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.deps-resolver": patch
"@pnpm/installing.deps-restorer": patch
"@pnpm/types": patch
"pnpm": patch
---

Require trusted package identity before package-name `allowBuilds` entries can approve lifecycle scripts for git, git-hosted tarball, direct tarball, and local directory artifacts. To approve one of those artifacts explicitly, use its peer-suffix-free lockfile depPath as the `allowBuilds` key. Lockfile verification now rejects lockfiles where a registry-style dependency path (`name@semver`) is backed by a git, directory, or git-hosted tarball resolution (`ERR_PNPM_RESOLUTION_SHAPE_MISMATCH`), so the dependency path is a reliable artifact identity by the time scripts can run.
64 changes: 58 additions & 6 deletions building/after-install/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert'
import fs from 'node:fs'
import path from 'node:path'
import util from 'node:util'

Expand Down Expand Up @@ -37,6 +38,7 @@ import { pickStoreIndexKey, StoreIndex } from '@pnpm/store.index'
import type {
DepPath,
IgnoredBuilds,
PkgIdWithPatchHash,
ProjectId,
ProjectManifest,
ProjectRootDir,
Expand Down Expand Up @@ -78,19 +80,23 @@ function findPackages (
})
return false
}
return matches(searched, pkgInfo)
return matches(searched, pkgInfo, dp.getPkgIdWithPatchHash(relativeDepPath))
})
}

// TODO: move this logic to separate package as this is also used in tree-builder
function matches (
searched: PackageSelector[],
manifest: { name: string, version?: string }
manifest: { name: string, version?: string },
pkgIdWithPatchHash: PkgIdWithPatchHash
): boolean {
return searched.some((searchedPkg) => {
if (typeof searchedPkg === 'string') {
return manifest.name === searchedPkg
}
if ('pkgIdWithPatchHash' in searchedPkg) {
return searchedPkg.pkgIdWithPatchHash === pkgIdWithPatchHash
}
return searchedPkg.name === manifest.name && !!manifest.version &&
semver.satisfies(manifest.version, searchedPkg.range)
})
Expand All @@ -99,6 +105,9 @@ function matches (
type PackageSelector = string | {
name: string
range: string
} | {
/** A user-written depPath spec, normalized with the peer suffix stripped. */
pkgIdWithPatchHash: string
}

export async function buildSelectedPkgs (
Expand All @@ -117,6 +126,9 @@ export async function buildSelectedPkgs (
const packages = ctx.currentLockfile.packages

const searched: PackageSelector[] = pkgSpecs.map((arg) => {
if (matchesDepPath(packages, arg)) {
return { pkgIdWithPatchHash: dp.removePeersSuffix(arg) }
}
const { fetchSpec, name, raw, type } = npa(arg)
if (raw === name) {
return name
Expand Down Expand Up @@ -168,6 +180,11 @@ export async function buildSelectedPkgs (
}
}

function matchesDepPath (packages: PackageSnapshots, pkgSpec: string): boolean {
const normalizedPkgSpec = dp.removePeersSuffix(pkgSpec)
return Object.keys(packages).some((depPath) => dp.removePeersSuffix(depPath) === normalizedPkgSpec)
}

export async function buildProjects (
projects: Array<{ buildIndex: number, manifest: ProjectManifest, rootDir: ProjectRootDir }>,
maybeOpts: BuildOptions
Expand Down Expand Up @@ -330,8 +347,8 @@ async function _rebuild (

const ignoredPkgs = new Set<DepPath>()
const _allowBuild = createAllowBuildFunction(opts) ?? (() => undefined)
const allowBuild = (pkgName: string, version: string, depPath: DepPath) => {
switch (_allowBuild(pkgName, version)) {
const allowBuild = (depPath: DepPath) => {
switch (_allowBuild(depPath)) {
case true: return true
case undefined: {
ignoredPkgs.add(depPath)
Expand All @@ -356,7 +373,10 @@ async function _rebuild (
opts.supportedArchitectures,
nodeVersion
)) {
gvsDirByDepPath.set(pkgMeta.depPath, path.join(globalVirtualStoreDir, hash))
const preferredGvsDir = path.join(globalVirtualStoreDir, hash)
gvsDirByDepPath.set(pkgMeta.depPath, fs.existsSync(preferredGvsDir)
? preferredGvsDir
: findLinkedGvsDir(pkgMeta.name, Object.values(ctx.projects), globalVirtualStoreDir) ?? preferredGvsDir)
Comment thread
zkochan marked this conversation as resolved.
}
}
const pkgModulesDir = (depPath: DepPath): string =>
Expand Down Expand Up @@ -438,7 +458,7 @@ async function _rebuild (
requiresBuild = pkgRequiresBuild(pgkManifest, new Map())
}

const hasSideEffects = requiresBuild && allowBuild(pkgInfo.name, pkgInfo.version, depPath) && await runPostinstallHooks({
const hasSideEffects = requiresBuild && allowBuild(depPath) && await runPostinstallHooks({
depPath,
extraBinPaths,
extraEnv: opts.extraEnv,
Expand Down Expand Up @@ -530,6 +550,38 @@ async function _rebuild (
return { pkgsThatWereRebuilt, ignoredPkgs }
}

// TODO: delete once rebuild relocates GVS projections to the newly computed
// hash instead of building in place (https://github.com/pnpm/pnpm/issues/12302).
function findLinkedGvsDir (
pkgName: string,
projects: Array<{ rootDir: ProjectRootDir }>,
globalVirtualStoreDir: string
): string | undefined {
const normalizedGvsRoot = `${path.resolve(globalVirtualStoreDir)}${path.sep}`
for (const { rootDir } of projects) {
const pkgLink = path.join(rootDir, 'node_modules', pkgName)
try {
const target = fs.readlinkSync(pkgLink)
const pkgRoot = path.resolve(path.dirname(pkgLink), target)
if (!pkgRoot.startsWith(normalizedGvsRoot)) continue
return nthAncestorDir(pkgRoot, pkgName.split('/').length + 1)
} catch (err: unknown) {
// EINVAL: pkgLink exists but is not a symlink.
if (util.types.isNativeError(err) && 'code' in err && (err.code === 'EINVAL' || err.code === 'ENOENT')) continue
throw err
}
}
return undefined
}

function nthAncestorDir (dir: string, levels: number): string {
let result = dir
for (let i = 0; i < levels; i++) {
result = path.dirname(result)
}
return result
}

function binDirsInAllParentDirs (pkgRoot: string, lockfileDir: string): string[] {
const binDirs: string[] = []
let dir = pkgRoot
Expand Down
1 change: 1 addition & 0 deletions building/commands/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/building.after-install": "workspace:*",
"@pnpm/building.policy": "workspace:*",
"@pnpm/cli.command": "workspace:*",
"@pnpm/cli.common-cli-options-help": "workspace:*",
"@pnpm/cli.utils": "workspace:*",
Expand Down
4 changes: 2 additions & 2 deletions building/commands/src/policy/approveBuilds.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { checkbox, confirm } from '@inquirer/prompts'
import { allowBuildKeyFromIgnoredBuild } from '@pnpm/building.policy'
import type { CommandHandlerMap } from '@pnpm/cli.command'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { writeSettings } from '@pnpm/config.writer'
import { parse } from '@pnpm/deps.path'
import { PnpmError } from '@pnpm/error'
import { install } from '@pnpm/installing.commands'
import { type StrictModules, writeModulesManifest } from '@pnpm/installing.modules-yaml'
Expand Down Expand Up @@ -195,7 +195,7 @@ export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOp
if (params.length) {
const decided = new Set([...approved, ...denied])
for (const depPath of Array.from(modulesManifest.ignoredBuilds)) {
const name = parse(depPath).name ?? depPath
const name = allowBuildKeyFromIgnoredBuild(depPath)
if (decided.has(name)) {
modulesManifest.ignoredBuilds.delete(depPath)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'node:path'

import { parse } from '@pnpm/deps.path'
import { allowBuildKeyFromIgnoredBuild } from '@pnpm/building.policy'
import { type Modules, readModulesManifest } from '@pnpm/installing.modules-yaml'

import type { IgnoredBuildsCommandOpts } from './ignoredBuilds.js'
Expand All @@ -18,7 +18,7 @@ export async function getAutomaticallyIgnoredBuilds (opts: IgnoredBuildsCommandO
if (modulesManifest?.ignoredBuilds) {
const ignoredPkgNames = new Set<string>()
for (const depPath of modulesManifest.ignoredBuilds) {
ignoredPkgNames.add(parse(depPath).name ?? depPath)
ignoredPkgNames.add(allowBuildKeyFromIgnoredBuild(depPath))
}
automaticallyIgnoredBuilds = Array.from(ignoredPkgNames)
} else {
Expand Down
53 changes: 50 additions & 3 deletions building/commands/test/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ test('rebuilds dependencies', async () => {
'@pnpm.e2e/[email protected]',
'test-git-fetch@https://codeload.github.com/pnpm/test-git-fetch/tar.gz/8b333f12d5357f4f25a654c305c826294cb073bf',
])
const gitDepPath = modules!.pendingBuilds[1]

const modulesManifest = project.readModulesManifest()
await rebuild.handler({
Expand All @@ -56,7 +57,7 @@ test('rebuilds dependencies', async () => {
pending: false,
registries: modulesManifest!.registries!,
storeDir,
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, 'test-git-fetch': true },
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, [gitDepPath]: true },
}, [])

modules = project.readModulesManifest()
Expand Down Expand Up @@ -329,14 +330,15 @@ test('rebuilds specific dependencies', async () => {
])

const modulesManifest = project.readModulesManifest()
const gitDepPath = modulesManifest!.pendingBuilds.find((depPath) => depPath.startsWith('install-scripts-example-for-pnpm@'))!
await rebuild.handler({
...DEFAULT_OPTS,
cacheDir,
dir: process.cwd(),
pending: false,
registries: modulesManifest!.registries!,
storeDir,
allowBuilds: { 'install-scripts-example-for-pnpm': true },
allowBuilds: { [gitDepPath]: true },
}, ['install-scripts-example-for-pnpm'])

project.hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall')
Expand Down Expand Up @@ -382,6 +384,7 @@ test('rebuild with pending option', async () => {
// not to
// install-scripts-example-for-pnpm@https://codeload.github.com/pnpm-e2e/install-scripts-example/tar.gz/b6cfdb8af6f8d5ebc5e7de6831af9d38084d765b
expect(modules!.pendingBuilds[1]).toMatch(/^install-scripts-example-for-pnpm@.*b6cfdb8af6f8d5ebc5e7de6831af9d38084d765b.*/)
const gitDepPath = modules!.pendingBuilds[1]

project.hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall')
project.hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall')
Expand All @@ -396,7 +399,7 @@ test('rebuild with pending option', async () => {
pending: true,
registries: modules!.registries!,
storeDir,
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, 'install-scripts-example-for-pnpm': true },
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, [gitDepPath]: true },
}, [])

modules = project.readModulesManifest()
Expand Down Expand Up @@ -538,3 +541,47 @@ test(`rebuild should not fail on incomplete ${WANTED_LOCKFILE}`, async () => {
}, [])
})

test('rebuilds in the global virtual store when the approval was granted after the install', async () => {
const project = prepare()
const cacheDir = path.resolve('cache')
const storeDir = path.resolve('store')

// No allowBuilds at install time: the GVS projection is created under the
// not-built hash. The approval arrives only at rebuild time, so the rebuild
// recomputes a different (built) hash and must locate the existing
// projection through the project's node_modules link.
fs.writeFileSync('pnpm-workspace.yaml', [
'enableGlobalVirtualStore: true',
'',
].join('\n'))

await execa('node', [
pnpmBin,
'add',
'--save-dev',
'@pnpm.e2e/[email protected]',
`--registry=${REGISTRY}`,
`--store-dir=${storeDir}`,
'--ignore-scripts',
`--cache-dir=${cacheDir}`,
])

const pkgVersionDir = path.join(storeDir, STORE_VERSION, 'links/@pnpm.e2e/pre-and-postinstall-scripts-example/1.0.0')
const hash = fs.readdirSync(pkgVersionDir)[0]
const pkgInGvs = path.join(pkgVersionDir, hash, 'node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example')
expect(fs.existsSync(path.join(pkgInGvs, 'generated-by-postinstall.js'))).toBeFalsy()

const modulesManifest = project.readModulesManifest()
await rebuild.handler({
...DEFAULT_OPTS,
cacheDir,
dir: process.cwd(),
enableGlobalVirtualStore: true,
pending: false,
registries: modulesManifest!.registries!,
storeDir,
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true },
}, [])

expect(fs.existsSync(path.join(pkgInGvs, 'generated-by-postinstall.js'))).toBeTruthy()
})
3 changes: 3 additions & 0 deletions building/commands/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@
},
{
"path": "../after-install"
},
{
"path": "../policy"
}
]
}
2 changes: 1 addition & 1 deletion building/during-install/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export async function buildModules<T extends string> (
if (!ignoreScripts) {
const node = depGraph[depPath]
if (node.requiresBuild) {
const allowed = allowBuild(node.name, node.version)
const allowed = allowBuild(node.depPath)
switch (allowed) {
case false:
// Explicitly disallowed - don't report as ignored
Expand Down
Loading
Loading