@pnpm/resolving.npm-resolver 1101.1.0 → 1101.1.1

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.
package/lib/fetch.js CHANGED
@@ -3,24 +3,71 @@ import { requestRetryLogger } from '@pnpm/core-loggers';
3
3
  import { FetchError, PnpmError, } from '@pnpm/error';
4
4
  import { globalWarn } from '@pnpm/logger';
5
5
  import * as retry from '@zkochan/retry';
6
- // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
7
- // eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/use-ignore-case
8
- const semverRegex = /(.*)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
6
+ import semver from 'semver';
9
7
  export class RegistryResponseError extends FetchError {
10
8
  pkgName;
11
9
  constructor(request, response, pkgName) {
12
10
  let hint;
13
11
  if (response.status === 404) {
14
12
  hint = `${pkgName} is not in the npm registry, or you have no permission to fetch it.`;
15
- const matched = pkgName.match(semverRegex);
16
- if (matched != null) {
17
- hint += ` Did you mean ${matched[1]}?`;
13
+ const nameWithoutVersion = stripTrailingSemverSuffix(pkgName);
14
+ if (nameWithoutVersion != null) {
15
+ hint += ` Did you mean ${nameWithoutVersion}?`;
18
16
  }
19
17
  }
20
18
  super(request, response, hint);
21
19
  this.pkgName = pkgName;
22
20
  }
23
21
  }
22
+ /**
23
+ * Detect when a package name accidentally includes a `<version>` suffix
24
+ * (e.g. `lodash@4.17.21` or `lodash4.17.21`) and return the part before the
25
+ * version. Returns `undefined` when no semver suffix is present.
26
+ *
27
+ * Implemented as an O(n) scan to avoid polynomial backtracking on adversarial
28
+ * input (CodeQL: js/polynomial-redos).
29
+ */
30
+ function stripTrailingSemverSuffix(pkgName) {
31
+ // Common case: "name@version" – split on the rightmost '@'.
32
+ // `atIdx > 0` rules out the leading '@' of scoped names like '@scope/foo'.
33
+ const atIdx = pkgName.lastIndexOf('@');
34
+ if (atIdx > 0 && semver.valid(pkgName.slice(atIdx + 1)) != null) {
35
+ return pkgName.slice(0, atIdx);
36
+ }
37
+ // Fallback: detect a trailing "<digits>.<digits>.<digits>" appended to a name
38
+ // with no separator (e.g. "foo1.0.0"). We walk backwards through three
39
+ // digit-blocks separated by dots; this is O(n) and free of regex backtracking.
40
+ let i = pkgName.length;
41
+ i = consumeTrailingDigits(pkgName, i);
42
+ if (i === pkgName.length || i === 0 || pkgName.charCodeAt(i - 1) !== 46 /* '.' */)
43
+ return undefined;
44
+ i--;
45
+ const beforePatch = i;
46
+ i = consumeTrailingDigits(pkgName, i);
47
+ if (i === beforePatch || i === 0 || pkgName.charCodeAt(i - 1) !== 46)
48
+ return undefined;
49
+ i--;
50
+ const beforeMinor = i;
51
+ i = consumeTrailingDigits(pkgName, i);
52
+ if (i === beforeMinor || i === 0)
53
+ return undefined;
54
+ if (semver.valid(pkgName.slice(i)) == null)
55
+ return undefined;
56
+ let prefix = pkgName.slice(0, i);
57
+ if (prefix.endsWith('@'))
58
+ prefix = prefix.slice(0, -1);
59
+ return prefix.length > 0 ? prefix : undefined;
60
+ }
61
+ function consumeTrailingDigits(s, end) {
62
+ let i = end;
63
+ while (i > 0) {
64
+ const c = s.charCodeAt(i - 1);
65
+ if (c < 48 || c > 57)
66
+ break;
67
+ i--;
68
+ }
69
+ return i;
70
+ }
24
71
  export async function fetchMetadataFromFromRegistry(fetchOpts, pkgName, { authHeaderValue, etag: cachedEtag, fullMetadata, modified: cachedModified, registry, }) {
25
72
  const uri = toUri(pkgName, registry);
26
73
  const op = retry.operation(fetchOpts.retry);
package/lib/index.js CHANGED
@@ -180,7 +180,13 @@ async function resolveNpm(ctx, wantedDependency, opts) {
180
180
  // Fast path: if we have a current resolution with integrity, try to peek the manifest from the store.
181
181
  // This avoids the expensive metadata fetch from the registry.
182
182
  // We do this AFTER ensuring the spec is valid for this resolver to avoids hijacking other resolvers.
183
- if (ctx.peekManifestFromStore && opts.currentPkg?.resolution && !opts.update) {
183
+ // If publishedBy is set (resolutionMode=time-based or minimumReleaseAge is configured), we only take
184
+ // the fast path when publishedAt is already known from the lockfile's `time:` block; otherwise we
185
+ // fall through to a registry fetch so the cutoff isn't computed from missing data.
186
+ if (ctx.peekManifestFromStore &&
187
+ opts.currentPkg?.resolution &&
188
+ !opts.update &&
189
+ (opts.publishedBy == null || opts.currentPkg.publishedAt != null)) {
184
190
  const currentResolution = opts.currentPkg.resolution;
185
191
  // Only use this optimization for tarball resolutions with integrity (npm packages)
186
192
  if ('tarball' in currentResolution && currentResolution.integrity) {
@@ -200,7 +206,7 @@ async function resolveNpm(ctx, wantedDependency, opts) {
200
206
  manifest,
201
207
  resolution: currentResolution,
202
208
  resolvedVia: 'npm-registry',
203
- publishedAt: undefined, // Don't have this without metadata
209
+ publishedAt: opts.currentPkg.publishedAt,
204
210
  };
205
211
  }
206
212
  }
@@ -123,15 +123,30 @@ export async function pickPackage(ctx, spec, opts) {
123
123
  : ABBREVIATED_META_DIR;
124
124
  // Cache key includes fullMetadata to avoid returning abbreviated metadata when full metadata is requested.
125
125
  const cacheKey = fullMetadata ? `${spec.name}:full` : spec.name;
126
+ const registryName = getRegistryName(opts.registry);
127
+ const pkgMirror = path.join(ctx.cacheDir, metaDir, registryName, `${encodePkgName(spec.name)}.jsonl`);
126
128
  const cachedMeta = ctx.metaCache.get(cacheKey);
127
129
  if (cachedMeta != null) {
130
+ // The in-memory cache may hold abbreviated metadata from an earlier call
131
+ // that didn't need `time` (no publishedBy then). If this call has
132
+ // publishedBy and the package was modified recently, upgrade to full
133
+ // metadata so the maturity check runs properly.
134
+ const upgrade = await maybeUpgradeAbbreviatedMetaForReleaseAge(ctx, spec, opts, cachedMeta);
135
+ let metaForCache = upgrade.meta;
136
+ if (upgrade.upgradedFrom != null) {
137
+ // Persist the upgraded meta to disk too: the on-disk mirror still holds
138
+ // the abbreviated form, so without this a fresh process would re-trigger
139
+ // the upgrade fetch on its next install.
140
+ metaForCache = opts.dryRun
141
+ ? upgrade.meta
142
+ : persistUpgradedMeta(ctx, pkgMirror, upgrade.upgradedFrom);
143
+ ctx.metaCache.set(cacheKey, metaForCache);
144
+ }
128
145
  return {
129
- meta: cachedMeta,
130
- pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, cachedMeta),
146
+ meta: metaForCache,
147
+ pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, metaForCache),
131
148
  };
132
149
  }
133
- const registryName = getRegistryName(opts.registry);
134
- const pkgMirror = path.join(ctx.cacheDir, metaDir, registryName, `${encodePkgName(spec.name)}.jsonl`);
135
150
  return runLimited(pkgMirror, async (limit) => {
136
151
  let metaCachedInStore;
137
152
  if (ctx.offline === true || ctx.preferOffline === true || opts.pickLowestVersion) {
@@ -145,6 +160,17 @@ export async function pickPackage(ctx, spec, opts) {
145
160
  throw new PnpmError('NO_OFFLINE_META', `Failed to resolve ${toRaw(spec)} in package mirror ${pkgMirror}`);
146
161
  }
147
162
  if (metaCachedInStore != null) {
163
+ // Disk-cached meta may be abbreviated; upgrade for the maturity check
164
+ // before letting pickMatchingVersionFinal warn-and-skip on missing time.
165
+ const upgrade = await maybeUpgradeAbbreviatedMetaForReleaseAge(ctx, spec, opts, metaCachedInStore);
166
+ metaCachedInStore = upgrade.meta;
167
+ if (upgrade.upgradedFrom != null) {
168
+ // Persist so the next install skips this upgrade fetch entirely.
169
+ if (!opts.dryRun) {
170
+ metaCachedInStore = persistUpgradedMeta(ctx, pkgMirror, upgrade.upgradedFrom);
171
+ }
172
+ ctx.metaCache.set(cacheKey, metaCachedInStore);
173
+ }
148
174
  const pickedPackage = pickMatchingVersionFinal(pickerOpts, spec, metaCachedInStore);
149
175
  if (pickedPackage) {
150
176
  return {
@@ -169,7 +195,7 @@ export async function pickPackage(ctx, spec, opts) {
169
195
  }
170
196
  }
171
197
  catch (err) {
172
- if (ctx.strictPublishedByCheck) {
198
+ if (shouldRethrowFromFastPathCache(err, ctx.strictPublishedByCheck)) {
173
199
  throw err;
174
200
  }
175
201
  }
@@ -190,10 +216,7 @@ export async function pickPackage(ctx, spec, opts) {
190
216
  }
191
217
  }
192
218
  catch (err) {
193
- // Don't rethrow ERR_PNPM_MISSING_TIME from cached abbreviated metadata —
194
- // let the code fall through to the network fetch path which will get full metadata.
195
- if (ctx.strictPublishedByCheck &&
196
- !(isMissingTimeError(err))) {
219
+ if (shouldRethrowFromFastPathCache(err, ctx.strictPublishedByCheck)) {
197
220
  throw err;
198
221
  }
199
222
  }
@@ -219,6 +242,19 @@ export async function pickPackage(ctx, spec, opts) {
219
242
  if (fetchResult.notModified) {
220
243
  metaCachedInStore = metaCachedInStore ?? await limit(async () => loadMeta(pkgMirror));
221
244
  if (metaCachedInStore != null) {
245
+ // The cached metadata may be abbreviated (no per-version `time`).
246
+ // When minimumReleaseAge is active we need `time` for the maturity check,
247
+ // so upgrade to full metadata via a follow-up fetch when warranted.
248
+ // Without this, repeat installs of recently-modified packages would
249
+ // silently bypass the maturity check via the warn-and-skip fallback.
250
+ const upgrade = await maybeUpgradeAbbreviatedMetaForReleaseAge(ctx, spec, opts, metaCachedInStore);
251
+ metaCachedInStore = upgrade.meta;
252
+ if (upgrade.upgradedFrom != null && !opts.dryRun) {
253
+ // Persist the upgraded full metadata to disk so subsequent installs
254
+ // skip this upgrade fetch entirely (the cached meta will then have
255
+ // `time` populated, so the upgrade condition won't trigger).
256
+ metaCachedInStore = persistUpgradedMeta(ctx, pkgMirror, upgrade.upgradedFrom);
257
+ }
222
258
  ctx.metaCache.set(cacheKey, metaCachedInStore);
223
259
  return {
224
260
  meta: metaCachedInStore,
@@ -307,6 +343,77 @@ export async function pickPackage(ctx, spec, opts) {
307
343
  }
308
344
  });
309
345
  }
346
+ // When `minimumReleaseAge` is active and we have abbreviated metadata (which
347
+ // the npm registry serves by default and which omits per-version `time`),
348
+ // the maturity check can't run on the data we have. If the package has been
349
+ // modified since the maturity cutoff, re-fetch with `fullMetadata: true` so
350
+ // `time` is populated and the check can proceed properly. Without this,
351
+ // `pickMatchingVersionFinal` would fall back to its warn-and-skip path,
352
+ // silently bypassing the minimumReleaseAge guarantee for affected packages.
353
+ //
354
+ // Returns the original meta when no upgrade is needed. When an upgrade
355
+ // happens, returns both the upgraded meta and the underlying fetch result
356
+ // so callers can persist it to disk and avoid re-fetching on next install.
357
+ async function maybeUpgradeAbbreviatedMetaForReleaseAge(ctx, spec, opts, meta) {
358
+ if (ctx.offline === true ||
359
+ !opts.publishedBy ||
360
+ meta.time != null ||
361
+ opts.publishedByExclude?.(spec.name) === true) {
362
+ return { meta };
363
+ }
364
+ const modifiedDate = meta.modified ? new Date(meta.modified) : null;
365
+ const isModifiedValid = modifiedDate != null && !Number.isNaN(modifiedDate.getTime());
366
+ if (isModifiedValid && modifiedDate < opts.publishedBy) {
367
+ // The package was last modified before the maturity cutoff. No individual
368
+ // version can be newer than the cutoff, so the abbreviated form is fine.
369
+ return { meta };
370
+ }
371
+ // When `modified` is missing or malformed we fall through to the upgrade
372
+ // fetch: prefer correctness (run the maturity check on real `time` data)
373
+ // over saving a network call when our cached freshness signal is unusable.
374
+ // Forward etag/modified so the registry can answer 304 if the upgraded
375
+ // representation hasn't actually changed (rare on the npm registry where
376
+ // full and abbreviated have distinct etags, but cheap to support).
377
+ const fullFetchResult = await ctx.fetch(spec.name, {
378
+ authHeaderValue: opts.authHeaderValue,
379
+ fullMetadata: true,
380
+ etag: meta.etag,
381
+ modified: meta.modified,
382
+ registry: opts.registry,
383
+ });
384
+ if (fullFetchResult.notModified) {
385
+ // Upgrade fetch came back 304: keep the abbreviated meta. The downstream
386
+ // `pickMatchingVersionFinal` will fall through to its warn-and-skip path.
387
+ return { meta };
388
+ }
389
+ return { meta: fullFetchResult.meta, upgradedFrom: fullFetchResult };
390
+ }
391
+ // Returns true when a fast-path cache catch should rethrow under
392
+ // strictPublishedByCheck. ERR_PNPM_MISSING_TIME is excluded so callers fall
393
+ // through to the network fetch path, which can upgrade abbreviated cached
394
+ // metadata to full and run the maturity check on real `time` data.
395
+ function shouldRethrowFromFastPathCache(err, strictPublishedByCheck) {
396
+ return strictPublishedByCheck === true && !isMissingTimeError(err);
397
+ }
398
+ // Persists upgraded full metadata to the on-disk cache mirror and returns
399
+ // the meta to store in the in-memory cache. When `filterMetadata` is on, the
400
+ // in-memory and on-disk forms are both stripped via `clearMeta`; otherwise
401
+ // the original raw response body is written and the unstripped meta is kept.
402
+ function persistUpgradedMeta(ctx, pkgMirror, upgradedFrom) {
403
+ const metaForCache = ctx.filterMetadata ? clearMeta(upgradedFrom.meta) : upgradedFrom.meta;
404
+ const jsonForDisk = ctx.filterMetadata
405
+ ? prepareJsonForDisk(metaForCache, upgradedFrom.etag)
406
+ : prepareJsonForDisk(upgradedFrom.meta, upgradedFrom.etag, upgradedFrom.jsonText);
407
+ runLimited(pkgMirror, (l) => l(async () => {
408
+ try {
409
+ await saveMeta(pkgMirror, jsonForDisk);
410
+ }
411
+ catch (err) { // eslint-disable-line
412
+ // We don't care if this file was not written to the cache
413
+ }
414
+ }));
415
+ return metaForCache;
416
+ }
310
417
  function clearMeta(pkg) {
311
418
  const versions = {};
312
419
  for (const [version, info] of Object.entries(pkg.versions)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/resolving.npm-resolver",
3
- "version": "1101.1.0",
3
+ "version": "1101.1.1",
4
4
  "description": "Resolver for npm-hosted packages",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -40,26 +40,26 @@
40
40
  "semver-utils": "^1.1.4",
41
41
  "ssri": "13.0.1",
42
42
  "version-selector-type": "^3.0.0",
43
- "@pnpm/config.pick-registry-for-package": "1100.0.3",
43
+ "@pnpm/core-loggers": "1100.0.2",
44
+ "@pnpm/constants": "1100.0.0",
44
45
  "@pnpm/crypto.hash": "1100.0.1",
46
+ "@pnpm/fs.graceful-fs": "1100.1.0",
45
47
  "@pnpm/error": "1100.0.0",
46
48
  "@pnpm/fetching.types": "1100.0.1",
47
- "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
48
- "@pnpm/fs.graceful-fs": "1100.1.0",
49
- "@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.3",
50
- "@pnpm/store.cafs": "1100.1.3",
51
- "@pnpm/store.index": "1100.1.0",
52
49
  "@pnpm/resolving.registry.types": "1100.0.3",
53
- "@pnpm/types": "1101.1.0",
50
+ "@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.3",
54
51
  "@pnpm/resolving.resolver-base": "1100.1.3",
55
- "@pnpm/constants": "1100.0.0",
56
- "@pnpm/workspace.range-resolver": "1100.0.1",
52
+ "@pnpm/types": "1101.1.0",
53
+ "@pnpm/store.index": "1100.1.0",
54
+ "@pnpm/store.cafs": "1100.1.4",
57
55
  "@pnpm/workspace.spec-parser": "1100.0.0",
58
- "@pnpm/core-loggers": "1100.0.2"
56
+ "@pnpm/workspace.range-resolver": "1100.0.1",
57
+ "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
58
+ "@pnpm/config.pick-registry-for-package": "1100.0.3"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@pnpm/logger": ">=1001.0.0 <1002.0.0",
62
- "@pnpm/worker": "^1100.1.4"
62
+ "@pnpm/worker": "^1100.1.5"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@jest/globals": "30.3.0",
@@ -70,11 +70,11 @@
70
70
  "load-json-file": "^7.0.1",
71
71
  "tempy": "3.0.0",
72
72
  "@pnpm/config.version-policy": "1100.0.3",
73
+ "@pnpm/network.fetch": "1100.0.4",
74
+ "@pnpm/resolving.npm-resolver": "1101.1.1",
73
75
  "@pnpm/logger": "1100.0.0",
74
- "@pnpm/resolving.npm-resolver": "1101.1.0",
75
- "@pnpm/network.fetch": "1100.0.3",
76
76
  "@pnpm/test-fixtures": "1100.0.0",
77
- "@pnpm/testing.mock-agent": "1100.0.3"
77
+ "@pnpm/testing.mock-agent": "1100.0.4"
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=22.13"