@dependably/npm-check 1.7.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.
package/src/index.js ADDED
@@ -0,0 +1,138 @@
1
+ // src/index.js
2
+ export { parseLockfile, serializeLockfile } from './parser.js';
3
+ export { validatePackageLock, ValidationError } from './validator.js';
4
+ export { validatePackageJson, PackageJsonValidationError } from './package-json-validator.js';
5
+ export { parseNpmrc, validateNpmrc, NpmrcValidationError, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
6
+ export { parsePnpmWorkspace, validatePnpmWorkspace, PnpmWorkspaceValidationError } from './pnpm-workspace-validator.js';
7
+ export { forEachPnpmPackageEntry, resolvePnpmRegistryBase, parsePnpmDepPath } from './pnpm-format.js';
8
+ export { migrateToVersion, PackageLockMigrator, MigrationError } from './migrator.js';
9
+ export {
10
+ upgradeIntegrityHashes,
11
+ deduplicatePackages,
12
+ findPackagesMatching,
13
+ countUniquePackages,
14
+ findDuplicatePackages
15
+ } from './updater.js';
16
+ export { fixPackageLock, FixerError } from './fixer.js';
17
+ export {
18
+ LOCKFILE_VERSIONS,
19
+ detectLockfileVersion,
20
+ detectLockfileFlavor,
21
+ hasPackagesMap,
22
+ hasDependenciesTree,
23
+ forEachPackageEntry,
24
+ resolvePackageName
25
+ } from './format-library.js';
26
+ export {
27
+ createBackup,
28
+ listBackups,
29
+ restoreFromLatestBackup,
30
+ cleanOldBackups,
31
+ BackupError
32
+ } from './backup.js';
33
+ export {
34
+ generateIntegrityFromData,
35
+ generateIntegrityFromFile,
36
+ generateIntegrityFromRegistry,
37
+ generateOrPlaceholderIntegrity,
38
+ fetchPackumentIntegrity,
39
+ fetchPackumentManifest,
40
+ fetchPackument,
41
+ fetchLatestVersion,
42
+ isValidIntegrity,
43
+ isPlaceholder,
44
+ DEFAULT_REGISTRY
45
+ } from './integrity.js';
46
+ export {
47
+ fixChecksums,
48
+ deriveRegistryBase,
49
+ ChecksumFixError
50
+ } from './checksum-fixer.js';
51
+ export {
52
+ pinVersions,
53
+ classifyRange,
54
+ detectIndent,
55
+ PinnerError
56
+ } from './pinner.js';
57
+ export {
58
+ runAudit,
59
+ formatAuditReport,
60
+ classifyInstallScripts,
61
+ rules as auditRules,
62
+ AuditError
63
+ } from './audit.js';
64
+ export {
65
+ findOrphanedPackages,
66
+ prunePackages,
67
+ PrunerError
68
+ } from './pruner.js';
69
+ export {
70
+ scanUsedPackages,
71
+ findUnusedDependencies,
72
+ specifierToPackageName,
73
+ DEFAULT_BUILD_DIRS,
74
+ UsageScannerError
75
+ } from './usage-scanner.js';
76
+ export {
77
+ loadAuditConfig,
78
+ mergeConfig,
79
+ normalizeRuleEntry,
80
+ DEFAULT_CONFIG as DEFAULT_AUDIT_CONFIG,
81
+ CONFIG_FILENAMES as AUDIT_CONFIG_FILENAMES,
82
+ AuditConfigError
83
+ } from './audit-config.js';
84
+ export {
85
+ shallowCopyLockfile,
86
+ processBatchedPackages,
87
+ getMemoryStats,
88
+ filterPackagesLazy,
89
+ createDedupeMap,
90
+ reconstructFromDedupeMap,
91
+ chunkLockfile,
92
+ mergeLockfileChunks,
93
+ estimateLockfileSize,
94
+ isLargeLockfile
95
+ } from './performance.js';
96
+ export {
97
+ ProgressReporter,
98
+ createProgressReporter,
99
+ formatProgress,
100
+ createProgressBar
101
+ } from './progress-reporter.js';
102
+ export {
103
+ StreamingParser,
104
+ parseLockfileStream,
105
+ parseLockfileStreamSync
106
+ } from './streaming-parser.js';
107
+ export {
108
+ WorkerPool,
109
+ processInParallel,
110
+ parallelUpgradeIntegrityHashes,
111
+ parallelDeduplicatePackages,
112
+ parallelMigrate
113
+ } from './parallel-processor.js';
114
+ export {
115
+ checkIntegrity,
116
+ checkLicenses,
117
+ checkAll,
118
+ parseLicensesCsv,
119
+ hashPackageDirectory,
120
+ CheckError
121
+ } from './checker.js';
122
+ export {
123
+ checkVulnerabilities,
124
+ VulnError
125
+ } from './vuln.js';
126
+ export {
127
+ checkDeprecations,
128
+ DeprecationError
129
+ } from './deprecation.js';
130
+ export {
131
+ remediateDependencies,
132
+ RemediationError
133
+ } from './remediate.js';
134
+ export {
135
+ runReport,
136
+ formatReport,
137
+ ReportError
138
+ } from './report.js';
@@ -0,0 +1,519 @@
1
+ // src/integrity.js
2
+ import crypto from 'crypto';
3
+ import fs from 'fs';
4
+ import http from 'http';
5
+ import https from 'https';
6
+
7
+ /**
8
+ * Generate SHA512 integrity hash for raw data
9
+ * @param {string} data - Content to hash
10
+ * @returns {string} Integrity hash in 'sha512-<base64>' format
11
+ */
12
+ export function generateIntegrityFromData(data) {
13
+ const hash = crypto.createHash('sha512');
14
+ hash.update(data);
15
+ const digest = hash.digest('base64');
16
+ return `sha512-${digest}`;
17
+ }
18
+
19
+ /**
20
+ * Generate SHA512 integrity hash from a file
21
+ * @param {string} filePath - Path to the file
22
+ * @returns {string} Integrity hash in 'sha512-<base64>' format
23
+ */
24
+ export function generateIntegrityFromFile(filePath) {
25
+ try {
26
+ // Read raw bytes — hashing decoded utf8 corrupts binary content (e.g. tarballs)
27
+ const data = fs.readFileSync(filePath);
28
+ return generateIntegrityFromData(data);
29
+ } catch (e) {
30
+ console.error(`Failed to read file ${filePath}: ${e.message}`);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
36
+
37
+ /**
38
+ * Return true when `url`'s host is acceptable to query. The lockfile's `resolved`
39
+ * URL is attacker-controlled, so a caller that wants to pin the trusted registry
40
+ * set (SSRF / self-attesting-host defense, issue #12) passes an `allowedHosts`
41
+ * allowlist; with no allowlist the historical "trust whatever the lockfile says"
42
+ * behavior is preserved so existing callers are unaffected.
43
+ * @param {URL} url - Parsed resolved URL
44
+ * @param {string[]|Set<string>} [allowedHosts] - Permitted host (or host:port) values
45
+ * @returns {boolean}
46
+ */
47
+ function isAllowedRegistryHost(url, allowedHosts) {
48
+ if (!allowedHosts) return true;
49
+ const list = Array.isArray(allowedHosts) ? allowedHosts : Array.from(allowedHosts);
50
+ if (list.length === 0) return true;
51
+ const host = url.host.toLowerCase(); // hostname[:port]
52
+ const hostname = url.hostname.toLowerCase();
53
+ return list.some((h) => {
54
+ if (typeof h !== 'string') return false;
55
+ const allowed = h.toLowerCase();
56
+ if (allowed.includes(':')) {
57
+ // Entry pins an explicit port — require an exact host:port match.
58
+ return allowed === host;
59
+ }
60
+ // A port-less entry matches the hostname ONLY on the default port, so a
61
+ // hostile lockfile cannot redirect the request to a different service
62
+ // (e.g. :9200) on an otherwise-allowed host.
63
+ return allowed === hostname && url.port === '';
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Derive the registry base URL from a package's resolved tarball URL.
69
+ * npm tarball URLs follow <registryBase>/<name>/-/<file>.tgz, where scoped
70
+ * names may appear as '@scope/name' or '@scope%2fname' in the path.
71
+ * @param {string} resolvedUrl - The entry's resolved URL
72
+ * @param {string} packageName - The real package name
73
+ * @param {object} [options] - { allowedHosts } to pin the trusted registry set
74
+ * @returns {string|null} Registry base or null if not derivable / not allowed
75
+ */
76
+ export function deriveRegistryBase(resolvedUrl, packageName, options = {}) {
77
+ if (!resolvedUrl || !packageName) return null;
78
+ let url;
79
+ try {
80
+ url = new URL(resolvedUrl);
81
+ } catch {
82
+ return null;
83
+ }
84
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
85
+ // Refuse to derive a base from a host outside the caller's allowlist. Without
86
+ // this, a hostile lockfile could steer a fetch to an arbitrary (internal) host
87
+ // of its choosing and self-attest a tampered `integrity` by also pointing
88
+ // `resolved` at a server it controls. NOTE: only checkIntegrity() currently
89
+ // passes `allowedHosts` (see checker.js); vuln/deprecation/checksum-fixer keep
90
+ // the historical "trust the lockfile" default until an allowlist is threaded
91
+ // through those entry points. With no allowlist this is a no-op.
92
+ if (!isAllowedRegistryHost(url, options.allowedHosts)) return null;
93
+
94
+ const markerIdx = url.pathname.indexOf('/-/');
95
+ if (markerIdx === -1) return null;
96
+
97
+ let beforeMarker = url.pathname.slice(0, markerIdx);
98
+ // Strip the package name (possibly %2f-encoded for scopes) off the tail
99
+ const encodedName = packageName.replace('/', '%2f');
100
+ for (const candidate of [`/${packageName}`, `/${encodedName}`]) {
101
+ if (beforeMarker.toLowerCase().endsWith(candidate.toLowerCase())) {
102
+ beforeMarker = beforeMarker.slice(0, beforeMarker.length - candidate.length);
103
+ return `${url.origin}${beforeMarker}`;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // Hard ceiling on a single registry response body. The `resolved` host is
110
+ // attacker-controlled, so a hostile/broken registry could otherwise stream an
111
+ // unbounded body and exhaust memory (issue #12). Overridable per call for tests.
112
+ const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
113
+
114
+ // The wall-clock deadline defaults to a MULTIPLE of the idle timeout. Making it
115
+ // equal to timeoutMs (as before) rendered the idle timeout dead code and starved
116
+ // legitimately large responses (e.g. a multi-MB packument on a slow link) that
117
+ // stream steadily but take longer than a single idle window. The idle timeout
118
+ // still catches true inactivity; the size cap still bounds memory.
119
+ const DEADLINE_MULTIPLIER = 6;
120
+
121
+ /**
122
+ * Pick the transport module for a URL by scheme. npm itself supports plaintext
123
+ * `http://` registries (Verdaccio/Nexus on a LAN); the old https-only transport
124
+ * threw ERR_INVALID_PROTOCOL on them (issue #16).
125
+ * @param {string} url
126
+ * @returns {typeof http | typeof https}
127
+ */
128
+ function transportFor(url) {
129
+ return new URL(url).protocol === 'http:' ? http : https;
130
+ }
131
+
132
+ /**
133
+ * Wrap resolve/reject so the promise settles exactly once. Many independent
134
+ * events (size cap, response error, timeout, deadline, end) race to settle a
135
+ * single request; without a guard a later one throws "already settled".
136
+ */
137
+ function onceSettlers(resolve, reject) {
138
+ let done = false;
139
+ return {
140
+ resolve: (v) => { if (!done) { done = true; resolve(v); } },
141
+ reject: (e) => { if (!done) { done = true; reject(e); } }
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Attach body handlers to a response: enforce the size cap, parse JSON on end,
147
+ * and — critically — handle a mid-body stream `error`. Once headers arrive a
148
+ * socket reset (ECONNRESET / premature close, routine under high concurrency) is
149
+ * emitted on the IncomingMessage, not the request; without this listener it is an
150
+ * uncaught exception that crashes the whole run (issue #16).
151
+ */
152
+ function collectJsonBody(res, url, maxBytes, settle) {
153
+ let data = '';
154
+ let bytes = 0;
155
+ res.on('data', (chunk) => {
156
+ bytes += chunk.length;
157
+ if (bytes > maxBytes) {
158
+ settle.reject(new Error(`Registry response exceeded ${maxBytes} bytes for ${url}`));
159
+ res.destroy();
160
+ return;
161
+ }
162
+ data += chunk;
163
+ });
164
+ res.on('end', () => {
165
+ try {
166
+ settle.resolve(JSON.parse(data));
167
+ } catch {
168
+ settle.reject(new Error(`Invalid JSON from registry for ${url}`));
169
+ }
170
+ });
171
+ res.on('error', (e) => settle.reject(e));
172
+ }
173
+
174
+ /**
175
+ * Shared GET/POST core: protocol-aware transport, single-host redirect, size cap,
176
+ * an idle (socket-inactivity) timeout AND a wall-clock deadline (a byte-trickle
177
+ * can keep resetting the idle timer forever — issue #12), plus a response-stream
178
+ * error handler.
179
+ */
180
+ function buildRequestHeaders(payload, accept) {
181
+ if (payload !== null) {
182
+ return {
183
+ 'Content-Type': 'application/json',
184
+ 'Accept': accept || 'application/json',
185
+ 'Content-Length': Buffer.byteLength(payload)
186
+ };
187
+ }
188
+ // GET with an explicit Accept (e.g. the abbreviated-packument media type).
189
+ return accept ? { 'Accept': accept } : undefined;
190
+ }
191
+
192
+ // Handle a same-host redirect. Returns true if the response WAS a redirect (and
193
+ // has been dealt with — followed, or rejected for an invalid/cross-host target),
194
+ // false if it wasn't a redirect and normal status handling should proceed.
195
+ function handleRedirect(res, { url, redirectsLeft, settle, followRedirect }) {
196
+ const isRedirect = res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0;
197
+ if (!isRedirect) return false;
198
+ res.resume();
199
+ let target;
200
+ try {
201
+ target = new URL(res.headers.location, url);
202
+ } catch {
203
+ settle.reject(new Error(`Invalid redirect location "${res.headers.location}" for ${url}`));
204
+ return true;
205
+ }
206
+ // Only follow a redirect to the SAME host — a security check must not be
207
+ // bounced to an arbitrary attacker-controlled origin for its answer.
208
+ if (target.host !== new URL(url).host) {
209
+ settle.reject(new Error(`refusing cross-host redirect to ${target.host} for ${url}`));
210
+ return true;
211
+ }
212
+ followRedirect(target.toString(), redirectsLeft - 1).then(settle.resolve, settle.reject);
213
+ return true;
214
+ }
215
+
216
+ // Classify a registry response: follow redirects, resolve null on 404, reject on
217
+ // other non-200s, else collect + parse the JSON body.
218
+ function handleRegistryResponse(res, ctx) {
219
+ const { url, maxBytes, settle } = ctx;
220
+ if (handleRedirect(res, ctx)) return;
221
+ if (res.statusCode === 404) {
222
+ res.resume();
223
+ settle.resolve(null);
224
+ return;
225
+ }
226
+ if (res.statusCode !== 200) {
227
+ res.resume();
228
+ settle.reject(new Error(`Registry responded with status ${res.statusCode} for ${url}`));
229
+ return;
230
+ }
231
+ collectJsonBody(res, url, maxBytes, settle);
232
+ }
233
+
234
+ function requestJson({ url, method, payload, timeoutMs, redirectsLeft, maxBytes, deadlineMs, accept, followRedirect }) {
235
+ return new Promise((resolve, reject) => {
236
+ const settle = onceSettlers(resolve, reject);
237
+ const requestOptions = { method };
238
+ const headers = buildRequestHeaders(payload, accept);
239
+ if (headers) requestOptions.headers = headers;
240
+ const ctx = { url, redirectsLeft, maxBytes, settle, followRedirect };
241
+
242
+ let req;
243
+ try {
244
+ req = transportFor(url).request(url, requestOptions, (res) => handleRegistryResponse(res, ctx));
245
+ } catch (e) {
246
+ settle.reject(e);
247
+ return;
248
+ }
249
+ req.on('error', settle.reject);
250
+ // Idle timeout: fires after `timeoutMs` of socket inactivity.
251
+ req.setTimeout(timeoutMs, () => {
252
+ req.destroy(new Error(`Registry request timed out after ${timeoutMs}ms: ${url}`));
253
+ });
254
+ // Wall-clock deadline: a hard ceiling on total request duration regardless of
255
+ // activity, so a slow trickle cannot hang the run indefinitely.
256
+ const deadline = setTimeout(() => {
257
+ req.destroy(new Error(`Registry request exceeded ${deadlineMs}ms deadline: ${url}`));
258
+ }, deadlineMs);
259
+ if (typeof deadline.unref === 'function') deadline.unref();
260
+ const clearDeadline = () => clearTimeout(deadline);
261
+ req.on('close', clearDeadline);
262
+ req.on('error', clearDeadline);
263
+
264
+ if (payload !== null) req.write(payload);
265
+ req.end();
266
+ });
267
+ }
268
+
269
+ /**
270
+ * GET and parse JSON from a registry URL.
271
+ * @param {string} url - Full URL
272
+ * @param {number} timeoutMs - Per-request idle timeout (also the default deadline)
273
+ * @param {number} [redirectsLeft] - Remaining same-host redirect hops
274
+ * @param {object} [options] - { maxBytes, deadlineMs } (deadlineMs defaults to timeoutMs)
275
+ * @returns {Promise<object|null>} Parsed JSON, or null on 404
276
+ */
277
+ export function getJson(url, timeoutMs, redirectsLeft = 1, options = {}) {
278
+ const maxBytes = options.maxBytes ?? MAX_RESPONSE_BYTES;
279
+ const deadlineMs = options.deadlineMs ?? timeoutMs * DEADLINE_MULTIPLIER;
280
+ return requestJson({
281
+ url,
282
+ method: 'GET',
283
+ payload: null,
284
+ timeoutMs,
285
+ redirectsLeft,
286
+ maxBytes,
287
+ deadlineMs,
288
+ accept: options.accept,
289
+ followRedirect: (target, left) => getJson(target, timeoutMs, left, options)
290
+ });
291
+ }
292
+
293
+ /**
294
+ * POST a JSON body to a registry endpoint and resolve the parsed JSON response.
295
+ * Modeled on the GET helper above; used for the bulk advisory endpoint.
296
+ * Resolves null on 404 (endpoint not supported by this registry); rejects on
297
+ * network errors/timeouts/non-200 so callers can distinguish "offline" from
298
+ * "unsupported".
299
+ * @param {string} url - Full endpoint URL
300
+ * @param {object} bodyObject - JSON-serializable request body
301
+ * @param {number} timeoutMs - Per-request idle timeout (also the default deadline)
302
+ * @param {number} [redirectsLeft] - Remaining redirect hops
303
+ * @param {object} [options] - { maxBytes, deadlineMs }
304
+ * @returns {Promise<object|null>} Parsed JSON, or null on 404
305
+ */
306
+ export function postJson(url, bodyObject, timeoutMs, redirectsLeft = 1, options = {}) {
307
+ const payload = JSON.stringify(bodyObject);
308
+ const maxBytes = options.maxBytes ?? MAX_RESPONSE_BYTES;
309
+ const deadlineMs = options.deadlineMs ?? timeoutMs * DEADLINE_MULTIPLIER;
310
+ return requestJson({
311
+ url,
312
+ method: 'POST',
313
+ payload,
314
+ timeoutMs,
315
+ redirectsLeft,
316
+ maxBytes,
317
+ deadlineMs,
318
+ accept: options.accept,
319
+ followRedirect: (target, left) => postJson(target, bodyObject, timeoutMs, left, options)
320
+ });
321
+ }
322
+
323
+ // npm package-name grammar (case-insensitive to tolerate legacy mixed-case names
324
+ // such as `JSONStream`). A valid name is an optional single `@scope/` segment
325
+ // plus a name segment, with a restricted charset — so it can contain no extra
326
+ // `/`, no `?`/`#`, and can't start with `.`/`_`. This is the primary defense.
327
+ const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
328
+
329
+ /**
330
+ * Validate a package name against npm's naming grammar, then percent-encode it
331
+ * for a registry URL path. A lockfile-controlled name (an entry's `name` field,
332
+ * an `npm:` alias, or a pnpm depPath) is untrusted; the old `replace('/', '%2f')`
333
+ * only touched the first slash and encoded nothing else, so a scoped name could
334
+ * smuggle extra path segments or a query string onto the target (issue #30).
335
+ * Scoped names keep the literal '@' and encode the '/' separator as '%2f'; every
336
+ * other segment is fully `encodeURIComponent`d as defense-in-depth.
337
+ * @param {string} packageName - Name of the package
338
+ * @returns {string} URL-safe name path
339
+ * @throws {Error} when the name is not a valid npm package name
340
+ */
341
+ function encodePackageNamePath(packageName) {
342
+ if (typeof packageName !== 'string' || packageName.length > 214 || !NPM_NAME_RE.test(packageName)) {
343
+ throw new Error(`Invalid package name for registry URL: ${JSON.stringify(packageName)}`);
344
+ }
345
+ return packageName
346
+ .split('/')
347
+ .map((segment) => (segment.startsWith('@')
348
+ ? `@${encodeURIComponent(segment.slice(1))}`
349
+ : encodeURIComponent(segment)))
350
+ .join('%2f');
351
+ }
352
+
353
+ /**
354
+ * Build the registry URL for a single package version manifest.
355
+ * Scoped names keep the literal '/' encoded as %2f per registry convention.
356
+ * @param {string} registryBase - Registry base URL
357
+ * @param {string} packageName - Name of the package
358
+ * @param {string} version - Exact version
359
+ * @returns {string} Full manifest URL
360
+ */
361
+ function packumentVersionUrl(registryBase, packageName, version) {
362
+ let base = registryBase;
363
+ while (base.endsWith('/')) base = base.slice(0, -1);
364
+ return `${base}/${encodePackageNamePath(packageName)}/${encodeURIComponent(version)}`;
365
+ }
366
+
367
+ /**
368
+ * Fetch a package's ABBREVIATED packument (dist-tags + per-version `dist`
369
+ * metadata, the "corgi" format) from a registry. NOTE: this intentionally does
370
+ * NOT return the full document (readme, `time`, full metadata) — the full
371
+ * packument can be enormous and blow the response size cap; callers here only
372
+ * need dist-tags + dist.integrity.
373
+ * Resolves the parsed packument, null on 404, rejects on network errors/timeouts.
374
+ * @param {string} packageName - Name of the package
375
+ * @param {object} options - { registryBase, timeoutMs, fetchJson (injectable transport for tests) }
376
+ * @returns {Promise<object|null>} Abbreviated packument or null
377
+ */
378
+ export async function fetchPackument(packageName, options = {}) {
379
+ const { registryBase = DEFAULT_REGISTRY, timeoutMs = 10000, fetchJson = getJson, maxBytes, deadlineMs } = options;
380
+ let base = registryBase;
381
+ while (base.endsWith('/')) base = base.slice(0, -1);
382
+ // Request the ABBREVIATED packument. The full document can be enormous
383
+ // (renovate's is ~80MB — 5× the response size cap, so a full fetch always
384
+ // fails), while the abbreviated form carries dist-tags + per-version
385
+ // dist.integrity, which is everything fetchLatestVersion/remediate need.
386
+ // Mirror npm's own content negotiation: prefer the abbreviated ("corgi") type
387
+ // but fall back to the full packument JSON on a registry that 406s the corgi
388
+ // type, rather than surfacing a spurious "unreachable".
389
+ return fetchJson(`${base}/${encodePackageNamePath(packageName)}`, timeoutMs, 1, {
390
+ accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*',
391
+ maxBytes,
392
+ deadlineMs
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Fetch a package's "latest" dist-tag version from a registry.
398
+ * Resolves the version string, or null if the package/tag is unavailable.
399
+ * @param {string} packageName - Name of the package
400
+ * @param {object} options - { registryBase, timeoutMs, fetchJson }
401
+ * @returns {Promise<string|null>} Latest version or null
402
+ */
403
+ export async function fetchLatestVersion(packageName, options = {}) {
404
+ const packument = await fetchPackument(packageName, options);
405
+ const latest = packument && packument['dist-tags'] && packument['dist-tags'].latest;
406
+ return typeof latest === 'string' ? latest : null;
407
+ }
408
+
409
+ /**
410
+ * Fetch a single package version's manifest from a registry.
411
+ * Resolves the parsed manifest object, null when the package/version is not
412
+ * found (404), and rejects on network errors/timeouts so callers can
413
+ * distinguish "offline" from "not on npm".
414
+ * @param {string} packageName - Name of the package
415
+ * @param {string} version - Exact version
416
+ * @param {object} options - { registryBase, timeoutMs, fetchJson (injectable transport for tests) }
417
+ * @returns {Promise<object|null>} Version manifest or null
418
+ */
419
+ export async function fetchPackumentManifest(packageName, version, options = {}) {
420
+ const { registryBase = DEFAULT_REGISTRY, timeoutMs = 10000, fetchJson = getJson } = options;
421
+ const url = packumentVersionUrl(registryBase, packageName, version);
422
+ return fetchJson(url, timeoutMs);
423
+ }
424
+
425
+ /**
426
+ * Fetch a package version's integrity hash from a registry packument.
427
+ * Resolves null when the package/version is not found (404 or no dist.integrity);
428
+ * rejects on network errors/timeouts so callers can distinguish "offline" from "not on npm".
429
+ * @param {string} packageName - Name of the package
430
+ * @param {string} version - Exact version
431
+ * @param {object} options - { registryBase, timeoutMs, fetchJson (injectable transport for tests) }
432
+ * @returns {Promise<string|null>} Integrity hash or null
433
+ */
434
+ export async function fetchPackumentIntegrity(packageName, version, options = {}) {
435
+ const { registryBase = DEFAULT_REGISTRY, timeoutMs = 10000, fetchJson = getJson } = options;
436
+ const url = packumentVersionUrl(registryBase, packageName, version);
437
+
438
+ const pkg = await fetchJson(url, timeoutMs);
439
+ if (pkg && pkg.dist && pkg.dist.integrity) {
440
+ return pkg.dist.integrity;
441
+ }
442
+ return null;
443
+ }
444
+
445
+ /**
446
+ * Fetch a package from npm registry and generate its integrity hash
447
+ * (back-compat wrapper around fetchPackumentIntegrity; swallows errors)
448
+ * @param {string} packageName - Name of the package
449
+ * @param {string} version - Version of the package
450
+ * @returns {Promise<string>} Integrity hash or null if fetch fails
451
+ */
452
+ export async function generateIntegrityFromRegistry(packageName, version) {
453
+ try {
454
+ return await fetchPackumentIntegrity(packageName, version);
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Attempt to generate real integrity hash for a package
462
+ * Falls back to placeholder if generation fails
463
+ * @param {object} pkg - Package object with name and version
464
+ * @param {object} options - Options { tryRegistry: boolean }
465
+ * @returns {string} Integrity hash or placeholder
466
+ */
467
+ export async function generateOrPlaceholderIntegrity(pkg, options = {}) {
468
+ const { tryRegistry = false } = options;
469
+
470
+ if (!pkg || typeof pkg !== 'object') {
471
+ return 'sha512-PLACEHOLDER';
472
+ }
473
+
474
+ // If package already has integrity, return it
475
+ if (pkg.integrity) {
476
+ return pkg.integrity;
477
+ }
478
+
479
+ // Try registry if enabled and package has name/version
480
+ if (tryRegistry && pkg.name && pkg.version) {
481
+ try {
482
+ const hash = await generateIntegrityFromRegistry(pkg.name, pkg.version);
483
+ if (hash) {
484
+ return hash;
485
+ }
486
+ } catch {
487
+ // Silently fall through to placeholder
488
+ }
489
+ }
490
+
491
+ // Return placeholder
492
+ return 'sha512-PLACEHOLDER';
493
+ }
494
+
495
+ /**
496
+ * Check if an integrity string looks valid
497
+ * @param {string} integrity - Integrity string
498
+ * @returns {boolean} True if valid format
499
+ */
500
+ export function isValidIntegrity(integrity) {
501
+ if (!integrity || typeof integrity !== 'string') {
502
+ return false;
503
+ }
504
+ // Match 'sha512-<base64>' or 'sha256-<base64>' format
505
+ return /^sha(256|512)-[A-Za-z0-9+/]+={0,2}$/.test(integrity);
506
+ }
507
+
508
+ /**
509
+ * Check if integrity is a placeholder
510
+ * @param {string} integrity - Integrity string
511
+ * @returns {boolean} True if placeholder
512
+ */
513
+ export function isPlaceholder(integrity) {
514
+ return integrity && (
515
+ integrity.includes('PLACEHOLDER') ||
516
+ integrity === 'sha512-PLACEHOLDER' ||
517
+ integrity === 'sha256-PLACEHOLDER'
518
+ );
519
+ }