@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.
@@ -0,0 +1,419 @@
1
+ // src/checksum-fixer.js
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { detectLockfileVersion, LOCKFILE_VERSIONS, forEachPackageEntry } from './format-library.js';
5
+ import {
6
+ fetchPackumentIntegrity,
7
+ generateIntegrityFromFile,
8
+ isPlaceholder,
9
+ deriveRegistryBase,
10
+ DEFAULT_REGISTRY
11
+ } from './integrity.js';
12
+ import { hashPackageDirectory, collectPackageFiles } from './checker.js';
13
+
14
+ // Re-exported for back-compat; the canonical definition now lives in integrity.js
15
+ export { deriveRegistryBase } from './integrity.js';
16
+
17
+ export class ChecksumFixError extends Error {
18
+ constructor(message, code, context = {}) {
19
+ super(message);
20
+ this.name = 'ChecksumFixError';
21
+ this.code = code;
22
+ this.context = context;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Map items through an async fn with a concurrency cap.
28
+ * Results keep input order; rejections propagate.
29
+ */
30
+ async function mapWithConcurrency(items, limit, fn) {
31
+ const results = new Array(items.length);
32
+ let next = 0;
33
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
34
+ while (next < items.length) {
35
+ const index = next++;
36
+ results[index] = await fn(items[index], index);
37
+ }
38
+ });
39
+ await Promise.all(workers);
40
+ return results;
41
+ }
42
+
43
+ const NEEDS_FIX_REASONS = {
44
+ missing: (integrity) => !integrity,
45
+ placeholder: (integrity) => isPlaceholder(integrity),
46
+ sha1: (integrity) => typeof integrity === 'string' && integrity.startsWith('sha1-')
47
+ };
48
+
49
+ function needsFix(integrity) {
50
+ if (NEEDS_FIX_REASONS.missing(integrity)) return 'missing';
51
+ if (NEEDS_FIX_REASONS.placeholder(integrity)) return 'placeholder';
52
+ if (NEEDS_FIX_REASONS.sha1(integrity)) return 'sha1';
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Build a change record for a resolved hash. Centralizes the common shape so
58
+ * each source (registry, local-file, local-directory) reads identically.
59
+ */
60
+ function makeChange(key, entry, name, hash, source) {
61
+ return { packagePath: key, name, version: entry.version, from: entry.integrity || null, to: hash, source };
62
+ }
63
+
64
+ /**
65
+ * Classify a single lockfile entry into either a skip reason or a fix
66
+ * candidate. Returns the work item without recording it, so the caller owns
67
+ * the skipped/candidate buckets. file: tarballs become file-tarball
68
+ * candidates; file: directories are skipped.
69
+ */
70
+ function classifyEntry(info, baseDir) {
71
+ const { key, entry, name, isRoot, isWorkspaceSource, isLink, isBundled, isGitDep, isFileDep } = info;
72
+
73
+ if (isRoot) return { skip: 'root' };
74
+ if (isWorkspaceSource) return { skip: 'workspace' };
75
+ if (isLink) return { skip: 'link' };
76
+
77
+ const reason = needsFix(entry.integrity);
78
+ if (!reason) return { skip: 'valid' };
79
+
80
+ if (isBundled) return { skip: 'bundled' };
81
+ if (isGitDep) return { skip: 'git' };
82
+
83
+ if (isFileDep) {
84
+ // file: specs are relative to the lockfile's directory, not the cwd
85
+ const filePath = path.resolve(baseDir, entry.resolved.slice('file:'.length).replace(/^\/\//, ''));
86
+ if (/\.(tgz|tar\.gz|tar)$/i.test(filePath)) {
87
+ return { candidate: { key, entry, name, fixReason: reason, source: 'file-tarball', filePath } };
88
+ }
89
+ return { skip: 'file-dir' };
90
+ }
91
+
92
+ return { candidate: { key, entry, name, fixReason: reason, source: 'registry' } };
93
+ }
94
+
95
+ /**
96
+ * Walk every package entry, sorting them into skips and fix candidates.
97
+ */
98
+ function collectCandidates(lockfile, baseDir) {
99
+ const candidates = [];
100
+ const skipped = [];
101
+ let total = 0;
102
+
103
+ forEachPackageEntry(lockfile, (info) => {
104
+ total++;
105
+ const outcome = classifyEntry(info, baseDir);
106
+ if (outcome.skip) {
107
+ skipped.push({ packagePath: info.key, reason: outcome.skip });
108
+ } else {
109
+ candidates.push(outcome.candidate);
110
+ }
111
+ });
112
+
113
+ return { candidates, skipped, total };
114
+ }
115
+
116
+ /**
117
+ * Resolve a hash for a file-tarball candidate from the local tarball, pushing
118
+ * either a change or an unresolved record.
119
+ */
120
+ function resolveFileTarball(candidate, buckets) {
121
+ const { key, entry, name } = candidate;
122
+ const hash = generateIntegrityFromFile(candidate.filePath);
123
+ if (hash) {
124
+ buckets.changes.push(makeChange(key, entry, name, hash, 'local-file'));
125
+ } else {
126
+ buckets.unresolved.push({ packagePath: key, reason: `local tarball not readable: ${candidate.filePath}` });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Hash a registry candidate's local node_modules copy as a fallback. Records a
132
+ * local-directory change on success and returns true; returns false (leaving
133
+ * the entry unresolved) on any failure.
134
+ *
135
+ * Fix #17: derive the on-disk path from the lockfile key (the install path
136
+ * relative to the project root) rather than slicing at the last node_modules/
137
+ * segment. The old approach mapped nested packages (node_modules/a/node_modules/b)
138
+ * to the wrong hoisted location (node_modules/b). It also silently recorded a
139
+ * constant sha512-of-nothing when the directory was absent, because
140
+ * hashPackageDirectory swallows ENOENT rather than throwing. This function now
141
+ * enforces two invariants before delegating to the hasher:
142
+ * 1. Containment — the resolved path must stay inside baseDir (blocks traversal
143
+ * keys like node_modules/../../secret).
144
+ * 2. Existence — the directory must be present on disk; absent → unresolved.
145
+ */
146
+ async function tryLocalFallback(candidate, baseDir, buckets) {
147
+ const { key, entry, name } = candidate;
148
+
149
+ // The lockfile key is the install path relative to the project root, so
150
+ // joining directly with baseDir gives the correct on-disk location for both
151
+ // top-level (node_modules/foo) and nested (node_modules/a/node_modules/b)
152
+ // packages without any segment-slicing.
153
+ const resolvedBase = path.resolve(baseDir);
154
+ const pkgDir = path.resolve(path.join(baseDir, key));
155
+
156
+ // Containment check (textual): reject any key that resolves outside the
157
+ // project root (e.g. node_modules/../../secret).
158
+ if (!pkgDir.startsWith(resolvedBase + path.sep)) {
159
+ return false;
160
+ }
161
+
162
+ // Existence check: hashPackageDirectory digests zero bytes and returns a
163
+ // constant sha512-of-nothing for a missing directory rather than throwing —
164
+ // without this guard that garbage constant would be silently recorded.
165
+ if (!fs.existsSync(pkgDir)) {
166
+ return false;
167
+ }
168
+
169
+ // Symlink-aware containment: the textual resolve above can't see through a
170
+ // symlink at the package path (node_modules/x -> ../../outside). Resolve the
171
+ // real on-disk location and re-check it stays inside the (real) project root.
172
+ let realDir;
173
+ try {
174
+ realDir = fs.realpathSync(pkgDir);
175
+ } catch {
176
+ return false;
177
+ }
178
+ let realBase = resolvedBase;
179
+ try {
180
+ realBase = fs.realpathSync(resolvedBase);
181
+ } catch { /* base unreadable — fall back to the textual base */ }
182
+ // Require STRICT containment (a subpath), matching the textual check above. A
183
+ // package dir is always node_modules/... — never the project root itself — so
184
+ // a symlink resolving TO the root (which would hash the whole project) is
185
+ // correctly rejected rather than allowed by an equality carve-out.
186
+ if (!realDir.startsWith(realBase + path.sep)) {
187
+ return false;
188
+ }
189
+
190
+ // A non-directory at the path can't be a package dir.
191
+ try {
192
+ if (!fs.statSync(realDir).isDirectory()) return false;
193
+ } catch {
194
+ return false;
195
+ }
196
+
197
+ // An empty or interrupted-install directory yields zero hashable files, which
198
+ // hashPackageDirectory digests to the constant sha512-of-nothing — recording
199
+ // that as a "fix" is worse than leaving the entry unresolved. Use the same
200
+ // file-collection the hasher uses, so the emptiness check matches exactly.
201
+ const hashableFiles = await collectPackageFiles(realDir);
202
+ if (hashableFiles.length === 0) {
203
+ return false;
204
+ }
205
+
206
+ try {
207
+ const localHash = await hashPackageDirectory(realDir);
208
+ buckets.changes.push(makeChange(key, entry, name, localHash, 'local-directory'));
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Record why a registry candidate could not be resolved, distinguishing a
217
+ * network failure, a versionless entry, and a registry with no sha512 hash.
218
+ */
219
+ function recordUnresolved(candidate, networkError, localFallback, buckets) {
220
+ const { key, entry, name, fixReason } = candidate;
221
+ if (networkError) {
222
+ buckets.unresolved.push({ packagePath: key, reason: `registry unreachable (${networkError.message})${localFallback ? '' : '; consider --local-fallback'}` });
223
+ } else if (!entry.version) {
224
+ buckets.unresolved.push({ packagePath: key, reason: `cannot fix ${fixReason} integrity: entry has no version` });
225
+ } else {
226
+ buckets.unresolved.push({ packagePath: key, reason: `registry has no sha512 integrity for ${name}@${entry.version}` });
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Resolve a hash for a registry candidate: try the registry, then the optional
232
+ * local fallback, then record an unresolved reason.
233
+ */
234
+ async function resolveRegistryCandidate(candidate, settings, buckets) {
235
+ const { key, entry, name } = candidate;
236
+ const { fetcher, defaultRegistry, localFallback, baseDir } = settings;
237
+
238
+ const registryBase = deriveRegistryBase(entry.resolved, name) || defaultRegistry;
239
+ let hash = null;
240
+ let networkError = null;
241
+ try {
242
+ hash = entry.version ? await fetcher(name, entry.version, registryBase) : null;
243
+ } catch (e) {
244
+ networkError = e;
245
+ }
246
+
247
+ if (hash) {
248
+ buckets.changes.push(makeChange(key, entry, name, hash, 'registry'));
249
+ return;
250
+ }
251
+
252
+ if (localFallback && await tryLocalFallback(candidate, baseDir, buckets)) {
253
+ return;
254
+ }
255
+
256
+ recordUnresolved(candidate, networkError, localFallback, buckets);
257
+ }
258
+
259
+ /**
260
+ * Navigate to the node in the legacy v2 dependencies tree that corresponds to
261
+ * a packages-map key, returning the leaf object or null when the path is
262
+ * absent.
263
+ *
264
+ * "node_modules/foo" → dependencies["foo"]
265
+ * "node_modules/a/node_modules/b" → dependencies["a"]["dependencies"]["b"]
266
+ * "node_modules/@scope/pkg" → dependencies["@scope/pkg"]
267
+ */
268
+ function getDepsNode(dependencies, pkgKey) {
269
+ // Only handle keys rooted at "node_modules/…"; workspace-level keys are skipped.
270
+ if (!pkgKey.startsWith('node_modules/')) return null;
271
+ // Slice off the leading "node_modules/" then split the remainder by the
272
+ // nested-package delimiter to obtain the name chain.
273
+ // "node_modules/foo" → rest="foo" → names=["foo"]
274
+ // "node_modules/a/node_modules/b" → rest="a/node_modules/b" → names=["a","b"]
275
+ const names = pkgKey.slice('node_modules/'.length).split('/node_modules/');
276
+ let current = dependencies;
277
+ for (let i = 0; i < names.length - 1; i++) {
278
+ current = current[names[i]]?.dependencies;
279
+ if (!current) return null;
280
+ }
281
+ return current[names[names.length - 1]] ?? null;
282
+ }
283
+
284
+ /**
285
+ * Apply the collected integrity changes to a shallow copy of the lockfile.
286
+ *
287
+ * Fix #19: v2 lockfiles carry both a packages map and a legacy dependencies
288
+ * tree. The old code only updated packages, leaving the dependencies tree with
289
+ * stale hashes — the exact inconsistency the validator flags as an error.
290
+ * When lockfile.dependencies is present, each change is now mirrored into the
291
+ * corresponding node of the legacy tree so both sections stay consistent.
292
+ */
293
+ function applyChanges(lockfile, changes) {
294
+ const updated = {
295
+ ...lockfile,
296
+ packages: { ...lockfile.packages }
297
+ };
298
+
299
+ // Deep-copy the legacy dependencies tree once so we can mutate the copy
300
+ // without touching the input lockfile.
301
+ const updatedDeps = lockfile.dependencies
302
+ ? JSON.parse(JSON.stringify(lockfile.dependencies))
303
+ : null;
304
+
305
+ for (const change of changes) {
306
+ updated.packages[change.packagePath] = {
307
+ ...updated.packages[change.packagePath],
308
+ integrity: change.to
309
+ };
310
+
311
+ // Mirror into the v2 legacy dependencies tree when present.
312
+ if (updatedDeps) {
313
+ const node = getDepsNode(updatedDeps, change.packagePath);
314
+ if (node) {
315
+ node.integrity = change.to;
316
+ }
317
+ }
318
+ }
319
+
320
+ if (updatedDeps) {
321
+ updated.dependencies = updatedDeps;
322
+ }
323
+
324
+ return updated;
325
+ }
326
+
327
+ /**
328
+ * Fill missing, placeholder, or weak (sha1) integrity hashes with real ones.
329
+ * Fetches the authoritative dist.integrity from the package's registry
330
+ * (derived per-package from its resolved URL); optionally falls back to
331
+ * hashing the local node_modules copy — those hashes are flagged because
332
+ * they are NOT npm tarball hashes and npm ci will not verify them.
333
+ *
334
+ * @param {object} lockfile - Parsed lockfile (v2/v3)
335
+ * @param {object} options
336
+ * @returns {Promise<{lockfile, changes, unresolved, skipped, warnings, summary}>}
337
+ */
338
+ export async function fixChecksums(lockfile, options = {}) {
339
+ const {
340
+ onProgress = null,
341
+ concurrency = 8,
342
+ timeoutMs = 10000,
343
+ localFallback = false,
344
+ // `nodeModulesPath` is still accepted in options for API compat but no longer
345
+ // used — the on-disk path is derived from baseDir + the lockfile key (fix #17).
346
+ defaultRegistry = DEFAULT_REGISTRY,
347
+ fetchIntegrity = null,
348
+ baseDir = '.'
349
+ } = options;
350
+
351
+ if (detectLockfileVersion(lockfile) === LOCKFILE_VERSIONS.V1) {
352
+ throw new ChecksumFixError(
353
+ 'v1 lockfiles are not supported; run `npm-check migrate 3` first',
354
+ 'UNSUPPORTED_VERSION'
355
+ );
356
+ }
357
+
358
+ const fetcher = fetchIntegrity ||
359
+ ((name, ver, registryBase) => fetchPackumentIntegrity(name, ver, { registryBase, timeoutMs }));
360
+
361
+ const { candidates, skipped, total } = collectCandidates(lockfile, baseDir);
362
+
363
+ const buckets = { changes: [], unresolved: [], warnings: [] };
364
+ let completed = 0;
365
+
366
+ const reportProgress = (stage) => {
367
+ if (onProgress) {
368
+ onProgress({
369
+ current: completed,
370
+ total: candidates.length,
371
+ percentage: candidates.length === 0 ? 100 : Math.round((completed / candidates.length) * 100),
372
+ stage
373
+ });
374
+ }
375
+ };
376
+
377
+ reportProgress('Fetching integrity hashes');
378
+
379
+ const settings = { fetcher, defaultRegistry, localFallback, baseDir };
380
+ await mapWithConcurrency(candidates, concurrency, async (candidate) => {
381
+ try {
382
+ if (candidate.source === 'file-tarball') {
383
+ resolveFileTarball(candidate, buckets);
384
+ } else {
385
+ await resolveRegistryCandidate(candidate, settings, buckets);
386
+ }
387
+ } finally {
388
+ completed++;
389
+ reportProgress('Fetching integrity hashes');
390
+ }
391
+ });
392
+
393
+ const { changes, unresolved, warnings } = buckets;
394
+
395
+ const localDirCount = changes.filter((c) => c.source === 'local-directory').length;
396
+ if (localDirCount > 0) {
397
+ warnings.push(
398
+ `${localDirCount} hash(es) were computed from node_modules directories; these are NOT npm ` +
399
+ 'tarball hashes and `npm ci` will fail integrity verification against the registry. ' +
400
+ 'Use only for air-gapped/internal verification.'
401
+ );
402
+ }
403
+
404
+ return {
405
+ lockfile: applyChanges(lockfile, changes),
406
+ changes,
407
+ unresolved,
408
+ skipped,
409
+ warnings,
410
+ summary: {
411
+ total,
412
+ candidates: candidates.length,
413
+ fixedFromRegistry: changes.filter((c) => c.source === 'registry').length,
414
+ fixedFromLocal: changes.filter((c) => c.source === 'local-directory' || c.source === 'local-file').length,
415
+ unresolved: unresolved.length,
416
+ skipped: skipped.length
417
+ }
418
+ };
419
+ }