@pnpm/installing.deps-installer 1101.7.0 → 1101.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,7 +58,7 @@ export async function install(manifest, opts) {
58
58
  // When a pnpr server is configured, use server-side resolution
59
59
  // instead of the normal resolution flow.
60
60
  if (opts.pnprServer) {
61
- return installFromPnpmRegistry(manifest, rootDir, opts);
61
+ return installViaPnprServer(manifest, rootDir, opts);
62
62
  }
63
63
  const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules([
64
64
  {
@@ -262,7 +262,7 @@ export async function mutateModules(projects, maybeOpts) {
262
262
  }
263
263
  let ignoredBuilds = result.ignoredBuilds;
264
264
  if (!opts.ignoreScripts && ignoredBuilds?.size) {
265
- ignoredBuilds = await runUnignoredDependencyBuilds(opts, ignoredBuilds, allowBuild);
265
+ ignoredBuilds = await runUnignoredDependencyBuilds(opts, ignoredBuilds, ctx.wantedLockfile, allowBuild);
266
266
  }
267
267
  // Detect packages whose build approval was revoked between the previous
268
268
  // and current install. A package is considered revoked when it was
@@ -277,10 +277,13 @@ export async function mutateModules(projects, maybeOpts) {
277
277
  for (const depPath of Object.keys(ctx.wantedLockfile.packages)) {
278
278
  if (ignoredBuilds?.has(depPath))
279
279
  continue;
280
- const { name, version } = dp.parse(depPath);
281
- if (!name || !version)
280
+ // The old policy is evaluated with identity trust overridden so that
281
+ // package-name approvals count as they did when they were granted,
282
+ // even for git/tarball artifacts that the current policy no longer
283
+ // approves by name.
284
+ if (oldAllowBuild(depPath, { trustPackageIdentity: true }) !== true)
282
285
  continue;
283
- if (oldAllowBuild(name, version) === true && allowBuild?.(name, version) === undefined) {
286
+ if (allowBuild?.(depPath) === undefined) {
284
287
  ignoredBuilds ??= new Set();
285
288
  ignoredBuilds.add(depPath);
286
289
  }
@@ -796,19 +799,17 @@ Note that in CI environments, this setting is enabled by default.`,
796
799
  }
797
800
  }
798
801
  }
799
- async function runUnignoredDependencyBuilds(opts, previousIgnoredBuilds, allowBuild) {
802
+ async function runUnignoredDependencyBuilds(opts, previousIgnoredBuilds, currentLockfile, allowBuild) {
800
803
  if (!allowBuild) {
801
804
  return previousIgnoredBuilds;
802
805
  }
803
806
  const pkgsToBuild = [];
804
807
  for (const ignoredPkg of previousIgnoredBuilds) {
805
- const parsed = dp.parse(ignoredPkg);
806
- if (!parsed.name || !parsed.version)
808
+ if (currentLockfile.packages?.[ignoredPkg] == null)
807
809
  continue;
808
- const allowed = allowBuild(parsed.name, parsed.version);
809
- if (allowed === true) {
810
+ if (allowBuild(ignoredPkg) === true) {
810
811
  // Package is explicitly allowed - rebuild it
811
- pkgsToBuild.push(`${parsed.name}@${parsed.version}`);
812
+ pkgsToBuild.push(dp.getPkgIdWithPatchHash(ignoredPkg));
812
813
  }
813
814
  }
814
815
  if (pkgsToBuild.length) {
@@ -1572,7 +1573,7 @@ export class IgnoredBuildsError extends PnpmError {
1572
1573
  }
1573
1574
  }
1574
1575
  function dedupePackageNamesFromIgnoredBuilds(ignoredBuilds) {
1575
- return Array.from(new Set(Array.from(ignoredBuilds ?? []).map(dp.removeSuffix))).sort(lexCompare);
1576
+ return Array.from(new Set(Array.from(ignoredBuilds ?? []).map(depPath => dp.getPkgIdWithPatchHash(depPath)))).sort(lexCompare);
1576
1577
  }
1577
1578
  /**
1578
1579
  * Build injectionTargetsByDepPath from the dependenciesGraph for injected workspace packages
@@ -1777,10 +1778,10 @@ async function mutateModulesViaPnpr(projects, opts) {
1777
1778
  const pnprProjects = await preparePnprProjects(projects, opts);
1778
1779
  if (!pnprProjects)
1779
1780
  return null;
1780
- // installFromPnpmRegistry runs the headless install for the first
1781
+ // installViaPnprServer runs the headless install for the first
1781
1782
  // project's root and the workspace path for the rest. Pass the
1782
1783
  // pre-processed manifests so resolution sees the post-mutation state.
1783
- const result = await installFromPnpmRegistry(pnprProjects[0].manifest, pnprProjects[0].rootDir, opts, pnprProjects.map((p) => ({ rootDir: p.rootDir, manifest: p.manifest })));
1784
+ const result = await installViaPnprServer(pnprProjects[0].manifest, pnprProjects[0].rootDir, opts, pnprProjects.map((p) => ({ rootDir: p.rootDir, manifest: p.manifest })));
1784
1785
  // For installSome projects, copy resolved specs from the lockfile importer
1785
1786
  // entries back into the client manifest so save-prefix/catalog/etc. take
1786
1787
  // effect (the server applies these during its resolution step).
@@ -1805,11 +1806,11 @@ async function mutateModulesViaPnpr(projects, opts) {
1805
1806
  };
1806
1807
  }
1807
1808
  /**
1808
- * When a pnpr server is configured, resolve dependencies server-side
1809
- * and download only the missing files. Then run a headless install to link
1810
- * packages into node_modules.
1809
+ * When a pnpr server is configured, resolve dependencies server-side,
1810
+ * then run a headless install that fetches tarballs from the registries
1811
+ * and links packages into node_modules — like a normal install.
1811
1812
  */
1812
- async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjects) {
1813
+ async function installViaPnprServer(manifest, rootDir, opts, allInstallProjects) {
1813
1814
  // The pnpr server path skips client-side resolution, so resolver-side policies
1814
1815
  // can't be enforced locally. `minimumReleaseAge` is forwarded to the
1815
1816
  // pnpr server and enforced server-side. `trustPolicy` has no server-side
@@ -1818,20 +1819,14 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1818
1819
  if (opts.trustPolicy === 'no-downgrade') {
1819
1820
  throw new PnpmError('TRUST_POLICY_INCOMPATIBLE_WITH_PNPR', 'The pnpr server does not yet enforce `trustPolicy: no-downgrade`, so running an install through it under this policy would produce a lockfile that the local verifier rejects.', { hint: 'Unset `trustPolicy` for this install, or disable the pnpr server (unset `--pnpr-server` / `pnprServer` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' });
1820
1821
  }
1821
- const { fetchFromPnpmRegistry } = await import('@pnpm/pnpr.client');
1822
+ const { resolveViaPnprServer } = await import('@pnpm/pnpr.client');
1822
1823
  const { createGetAuthHeaderByURI, getAuthHeadersFromCreds } = await import('@pnpm/network.auth-header');
1823
- const { StoreIndex } = await import('@pnpm/store.index');
1824
- const { setImportConcurrency } = await import('@pnpm/worker');
1825
1824
  // Forward the whole credential map (the registries a graph touches
1826
1825
  // aren't known up front), so the server attaches the right token per
1827
1826
  // URL. `authorization` also identifies the caller to pnpr's gate.
1828
1827
  const configByUri = opts.configByUri ?? {};
1829
1828
  const forwardedAuthHeaders = getAuthHeadersFromCreds(configByUri);
1830
1829
  const pnprAuthorization = createGetAuthHeaderByURI(configByUri)(opts.pnprServer);
1831
- // Raise import concurrency for this install only — the pnpr server path has no
1832
- // concurrent fetching competing for workers. Restore afterwards so we
1833
- // don't leak a process-wide mutation to other installs (e.g. tests).
1834
- const restoreImportConcurrency = setImportConcurrency(6);
1835
1830
  try {
1836
1831
  const lockfileDir = opts.lockfileDir ?? rootDir;
1837
1832
  // Read the existing lockfile (if any) in its on-disk shape — that's
@@ -1841,49 +1836,32 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1841
1836
  ignoreIncompatible: true,
1842
1837
  }).catch(() => null);
1843
1838
  logger.info({ message: 'Resolving dependencies via the pnpr server', prefix: rootDir });
1844
- // Open the store index to read integrities and write new entries.
1845
- // Close it in a finally so a failure in fetchFromPnpmRegistry doesn't
1846
- // leak an open SQLite handle (on Windows that also blocks store cleanup).
1847
- const storeIndex = new StoreIndex(opts.storeDir);
1848
- let lockfile, pnprStats, fileDownloads, indexEntries;
1849
- try {
1850
- // Build projects list for workspace support.
1851
- // Normalize separators to POSIX — on Windows `path.relative` returns
1852
- // backslashes, which the pnpr server rejects (it treats `\` as an
1853
- // unsafe/YAML-injection character and normalizes paths as POSIX).
1854
- const projectsList = allInstallProjects && allInstallProjects.length > 1
1855
- ? allInstallProjects.map(p => ({
1856
- dir: (path.relative(lockfileDir, p.rootDir) || '.').split(path.sep).join('/'),
1857
- dependencies: p.manifest.dependencies,
1858
- devDependencies: p.manifest.devDependencies,
1859
- optionalDependencies: p.manifest.optionalDependencies,
1860
- }))
1861
- : undefined;
1862
- ({ lockfile, stats: pnprStats, fileDownloads, indexEntries } = await fetchFromPnpmRegistry({
1863
- registryUrl: opts.pnprServer,
1864
- storeDir: opts.storeDir,
1865
- storeIndex,
1866
- dependencies: projectsList ? undefined : manifest.dependencies,
1867
- devDependencies: projectsList ? undefined : manifest.devDependencies,
1868
- optionalDependencies: projectsList ? undefined : manifest.optionalDependencies,
1869
- projects: projectsList,
1870
- registry: opts.registries?.default,
1871
- namedRegistries: opts.namedRegistries,
1872
- authHeaders: forwardedAuthHeaders,
1873
- authorization: pnprAuthorization,
1874
- overrides: opts.overrides,
1875
- minimumReleaseAge: opts.minimumReleaseAge,
1876
- lockfile: existingLockfile ?? undefined,
1877
- lockfileOnly: opts.lockfileOnly,
1878
- }));
1879
- // Write store index entries so headless install finds them.
1880
- const { writeRawIndexEntries } = await import('@pnpm/pnpr.client');
1881
- writeRawIndexEntries(indexEntries, storeIndex);
1882
- storeIndex.checkpoint();
1883
- }
1884
- finally {
1885
- storeIndex.close();
1886
- }
1839
+ // Build projects list for workspace support.
1840
+ // Normalize separators to POSIX on Windows `path.relative` returns
1841
+ // backslashes, which the pnpr server rejects (it treats `\` as an
1842
+ // unsafe/YAML-injection character and normalizes paths as POSIX).
1843
+ const projectsList = allInstallProjects && allInstallProjects.length > 1
1844
+ ? allInstallProjects.map(p => ({
1845
+ dir: (path.relative(lockfileDir, p.rootDir) || '.').split(path.sep).join('/'),
1846
+ dependencies: p.manifest.dependencies,
1847
+ devDependencies: p.manifest.devDependencies,
1848
+ optionalDependencies: p.manifest.optionalDependencies,
1849
+ }))
1850
+ : undefined;
1851
+ const { lockfile, stats: pnprStats } = await resolveViaPnprServer({
1852
+ registryUrl: opts.pnprServer,
1853
+ dependencies: projectsList ? undefined : manifest.dependencies,
1854
+ devDependencies: projectsList ? undefined : manifest.devDependencies,
1855
+ optionalDependencies: projectsList ? undefined : manifest.optionalDependencies,
1856
+ projects: projectsList,
1857
+ registry: opts.registries?.default,
1858
+ namedRegistries: opts.namedRegistries,
1859
+ authHeaders: forwardedAuthHeaders,
1860
+ authorization: pnprAuthorization,
1861
+ overrides: opts.overrides,
1862
+ minimumReleaseAge: opts.minimumReleaseAge,
1863
+ lockfile: existingLockfile ?? undefined,
1864
+ });
1887
1865
  await writeWantedLockfileAndRecordVerified({
1888
1866
  lockfileDir,
1889
1867
  lockfile,
@@ -1893,17 +1871,13 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1893
1871
  mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
1894
1872
  });
1895
1873
  logger.info({
1896
- message: `Resolved ${pnprStats.totalPackages} packages: ${pnprStats.alreadyInStore} cached, ${pnprStats.filesToDownload} files to download`,
1874
+ message: `Resolved ${pnprStats.totalPackages} packages`,
1897
1875
  prefix: rootDir,
1898
1876
  });
1899
1877
  // `--lockfile-only`: the pnpr server resolved and we wrote the lockfile, but
1900
1878
  // pnpm fetches nothing and links nothing in this mode — stop before the
1901
1879
  // headless install. See https://github.com/pnpm/pnpm/issues/12146.
1902
1880
  if (opts.lockfileOnly) {
1903
- // Nothing is downloaded in this mode, but the lockfile arrives before
1904
- // the stream closes — observe `fileDownloads` so a stream error after
1905
- // the `L` frame doesn't surface as an unhandled rejection.
1906
- void fileDownloads.catch(() => { });
1907
1881
  return {
1908
1882
  updatedCatalogs: undefined,
1909
1883
  updatedManifest: manifest,
@@ -1913,43 +1887,12 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1913
1887
  resolutionPolicyViolations: [],
1914
1888
  };
1915
1889
  }
1916
- // Wrap fetchPackage to:
1917
- // 1. Wait for pnpr server file downloads before checking the store
1918
- // 2. Skip integrity verification files just written from the pnpr server
1919
- // are guaranteed correct (server verified, no rehashing needed)
1920
- const { readPkgFromCafs } = await import('@pnpm/worker');
1921
- const { storeIndexKey: _storeIndexKey } = await import('@pnpm/store.index');
1922
- const wrappedStoreController = {
1923
- ...opts.storeController,
1924
- fetchPackage: async (fetchOpts) => {
1925
- await fileDownloads;
1926
- const resolution = fetchOpts.pkg.resolution;
1927
- const integrity = resolution?.integrity;
1928
- // Fall through to the regular store controller for git-hosted tarballs.
1929
- // Their cached entry lives under gitHostedStoreIndexKey (preserves the
1930
- // built/not-built dimension), not the integrity-keyed path the pnpr server
1931
- // uses for npm tarballs. See @pnpm/store.pkg-finder for the rationale.
1932
- if (integrity && !resolution?.gitHosted) {
1933
- const filesIndexFile = _storeIndexKey(integrity, fetchOpts.pkg.id);
1934
- const result = await readPkgFromCafs({ storeDir: opts.storeDir, verifyStoreIntegrity: false }, filesIndexFile, { readManifest: true, expectedPkg: { name: fetchOpts.pkg.name, version: fetchOpts.pkg.version } });
1935
- return {
1936
- fetching: () => Promise.resolve({
1937
- files: result.files,
1938
- bundledManifest: result.bundledManifest,
1939
- integrity,
1940
- }),
1941
- filesIndexFile,
1942
- };
1943
- }
1944
- return opts.storeController.fetchPackage(fetchOpts);
1945
- },
1946
- };
1890
+ // The pnpr server only resolves; it serves no file content. Fetch every
1891
+ // tarball from the registries with the regular store controller, in
1892
+ // parallel, exactly like a normal install. See
1893
+ // https://github.com/pnpm/pnpm/issues/12230.
1947
1894
  const headlessOpts = {
1948
1895
  ...opts,
1949
- // Skip re-verifying files just written from the pnpr server — they're
1950
- // guaranteed correct (server verified, no rehashing needed).
1951
- verifyStoreIntegrity: false,
1952
- storeController: wrappedStoreController,
1953
1896
  dir: rootDir,
1954
1897
  lockfileDir,
1955
1898
  engineStrict: opts.engineStrict ?? false,
@@ -2006,7 +1949,6 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
2006
1949
  // normal install path does the same; skipping it here would leave
2007
1950
  // pending writes on disk and diverge from lifecycle expectations.
2008
1951
  await opts.storeController.close();
2009
- restoreImportConcurrency();
2010
1952
  }
2011
1953
  }
2012
1954
  //# sourceMappingURL=index.js.map
@@ -328,7 +328,7 @@ async function linkAllPkgs(storeController, depNodes, opts) {
328
328
  depNode.requiresBuild = files.requiresBuild;
329
329
  let sideEffectsCacheKey;
330
330
  if (opts.sideEffectsCacheRead && files.sideEffectsMaps && !isEmpty(files.sideEffectsMaps)) {
331
- if (opts.allowBuild?.(depNode.name, depNode.version) === true) {
331
+ if (opts.allowBuild?.(depNode.depPath) === true) {
332
332
  sideEffectsCacheKey = calcDepState(opts.depGraph, opts.depsStateCache, depNode.depPath, {
333
333
  includeDepGraphHash: !opts.ignoreScripts && depNode.requiresBuild, // true when is built
334
334
  patchFileHash: depNode.patch?.hash,
@@ -1,4 +1,5 @@
1
1
  import { hashObject } from '@pnpm/crypto.object-hasher';
2
+ import { withResolutionShapeCacheIdentity } from './verifyLockfileResolutions.js';
2
3
  import { recordVerification } from './verifyLockfileResolutionsCache.js';
3
4
  /**
4
5
  * Records the post-resolution lockfile as verified so the next install
@@ -17,7 +18,7 @@ export function recordLockfileVerified(opts) {
17
18
  return;
18
19
  recordVerification(opts.cacheDir, {
19
20
  lockfilePath: opts.lockfilePath,
20
- verifiers: opts.resolutionVerifiers,
21
+ verifiers: withResolutionShapeCacheIdentity(opts.resolutionVerifiers),
21
22
  hashLockfile: () => hashObject(opts.lockfile),
22
23
  });
23
24
  }
@@ -1,6 +1,17 @@
1
1
  import type { LockfileObject } from '@pnpm/lockfile.fs';
2
2
  import type { ResolutionPolicyViolation, ResolutionVerifier } from '@pnpm/resolving.resolver-base';
3
+ import { type VerifierCacheIdentity } from './verifyLockfileResolutionsCache.js';
3
4
  export type { ResolutionPolicyViolation };
5
+ export declare const RESOLUTION_SHAPE_MISMATCH_VIOLATION_CODE = "RESOLUTION_SHAPE_MISMATCH";
6
+ /**
7
+ * Every verifier list that flows into the verification cache must carry
8
+ * the resolution-shape identity, so records written before the shape rule
9
+ * existed cannot stat-fast-path around it. Used by the gate itself and by
10
+ * {@link recordLockfileVerified}, whose freshly-resolved lockfile satisfies
11
+ * the shape invariant by construction (the writer derives every key from
12
+ * the resolution it just produced).
13
+ */
14
+ export declare function withResolutionShapeCacheIdentity(verifiers: readonly VerifierCacheIdentity[]): VerifierCacheIdentity[];
4
15
  export interface VerifyLockfileResolutionsOptions {
5
16
  concurrency?: number;
6
17
  /**
@@ -1,17 +1,33 @@
1
1
  import { lockfileVerificationLogger } from '@pnpm/core-loggers';
2
2
  import { hashObject } from '@pnpm/crypto.object-hasher';
3
3
  import { PnpmError } from '@pnpm/error';
4
- import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils';
4
+ import { isGitHostedTarballUrl, nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils';
5
5
  import pLimit from 'p-limit';
6
6
  import { recordVerification, tryLockfileVerificationCache, } from './verifyLockfileResolutionsCache.js';
7
7
  // Cap the per-entry breakdown so a verifier rejecting hundreds of entries
8
8
  // (e.g. a poisoned lockfile) doesn't flood the terminal / CI log; the full
9
9
  // count is in the header and the remainder is summarized at the end.
10
10
  const MAX_VIOLATIONS_TO_PRINT = 20;
11
- // 16 mirrors the floor of pnpm's package-requester network-concurrency
12
- // (Math.min(64, Math.max(workers*3, 16))); keep them aligned so the
11
+ // 64 mirrors the floor of pnpm's package-requester network-concurrency
12
+ // (Math.min(96, Math.max(workers*3, 64))); keep them aligned so the
13
13
  // verification pass doesn't push past what the rest of the install respects.
14
- const DEFAULT_CONCURRENCY = 16;
14
+ const DEFAULT_CONCURRENCY = 64;
15
+ export const RESOLUTION_SHAPE_MISMATCH_VIOLATION_CODE = 'RESOLUTION_SHAPE_MISMATCH';
16
+ const RESOLUTION_SHAPE_CACHE_IDENTITY = {
17
+ policy: { resolutionShapeCheck: true },
18
+ canTrustPastCheck: (cached) => cached.resolutionShapeCheck === true,
19
+ };
20
+ /**
21
+ * Every verifier list that flows into the verification cache must carry
22
+ * the resolution-shape identity, so records written before the shape rule
23
+ * existed cannot stat-fast-path around it. Used by the gate itself and by
24
+ * {@link recordLockfileVerified}, whose freshly-resolved lockfile satisfies
25
+ * the shape invariant by construction (the writer derives every key from
26
+ * the resolution it just produced).
27
+ */
28
+ export function withResolutionShapeCacheIdentity(verifiers) {
29
+ return [...verifiers, RESOLUTION_SHAPE_CACHE_IDENTITY];
30
+ }
15
31
  /**
16
32
  * Policy-neutral pass that asks every resolver-supplied
17
33
  * {@link ResolutionVerifier} to check every entry in a lockfile loaded
@@ -40,8 +56,6 @@ const DEFAULT_CONCURRENCY = 16;
40
56
  * the lookup logic.
41
57
  */
42
58
  export async function verifyLockfileResolutions(lockfile, verifiers, options) {
43
- if (verifiers.length === 0)
44
- return;
45
59
  if (!lockfile.packages)
46
60
  return;
47
61
  // Caching kicks in only when the caller surfaced both a writable
@@ -51,6 +65,7 @@ export async function verifyLockfileResolutions(lockfile, verifiers, options) {
51
65
  const cache = options?.cacheDir && options?.lockfilePath
52
66
  ? { cacheDir: options.cacheDir, lockfilePath: options.lockfilePath }
53
67
  : undefined;
68
+ const cacheVerifiers = withResolutionShapeCacheIdentity(verifiers);
54
69
  let cachePrecomputed;
55
70
  // hashObject streams and is key-order-stable, unlike JSON.stringify.
56
71
  let cachedHash;
@@ -62,26 +77,44 @@ export async function verifyLockfileResolutions(lockfile, verifiers, options) {
62
77
  if (cache) {
63
78
  const result = tryLockfileVerificationCache(cache.cacheDir, {
64
79
  lockfilePath: cache.lockfilePath,
65
- verifiers,
80
+ verifiers: cacheVerifiers,
66
81
  hashLockfile,
67
82
  });
68
- if (result.hit)
83
+ if (result.hit) {
84
+ // A silent short-circuit looks like the policy gate never ran
85
+ // (pnpm/pnpm#12324), so surface the reused verdict — but only
86
+ // when policy verifiers are active; the shape-only run that
87
+ // every install performs stays quiet.
88
+ if (verifiers.length > 0) {
89
+ lockfileVerificationLogger.debug({
90
+ status: 'cached',
91
+ verifiedAt: result.verifiedAt,
92
+ lockfilePath: options?.lockfilePath,
93
+ });
94
+ }
69
95
  return;
96
+ }
70
97
  cachePrecomputed = result.precomputed;
71
98
  }
72
99
  // Emit started/done around the actual verification pass — the
73
100
  // round-trip can be slow on a cold registry cache, and the cached
74
- // short-circuit above doesn't reach this branch, so a user only
75
- // sees these messages on installs that are doing real work.
101
+ // short-circuit above announces itself with its own `cached` event,
102
+ // so a user only sees these messages on installs that are doing
103
+ // real work.
76
104
  // A degenerate lockfile where every snapshot fails the
77
105
  // name/version extraction (so candidates is empty) skips emission
78
106
  // entirely — no work, no noise.
79
- const candidates = collectCandidates(lockfile);
107
+ const { candidates, shapeViolations } = collectCandidates(lockfile);
108
+ if (shapeViolations.length > 0) {
109
+ throw buildVerificationError(shapeViolations);
110
+ }
111
+ if (verifiers.length === 0)
112
+ return;
80
113
  if (candidates.size === 0) {
81
114
  if (cache) {
82
115
  recordVerification(cache.cacheDir, {
83
116
  lockfilePath: cache.lockfilePath,
84
- verifiers,
117
+ verifiers: cacheVerifiers,
85
118
  hashLockfile,
86
119
  }, cachePrecomputed);
87
120
  }
@@ -108,7 +141,7 @@ export async function verifyLockfileResolutions(lockfile, verifiers, options) {
108
141
  if (cache) {
109
142
  recordVerification(cache.cacheDir, {
110
143
  lockfilePath: cache.lockfilePath,
111
- verifiers,
144
+ verifiers: cacheVerifiers,
112
145
  hashLockfile,
113
146
  }, cachePrecomputed);
114
147
  }
@@ -174,7 +207,48 @@ export async function collectResolutionPolicyViolations(lockfile, verifiers, opt
174
207
  return [];
175
208
  if (!lockfile.packages)
176
209
  return [];
177
- return iterateLockfileViolations(collectCandidates(lockfile), verifiers, options?.concurrency);
210
+ // Shape violations are deliberately not collected here: they are hard
211
+ // tampering failures, not policy picks a caller may auto-exclude.
212
+ return iterateLockfileViolations(collectCandidates(lockfile).candidates, verifiers, options?.concurrency);
213
+ }
214
+ function isRegistryShapedResolution(resolution) {
215
+ if (resolution == null)
216
+ return true;
217
+ if (typeof resolution !== 'object')
218
+ return false;
219
+ const { type, gitHosted, tarball, variants } = resolution;
220
+ if (type === 'variations') {
221
+ return Array.isArray(variants) && variants.every((variant) => isRegistryShapedResolution(variant?.resolution));
222
+ }
223
+ // Custom resolver protocols (`type: 'custom:*'`) are a legitimate
224
+ // non-registry source the user opted into. They can only be materialized by
225
+ // a project-configured custom fetcher — an unrecognized custom type throws at
226
+ // fetch time (see @pnpm/fetching.pick-fetcher) — so a forged custom type
227
+ // cannot launder an artifact past this gate into a build.
228
+ if (typeof type === 'string' && type.startsWith('custom:'))
229
+ return true;
230
+ if (type != null)
231
+ return false;
232
+ // Plain tarball / registry resolution. The lockfile is parsed from YAML
233
+ // without schema validation, so the `gitHosted` flag is not trustworthy on
234
+ // its own: a tampered entry could set a non-boolean (dodging a strict
235
+ // `=== true`) or an explicit `false` on a git-host URL (the loader only
236
+ // backfills the flag when absent). Treat any non-boolean flag as git-hosted
237
+ // and gate on the URL so the verdict never depends on the flag alone.
238
+ if (gitHosted != null && (typeof gitHosted !== 'boolean' || gitHosted))
239
+ return false;
240
+ // A registry resolution reconstructs its tarball URL from name+version, so
241
+ // an absent/empty `tarball` is registry-shaped. When a URL is present it
242
+ // must be an http(s) registry artifact: the npm verifier's tarball-URL
243
+ // binding skips non-http(s) schemes (file:, etc.), so a `file:` tarball
244
+ // under a name@semver key would otherwise be trusted with no safety net.
245
+ if (typeof tarball === 'string' && tarball !== '') {
246
+ if (!/^https?:\/\//i.test(tarball))
247
+ return false;
248
+ if (isGitHostedTarballUrl(tarball))
249
+ return false;
250
+ }
251
+ return true;
178
252
  }
179
253
  // depPath can include peer-dependency and patch_hash suffixes (e.g.
180
254
  // `react@18.0.0(peer)(patch_hash=…)`); the same (name, version) pair may
@@ -191,10 +265,25 @@ export async function collectResolutionPolicyViolations(lockfile, verifiers, opt
191
265
  // never checked.
192
266
  function collectCandidates(lockfile) {
193
267
  const candidates = new Map();
268
+ const shapeViolations = [];
194
269
  for (const [depPath, snapshot] of Object.entries(lockfile.packages ?? {})) {
195
270
  const { name, version, nonSemverVersion } = nameVerFromPkgSnapshot(depPath, snapshot);
196
271
  if (!name || !version)
197
272
  continue;
273
+ // A registry-style depPath (name@semver) must be backed by a
274
+ // registry-shaped resolution: the allowBuilds policy derives a
275
+ // trusted package identity from that key shape, which is only sound
276
+ // while this invariant holds. The check is offline, so it applies
277
+ // even when no policy verifiers are active.
278
+ if (nonSemverVersion == null && !isRegistryShapedResolution(snapshot.resolution)) {
279
+ shapeViolations.push({
280
+ name,
281
+ version,
282
+ resolution: snapshot.resolution,
283
+ code: RESOLUTION_SHAPE_MISMATCH_VIOLATION_CODE,
284
+ reason: 'a registry-style dependency path is backed by a non-registry resolution',
285
+ });
286
+ }
198
287
  const key = `${name}@${version}@${nonSemverVersion ?? ''}@${JSON.stringify(snapshot.resolution)}`;
199
288
  candidates.set(key, {
200
289
  name,
@@ -203,7 +292,7 @@ function collectCandidates(lockfile) {
203
292
  resolution: snapshot.resolution,
204
293
  });
205
294
  }
206
- return candidates;
295
+ return { candidates, shapeViolations };
207
296
  }
208
297
  async function iterateLockfileViolations(candidates, verifiers, concurrency) {
209
298
  const violations = [];
@@ -9,6 +9,13 @@ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
9
9
  export type VerifierCacheIdentity = Pick<ResolutionVerifier, 'policy' | 'canTrustPastCheck'>;
10
10
  export interface CacheLookupResult {
11
11
  hit: boolean;
12
+ /**
13
+ * ISO-8601 timestamp of the verification run the hit is reusing.
14
+ * Set only on a hit, and only when the record carries a usable
15
+ * timestamp (records written before the field existed normalize to
16
+ * an empty string and surface as `undefined` here).
17
+ */
18
+ verifiedAt?: string;
12
19
  /**
13
20
  * stat + hash already computed during the lookup. When the caller
14
21
  * follows up with {@link recordVerification} after running the gate,
@@ -159,8 +159,10 @@ export function tryLockfileVerificationCache(cacheDir, key) {
159
159
  // hash without reading the file. Microseconds.
160
160
  const byPathRecord = indexes.byPath.get(key.lockfilePath);
161
161
  if (byPathRecord && statMatches(stat, byPathRecord.lockfile)) {
162
+ const hit = everyVerifierTrustsCachedRun(byPathRecord, key.verifiers);
162
163
  return {
163
- hit: everyVerifierTrustsCachedRun(byPathRecord, key.verifiers),
164
+ hit,
165
+ verifiedAt: hit ? byPathRecord.verifiedAt || undefined : undefined,
164
166
  // The stat-match implies the file content is unchanged since the
165
167
  // cached record was written, so its hash is still correct. Pass
166
168
  // it through to skip hashing on the miss-then-record path.
@@ -190,7 +192,7 @@ export function tryLockfileVerificationCache(cacheDir, key) {
190
192
  ...byHashRecord,
191
193
  lockfile: { ...byHashRecord.lockfile, path: key.lockfilePath, size: stat.size, mtimeNs: stat.mtimeNs, inode: stat.inode },
192
194
  });
193
- return { hit: true, precomputed: { stat, hash } };
195
+ return { hit: true, verifiedAt: byHashRecord.verifiedAt || undefined, precomputed: { stat, hash } };
194
196
  }
195
197
  function everyVerifierTrustsCachedRun(record, verifiers) {
196
198
  for (const verifier of verifiers) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/installing.deps-installer",
3
- "version": "1101.7.0",
3
+ "version": "1101.9.0",
4
4
  "description": "Fast, disk space efficient installation engine",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -65,61 +65,61 @@
65
65
  "ramda": "npm:@pnpm/ramda@0.28.1",
66
66
  "run-groups": "^5.0.0",
67
67
  "semver": "^7.8.1",
68
- "@pnpm/building.after-install": "1101.0.19",
69
- "@pnpm/bins.linker": "1100.0.11",
70
- "@pnpm/building.policy": "1100.0.8",
68
+ "@pnpm/bins.linker": "1100.0.13",
69
+ "@pnpm/building.after-install": "1101.0.21",
70
+ "@pnpm/building.during-install": "1101.0.18",
71
+ "@pnpm/building.policy": "1100.0.9",
71
72
  "@pnpm/catalogs.resolver": "1100.0.0",
72
- "@pnpm/config.matcher": "1100.0.1",
73
- "@pnpm/catalogs.protocol-parser": "1100.0.0",
74
- "@pnpm/building.during-install": "1101.0.16",
75
- "@pnpm/config.normalize-registries": "1100.0.6",
76
- "@pnpm/core-loggers": "1100.1.3",
77
- "@pnpm/bins.remover": "1100.0.7",
78
73
  "@pnpm/catalogs.types": "1100.0.0",
79
- "@pnpm/deps.graph-sequencer": "1100.0.0",
74
+ "@pnpm/catalogs.protocol-parser": "1100.0.0",
75
+ "@pnpm/config.parse-overrides": "1100.0.1",
80
76
  "@pnpm/constants": "1100.0.0",
81
- "@pnpm/crypto.object-hasher": "1100.0.0",
82
- "@pnpm/deps.path": "1100.0.6",
83
- "@pnpm/deps.graph-hasher": "1100.2.3",
77
+ "@pnpm/config.matcher": "1100.0.1",
78
+ "@pnpm/config.normalize-registries": "1100.0.7",
79
+ "@pnpm/core-loggers": "1100.2.0",
80
+ "@pnpm/deps.graph-hasher": "1100.2.4",
81
+ "@pnpm/crypto.hash": "1100.0.1",
82
+ "@pnpm/deps.graph-sequencer": "1100.0.0",
84
83
  "@pnpm/error": "1100.0.0",
85
- "@pnpm/config.parse-overrides": "1100.0.1",
86
- "@pnpm/exec.lifecycle": "1100.0.15",
87
- "@pnpm/hooks.read-package-hook": "1100.0.6",
84
+ "@pnpm/deps.path": "1100.0.7",
85
+ "@pnpm/exec.lifecycle": "1100.0.17",
86
+ "@pnpm/fs.symlink-dependency": "1100.0.9",
87
+ "@pnpm/crypto.object-hasher": "1100.0.0",
88
+ "@pnpm/hooks.read-package-hook": "1100.0.7",
89
+ "@pnpm/hooks.types": "1100.0.11",
90
+ "@pnpm/installing.deps-resolver": "1100.2.2",
91
+ "@pnpm/bins.remover": "1100.0.9",
92
+ "@pnpm/installing.context": "1100.0.17",
93
+ "@pnpm/installing.linking.hoist": "1100.0.13",
94
+ "@pnpm/installing.deps-restorer": "1101.1.11",
95
+ "@pnpm/installing.linking.modules-cleaner": "1100.1.7",
96
+ "@pnpm/installing.linking.direct-dep-linker": "1100.0.9",
97
+ "@pnpm/installing.modules-yaml": "1100.0.8",
98
+ "@pnpm/lockfile.filtering": "1100.1.6",
99
+ "@pnpm/lockfile.fs": "1100.1.4",
100
+ "@pnpm/installing.package-requester": "1101.1.0",
101
+ "@pnpm/lockfile.preferred-versions": "1100.0.15",
88
102
  "@pnpm/fs.read-modules-dir": "1100.0.1",
89
- "@pnpm/hooks.types": "1100.0.10",
90
- "@pnpm/installing.context": "1100.0.15",
91
- "@pnpm/crypto.hash": "1100.0.1",
92
- "@pnpm/installing.deps-resolver": "1100.2.0",
93
- "@pnpm/installing.deps-restorer": "1101.1.9",
94
- "@pnpm/installing.linking.direct-dep-linker": "1100.0.7",
95
- "@pnpm/installing.linking.hoist": "1100.0.11",
96
- "@pnpm/installing.modules-yaml": "1100.0.7",
97
- "@pnpm/installing.package-requester": "1101.0.11",
98
- "@pnpm/lockfile.fs": "1100.1.3",
99
- "@pnpm/lockfile.filtering": "1100.1.4",
100
- "@pnpm/lockfile.to-pnp": "1100.0.12",
101
- "@pnpm/lockfile.preferred-versions": "1100.0.13",
102
- "@pnpm/lockfile.utils": "1100.0.11",
103
- "@pnpm/lockfile.walker": "1100.0.9",
104
- "@pnpm/network.auth-header": "1101.1.0",
105
- "@pnpm/pkg-manifest.utils": "1100.2.2",
106
- "@pnpm/patching.config": "1100.0.6",
107
- "@pnpm/lockfile.pruner": "1100.0.9",
103
+ "@pnpm/lockfile.settings-checker": "1100.0.17",
104
+ "@pnpm/lockfile.to-pnp": "1100.0.13",
105
+ "@pnpm/lockfile.pruner": "1100.0.10",
106
+ "@pnpm/lockfile.utils": "1100.0.12",
107
+ "@pnpm/lockfile.walker": "1100.0.10",
108
+ "@pnpm/network.auth-header": "1101.1.1",
109
+ "@pnpm/lockfile.verification": "1100.0.17",
110
+ "@pnpm/patching.config": "1100.0.7",
111
+ "@pnpm/pnpr.client": "1.2.0",
112
+ "@pnpm/pkg-manifest.utils": "1100.2.4",
113
+ "@pnpm/store.controller-types": "1100.1.4",
108
114
  "@pnpm/store.index": "1100.1.0",
109
- "@pnpm/resolving.resolver-base": "1100.4.0",
115
+ "@pnpm/resolving.resolver-base": "1100.4.1",
116
+ "@pnpm/types": "1101.3.1",
110
117
  "@pnpm/resolving.parse-wanted-dependency": "1100.0.1",
111
- "@pnpm/workspace.project-manifest-reader": "1100.0.10",
112
- "@pnpm/pnpr.client": "1.1.0",
113
- "@pnpm/types": "1101.3.0",
114
- "@pnpm/lockfile.settings-checker": "1100.0.15",
115
- "@pnpm/installing.linking.modules-cleaner": "1100.1.5",
116
- "@pnpm/store.controller-types": "1100.1.3",
117
- "@pnpm/lockfile.verification": "1100.0.15",
118
- "@pnpm/fs.symlink-dependency": "1100.0.7"
118
+ "@pnpm/workspace.project-manifest-reader": "1100.0.12"
119
119
  },
120
120
  "peerDependencies": {
121
121
  "@pnpm/logger": "^1001.0.1",
122
- "@pnpm/worker": "^1100.1.9"
122
+ "@pnpm/worker": "^1100.1.11"
123
123
  },
124
124
  "devDependencies": {
125
125
  "@jest/globals": "30.3.0",
@@ -141,22 +141,22 @@
141
141
  "symlink-dir": "^10.0.1",
142
142
  "write-json-file": "^7.0.0",
143
143
  "write-yaml-file": "^6.0.0",
144
- "@pnpm/assert-project": "1100.0.13",
145
- "@pnpm/lockfile.types": "1100.0.9",
146
- "@pnpm/installing.deps-installer": "1101.7.0",
147
- "@pnpm/prepare": "1100.0.13",
148
- "@pnpm/pkg-manifest.reader": "1100.0.6",
149
- "@pnpm/network.git-utils": "1100.0.1",
144
+ "@pnpm/assert-project": "1100.0.15",
145
+ "@pnpm/assert-store": "1100.0.15",
146
+ "@pnpm/installing.deps-installer": "1101.9.0",
150
147
  "@pnpm/logger": "1100.0.0",
151
- "@pnpm/resolving.registry.types": "1100.1.1",
152
- "@pnpm/store.cafs": "1100.1.8",
153
- "@pnpm/testing.mock-agent": "1101.0.0",
154
- "@pnpm/test-ipc-server": "1100.0.0",
148
+ "@pnpm/network.git-utils": "1100.0.1",
149
+ "@pnpm/lockfile.types": "1100.0.10",
150
+ "@pnpm/resolving.registry.types": "1100.1.2",
151
+ "@pnpm/pkg-manifest.reader": "1100.0.7",
152
+ "@pnpm/store.cafs": "1100.1.9",
155
153
  "@pnpm/store.path": "1100.0.1",
154
+ "@pnpm/prepare": "1100.0.15",
155
+ "@pnpm/test-ipc-server": "1100.0.0",
156
156
  "@pnpm/test-fixtures": "1100.0.0",
157
- "@pnpm/testing.registry-mock": "1100.0.3",
158
- "@pnpm/testing.temp-store": "1100.1.6",
159
- "@pnpm/assert-store": "1100.0.13"
157
+ "@pnpm/testing.mock-agent": "1101.0.2",
158
+ "@pnpm/testing.temp-store": "1100.1.8",
159
+ "@pnpm/testing.registry-mock": "1100.0.5"
160
160
  },
161
161
  "engines": {
162
162
  "node": ">=22.13"