mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 12:41:16 -04:00
fix: enforce minimumReleaseAge on existing lockfile entries (#11583)
Closes #10438. ## What Re-verify every entry in `pnpm-lock.yaml` against the policies the resolver chain was configured with — today: `minimumReleaseAge` in strict mode — right after the lockfile is loaded from disk and before any tarball is fetched. A locked version that fails the policy aborts the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored. ## Why The policy only fires while pnpm is *choosing* a version. Once a version is pinned in the lockfile — e.g. a developer disabled the policy locally and committed a fresh dependency, or a CI cache restored a stale lockfile — every later `pnpm install` (including `--frozen-lockfile` and `pnpm fetch`) installs it without re-checking, which defeats the supply-chain protection the setting is supposed to provide. The threat model is **a lockfile someone else resolved**, not local resolution: local resolution is already covered by the resolver's own per-version filter. bun fixed the same shape of bug in [oven-sh/bun#30526](https://github.com/oven-sh/bun/pull/30526); this PR is the pnpm side. ## How The fix introduces a generic `ResolutionVerifier` abstraction in the resolver chain — each resolver factory can ship a sibling verifier factory, exactly the way each resolver ships a `resolve` function. Today there's one verifier (npm); the shape leaves room for future ones (jsr, attestation-based, etc.) without changing the install-side interface. - **`@pnpm/resolving.resolver-base`** exports the `ResolutionVerifier` / `ResolutionVerification` types — the shared contract. - **`@pnpm/resolving.npm-resolver`** exports `createNpmResolutionVerifier`. Returns `undefined` when no policy is active, so callers can cheaply decide whether to iterate at all. When active, it inspects each lockfile entry, handles `minimumReleaseAgeExclude`, routes through named-registry prefixes (built-ins like `gh:` merged in), and uses `fetchFullMetadataCached` to fetch full registry metadata — decoupled from the resolver pipeline so neither `peekManifestFromStore` nor abbreviated metadata can hide the publish timestamp. - **`@pnpm/resolving.default-resolver`** exports `createResolutionVerifier`, a combinator that asks each underlying verifier (today: npm) if it has work and returns `undefined` when none does. Designed so that adding more verifiers later doesn't change the install side. - **`@pnpm/installing.client`** exposes `verifyResolution` on `Client`, built from the same `fetchFromRegistry` / `getAuthHeader` the resolver chain already uses — **no second fetcher is constructed**. - **`@pnpm/store.connection-manager`** and **`@pnpm/testing.temp-store`** surface `verifyResolution` alongside the store controller they hand back, so it reaches `mutateModules` through the existing plumbing. - **`@pnpm/installing.deps-installer`** gains one option on `StrictInstallOptions`: `verifyResolution?: ResolutionVerifier`. `mutateModules` invokes `verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)` **once**, right after `getContext` returns the on-disk lockfile and before any path branches. When the verifier is `undefined`, the call is a no-op. The iteration is policy-neutral: dedupes by `(name, version)`, applies `pLimit(16)`, sorts violations stably, caps the printed list at 20 with an `…and N more` summary, throws a `PnpmError` carrying the verifier-supplied error code. The error includes a recovery hint that points at `pnpm clean --lockfile` followed by `pnpm install` — the safe way to throw away a poisoned lockfile and rebuild from fresh resolution. ## Tests - **9 unit tests** for `verifyLockfileResolutions` against a mock `ResolutionVerifier` — dedup, aggregation, stable ordering, the 20-entry cap, no-op behavior, the verifier-supplied error code surfacing in `PnpmError`. - **13 integration tests** in `installing/deps-installer/test/install/minimumReleaseAge.ts` via the real `install()` entry — `testDefaults()` wires `verifyResolution` from `createTempStore` → `createClient`, so the npm verifier runs end-to-end at the install boundary. Covers the rejection scenario, `minimumReleaseAgeExclude`, the strict-mode toggle, the existing `minimumReleaseAge` resolver-side suite, and a `pnpm add` scenario where a pre-existing entry would otherwise survive resolution. - **3 e2e tests** in `pnpm/test/install/minimumReleaseAge.ts` against the bundled CLI: rejection path with the right `ERR_PNPM_*` code and `pnpm clean --lockfile` hint in output, `minimumReleaseAgeExclude` honored, and the strict-off path (which now requires an explicit `minimumReleaseAgeStrict: false` since the config reader auto-enables strict mode when `minimumReleaseAge` is set). - Existing `frozenLockfile` suite (12 tests) and npm-resolver suite (179 tests) still pass. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
12
.changeset/cache-aware-minimum-release-age-gate.md
Normal file
12
.changeset/cache-aware-minimum-release-age-gate.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
"@pnpm/resolving.resolver-base": minor
|
||||
"@pnpm/resolving.npm-resolver": minor
|
||||
"@pnpm/resolving.default-resolver": minor
|
||||
"@pnpm/installing.client": minor
|
||||
"@pnpm/store.connection-manager": minor
|
||||
"@pnpm/testing.temp-store": minor
|
||||
"@pnpm/installing.deps-installer": minor
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Restructured the `minimumReleaseAge` lockfile revalidation gate around a generic `ResolutionVerifier` interface. Each resolver may now export a sibling verifier factory (today: `createNpmResolutionVerifier`) that re-checks an already-resolved lockfile entry against its policies; `createResolver`'s companion `createResolutionVerifier` combines them and the `Client` exposes the combined `verifyResolution` for the install layer to consume. The npm verifier reuses the same on-disk metadata mirror the resolver writes to, so steady-state installs pay only a headers-only conditional GET per locked package [#11675](https://github.com/pnpm/pnpm/issues/11675).
|
||||
6
.changeset/revalidate-minimum-release-age.md
Normal file
6
.changeset/revalidate-minimum-release-age.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/installing.deps-installer": minor
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
`minimumReleaseAge` is now re-checked against `pnpm-lock.yaml` before any tarball is installed, so a freshly-published version pinned in the lockfile (e.g. by a developer who bypassed the policy locally) is no longer installed silently by other consumers or CI. Violating entries abort the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored. [#10438](https://github.com/pnpm/pnpm/issues/10438).
|
||||
@@ -9,14 +9,17 @@ import type { CustomFetcher, CustomResolver } from '@pnpm/hooks.types'
|
||||
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
|
||||
import { createFetchFromRegistry, type DispatcherOptions } from '@pnpm/network.fetch'
|
||||
import {
|
||||
createResolutionVerifier,
|
||||
createResolver as _createResolver,
|
||||
type ResolutionVerifierFactoryOptions,
|
||||
type ResolveFunction,
|
||||
type ResolverFactoryOptions,
|
||||
} from '@pnpm/resolving.default-resolver'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
import type { StoreIndex } from '@pnpm/store.index'
|
||||
import type { RegistryConfig } from '@pnpm/types'
|
||||
|
||||
export type { ResolveFunction }
|
||||
export type { ResolutionVerifier, ResolveFunction }
|
||||
|
||||
export type ClientOptions = {
|
||||
configByUri: Record<string, RegistryConfig>
|
||||
@@ -35,11 +38,19 @@ export type ClientOptions = {
|
||||
preserveAbsolutePaths?: boolean
|
||||
fetchMinSpeedKiBps?: number
|
||||
} & ResolverFactoryOptions & DispatcherOptions
|
||||
& Pick<ResolutionVerifierFactoryOptions, 'minimumReleaseAge' | 'minimumReleaseAgeStrict' | 'minimumReleaseAgeExclude'>
|
||||
|
||||
export interface Client {
|
||||
fetchers: Fetchers
|
||||
resolve: ResolveFunction
|
||||
clearResolutionCache: () => void
|
||||
/**
|
||||
* Combined verifier across the resolver chain. `undefined` when no
|
||||
* resolver-level policy is active (today: minimumReleaseAge strict mode).
|
||||
* Used by the install layer to re-validate an already-resolved lockfile
|
||||
* entry without re-doing resolution.
|
||||
*/
|
||||
verifyResolution?: ResolutionVerifier
|
||||
}
|
||||
|
||||
export function createClient (opts: ClientOptions): Client {
|
||||
@@ -47,10 +58,12 @@ export function createClient (opts: ClientOptions): Client {
|
||||
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
|
||||
|
||||
const { resolve, clearCache: clearResolutionCache } = _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
|
||||
const verifyResolution = createResolutionVerifier(fetchFromRegistry, opts)
|
||||
return {
|
||||
fetchers: createFetchers(fetchFromRegistry, getAuthHeader, opts),
|
||||
resolve,
|
||||
clearResolutionCache,
|
||||
verifyResolution,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export async function handler (opts: FetchCommandOptions): Promise<void> {
|
||||
pruneStore: true,
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
verifyResolution: store.verifyResolution,
|
||||
// Hoisting is skipped anyway,
|
||||
// so we store these empty patterns in node_modules/.modules.yaml
|
||||
// to let the subsequent install know that hoisting should be performed.
|
||||
|
||||
@@ -186,6 +186,7 @@ export async function handler (
|
||||
preferredVersions,
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
verifyResolution: store.verifyResolution,
|
||||
}
|
||||
await install(manifest, installOpts)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ export async function installDeps (
|
||||
skipRuntimes: opts.runtime === false,
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
verifyResolution: store.verifyResolution,
|
||||
workspacePackages,
|
||||
preferredVersions: opts.packageVulnerabilityAudit ? preferNonvulnerablePackageVersions(opts.packageVulnerabilityAudit) : undefined,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { logger } from '@pnpm/logger'
|
||||
import { filterDependenciesByType } from '@pnpm/pkg-manifest.utils'
|
||||
import type { PreferredVersions } from '@pnpm/resolving.resolver-base'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
|
||||
import type { StoreController } from '@pnpm/store.controller'
|
||||
import type {
|
||||
@@ -114,6 +115,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
|
||||
storeControllerAndDir?: {
|
||||
ctrl: StoreController
|
||||
dir: string
|
||||
verifyResolution?: ResolutionVerifier
|
||||
}
|
||||
pnpmfile: string[]
|
||||
} & Partial<
|
||||
@@ -165,6 +167,7 @@ export async function recursive (
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
targetDependenciesField,
|
||||
verifyResolution: store.verifyResolution,
|
||||
workspacePackages,
|
||||
}) as InstallOptions
|
||||
|
||||
@@ -296,6 +299,7 @@ export async function recursive (
|
||||
} = await mutateModules(mutatedImporters, {
|
||||
...installOpts,
|
||||
storeController: store.ctrl,
|
||||
verifyResolution: store.verifyResolution,
|
||||
})
|
||||
if (opts.save !== false) {
|
||||
const promises: Array<Promise<void>> = mutatedPkgs.map(async ({ originalManifest, manifest, rootDir }) => {
|
||||
@@ -414,6 +418,7 @@ export async function recursive (
|
||||
}),
|
||||
configByUri: installOpts.configByUri,
|
||||
storeController: store.ctrl,
|
||||
verifyResolution: store.verifyResolution,
|
||||
}
|
||||
)
|
||||
if (opts.save !== false) {
|
||||
|
||||
@@ -188,6 +188,7 @@ export async function handler (
|
||||
linkWorkspacePackagesDepth: opts.linkWorkspacePackages === 'deep' ? Infinity : opts.linkWorkspacePackages ? 0 : -1,
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
verifyResolution: store.verifyResolution,
|
||||
include,
|
||||
})
|
||||
const allProjects = opts.allProjects ?? (
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ProjectOptions } from '@pnpm/installing.context'
|
||||
import type { HoistingLimits } from '@pnpm/installing.deps-restorer'
|
||||
import type { IncludedDependencies } from '@pnpm/installing.modules-yaml'
|
||||
import type { LockfileObject } from '@pnpm/lockfile.fs'
|
||||
import type { WorkspacePackages } from '@pnpm/resolving.resolver-base'
|
||||
import type { ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base'
|
||||
import type { StoreController } from '@pnpm/store.controller-types'
|
||||
import type {
|
||||
AllowedDeprecatedVersions,
|
||||
@@ -175,6 +175,15 @@ export interface StrictInstallOptions {
|
||||
ci?: boolean
|
||||
minimumReleaseAge?: number
|
||||
minimumReleaseAgeExclude?: string[]
|
||||
/**
|
||||
* Optional verifier that re-checks each lockfile-pinned resolution
|
||||
* against policies configured upstream (today: minimumReleaseAge strict
|
||||
* mode). Constructed by `createClient` and surfaced via the
|
||||
* `createStoreController` return; mutateModules invokes it once, right
|
||||
* after the lockfile is loaded from disk. When omitted, no revalidation
|
||||
* runs.
|
||||
*/
|
||||
verifyResolution?: ResolutionVerifier
|
||||
trustPolicy?: TrustPolicy
|
||||
trustPolicyExclude?: string[]
|
||||
trustPolicyIgnoreAfter?: number
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
import { linkPackages } from './link.js'
|
||||
import { reportPeerDependencyIssues } from './reportPeerDependencyIssues.js'
|
||||
import { validateModules } from './validateModules.js'
|
||||
import { verifyLockfileResolutions } from './verifyLockfileResolutions.js'
|
||||
|
||||
class LockfileConfigMismatchError extends PnpmError {
|
||||
constructor (outdatedLockfileSettingName: string) {
|
||||
@@ -274,6 +275,11 @@ export async function mutateModules (
|
||||
maybeOpts: MutateModulesOptions
|
||||
): Promise<MutateModulesResult> {
|
||||
const reporter = maybeOpts?.reporter
|
||||
const detachReporter = (reporter != null) && typeof reporter === 'function'
|
||||
? () => {
|
||||
streamParser.removeListener('data', reporter)
|
||||
}
|
||||
: () => {}
|
||||
if ((reporter != null) && typeof reporter === 'function') {
|
||||
streamParser.on('data', reporter)
|
||||
}
|
||||
@@ -328,6 +334,26 @@ export async function mutateModules (
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate every entry in the lockfile against the policies the
|
||||
// resolver chain was built with (today: minimumReleaseAge in strict mode
|
||||
// via the npm verifier; the abstraction supports other resolvers
|
||||
// attaching their own verifiers). The threat model is a lockfile that
|
||||
// someone else resolved — committed to the repo, restored from a CI
|
||||
// cache, etc. — bypassing the local resolver's policy filters; the local
|
||||
// resolver's own filters already cover fresh resolution. We run this
|
||||
// exactly once, right after the lockfile is loaded from disk, before any
|
||||
// path branches.
|
||||
try {
|
||||
await verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)
|
||||
} catch (err) {
|
||||
// verifyLockfileResolutions is the one throw site in this function
|
||||
// that's part of normal user-facing operation (a rejected lockfile);
|
||||
// other throws here are unexpected. Detach the reporter listener so
|
||||
// long-lived processes don't leak it on every rejected install.
|
||||
detachReporter()
|
||||
throw err
|
||||
}
|
||||
|
||||
if (opts.hooks.preResolution) {
|
||||
for (const preResolution of opts.hooks.preResolution) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
@@ -415,9 +441,7 @@ export async function mutateModules (
|
||||
packageNames: ignoredBuilds ? dedupePackageNamesFromIgnoredBuilds(ignoredBuilds) : [],
|
||||
})
|
||||
|
||||
if ((reporter != null) && typeof reporter === 'function') {
|
||||
streamParser.removeListener('data', reporter)
|
||||
}
|
||||
detachReporter()
|
||||
|
||||
return {
|
||||
updatedCatalogs: result.updatedCatalogs,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { LockfileObject } from '@pnpm/lockfile.fs'
|
||||
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
import type { DepPath } from '@pnpm/types'
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
interface Violation {
|
||||
pkgId: string
|
||||
code: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
// Cap the per-entry breakdown so a verifier rejecting hundreds of entries
|
||||
// (e.g. a poisoned lockfile) doesn't flood the terminal / CI log; the full
|
||||
// count is in the header and the remainder is summarized at the end.
|
||||
const MAX_VIOLATIONS_TO_PRINT = 20
|
||||
|
||||
// 16 mirrors the floor of pnpm's package-requester network-concurrency
|
||||
// (Math.min(64, Math.max(workers*3, 16))); keep them aligned so the
|
||||
// verification pass doesn't push past what the rest of the install respects.
|
||||
const DEFAULT_CONCURRENCY = 16
|
||||
|
||||
/**
|
||||
* Policy-neutral pass that asks each resolver-supplied {@link ResolutionVerifier}
|
||||
* to check every entry in a lockfile loaded from disk. Iteration runs
|
||||
* before resolution decisions are touched and before any tarball is
|
||||
* fetched, so a lockfile whose entries were resolved elsewhere (committed
|
||||
* to the repo, restored from a cache, etc.) under a weaker or absent
|
||||
* policy cannot reach the filesystem. Fresh local resolution is covered
|
||||
* by the resolver's own per-version filter.
|
||||
*
|
||||
* Designed for fail-closed semantics at the verifier level: a verifier that
|
||||
* can't confirm a resolution is expected to return `{ ok: false }` rather
|
||||
* than passing silently — otherwise a registry hiccup or an unpublished
|
||||
* version would re-open the bypass.
|
||||
*
|
||||
* No-op when `verifyResolution` is undefined (no active policies).
|
||||
*/
|
||||
export async function verifyLockfileResolutions (
|
||||
lockfile: LockfileObject,
|
||||
verifyResolution: ResolutionVerifier | undefined,
|
||||
options?: { concurrency?: number }
|
||||
): Promise<void> {
|
||||
if (verifyResolution == null) return
|
||||
if (!lockfile.packages) return
|
||||
|
||||
// depPath can include peer-dependency and patch_hash suffixes (e.g.
|
||||
// `react@18.0.0(peer)(patch_hash=…)`); the same (name, version) pair may
|
||||
// therefore appear multiple times. Dedupe so we issue at most one
|
||||
// verification per package version.
|
||||
const candidates = new Map<string, { name: string, version: string, resolution: unknown }>()
|
||||
for (const [depPath, snapshot] of Object.entries(lockfile.packages)) {
|
||||
const { name, version } = nameVerFromPkgSnapshot(depPath as DepPath, snapshot)
|
||||
if (!name || !version) continue
|
||||
candidates.set(`${name}@${version}`, { name, version, resolution: snapshot.resolution })
|
||||
}
|
||||
|
||||
const violations: Violation[] = []
|
||||
const limit = pLimit(options?.concurrency ?? DEFAULT_CONCURRENCY)
|
||||
await Promise.all(
|
||||
Array.from(candidates.values(), ({ name, version, resolution }) => limit(async () => {
|
||||
const pkgId = `${name}@${version}`
|
||||
const result = await verifyResolution(resolution as Parameters<ResolutionVerifier>[0], { name, version })
|
||||
if (!result.ok) {
|
||||
violations.push({ pkgId, code: result.code, reason: result.reason })
|
||||
}
|
||||
}))
|
||||
)
|
||||
|
||||
if (violations.length === 0) return
|
||||
|
||||
// Stable order so the error output is deterministic.
|
||||
violations.sort((a, b) => a.pkgId.localeCompare(b.pkgId))
|
||||
const visible = violations.slice(0, MAX_VIOLATIONS_TO_PRINT)
|
||||
const omitted = violations.length - visible.length
|
||||
const breakdown = visible.map((v) => ` ${v.pkgId} ${v.reason}`).join('\n')
|
||||
const details = omitted > 0
|
||||
? `${breakdown}\n …and ${omitted} more`
|
||||
: breakdown
|
||||
// Use the code of the first violation — all of today's violations are the
|
||||
// same shape (one verifier, one code). If multiple verifiers fire later
|
||||
// with mixed codes, switch to a generic LOCKFILE_RESOLUTION_VERIFICATION
|
||||
// code and list per-entry codes in the breakdown.
|
||||
throw new PnpmError(
|
||||
violations[0].code,
|
||||
`${violations.length} lockfile entries failed verification:\n${details}`,
|
||||
{
|
||||
hint: 'The lockfile contains entries that the active policies reject. ' +
|
||||
'This can mean the lockfile is stale, or that someone committed a ' +
|
||||
'lockfile that bypassed the policy locally — inspect recent changes ' +
|
||||
'to pnpm-lock.yaml before trusting it. If the changes look expected, ' +
|
||||
'run "pnpm clean --lockfile" and then "pnpm install" to rebuild from ' +
|
||||
'a fresh resolution. Alternatively, relax the policy that flagged ' +
|
||||
'them.',
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from '@jest/globals'
|
||||
import { addDependenciesToPackage } from '@pnpm/installing.deps-installer'
|
||||
import { addDependenciesToPackage, install } from '@pnpm/installing.deps-installer'
|
||||
import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs'
|
||||
import { prepareEmpty } from '@pnpm/prepare'
|
||||
|
||||
@@ -124,3 +124,73 @@ test('throws error when semver range is used in minimumReleaseAgeExclude', async
|
||||
await addDependenciesToPackage({}, ['is-odd@0.1'], opts)
|
||||
}).rejects.toThrow(/Invalid versions union/)
|
||||
})
|
||||
|
||||
test('minimumReleaseAge is enforced on an existing lockfile entry that does not meet the cutoff', async () => {
|
||||
prepareEmpty()
|
||||
|
||||
// Generate a lockfile without minimumReleaseAge — picks the latest 0.1.x (= 0.1.2),
|
||||
// which is immature relative to isOdd011ReleaseDate.
|
||||
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
|
||||
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
|
||||
|
||||
// Subsequent install enables minimumReleaseAge in strict mode. The lockfile
|
||||
// already has 0.1.2 so resolution is normally skipped; the revalidation pass
|
||||
// must catch this. `minimumReleaseAgeStrict` mirrors the CLI config reader's
|
||||
// auto-true behavior when the user explicitly sets `minimumReleaseAge`.
|
||||
await expect(
|
||||
install(manifest, testDefaults({ minimumReleaseAge, minimumReleaseAgeStrict: true }))
|
||||
).rejects.toThrow(/minimumReleaseAge/)
|
||||
})
|
||||
|
||||
test('minimumReleaseAge revalidation respects minimumReleaseAgeExclude on an existing lockfile entry', async () => {
|
||||
prepareEmpty()
|
||||
|
||||
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
|
||||
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
|
||||
|
||||
// is-odd@0.1.2 brings in is-buffer and kind-of as transitive deps; both were
|
||||
// published after the cutoff in this test, so all three must be excluded for
|
||||
// the install to succeed.
|
||||
await expect(
|
||||
install(manifest, testDefaults({
|
||||
minimumReleaseAge,
|
||||
minimumReleaseAgeStrict: true,
|
||||
minimumReleaseAgeExclude: ['is-odd@0.1.2', 'is-buffer', 'kind-of'],
|
||||
}))
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test('minimumReleaseAge is enforced on pre-existing lockfile entries during pnpm add', async () => {
|
||||
prepareEmpty()
|
||||
|
||||
// Populate the lockfile with an immature entry without the policy.
|
||||
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
|
||||
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
|
||||
|
||||
// Subsequent `pnpm add` for an unrelated package would normally let
|
||||
// is-odd@0.1.2 survive resolution as-is via the resolver's
|
||||
// peekManifestFromStore fast path, bypassing the policy. The post-resolution
|
||||
// gate must catch it.
|
||||
await expect(
|
||||
addDependenciesToPackage(
|
||||
manifest,
|
||||
['is-positive@1.0.0'],
|
||||
testDefaults({ minimumReleaseAge, minimumReleaseAgeStrict: true })
|
||||
)
|
||||
).rejects.toThrow(/minimumReleaseAge/)
|
||||
})
|
||||
|
||||
test('the lockfile minimumReleaseAge gate is inert when strict mode is off (default-value semantics)', async () => {
|
||||
prepareEmpty()
|
||||
|
||||
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
|
||||
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
|
||||
|
||||
// Without explicit strict mode — the same shape as the CLI built-in default
|
||||
// (1-day release-age window applied without `minimumReleaseAge` being set in
|
||||
// .npmrc) — the revalidation pass stays inert and the locked version
|
||||
// installs cleanly.
|
||||
await expect(
|
||||
install(manifest, testDefaults({ minimumReleaseAge }))
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from '@jest/globals'
|
||||
import type { LockfileObject } from '@pnpm/lockfile.fs'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
|
||||
import { verifyLockfileResolutions } from '../../src/install/verifyLockfileResolutions.js'
|
||||
|
||||
function makeLockfile (packages: Record<string, { resolution: unknown, version?: string }>): LockfileObject {
|
||||
return {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {},
|
||||
packages: packages as LockfileObject['packages'],
|
||||
} as LockfileObject
|
||||
}
|
||||
|
||||
const tarballResolution = (integrity: string = 'sha512-deadbeef') => ({ integrity, tarball: '' })
|
||||
|
||||
const okVerifier: ResolutionVerifier = async () => ({ ok: true })
|
||||
|
||||
test('no-op when verifyResolution is undefined', async () => {
|
||||
const lockfile = makeLockfile({
|
||||
'fresh@1.0.0': { resolution: tarballResolution() },
|
||||
})
|
||||
await expect(verifyLockfileResolutions(lockfile, undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('no-op when lockfile has no packages', async () => {
|
||||
const lockfile = makeLockfile({})
|
||||
await expect(verifyLockfileResolutions(lockfile, okVerifier)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('passes when every entry is verified ok', async () => {
|
||||
const lockfile = makeLockfile({
|
||||
'lodash@4.17.21': { resolution: tarballResolution() },
|
||||
'is-odd@0.1.0': { resolution: tarballResolution() },
|
||||
})
|
||||
await expect(verifyLockfileResolutions(lockfile, okVerifier)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('throws with the verifier-supplied code and reason on a single failure', async () => {
|
||||
const lockfile = makeLockfile({
|
||||
'is-odd@0.1.2': { resolution: tarballResolution() },
|
||||
})
|
||||
const verifier: ResolutionVerifier = async () => ({
|
||||
ok: false,
|
||||
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
|
||||
reason: 'was published yesterday',
|
||||
})
|
||||
|
||||
await expect(verifyLockfileResolutions(lockfile, verifier)).rejects.toMatchObject({
|
||||
code: 'ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION',
|
||||
message: expect.stringMatching(/is-odd@0\.1\.2 was published yesterday/),
|
||||
})
|
||||
})
|
||||
|
||||
test('lists violations in stable order across multiple failures', async () => {
|
||||
const lockfile = makeLockfile({
|
||||
'fresh-b@2.0.0': { resolution: tarballResolution('sha512-b') },
|
||||
'fresh-a@1.0.0': { resolution: tarballResolution('sha512-a') },
|
||||
})
|
||||
const verifier: ResolutionVerifier = async (_, { name, version }) => ({
|
||||
ok: false,
|
||||
code: 'POLICY_X',
|
||||
reason: `${name}@${version} failed`,
|
||||
})
|
||||
|
||||
await expect(verifyLockfileResolutions(lockfile, verifier))
|
||||
.rejects.toThrow(/fresh-a@1\.0\.0[\s\S]*fresh-b@2\.0\.0/)
|
||||
})
|
||||
|
||||
test('caps printed violations at 20 with an "…and N more" summary', async () => {
|
||||
const packages: Record<string, { resolution: unknown }> = {}
|
||||
for (let i = 0; i < 25; i++) {
|
||||
packages[`pkg-${String(i).padStart(2, '0')}@1.0.0`] = {
|
||||
resolution: tarballResolution(`sha512-${i}`),
|
||||
}
|
||||
}
|
||||
const lockfile = makeLockfile(packages)
|
||||
const verifier: ResolutionVerifier = async (_, { name, version }) => ({
|
||||
ok: false,
|
||||
code: 'POLICY_X',
|
||||
reason: `${name}@${version}`,
|
||||
})
|
||||
|
||||
await expect(verifyLockfileResolutions(lockfile, verifier))
|
||||
.rejects.toThrow(/25 lockfile entries failed verification[\s\S]*…and 5 more/)
|
||||
})
|
||||
|
||||
test('dedupes peer/patch-suffix variants and invokes the verifier once per (name, version)', async () => {
|
||||
const lockfile = makeLockfile({
|
||||
'react@18.0.0': { resolution: tarballResolution('sha512-a') },
|
||||
'react@18.0.0(peer-x)': { resolution: tarballResolution('sha512-a') },
|
||||
'react@18.0.0(patch_hash=abc)(peer-x)': { resolution: tarballResolution('sha512-a') },
|
||||
})
|
||||
const seen: Array<{ name: string, version: string }> = []
|
||||
const verifier: ResolutionVerifier = async (_, { name, version }) => {
|
||||
seen.push({ name, version })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
await verifyLockfileResolutions(lockfile, verifier)
|
||||
expect(seen).toEqual([{ name: 'react', version: '18.0.0' }])
|
||||
})
|
||||
|
||||
test('the verifier sees the resolution shape verbatim', async () => {
|
||||
const npmResolution = tarballResolution()
|
||||
const gitResolution = { type: 'git', repo: 'x', commit: 'abc' }
|
||||
const lockfile = makeLockfile({
|
||||
'npm-pkg@1.0.0': { resolution: npmResolution },
|
||||
'git-pkg@1.0.0': { resolution: gitResolution },
|
||||
})
|
||||
const received: unknown[] = []
|
||||
const verifier: ResolutionVerifier = async (resolution) => {
|
||||
received.push(resolution)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
await verifyLockfileResolutions(lockfile, verifier)
|
||||
expect(received).toEqual(expect.arrayContaining([npmResolution, gitResolution]))
|
||||
})
|
||||
|
||||
test('uses the first violation\'s code when multiple verifiers fire', async () => {
|
||||
const lockfile = makeLockfile({
|
||||
'a@1.0.0': { resolution: tarballResolution('sha512-a') },
|
||||
'b@1.0.0': { resolution: tarballResolution('sha512-b') },
|
||||
})
|
||||
const verifier: ResolutionVerifier = async (_, { name }) => ({
|
||||
ok: false,
|
||||
code: name === 'a' ? 'POLICY_A' : 'POLICY_B',
|
||||
reason: 'failed',
|
||||
})
|
||||
|
||||
await expect(verifyLockfileResolutions(lockfile, verifier)).rejects.toMatchObject({
|
||||
code: 'ERR_PNPM_POLICY_A',
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CustomResolver } from '@pnpm/hooks.types'
|
||||
import type { InstallOptions } from '@pnpm/installing.deps-installer'
|
||||
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
import type { StoreController } from '@pnpm/store.controller-types'
|
||||
import { createTempStore } from '@pnpm/testing.temp-store'
|
||||
import type { Registries } from '@pnpm/types'
|
||||
@@ -14,6 +15,9 @@ export function testDefaults<T> (
|
||||
prefix?: string
|
||||
registries?: Registries
|
||||
customResolvers?: CustomResolver[]
|
||||
minimumReleaseAge?: number
|
||||
minimumReleaseAgeStrict?: boolean
|
||||
minimumReleaseAgeExclude?: string[]
|
||||
},
|
||||
resolveOpts?: any, // eslint-disable-line
|
||||
fetchOpts?: any, // eslint-disable-line
|
||||
@@ -24,13 +28,23 @@ export function testDefaults<T> (
|
||||
registries: Registries
|
||||
storeController: StoreController
|
||||
storeDir: string
|
||||
verifyResolution?: ResolutionVerifier
|
||||
} &
|
||||
T {
|
||||
const { storeController, storeDir, cacheDir } = createTempStore({
|
||||
// Forward minimumReleaseAge policy into the Client so it builds the
|
||||
// matching ResolutionVerifier; tests that set these options exercise the
|
||||
// same code path the CLI command would.
|
||||
const policyClientOptions = {
|
||||
...(opts?.minimumReleaseAge != null ? { minimumReleaseAge: opts.minimumReleaseAge } : {}),
|
||||
...(opts?.minimumReleaseAgeStrict != null ? { minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict } : {}),
|
||||
...(opts?.minimumReleaseAgeExclude != null ? { minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude } : {}),
|
||||
}
|
||||
const { storeController, storeDir, cacheDir, verifyResolution } = createTempStore({
|
||||
...opts,
|
||||
clientOptions: {
|
||||
...(opts?.registries != null ? { registries: opts.registries } : {}),
|
||||
customResolvers: opts?.customResolvers,
|
||||
...policyClientOptions,
|
||||
...resolveOpts,
|
||||
...fetchOpts,
|
||||
},
|
||||
@@ -43,6 +57,7 @@ export function testDefaults<T> (
|
||||
},
|
||||
storeController,
|
||||
storeDir,
|
||||
verifyResolution,
|
||||
...opts,
|
||||
} as (
|
||||
InstallOptions &
|
||||
@@ -51,6 +66,7 @@ export function testDefaults<T> (
|
||||
registries: Registries
|
||||
storeController: StoreController
|
||||
storeDir: string
|
||||
verifyResolution?: ResolutionVerifier
|
||||
} &
|
||||
T
|
||||
)
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -8400,6 +8400,9 @@ importers:
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/network.auth-header':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/auth-header
|
||||
'@pnpm/resolving.git-resolver':
|
||||
specifier: workspace:*
|
||||
version: link:../git-resolver
|
||||
@@ -8539,6 +8542,9 @@ importers:
|
||||
'@pnpm/config.pick-registry-for-package':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/pick-registry-for-package
|
||||
'@pnpm/config.version-policy':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/version-policy
|
||||
'@pnpm/constants':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/constants
|
||||
@@ -8633,9 +8639,6 @@ importers:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
version: 30.3.0
|
||||
'@pnpm/config.version-policy':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/version-policy
|
||||
'@pnpm/logger':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/logger
|
||||
@@ -8986,6 +8989,9 @@ importers:
|
||||
'@pnpm/installing.client':
|
||||
specifier: workspace:*
|
||||
version: link:../../installing/client
|
||||
'@pnpm/resolving.resolver-base':
|
||||
specifier: workspace:*
|
||||
version: link:../../resolving/resolver-base
|
||||
'@pnpm/store.controller':
|
||||
specifier: workspace:*
|
||||
version: link:../controller
|
||||
@@ -9271,6 +9277,9 @@ importers:
|
||||
'@pnpm/registry-mock':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.0(encoding@0.1.13)(verdaccio@6.3.2(encoding@0.1.13)(typanion@3.14.0))
|
||||
'@pnpm/resolving.resolver-base':
|
||||
specifier: workspace:*
|
||||
version: link:../../resolving/resolver-base
|
||||
'@pnpm/store.controller':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/controller
|
||||
|
||||
96
pnpm/test/install/minimumReleaseAge.ts
Normal file
96
pnpm/test/install/minimumReleaseAge.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from '@jest/globals'
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
import { writeYamlFileSync } from 'write-yaml-file'
|
||||
|
||||
import { execPnpm, execPnpmSync } from '../utils/index.js'
|
||||
|
||||
// The public npm registry is used here instead of verdaccio because the
|
||||
// registry mock doesn't include the per-version `time` field in full-metadata
|
||||
// responses, which the lockfile verifier needs to evaluate the cutoff.
|
||||
// This mirrors the workaround in `pnpm/test/dlx.ts`.
|
||||
const PUBLIC_REGISTRY = '--config.registry=https://registry.npmjs.org/'
|
||||
|
||||
// `is-odd@0.1.2` was published in 2017. Setting an extreme minimumReleaseAge
|
||||
// (~27 years) ensures every locked version is "immature" relative to the
|
||||
// cutoff — the verifier rejects the entry regardless of when the test runs.
|
||||
const IMMATURE_FOR_EVERYTHING = 60 * 24 * 365 * 27
|
||||
|
||||
// execPnpm's createEnv defaults pnpm_config_minimum_release_age to '0',
|
||||
// which overrides anything in pnpm-workspace.yaml. Tests that need the
|
||||
// yaml policy to take effect must omit this default — same workaround
|
||||
// dlx.ts uses for its minimumReleaseAge tests.
|
||||
const omitMinReleaseAgeEnv = { omitEnvDefaults: ['pnpm_config_minimum_release_age' as const] }
|
||||
|
||||
describe('lockfile minimumReleaseAge verification', () => {
|
||||
test('install rejects a lockfile entry that does not satisfy the policy in strict mode', async () => {
|
||||
// Step 1: populate a lockfile under no policy. The resolver picks
|
||||
// is-odd@0.1.2 (latest 0.1.x) without applying any maturity filter.
|
||||
prepare({
|
||||
dependencies: { 'is-odd': '0.1.2' },
|
||||
})
|
||||
await execPnpm([PUBLIC_REGISTRY, 'install'])
|
||||
|
||||
// Step 2: turn on minimumReleaseAge in strict mode. The lockfile is now
|
||||
// "poisoned" relative to the new policy — exactly the scenario the
|
||||
// verifier exists to catch (a teammate committed a lockfile that
|
||||
// bypassed the policy locally, a CI cache restored a stale lockfile,
|
||||
// etc.).
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
|
||||
minimumReleaseAgeStrict: true,
|
||||
})
|
||||
|
||||
const result = execPnpmSync(
|
||||
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
|
||||
omitMinReleaseAgeEnv
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
const output = `${result.stdout.toString()}\n${result.stderr.toString()}`
|
||||
expect(output).toContain('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION')
|
||||
expect(output).toMatch(/is-odd@0\.1\.2/)
|
||||
// Confirm the recovery hint reaches the user.
|
||||
expect(output).toContain('pnpm clean --lockfile')
|
||||
})
|
||||
|
||||
test('install respects minimumReleaseAgeExclude during lockfile verification', () => {
|
||||
prepare({
|
||||
dependencies: { 'is-odd': '0.1.2' },
|
||||
})
|
||||
execPnpmSync([PUBLIC_REGISTRY, 'install'], { expectSuccess: true })
|
||||
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
|
||||
minimumReleaseAgeStrict: true,
|
||||
// is-odd@0.1.2 pulls in is-buffer, is-number, and kind-of transitively;
|
||||
// all four are immature in this test, so all four need exclusion.
|
||||
minimumReleaseAgeExclude: ['is-odd', 'is-buffer', 'is-number', 'kind-of'],
|
||||
})
|
||||
|
||||
execPnpmSync(
|
||||
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
|
||||
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
|
||||
)
|
||||
})
|
||||
|
||||
test('install is unaffected by minimumReleaseAge when strict mode is explicitly off', () => {
|
||||
// The config reader auto-enables strict mode the moment a user
|
||||
// explicitly sets `minimumReleaseAge`, so opting out requires an
|
||||
// explicit `minimumReleaseAgeStrict: false`. With that, the verifier
|
||||
// doesn't construct and the lockfile passes through untouched.
|
||||
prepare({
|
||||
dependencies: { 'is-odd': '0.1.2' },
|
||||
})
|
||||
execPnpmSync([PUBLIC_REGISTRY, 'install'], { expectSuccess: true })
|
||||
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
|
||||
minimumReleaseAgeStrict: false,
|
||||
})
|
||||
|
||||
execPnpmSync(
|
||||
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
|
||||
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/fetching.types": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/network.auth-header": "workspace:*",
|
||||
"@pnpm/resolving.git-resolver": "workspace:*",
|
||||
"@pnpm/resolving.local-resolver": "workspace:*",
|
||||
"@pnpm/resolving.npm-resolver": "workspace:*",
|
||||
|
||||
@@ -4,9 +4,12 @@ import { type NodeRuntimeResolveResult, resolveNodeRuntime } from '@pnpm/engine.
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { FetchFromRegistry, GetAuthHeader } from '@pnpm/fetching.types'
|
||||
import { checkCustomResolverCanResolve, type CustomResolver } from '@pnpm/hooks.types'
|
||||
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
|
||||
import { createGitResolver, type GitResolveResult } from '@pnpm/resolving.git-resolver'
|
||||
import { type LocalResolveResult, resolveFromLocalPath, resolveFromLocalScheme } from '@pnpm/resolving.local-resolver'
|
||||
import {
|
||||
createNpmResolutionVerifier,
|
||||
type CreateNpmResolutionVerifierOptions,
|
||||
createNpmResolver,
|
||||
type JsrResolveResult,
|
||||
type NamedRegistryResolveResult,
|
||||
@@ -18,12 +21,14 @@ import {
|
||||
type WorkspaceResolveResult,
|
||||
} from '@pnpm/resolving.npm-resolver'
|
||||
import type {
|
||||
ResolutionVerifier,
|
||||
ResolveFunction,
|
||||
ResolveOptions,
|
||||
ResolveResult,
|
||||
WantedDependency,
|
||||
} from '@pnpm/resolving.resolver-base'
|
||||
import { resolveFromTarball, type TarballResolveResult } from '@pnpm/resolving.tarball-resolver'
|
||||
import type { RegistryConfig } from '@pnpm/types'
|
||||
|
||||
export type {
|
||||
PackageMeta,
|
||||
@@ -139,3 +144,57 @@ export function createResolver (
|
||||
clearCache,
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolutionVerifierFactoryOptions =
|
||||
& Pick<ResolverFactoryOptions, 'cacheDir' | 'registries' | 'namedRegistries' | 'retry' | 'timeout' | 'fetchWarnTimeoutMs'>
|
||||
& Pick<CreateNpmResolutionVerifierOptions,
|
||||
| 'minimumReleaseAge'
|
||||
| 'minimumReleaseAgeStrict'
|
||||
| 'minimumReleaseAgeExclude'
|
||||
| 'now'
|
||||
> & {
|
||||
configByUri?: Record<string, RegistryConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion to {@link createResolver}. Combines the resolver-specific
|
||||
* verifier factories (today: npm) into a single {@link ResolutionVerifier},
|
||||
* dispatching by resolution shape. Returns `undefined` when none of the
|
||||
* underlying resolvers have any active policy — letting callers cheaply
|
||||
* decide whether to iterate at all.
|
||||
*/
|
||||
export function createResolutionVerifier (
|
||||
fetchFromRegistry: FetchFromRegistry,
|
||||
opts: ResolutionVerifierFactoryOptions
|
||||
): ResolutionVerifier | undefined {
|
||||
const fetchOpts = {
|
||||
fetch: fetchFromRegistry,
|
||||
retry: opts.retry ?? {},
|
||||
timeout: opts.timeout ?? 60_000,
|
||||
fetchWarnTimeoutMs: opts.fetchWarnTimeoutMs ?? 10_000,
|
||||
}
|
||||
const getAuthHeaderValueByURI = createGetAuthHeaderByURI(opts.configByUri ?? {}, opts.registries.default)
|
||||
const npmVerifier = createNpmResolutionVerifier({
|
||||
minimumReleaseAge: opts.minimumReleaseAge,
|
||||
minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict,
|
||||
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
|
||||
registries: opts.registries,
|
||||
namedRegistries: opts.namedRegistries,
|
||||
fetchOpts,
|
||||
getAuthHeaderValueByURI,
|
||||
cacheDir: opts.cacheDir,
|
||||
now: opts.now,
|
||||
})
|
||||
// Future protocols (jsr, git, etc.) plug in here. When every sub-verifier
|
||||
// is undefined, the combined verifier is too — caller short-circuits.
|
||||
//
|
||||
// When a second verifier lands, this combinator needs to dispatch by
|
||||
// resolution shape (so e.g. a git verifier doesn't run on npm-registry
|
||||
// entries and vice versa). The classification logic should live as a
|
||||
// shared helper in `@pnpm/resolving.resolver-base` — `pickFetcher` in
|
||||
// `fetching/pick-fetcher` already classifies the same shape today
|
||||
// (resolution.type / tarball / gitHosted / integrity); reconcile both
|
||||
// call sites onto one classifier rather than re-deriving it per verifier.
|
||||
if (!npmVerifier) return undefined
|
||||
return async (resolution, ctx) => npmVerifier(resolution, ctx)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../network/auth-header"
|
||||
},
|
||||
{
|
||||
"path": "../../network/fetch"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/config.pick-registry-for-package": "workspace:*",
|
||||
"@pnpm/config.version-policy": "workspace:*",
|
||||
"@pnpm/constants": "workspace:*",
|
||||
"@pnpm/core-loggers": "workspace:*",
|
||||
"@pnpm/crypto.hash": "workspace:*",
|
||||
@@ -70,7 +71,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/config.version-policy": "workspace:*",
|
||||
"@pnpm/logger": "workspace:*",
|
||||
"@pnpm/network.fetch": "workspace:*",
|
||||
"@pnpm/resolving.npm-resolver": "workspace:*",
|
||||
|
||||
228
resolving/npm-resolver/src/createNpmResolutionVerifier.ts
Normal file
228
resolving/npm-resolver/src/createNpmResolutionVerifier.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
|
||||
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type {
|
||||
Resolution,
|
||||
ResolutionVerifier,
|
||||
} from '@pnpm/resolving.resolver-base'
|
||||
import type { PackageVersionPolicy, Registries } from '@pnpm/types'
|
||||
import semver from 'semver'
|
||||
|
||||
import type { FetchMetadataFromFromRegistryOptions } from './fetch.js'
|
||||
import { fetchFullMetadataCached, type FetchFullMetadataCachedOptions } from './fetchFullMetadataCached.js'
|
||||
import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js'
|
||||
|
||||
export interface CreateNpmResolutionVerifierOptions {
|
||||
/**
|
||||
* Minimum age (in minutes) a published version must reach before it is
|
||||
* accepted. When unset, the verifier is a no-op for the age check.
|
||||
*/
|
||||
minimumReleaseAge?: number
|
||||
/**
|
||||
* Gate the age check on strict mode so the built-in default doesn't
|
||||
* silently enforce for users who never opted in. The verifier factory
|
||||
* returns `undefined` unless both `minimumReleaseAge > 0` and
|
||||
* `minimumReleaseAgeStrict` are set.
|
||||
*/
|
||||
minimumReleaseAgeStrict?: boolean
|
||||
minimumReleaseAgeExclude?: string[]
|
||||
registries: Registries
|
||||
/**
|
||||
* Registries reached via the named-registry resolver chain (e.g. `gh:` →
|
||||
* GitHub Packages). When a lockfile entry's tarball URL falls under one of
|
||||
* these registry base URLs, route the manifest fetch there instead of the
|
||||
* scope-derived default.
|
||||
*/
|
||||
namedRegistries?: Record<string, string>
|
||||
/**
|
||||
* Cache-aware full-metadata fetcher. Decoupled from the resolver pipeline
|
||||
* so abbreviated metadata and `peekManifestFromStore` fast paths cannot
|
||||
* hide the publish timestamp.
|
||||
*/
|
||||
fetchOpts: FetchMetadataFromFromRegistryOptions
|
||||
getAuthHeaderValueByURI: (registry: string) => string | undefined
|
||||
cacheDir?: FetchFullMetadataCachedOptions['cacheDir']
|
||||
/** Overrides Date.now() for tests. */
|
||||
now?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `ResolutionVerifier` that re-applies the `minimumReleaseAge`
|
||||
* policy to npm-registry-resolved lockfile entries, or `undefined` when no
|
||||
* policy is active. Pairs with `createNpmResolver`: each resolver factory
|
||||
* may export a sibling verifier factory that the default-resolver combines.
|
||||
*
|
||||
* Designed for fail-closed semantics: if the manifest can't be loaded or
|
||||
* the pinned version is missing from it, the verifier reports a violation
|
||||
* rather than silently passing. Mirrors the post-resolution gate bun added
|
||||
* for the same shape of bug in oven-sh/bun#30526.
|
||||
*/
|
||||
export function createNpmResolutionVerifier (
|
||||
opts: CreateNpmResolutionVerifierOptions
|
||||
): ResolutionVerifier | undefined {
|
||||
if (!opts.minimumReleaseAge || !opts.minimumReleaseAgeStrict) return undefined
|
||||
|
||||
const cutoff = (opts.now ?? Date.now()) - opts.minimumReleaseAge * 60 * 1000
|
||||
const excludePolicy = opts.minimumReleaseAgeExclude?.length
|
||||
? createExcludePolicy(opts.minimumReleaseAgeExclude)
|
||||
: undefined
|
||||
|
||||
// Pre-normalize named-registry URLs and sort by length so two registries
|
||||
// that share a hostname but differ by path (e.g. `https://npm/team-a/` vs
|
||||
// `https://npm/team-b/`) route to the longest matching prefix — matching
|
||||
// only `origin` would silently send lookups to the wrong one. Built-in
|
||||
// aliases (`gh:` → npm.pkg.github.com, etc.) are merged in alongside the
|
||||
// user-defined ones so the verifier recognizes the same set of named
|
||||
// registries the resolver does; otherwise a package resolved via `gh:`
|
||||
// would land in the lockfile with a tarball URL the verifier can't route.
|
||||
const namedRegistryPrefixes = Object.values({
|
||||
...BUILTIN_NAMED_REGISTRIES,
|
||||
...(opts.namedRegistries ?? {}),
|
||||
})
|
||||
.map((url) => {
|
||||
const parsed = tryParseUrl(url)
|
||||
if (!parsed) return null
|
||||
// Ensure trailing slash so prefix matching against tarball URLs (which
|
||||
// always include the package path under the registry root) does not
|
||||
// accidentally match a sibling registry whose URL shares a prefix string.
|
||||
const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`
|
||||
return `${parsed.origin}${pathname}`
|
||||
})
|
||||
.filter((value): value is string => value != null)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
|
||||
// In-memory dedup of the time map per (registry, name) for this verifier
|
||||
// instance. The on-disk conditional-GET cache is handled inside
|
||||
// fetchFullMetadataCached via the resolver's shared mirror at opts.cacheDir.
|
||||
const inflight = new Map<string, Promise<Record<string, string | undefined> | undefined>>()
|
||||
const fetchTimeMap = async (registry: string, name: string): Promise<Record<string, string | undefined> | undefined> => {
|
||||
const cacheKey = `${registry}\x00${name}`
|
||||
const cached = inflight.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const promise = fetchFullMetadataCached(opts.fetchOpts, name, {
|
||||
registry,
|
||||
authHeaderValue: opts.getAuthHeaderValueByURI(registry),
|
||||
cacheDir: opts.cacheDir,
|
||||
}).then((meta) => meta.time)
|
||||
inflight.set(cacheKey, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
return async (resolution, { name, version }) => {
|
||||
if (!isNpmRegistryResolution(resolution)) return { ok: true }
|
||||
// Non-semver versions identify URL tarballs, file: refs, git refs, etc.
|
||||
// The age policy doesn't apply and a registry lookup would 404.
|
||||
if (!semver.valid(version)) return { ok: true }
|
||||
if (isExcluded(excludePolicy, name, version)) return { ok: true }
|
||||
|
||||
const tarballUrl = (resolution as { tarball?: string }).tarball
|
||||
const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl)
|
||||
let time: Record<string, string | undefined> | undefined
|
||||
try {
|
||||
time = await fetchTimeMap(registry, name)
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
|
||||
reason: uncheckable(err instanceof Error ? err.message : String(err)),
|
||||
}
|
||||
}
|
||||
const published = time?.[version]
|
||||
if (!published) {
|
||||
// Full metadata is missing this version — either an unpublish or the
|
||||
// registry doesn't expose per-version timestamps for it. Either way
|
||||
// the release-age can't be verified, so report a violation rather
|
||||
// than silently passing.
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
|
||||
reason: uncheckable('version not present in registry manifest'),
|
||||
}
|
||||
}
|
||||
const publishedAt = new Date(published)
|
||||
const ts = publishedAt.getTime()
|
||||
if (Number.isNaN(ts)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
|
||||
reason: 'publish timestamp is not a valid date',
|
||||
}
|
||||
}
|
||||
if (ts > cutoff) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
|
||||
reason: `was published at ${publishedAt.toISOString()}, within the minimumReleaseAge cutoff (${new Date(cutoff).toISOString()})`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
|
||||
function pickRegistryForVersion (
|
||||
registries: Registries,
|
||||
namedRegistryPrefixes: string[],
|
||||
name: string,
|
||||
tarballUrl: string | undefined
|
||||
): string {
|
||||
// If the lockfile records where the tarball lives, prefer that — scope
|
||||
// routing (`@scope:registry`) only covers scoped packages, but named
|
||||
// registries (`gh:`, `jsr:` aliases, custom) ship un-scoped packages whose
|
||||
// origin we'd otherwise miss. Match the longest prefix so that two named
|
||||
// registries sharing a host but differing by path don't collide.
|
||||
if (tarballUrl) {
|
||||
const normalized = tryParseUrl(tarballUrl)?.toString()
|
||||
if (normalized) {
|
||||
for (const prefix of namedRegistryPrefixes) {
|
||||
if (normalized.startsWith(prefix)) return prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
return pickRegistryForPackage(registries, name)
|
||||
}
|
||||
|
||||
function tryParseUrl (url: string): URL | null {
|
||||
try {
|
||||
return new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function uncheckable (why: string): string {
|
||||
return `could not be checked against minimumReleaseAge (${why})`
|
||||
}
|
||||
|
||||
function createExcludePolicy (patterns: string[]): PackageVersionPolicy {
|
||||
// Mirror the wrapping done by the full-resolution path
|
||||
// (installing/deps-resolver/src/resolveDependencyTree.ts) so the error
|
||||
// code is identical regardless of which path surfaced the invalid pattern.
|
||||
try {
|
||||
return createPackageVersionPolicy(patterns)
|
||||
} catch (err) {
|
||||
if (!err || typeof err !== 'object' || !('message' in err)) throw err
|
||||
throw new PnpmError(
|
||||
'INVALID_MINIMUM_RELEASE_AGE_EXCLUDE',
|
||||
`Invalid value in minimumReleaseAgeExclude: ${(err as { message: string }).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function isExcluded (policy: PackageVersionPolicy | undefined, name: string, version: string): boolean {
|
||||
if (!policy) return false
|
||||
const result = policy(name)
|
||||
if (result === true) return true
|
||||
if (Array.isArray(result) && result.includes(version)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isNpmRegistryResolution (resolution: Resolution | unknown): boolean {
|
||||
if (resolution == null || typeof resolution !== 'object') return false
|
||||
// Only plain tarball resolutions (npm registry / named registries) have no
|
||||
// `type` field. Git / directory / binary / custom resolutions all carry one.
|
||||
if ('type' in resolution && (resolution as { type?: unknown }).type != null) return false
|
||||
// Git-hosted tarballs (codeload/gitlab/bitbucket) are special-cased in
|
||||
// the resolver and aren't subject to release-age policy.
|
||||
if ('gitHosted' in resolution && (resolution as { gitHosted?: boolean }).gitHosted) return false
|
||||
return 'tarball' in resolution || 'integrity' in resolution
|
||||
}
|
||||
73
resolving/npm-resolver/src/fetchFullMetadataCached.ts
Normal file
73
resolving/npm-resolver/src/fetchFullMetadataCached.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FULL_META_DIR } from '@pnpm/constants'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
|
||||
import { fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions } from './fetch.js'
|
||||
import { getPkgMirrorPath, loadMeta, loadMetaHeaders, prepareJsonForDisk, saveMeta } from './pickPackage.js'
|
||||
|
||||
export interface FetchFullMetadataCachedOptions {
|
||||
registry: string
|
||||
authHeaderValue?: string
|
||||
/**
|
||||
* pnpm's on-disk cache directory. When set, the call issues a conditional
|
||||
* GET against the same `FULL_META_DIR` mirror the resolver populates: a
|
||||
* 304 Not Modified response serves the body from disk, a 200 writes the
|
||||
* new body back. Omit to disable caching — every call re-fetches the
|
||||
* full manifest.
|
||||
*/
|
||||
cacheDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a full registry metadata document for `pkgName`, reusing pnpm's
|
||||
* shared on-disk metadata mirror when `cacheDir` is supplied. Built for the
|
||||
* `minimumReleaseAge` lockfile revalidation gate, which needs the `time`
|
||||
* field that abbreviated metadata omits; the cache reuse keeps repeat
|
||||
* installs from re-downloading the same multi-megabyte document for every
|
||||
* locked package.
|
||||
*/
|
||||
export async function fetchFullMetadataCached (
|
||||
fetchOpts: FetchMetadataFromFromRegistryOptions,
|
||||
pkgName: string,
|
||||
opts: FetchFullMetadataCachedOptions
|
||||
): Promise<PackageMeta> {
|
||||
const pkgMirror = opts.cacheDir != null
|
||||
? getPkgMirrorPath(opts.cacheDir, FULL_META_DIR, opts.registry, pkgName)
|
||||
: null
|
||||
const cacheHeaders = pkgMirror != null ? await loadMetaHeaders(pkgMirror) : null
|
||||
const result = await fetchMetadataFromFromRegistry(fetchOpts, pkgName, {
|
||||
registry: opts.registry,
|
||||
authHeaderValue: opts.authHeaderValue,
|
||||
fullMetadata: true,
|
||||
etag: cacheHeaders?.etag,
|
||||
modified: cacheHeaders?.modified,
|
||||
})
|
||||
if ('notModified' in result && result.notModified) {
|
||||
if (pkgMirror == null) {
|
||||
// We didn't send conditional headers (no cacheDir), but the registry
|
||||
// returned 304 anyway. There's no body to fall back on.
|
||||
throw new PnpmError(
|
||||
'META_NOT_MODIFIED_WITHOUT_CACHE',
|
||||
`Registry returned 304 for ${pkgName} without an existing cache to refresh.`
|
||||
)
|
||||
}
|
||||
const meta = await loadMeta(pkgMirror)
|
||||
if (meta == null) {
|
||||
// Cache file vanished between header-load and meta-load (concurrent
|
||||
// store cleanup, antivirus, etc.).
|
||||
throw new PnpmError(
|
||||
'META_CACHE_MISSING_AFTER_304',
|
||||
`Metadata cache for ${pkgName} disappeared between headers read and full read.`
|
||||
)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
if (pkgMirror != null) {
|
||||
// Persist so the next install can do a headers-only conditional GET.
|
||||
// Fire-and-forget — a cache-write failure isn't a reason to fail the
|
||||
// caller; the next install just won't get the speedup.
|
||||
const json = prepareJsonForDisk(result.meta, result.etag, result.jsonText)
|
||||
saveMeta(pkgMirror, json).catch(() => {})
|
||||
}
|
||||
return result.meta
|
||||
}
|
||||
@@ -129,6 +129,7 @@ export {
|
||||
RegistryResponseError,
|
||||
workspacePrefToNpm,
|
||||
}
|
||||
export { createNpmResolutionVerifier, type CreateNpmResolutionVerifierOptions } from './createNpmResolutionVerifier.js'
|
||||
export { whichVersionIsPinned } from './whichVersionIsPinned.js'
|
||||
|
||||
export interface ResolverFactoryOptions {
|
||||
|
||||
@@ -206,8 +206,7 @@ export async function pickPackage (
|
||||
: ABBREVIATED_META_DIR
|
||||
// Cache key includes fullMetadata to avoid returning abbreviated metadata when full metadata is requested.
|
||||
const cacheKey = fullMetadata ? `${spec.name}:full` : spec.name
|
||||
const registryName = getRegistryName(opts.registry)
|
||||
const pkgMirror = path.join(ctx.cacheDir, metaDir, registryName, `${encodePkgName(spec.name)}.jsonl`)
|
||||
const pkgMirror = getPkgMirrorPath(ctx.cacheDir, metaDir, opts.registry, spec.name)
|
||||
const cachedMeta = ctx.metaCache.get(cacheKey)
|
||||
if (cachedMeta != null) {
|
||||
// The in-memory cache may hold abbreviated metadata from an earlier call
|
||||
@@ -564,19 +563,27 @@ function clearMeta (pkg: PackageMeta): PackageMeta {
|
||||
}
|
||||
}
|
||||
|
||||
function encodePkgName (pkgName: string): string {
|
||||
export function encodePkgName (pkgName: string): string {
|
||||
if (pkgName !== pkgName.toLowerCase()) {
|
||||
return `${pkgName}_${createHexHash(pkgName)}`
|
||||
}
|
||||
return pkgName
|
||||
}
|
||||
|
||||
/**
|
||||
* Path of the on-disk JSONL document where pnpm mirrors a package's registry
|
||||
* metadata. `metaDir` selects between abbreviated and full caches.
|
||||
*/
|
||||
export function getPkgMirrorPath (cacheDir: string, metaDir: string, registry: string, pkgName: string): string {
|
||||
return path.join(cacheDir, metaDir, getRegistryName(registry), `${encodePkgName(pkgName)}.jsonl`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats metadata for disk storage as two-line NDJSON:
|
||||
* Line 1: cache headers (etag, modified) — small, fast to read
|
||||
* Line 2: the full registry metadata JSON — unchanged from the registry response
|
||||
*/
|
||||
function prepareJsonForDisk (meta: PackageMeta, etag: string | undefined, jsonText?: string): string {
|
||||
export function prepareJsonForDisk (meta: PackageMeta, etag: string | undefined, jsonText?: string): string {
|
||||
const modified = meta.modified ?? meta.time?.modified
|
||||
const headers = JSON.stringify({ etag, modified })
|
||||
const body = jsonText ?? JSON.stringify(meta)
|
||||
@@ -628,7 +635,7 @@ interface MetaHeaders {
|
||||
* parsing the full metadata (which can be megabytes for popular packages)
|
||||
* when we only need conditional-request headers.
|
||||
*/
|
||||
async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null> {
|
||||
export async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null> {
|
||||
let fh: fs.FileHandle | undefined
|
||||
try {
|
||||
fh = await fs.open(pkgMirror, 'r')
|
||||
@@ -652,7 +659,7 @@ async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null>
|
||||
* Line 1: cache headers (etag, modified)
|
||||
* Line 2: registry metadata JSON
|
||||
*/
|
||||
async function loadMeta (pkgMirror: string): Promise<PackageMeta | null> {
|
||||
export async function loadMeta (pkgMirror: string): Promise<PackageMeta | null> {
|
||||
try {
|
||||
const data = await gfs.readFile(pkgMirror, 'utf8')
|
||||
const newlineIdx = data.indexOf('\n')
|
||||
@@ -668,7 +675,7 @@ async function loadMeta (pkgMirror: string): Promise<PackageMeta | null> {
|
||||
|
||||
const createdDirs = new Set<string>()
|
||||
|
||||
async function saveMeta (pkgMirror: string, json: string): Promise<void> {
|
||||
export async function saveMeta (pkgMirror: string, json: string): Promise<void> {
|
||||
const dir = path.dirname(pkgMirror)
|
||||
if (!createdDirs.has(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
@@ -82,6 +82,31 @@ export interface VariationsResolution {
|
||||
|
||||
export type Resolution = AtomicResolution | VariationsResolution
|
||||
|
||||
/**
|
||||
* Outcome of asking a `ResolutionVerifier` whether a (name, version,
|
||||
* resolution) entry from a lockfile is acceptable under whatever policies
|
||||
* the resolver chain has been configured with. Resolvers that don't have
|
||||
* an opinion on a given resolution should return `{ ok: true }`.
|
||||
*/
|
||||
export type ResolutionVerification =
|
||||
| { ok: true }
|
||||
| { ok: false, code: string, reason: string }
|
||||
|
||||
/**
|
||||
* Optional companion to a resolver factory. Lets each resolver enforce
|
||||
* policies (e.g. minimumReleaseAge for npm) against an already-resolved
|
||||
* entry from a lockfile without re-doing resolution.
|
||||
*
|
||||
* The verifier inspects the `resolution` shape to decide whether the entry
|
||||
* is within its protocol; for entries outside its protocol it should
|
||||
* return `{ ok: true }`. Combined verifiers (in default-resolver) dispatch
|
||||
* across underlying resolver-specific verifiers.
|
||||
*/
|
||||
export type ResolutionVerifier = (
|
||||
resolution: Resolution,
|
||||
ctx: { name: string, version: string }
|
||||
) => Promise<ResolutionVerification>
|
||||
|
||||
/** Concrete platform selector used when picking a variant from a VariationsResolution. */
|
||||
export interface PlatformSelector {
|
||||
os: string
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@pnpm/cli.meta": "workspace:*",
|
||||
"@pnpm/config.reader": "workspace:*",
|
||||
"@pnpm/installing.client": "workspace:*",
|
||||
"@pnpm/resolving.resolver-base": "workspace:*",
|
||||
"@pnpm/store.controller": "workspace:*",
|
||||
"@pnpm/store.index": "workspace:*",
|
||||
"@pnpm/store.path": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs'
|
||||
import { packageManager } from '@pnpm/cli.meta'
|
||||
import type { Config, ConfigContext } from '@pnpm/config.reader'
|
||||
import { type ClientOptions, createClient } from '@pnpm/installing.client'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
import { type CafsLocker, createPackageStore, type StoreController } from '@pnpm/store.controller'
|
||||
import { StoreIndex } from '@pnpm/store.index'
|
||||
|
||||
@@ -34,6 +35,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
|
||||
| 'localAddress'
|
||||
| 'maxSockets'
|
||||
| 'minimumReleaseAge'
|
||||
| 'minimumReleaseAgeExclude'
|
||||
| 'minimumReleaseAgeIgnoreMissingTime'
|
||||
| 'minimumReleaseAgeStrict'
|
||||
| 'networkConcurrency'
|
||||
@@ -61,7 +63,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
|
||||
|
||||
export async function createNewStoreController (
|
||||
opts: CreateNewStoreControllerOptions
|
||||
): Promise<{ ctrl: StoreController, dir: string }> {
|
||||
): Promise<{ ctrl: StoreController, dir: string, verifyResolution?: ResolutionVerifier }> {
|
||||
const fullMetadata = opts.fetchFullMetadata ?? (
|
||||
(
|
||||
opts.resolutionMode === 'time-based' ||
|
||||
@@ -70,7 +72,7 @@ export async function createNewStoreController (
|
||||
)
|
||||
await fs.mkdir(opts.storeDir, { recursive: true })
|
||||
const storeIndex = new StoreIndex(opts.storeDir)
|
||||
const { resolve, fetchers, clearResolutionCache } = createClient({
|
||||
const { resolve, fetchers, clearResolutionCache, verifyResolution } = createClient({
|
||||
customResolvers: opts.hooks?.customResolvers,
|
||||
customFetchers: opts.hooks?.customFetchers,
|
||||
unsafePerm: opts.unsafePerm,
|
||||
@@ -115,6 +117,9 @@ export async function createNewStoreController (
|
||||
preserveAbsolutePaths: opts.preserveAbsolutePaths,
|
||||
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
|
||||
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
|
||||
minimumReleaseAge: opts.minimumReleaseAge,
|
||||
minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict,
|
||||
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
|
||||
storeIndex,
|
||||
})
|
||||
return {
|
||||
@@ -140,5 +145,6 @@ export async function createNewStoreController (
|
||||
storeIndex,
|
||||
}),
|
||||
dir: opts.storeDir,
|
||||
verifyResolution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from '@pnpm/config.reader'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
import type { StoreController } from '@pnpm/store.controller'
|
||||
import { getStorePath } from '@pnpm/store.path'
|
||||
|
||||
@@ -13,10 +14,16 @@ export type CreateStoreControllerOptions = Omit<CreateNewStoreControllerOptions,
|
||||
| 'workspaceDir'
|
||||
>
|
||||
|
||||
export interface StoreControllerHandle {
|
||||
ctrl: StoreController
|
||||
dir: string
|
||||
verifyResolution?: ResolutionVerifier
|
||||
}
|
||||
|
||||
export async function createStoreControllerCached (
|
||||
storeControllerCache: Map<string, Promise<{ ctrl: StoreController, dir: string }>>,
|
||||
storeControllerCache: Map<string, Promise<StoreControllerHandle>>,
|
||||
opts: CreateStoreControllerOptions
|
||||
): Promise<{ ctrl: StoreController, dir: string }> {
|
||||
): Promise<StoreControllerHandle> {
|
||||
const storeDir = await getStorePath({
|
||||
pkgRoot: opts.dir,
|
||||
storePath: opts.storeDir,
|
||||
@@ -25,15 +32,12 @@ export async function createStoreControllerCached (
|
||||
if (!storeControllerCache.has(storeDir)) {
|
||||
storeControllerCache.set(storeDir, createStoreController(opts))
|
||||
}
|
||||
return await storeControllerCache.get(storeDir) as { ctrl: StoreController, dir: string }
|
||||
return await storeControllerCache.get(storeDir) as StoreControllerHandle
|
||||
}
|
||||
|
||||
export async function createStoreController (
|
||||
opts: CreateStoreControllerOptions
|
||||
): Promise<{
|
||||
ctrl: StoreController
|
||||
dir: string
|
||||
}> {
|
||||
): Promise<StoreControllerHandle> {
|
||||
const storeDir = await getStorePath({
|
||||
pkgRoot: opts.workspaceDir ?? opts.dir,
|
||||
storePath: opts.storeDir,
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../installing/client"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/resolver-base"
|
||||
},
|
||||
{
|
||||
"path": "../controller"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"dependencies": {
|
||||
"@pnpm/installing.client": "workspace:*",
|
||||
"@pnpm/registry-mock": "catalog:",
|
||||
"@pnpm/resolving.resolver-base": "workspace:*",
|
||||
"@pnpm/store.controller": "workspace:*",
|
||||
"@pnpm/store.controller-types": "workspace:*",
|
||||
"@pnpm/store.index": "workspace:*"
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as path from 'node:path'
|
||||
|
||||
import { type ClientOptions, createClient } from '@pnpm/installing.client'
|
||||
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
|
||||
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
|
||||
import { createPackageStore, type CreatePackageStoreOptions } from '@pnpm/store.controller'
|
||||
import type { StoreController } from '@pnpm/store.controller-types'
|
||||
import { StoreIndex } from '@pnpm/store.index'
|
||||
@@ -12,6 +13,7 @@ export interface CreateTempStoreResult {
|
||||
storeController: StoreController
|
||||
storeDir: string
|
||||
cacheDir: string
|
||||
verifyResolution?: ResolutionVerifier
|
||||
}
|
||||
|
||||
export function createTempStore (opts?: {
|
||||
@@ -24,7 +26,7 @@ export function createTempStore (opts?: {
|
||||
const cacheDir = path.resolve('cache')
|
||||
const storeDir = opts?.storeDir ?? path.resolve('.store')
|
||||
const storeIndex = new StoreIndex(storeDir)
|
||||
const { resolve, fetchers, clearResolutionCache } = createClient({
|
||||
const { resolve, fetchers, clearResolutionCache, verifyResolution } = createClient({
|
||||
configByUri,
|
||||
retry: {
|
||||
retries: 4,
|
||||
@@ -58,5 +60,6 @@ export function createTempStore (opts?: {
|
||||
storeController,
|
||||
storeDir,
|
||||
cacheDir,
|
||||
verifyResolution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
{
|
||||
"path": "../../installing/client"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/resolver-base"
|
||||
},
|
||||
{
|
||||
"path": "../../store/controller"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user