fad-checker 2.1.0 → 2.1.2

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/fad-checker.js CHANGED
@@ -20,7 +20,10 @@ const ui = require("./lib/ui");
20
20
 
21
21
  const core = require("./lib/core");
22
22
 
23
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"));
23
+ // require() (not fs.readFileSync) so bun --compile statically bundles package.json
24
+ // into the binary — otherwise the compiled exe tries to read it off disk at runtime
25
+ // (from $bunfs/root) and crashes with ENOENT. Keeps the bun builds fully standalone.
26
+ const pkg = require("./package.json");
24
27
 
25
28
  // -------- bash/zsh completion shortcut (must run before required-options parse) --------
26
29
  if (process.argv.includes("--completion")) {
@@ -269,20 +272,87 @@ if (options.src && options.target) {
269
272
  }
270
273
  }
271
274
 
272
- async function checkMavenLibExist(groupId, artifactId, repos) {
275
+ // Maven Central presence cache (~/.fad-checker/maven-exists-cache.json) — keyed by
276
+ // "g:a", value true (on a repo) / false (absent → likely private). Persisted so an
277
+ // online warm-up populates it, --export-cache ships it, and an --offline air-gapped
278
+ // run reads it instead of probing the network. Returns:
279
+ // true → present on a configured repo
280
+ // false → absent (likely private)
281
+ // null → unknown (offline + not cached, or probe error) — caller must NOT guess
282
+ const MAVEN_EXISTS_CACHE_PATH = require("path").join(require("./lib/outdated").CACHE_DIR, "maven-exists-cache.json");
283
+ const MAVEN_EXISTS_MAX_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
284
+
285
+ async function checkMavenLibExist(groupId, artifactId, repos, cache, opts = {}) {
273
286
  const g = core.coord(groupId);
274
287
  const a = core.coord(artifactId);
275
- if (!g || !a) return false;
288
+ if (!g || !a) return null;
289
+ const key = `${g}:${a}`;
290
+ if (cache && Object.prototype.hasOwnProperty.call(cache.entries, key)) return cache.entries[key];
291
+ if (opts.offline) return null; // air-gapped + not warmed: honestly unknown, never network
276
292
  const p = `${g.replace(/\./g, "/")}/${a}/maven-metadata.xml`;
277
293
  const { existsInAny } = require("./lib/maven-repo");
278
294
  try {
279
295
  const hit = await existsInAny(repos, p, { userAgent: "fad-checker-existence" });
280
- if (hit) return true;
281
- if (verbose) console.log(chalk.dim(` not on any repo: ${g}:${a}`));
282
- return false;
296
+ if (cache) cache.entries[key] = !!hit;
297
+ if (!hit && verbose) console.log(chalk.dim(` not on any repo: ${g}:${a}`));
298
+ return !!hit;
283
299
  } catch (err) {
284
300
  if (verbose) console.info(chalk.dim(` error querying repos: ${g}:${a} — ${err.message}`));
285
- return false;
301
+ return null; // probe failed → unknown, don't poison the cache or mislabel as private
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Build an onProgress callback for the embedded-JAR scan so the user sees what's
307
+ * happening — the scan reads + unzips archives synchronously and can block for a
308
+ * while on big or numerous fat-jars (incl. the silent recursion through a fat-jar's
309
+ * bundled libs). The scanner reports EVERY archive (top-level + nested):
310
+ * - On a TTY: one transient line, rewritten in place, naming the archive being
311
+ * read right now (so a long pause clearly points at the culprit). Cleared at end.
312
+ * - Off a TTY (CI/pipe): a throttled line every ~250 archives so logs show forward
313
+ * motion without being spammed, plus a start and a final summary line.
314
+ * Returns a fresh closure per codec.
315
+ */
316
+ function makeJarProgress() {
317
+ let total = 0, lastLogged = 0;
318
+ const STEP = 250;
319
+ return (ev) => {
320
+ if (!ev) return;
321
+ if (ev.phase === "start") {
322
+ total = ev.total || 0;
323
+ if (total && !ui.isTTY) ui.info(chalk.dim(`scanning ${total} embedded archive(s) (.jar/.war/.ear)…`));
324
+ } else if (ev.phase === "scan" && total) {
325
+ if (ui.isTTY) {
326
+ const head = total ? `${ev.scanned}/${total}+` : String(ev.scanned);
327
+ process.stdout.write(`\r ${chalk.dim("·")} ${chalk.dim(`reading embedded JARs (${head})`)} ${chalk.dim(ev.path)}\x1b[K`);
328
+ } else if (ev.scanned - lastLogged >= STEP) {
329
+ lastLogged = ev.scanned;
330
+ ui.info(chalk.dim(`… ${ev.scanned} archive(s) read (current: ${ev.path})`));
331
+ }
332
+ } else if (ev.phase === "done") {
333
+ if (total && ui.isTTY) process.stdout.write("\r\x1b[K");
334
+ else if (total && !ui.isTTY) ui.info(chalk.dim(`scanned ${ev.scanned} archive(s) → ${ev.found} embedded coord(s)`));
335
+ }
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Run `fn` (sync or async) while telling the user which phase is in flight, so a
341
+ * long pause is attributable instead of a silent hang. TTY: a transient line that's
342
+ * cleared when done. Non-TTY: a plain "· <label> …" line. Either way, a phase that
343
+ * takes >3s prints "· <label> took Ns" so slow steps self-report even without -v.
344
+ */
345
+ async function timedPhase(label, fn) {
346
+ if (ui.isTTY) process.stdout.write(` ${chalk.dim("·")} ${chalk.dim(label + " …")}\x1b[K`);
347
+ else ui.info(chalk.dim(label + " …"));
348
+ const t0 = Date.now();
349
+ try {
350
+ return await fn();
351
+ } finally {
352
+ const ms = Date.now() - t0;
353
+ if (ui.isTTY) process.stdout.write("\r\x1b[K");
354
+ if (ms > 3000) ui.info(chalk.dim(`${label} took ${(ms / 1000).toFixed(1)}s`));
355
+ else if (verbose) ui.info(chalk.dim(`${label} done in ${ms}ms`));
286
356
  }
287
357
  }
288
358
 
@@ -332,13 +402,18 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
332
402
  const { detectCodecs, allCodecs, getCodec } = require("./lib/codecs");
333
403
  const { resolveActiveCodecs } = require("./lib/codecs/select");
334
404
  const eco = (options.ecosystem || "auto").toLowerCase();
335
- const detected = (eco === "auto") ? detectCodecs(options.src).map(c => c.id) : allCodecs().map(c => c.id);
405
+ const detected = (eco === "auto")
406
+ ? (await timedPhase("detecting ecosystems", () => detectCodecs(options.src))).map(c => c.id)
407
+ : allCodecs().map(c => c.id);
336
408
  const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
337
409
  const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
338
410
  const runMaven = activeIds.includes("maven");
339
411
  const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
340
412
 
341
413
  // --- Collect deps from every active codec into one Map (coordKeys never collide) ---
414
+ // Section header first so the embedded-JAR scan can print live progress under it
415
+ // (the scan reads + unzips archives synchronously and would otherwise block silently).
416
+ ui.section("Collection");
342
417
  const resolved = new Map();
343
418
  let mavenCtx = null;
344
419
  const collectWarnings = [];
@@ -347,7 +422,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
347
422
  const codec = getCodec(id);
348
423
  let res;
349
424
  try {
350
- res = await codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose, scanJars: options.jars !== false, srcRoot: options.src });
425
+ res = await timedPhase(`collecting ${codec.label || id}`, () => codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose, scanJars: options.jars !== false, srcRoot: options.src, onJarProgress: makeJarProgress() }));
351
426
  } catch (err) {
352
427
  console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
353
428
  continue;
@@ -358,7 +433,6 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
358
433
  }
359
434
 
360
435
  // --- Collection summary ---
361
- ui.section("Collection");
362
436
  const ecoCount = {};
363
437
  let embeddedCount = 0;
364
438
  for (const d of resolved.values()) {
@@ -432,7 +506,18 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
432
506
  ui.ok("no missing Maven parent POMs");
433
507
  }
434
508
 
509
+ // Private-lib detection asks each configured repo whether a missing coord
510
+ // exists (→ absent = likely private). Results are cached + bundled, so an
511
+ // --offline run reads the online-warmed cache instead of probing the network.
512
+ // A coord that's neither cached nor probeable (offline + cold) stays UNKNOWN —
513
+ // we never fake it as "private", which is what made offline both wrong and slow.
435
514
  if (options.allLibs) {
515
+ const { loadJsonCache, saveJsonCache } = require("./lib/outdated");
516
+ const existsCache = loadJsonCache(MAVEN_EXISTS_CACHE_PATH);
517
+ const fresh = existsCache.meta?.fetchedAt && (Date.now() - existsCache.meta.fetchedAt) < MAVEN_EXISTS_MAX_AGE_MS;
518
+ if (!fresh && !options.offline) existsCache.entries = {}; // refresh stale probes when online
519
+ if (!existsCache.entries) existsCache.entries = {};
520
+
436
521
  const anyMissingLibs = Object.keys(allPomMetadata.anyMissingById)
437
522
  .filter(id => {
438
523
  const parts = id.split(":");
@@ -442,14 +527,20 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
442
527
  const limit = pLimit(10);
443
528
  const results = await Promise.all(anyMissingLibs.map(id => {
444
529
  const [g, a] = id.split(":");
445
- return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos) }));
530
+ return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos, existsCache, { offline: options.offline }) }));
446
531
  }));
447
- for (const r of results) if (r && r.found === false) privateLibIds.push(r.id);
532
+ let unknown = 0;
533
+ for (const r of results) {
534
+ if (r.found === false) privateLibIds.push(r.id);
535
+ else if (r.found === null) unknown++;
536
+ }
537
+ if (!options.offline) { existsCache.meta = { fetchedAt: Date.now() }; saveJsonCache(MAVEN_EXISTS_CACHE_PATH, existsCache); }
448
538
  if (privateLibIds.length) {
449
539
  ui.warn(`${privateLibIds.length} lib(s) absent from Maven Central (likely private):`);
450
540
  for (const id of privateLibIds.slice(0, 10)) ui.info(chalk.magenta(id));
451
541
  if (privateLibIds.length > 10) ui.info(chalk.dim(`…and ${privateLibIds.length - 10} more`));
452
542
  }
543
+ if (unknown) ui.info(chalk.dim(`${unknown} lib(s) not in the presence cache — run online once (or --export-cache from an online host) to classify them`));
453
544
  }
454
545
 
455
546
  if (deps2Exclude) {
@@ -8,6 +8,8 @@
8
8
  * @author: N.BRAUN
9
9
  * @email: pp9ping@gmail.com
10
10
  */
11
+ const fs = require("fs");
12
+ const path = require("path");
11
13
  const { assertCodecShape } = require("./codec.interface");
12
14
  const maven = require("./maven.codec");
13
15
  const npm = require("./npm.codec");
@@ -33,10 +35,72 @@ function allCodecs() {
33
35
  return [...REGISTRY.values()].sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
34
36
  }
35
37
 
38
+ // Dirs skipped during detection. Starts from the INTERSECTION of every codec's own
39
+ // skip set (a dir skipped here only if ALL codecs skip it → detection never misses a
40
+ // manifest a codec would have found) plus well-known dependency/cache/tooling dirs
41
+ // that are NEVER a project root (all dot-dirs or vendored-dep caches), so detection
42
+ // can prune them safely. NOTE: "build" / "vendor" / "bin" / "obj" are deliberately
43
+ // NOT here — maven keeps build/ for BOMs and several codecs scan vendor/.
44
+ const DETECT_SKIP = new Set([
45
+ "node_modules", ".git", ".idea", ".vscode", "target", "dist", "out", // intersection
46
+ ".svn", ".hg", ".gradle", ".mvn", ".cache", ".m2", "bower_components", // never a project root
47
+ "jspm_packages", "__pycache__", ".venv", ".tox", ".mypy_cache", "coverage", ".next", ".nuxt",
48
+ ]);
49
+
50
+ const DETECT_CONCURRENCY = 48;
51
+
36
52
  // yarn est détecté via le même arbre JS que npm ; on ne le renvoie pas en
37
53
  // doublon de détection (npm.collect ramasse déjà yarn.lock).
38
- function detectCodecs(dir) {
39
- return allCodecs().filter(c => c.id !== "yarn" && c.detect(dir));
54
+ //
55
+ // ONE shared walk instead of one full-tree walk per codec: we descend the tree once
56
+ // and, per file, test it against each codec's manifestNames (supporting "*.ext"
57
+ // globs). Short-circuits as soon as every codec has matched. readdir runs with bounded
58
+ // concurrency — on a high-latency filesystem (WSL/9p, network mounts) the walk is
59
+ // latency-bound, so issuing many readdirs at once is far faster than a serial walk.
60
+ async function detectCodecs(dir) {
61
+ const codecs = allCodecs().filter(c => c.id !== "yarn");
62
+ const matchers = codecs.map(c => {
63
+ const exact = new Set();
64
+ const exts = [];
65
+ for (const n of c.manifestNames || []) {
66
+ if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
67
+ else exact.add(n);
68
+ }
69
+ return { codec: c, exact, exts, found: false };
70
+ });
71
+ let remaining = matchers.length;
72
+ const queue = [dir];
73
+ let active = 0;
74
+
75
+ await new Promise(resolve => {
76
+ let settled = false;
77
+ const finish = () => { if (!settled) { settled = true; resolve(); } };
78
+ const pump = () => {
79
+ if (settled) return;
80
+ if (remaining === 0) return finish();
81
+ if (queue.length === 0 && active === 0) return finish();
82
+ while (active < DETECT_CONCURRENCY && queue.length && remaining > 0) {
83
+ const cur = queue.pop();
84
+ active++;
85
+ fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
86
+ for (const e of entries) {
87
+ if (e.isDirectory()) {
88
+ if (!DETECT_SKIP.has(e.name)) queue.push(path.join(cur, e.name));
89
+ continue;
90
+ }
91
+ if (!e.isFile()) continue;
92
+ const name = e.name;
93
+ for (const m of matchers) {
94
+ if (m.found) continue;
95
+ if (m.exact.has(name) || m.exts.some(ext => name.endsWith(ext))) { m.found = true; remaining--; }
96
+ }
97
+ }
98
+ }).catch(() => { /* unreadable dir → skip */ }).finally(() => { active--; pump(); });
99
+ }
100
+ };
101
+ pump();
102
+ });
103
+ return matchers.filter(m => m.found).map(m => m.codec);
40
104
  }
41
105
 
42
106
  module.exports = { getCodec, allCodecs, detectCodecs, ORDER };
@@ -24,7 +24,7 @@
24
24
  */
25
25
  const fs = require("fs");
26
26
  const path = require("path");
27
- const { unzipSync, strFromU8 } = require("fflate");
27
+ const { unzipSync, inflateSync, strFromU8 } = require("fflate");
28
28
  const { makeDepRecord } = require("../../dep-record");
29
29
 
30
30
  const ARCHIVE_RE = /\.(jar|war|ear)$/i;
@@ -33,6 +33,15 @@ const SKIP_DIRS = new Set(["node_modules", ".git", "target", "build", "out", "di
33
33
  const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
34
34
  const MAX_DEPTH = 8;
35
35
 
36
+ // The ONLY entries we ever decompress out of an archive: its own Maven coordinate
37
+ // (pom.properties), its manifest, and any NESTED archive (a fat-jar's bundled libs,
38
+ // which we recurse into). Everything else — .class files, resources — is skipped.
39
+ function wantedEntry(name) {
40
+ return /^META-INF\/maven\/[^/]+\/[^/]+\/pom\.properties$/.test(name)
41
+ || name === "META-INF/MANIFEST.MF"
42
+ || ARCHIVE_RE.test(name);
43
+ }
44
+
36
45
  /** Parse a META-INF/maven/.../pom.properties body → { groupId, artifactId, version } | null. */
37
46
  function coordFromPomProperties(text) {
38
47
  const get = re => (String(text || "").match(re) || [])[1]?.trim();
@@ -90,6 +99,72 @@ function readEntries(buf, filter) {
90
99
  }
91
100
  }
92
101
 
102
+ const EOCD_SIG = 0x06054b50; // End Of Central Directory
103
+ const CEN_SIG = 0x02014b50; // central directory file header
104
+ const LOC_SIG = 0x04034b50; // local file header
105
+
106
+ /**
107
+ * Read ONLY the `filter`-matched entries of an on-disk zip via positioned reads on
108
+ * the file descriptor — without ever loading the whole archive into RAM. We read
109
+ * the End-Of-Central-Directory record, then the central directory, then seek to and
110
+ * inflate just the wanted entries. A 200 MB library jar with a 1 KB pom.properties
111
+ * costs a few KB of reads instead of 200 MB; its .class files are never touched.
112
+ *
113
+ * Throws on anything this minimal parser doesn't handle (ZIP64, encryption, an
114
+ * unknown compression method, a malformed record) so the caller can fall back to
115
+ * the battle-tested fflate whole-buffer path. Sizes are taken from the central
116
+ * directory (authoritative even when a streaming writer zeroes the local header).
117
+ *
118
+ * @returns {Object<string, Uint8Array>} matched entries, same shape as readEntries()
119
+ */
120
+ function readZipEntriesFromFd(fd, fileSize, filter) {
121
+ const back = Math.min(fileSize, 65557); // max EOCD comment (65535) + 22-byte record
122
+ const tail = Buffer.allocUnsafe(back);
123
+ fs.readSync(fd, tail, 0, back, fileSize - back);
124
+ let e = -1;
125
+ for (let i = tail.length - 22; i >= 0; i--) {
126
+ if (tail.readUInt32LE(i) === EOCD_SIG) { e = i; break; }
127
+ }
128
+ if (e < 0) throw new Error("no EOCD record");
129
+ const count = tail.readUInt16LE(e + 10);
130
+ const cdSize = tail.readUInt32LE(e + 12);
131
+ const cdOff = tail.readUInt32LE(e + 16);
132
+ if (count === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff) throw new Error("ZIP64");
133
+
134
+ const cd = Buffer.allocUnsafe(cdSize);
135
+ fs.readSync(fd, cd, 0, cdSize, cdOff);
136
+ const out = {};
137
+ let p = 0;
138
+ for (let i = 0; i < count; i++) {
139
+ if (p + 46 > cd.length || cd.readUInt32LE(p) !== CEN_SIG) throw new Error("bad central record");
140
+ const flags = cd.readUInt16LE(p + 8);
141
+ const method = cd.readUInt16LE(p + 10);
142
+ const compSize = cd.readUInt32LE(p + 20);
143
+ const uncompSize = cd.readUInt32LE(p + 24);
144
+ const nameLen = cd.readUInt16LE(p + 28);
145
+ const extraLen = cd.readUInt16LE(p + 30);
146
+ const commentLen = cd.readUInt16LE(p + 32);
147
+ const localOff = cd.readUInt32LE(p + 42);
148
+ const name = cd.toString("utf8", p + 46, p + 46 + nameLen);
149
+ p += 46 + nameLen + extraLen + commentLen;
150
+ if (!filter(name)) continue;
151
+ if (flags & 0x1) throw new Error("encrypted entry");
152
+ if (compSize === 0xffffffff || uncompSize === 0xffffffff || localOff === 0xffffffff) throw new Error("ZIP64 entry");
153
+ // The local header's name/extra lengths can differ from the central record's,
154
+ // so read it to locate the actual start of the entry's data.
155
+ const loc = Buffer.allocUnsafe(30);
156
+ fs.readSync(fd, loc, 0, 30, localOff);
157
+ if (loc.readUInt32LE(0) !== LOC_SIG) throw new Error("bad local header");
158
+ const dataOff = localOff + 30 + loc.readUInt16LE(26) + loc.readUInt16LE(28);
159
+ const comp = compSize > 0 ? Buffer.allocUnsafe(compSize) : Buffer.alloc(0);
160
+ if (compSize > 0) fs.readSync(fd, comp, 0, compSize, dataOff);
161
+ if (method === 0) out[name] = comp; // stored
162
+ else if (method === 8) out[name] = inflateSync(comp, { out: new Uint8Array(uncompSize) }); // deflate
163
+ else throw new Error("compression method " + method);
164
+ }
165
+ return out;
166
+ }
167
+
93
168
  /**
94
169
  * Resolve a single archive's own coordinate + confidence from its entries.
95
170
  * Returns { coord, source } | null. `source` ∈ pom.properties|manifest|filename.
@@ -115,24 +190,14 @@ function coordForArchive(entries, fileName) {
115
190
  }
116
191
 
117
192
  /**
118
- * Recursively scan one archive buffer. Pushes embedded depRecords into `out` and
119
- * warnings into `warnings`. `displayPath` is the `!/`-joined logical path.
193
+ * Given an archive's already-extracted entries, record its coordinate and recurse
194
+ * into any nested archives. `ctx` carries { out, warnings, onProgress, scanned }.
120
195
  */
121
- function scanArchiveBuffer(buf, displayPath, fileName, out, warnings, depth) {
122
- if (depth > MAX_DEPTH) { warnings.push({ type: "embedded-jar", message: `nesting too deep, skipped: ${displayPath}` }); return; }
123
- if (buf.length > MAX_ARCHIVE_BYTES) { warnings.push({ type: "embedded-jar", message: `archive too large (${Math.round(buf.length / 1048576)}MB), skipped: ${displayPath}` }); return; }
124
-
125
- // Read only what we need: this archive's identity files + any nested archives.
126
- const entries = readEntries(buf, name =>
127
- /^META-INF\/maven\/[^/]+\/[^/]+\/pom\.properties$/.test(name) ||
128
- name === "META-INF/MANIFEST.MF" ||
129
- ARCHIVE_RE.test(name));
130
- if (entries === null) { warnings.push({ type: "embedded-jar", message: `could not read archive (corrupt/unsupported): ${displayPath}` }); return; }
131
-
196
+ function processEntries(entries, displayPath, fileName, ctx, depth) {
132
197
  const resolved = coordForArchive(entries, fileName);
133
198
  if (resolved) {
134
- const { coord, source } = resolved;
135
- out.push(makeDepRecord({
199
+ const { coord } = resolved;
200
+ ctx.out.push(makeDepRecord({
136
201
  ecosystem: "maven",
137
202
  namespace: coord.groupId || "",
138
203
  name: coord.artifactId,
@@ -142,7 +207,7 @@ function scanArchiveBuffer(buf, displayPath, fileName, out, warnings, depth) {
142
207
  provenance: "embedded",
143
208
  }));
144
209
  } else {
145
- warnings.push({ type: "embedded-jar", message: `embedded archive with no resolvable Maven coordinate (not scanned): ${displayPath}` });
210
+ ctx.warnings.push({ type: "embedded-jar", message: `embedded archive with no resolvable Maven coordinate (not scanned): ${displayPath}` });
146
211
  }
147
212
 
148
213
  // Recurse into nested archives (fat-jar libs).
@@ -150,10 +215,62 @@ function scanArchiveBuffer(buf, displayPath, fileName, out, warnings, depth) {
150
215
  if (!ARCHIVE_RE.test(name)) continue;
151
216
  const nestedBuf = entries[name];
152
217
  if (!nestedBuf || !nestedBuf.length) continue;
153
- scanArchiveBuffer(Buffer.from(nestedBuf), `${displayPath}!/${name}`, path.basename(name), out, warnings, depth + 1);
218
+ scanArchiveBuffer(Buffer.from(nestedBuf), `${displayPath}!/${name}`, path.basename(name), ctx, depth + 1);
154
219
  }
155
220
  }
156
221
 
222
+ /**
223
+ * Recursively scan one archive held in memory (a nested fat-jar lib). Only the
224
+ * coordinate/manifest/nested-archive entries are decompressed (via wantedEntry).
225
+ * `displayPath` is the `!/`-joined logical path.
226
+ */
227
+ function scanArchiveBuffer(buf, displayPath, fileName, ctx, depth) {
228
+ if (depth > MAX_DEPTH) { ctx.warnings.push({ type: "embedded-jar", message: `nesting too deep, skipped: ${displayPath}` }); return; }
229
+ if (buf.length > MAX_ARCHIVE_BYTES) { ctx.warnings.push({ type: "embedded-jar", message: `archive too large (${Math.round(buf.length / 1048576)}MB), skipped: ${displayPath}` }); return; }
230
+ // Report before the (blocking) inflate so the displayed line names this archive.
231
+ ctx.scanned++;
232
+ ctx.onProgress?.({ phase: "scan", scanned: ctx.scanned, total: ctx.total, path: displayPath });
233
+
234
+ const entries = readEntries(buf, wantedEntry);
235
+ if (entries === null) { ctx.warnings.push({ type: "embedded-jar", message: `could not read archive (corrupt/unsupported): ${displayPath}` }); return; }
236
+ processEntries(entries, displayPath, fileName, ctx, depth);
237
+ }
238
+
239
+ /**
240
+ * Scan one TOP-LEVEL on-disk archive. Tries the lazy positioned-read path first
241
+ * (reads only the central directory + wanted entries — never the whole file), and
242
+ * falls back to reading the whole file + fflate on any case the lazy parser can't
243
+ * handle (ZIP64, encryption, corruption).
244
+ */
245
+ function scanArchiveFile(abs, displayPath, fileName, ctx) {
246
+ ctx.scanned++;
247
+ ctx.onProgress?.({ phase: "scan", scanned: ctx.scanned, total: ctx.total, path: displayPath });
248
+
249
+ let size;
250
+ try { size = fs.statSync(abs).size; }
251
+ catch (e) { ctx.warnings.push({ type: "embedded-jar", message: `could not stat ${abs}: ${e.message}` }); return; }
252
+ // Skip oversized archives WITHOUT pulling them into RAM.
253
+ if (size > MAX_ARCHIVE_BYTES) { ctx.warnings.push({ type: "embedded-jar", message: `archive too large (${Math.round(size / 1048576)}MB), skipped: ${displayPath}` }); return; }
254
+
255
+ // Fast path: lazy central-directory read (no whole-file load, no .class inflate).
256
+ let fd;
257
+ try {
258
+ fd = fs.openSync(abs, "r");
259
+ const entries = readZipEntriesFromFd(fd, size, wantedEntry);
260
+ processEntries(entries, displayPath, fileName, ctx, 0);
261
+ return;
262
+ } catch { /* fall back to the whole-buffer path below */ }
263
+ finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
264
+
265
+ // Fallback: read the whole file + fflate (handles ZIP64 etc.).
266
+ let buf;
267
+ try { buf = fs.readFileSync(abs); }
268
+ catch (e) { ctx.warnings.push({ type: "embedded-jar", message: `could not read ${abs}: ${e.message}` }); return; }
269
+ const entries = readEntries(buf, wantedEntry);
270
+ if (entries === null) { ctx.warnings.push({ type: "embedded-jar", message: `could not read archive (corrupt/unsupported): ${displayPath}` }); return; }
271
+ processEntries(entries, displayPath, fileName, ctx, 0);
272
+ }
273
+
157
274
  /** Recursively list archive file paths under `dir`. */
158
275
  function findArchives(dir, acc = []) {
159
276
  let entries;
@@ -170,22 +287,49 @@ function findArchives(dir, acc = []) {
170
287
  return acc;
171
288
  }
172
289
 
290
+ // Parallel equivalent of findArchives — concurrent readdir so a deep tree on a
291
+ // high-latency filesystem isn't walked one round-trip at a time.
292
+ async function findArchivesAsync(dir) {
293
+ const { walkDirs } = require("../../parallel-walk");
294
+ const acc = [];
295
+ await walkDirs(dir, {
296
+ skipDir: name => SKIP_DIRS.has(name) || name.startsWith("."),
297
+ onDir: (cur, entries) => {
298
+ for (const e of entries) if (e.isFile() && ARCHIVE_RE.test(e.name)) acc.push(path.join(cur, e.name));
299
+ },
300
+ });
301
+ return acc;
302
+ }
303
+
173
304
  /**
174
305
  * Scan `rootDir` for embedded JAR/WAR/EAR coordinates.
306
+ *
307
+ * Reading + unzipping is synchronous and can be slow on a tree with many or large
308
+ * fat-jars, so `opts.onProgress` is invoked around the work — for EVERY archive,
309
+ * nested ones included — so the caller can tell the user what's happening (it would
310
+ * otherwise block silently, especially while recursing through a fat-jar's libs):
311
+ * onProgress({ phase: "start", total }) once; total = top-level archive count
312
+ * onProgress({ phase: "scan", scanned, total, path }) before each archive is read (top-level + nested)
313
+ * onProgress({ phase: "done", total, found, scanned }) once, when finished
314
+ *
175
315
  * @returns { deps: depRecord[], warnings: [{type,message}] }
176
316
  */
177
- function scanEmbeddedJars(rootDir, opts = {}) {
178
- const out = [];
179
- const warnings = [];
180
- const archives = findArchives(rootDir);
317
+ async function scanEmbeddedJars(rootDir, opts = {}) {
318
+ const archives = await findArchivesAsync(rootDir);
319
+ const ctx = {
320
+ out: [],
321
+ warnings: [],
322
+ onProgress: typeof opts.onProgress === "function" ? opts.onProgress : null,
323
+ scanned: 0,
324
+ total: archives.length,
325
+ };
326
+ ctx.onProgress?.({ phase: "start", total: archives.length });
181
327
  for (const abs of archives) {
182
- let buf;
183
- try { buf = fs.readFileSync(abs); }
184
- catch (e) { warnings.push({ type: "embedded-jar", message: `could not read ${abs}: ${e.message}` }); continue; }
185
- const rel = opts.srcRoot ? path.relative(opts.srcRoot, abs) : abs;
186
- scanArchiveBuffer(buf, rel.split(path.sep).join("/"), path.basename(abs), out, warnings, 0);
328
+ const rel = (opts.srcRoot ? path.relative(opts.srcRoot, abs) : abs).split(path.sep).join("/");
329
+ scanArchiveFile(abs, rel, path.basename(abs), ctx);
187
330
  }
188
- return { deps: out, warnings };
331
+ ctx.onProgress?.({ phase: "done", total: archives.length, found: ctx.out.length, scanned: ctx.scanned });
332
+ return { deps: ctx.out, warnings: ctx.warnings };
189
333
  }
190
334
 
191
335
  module.exports = {
@@ -196,4 +340,6 @@ module.exports = {
196
340
  coordFromFilename,
197
341
  coordForArchive,
198
342
  scanArchiveBuffer,
343
+ readZipEntriesFromFd,
344
+ findArchivesAsync,
199
345
  };
@@ -40,7 +40,7 @@ module.exports = {
40
40
  // Expose _maven (store/propsByPom/pomFiles) pour que l'orchestrateur garde la
41
41
  // phase de réécriture des POM nettoyés (lib/core.rewritePoms).
42
42
  async collect(dir, opts = {}) {
43
- const pomFiles = core.findPomFiles(dir);
43
+ const pomFiles = await core.findPomFilesAsync(dir);
44
44
  const store = core.newMetadataStore();
45
45
  const propsByPom = {};
46
46
  for (const pom of pomFiles) {
@@ -58,7 +58,7 @@ module.exports = {
58
58
  if (opts.scanJars !== false) {
59
59
  try {
60
60
  const { scanEmbeddedJars } = require("./maven/jar-scan");
61
- const { deps: embedded, warnings: jarWarnings } = scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir });
61
+ const { deps: embedded, warnings: jarWarnings } = await scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir, onProgress: opts.onJarProgress });
62
62
  for (const rec of embedded) deps.set(rec.coordKey, rec);
63
63
  warnings.push(...jarWarnings);
64
64
  } catch (e) { warnings.push({ type: "embedded-jar", message: `embedded-jar scan failed: ${e.message}` }); }
@@ -137,7 +137,9 @@ function collectNpmDeps(rootDir, opts = {}) {
137
137
  const { ignoreTest, deps2Exclude, verbose } = opts;
138
138
  const out = new Map();
139
139
  const warnings = [];
140
- const manifestGroups = findJsManifests(rootDir);
140
+ // Caller may pass pre-discovered manifest groups (e.g. from the parallel
141
+ // findJsManifestsAsync walk) to avoid a second, serial tree traversal.
142
+ const manifestGroups = opts.manifestGroups || findJsManifests(rootDir);
141
143
  let parsedCount = 0;
142
144
 
143
145
  for (const group of manifestGroups) {
@@ -388,6 +388,30 @@ function findJsManifests(rootDir, opts = {}) {
388
388
  return found;
389
389
  }
390
390
 
391
+ // Parallel equivalent of findJsManifests — concurrent readdir so the walk isn't
392
+ // serialized one round-trip at a time on a high-latency filesystem.
393
+ async function findJsManifestsAsync(rootDir, opts = {}) {
394
+ const { skipDirs = DEFAULT_JS_SKIP_DIRS } = opts;
395
+ const { walkDirs } = require("../../parallel-walk");
396
+ const found = [];
397
+ await walkDirs(rootDir, {
398
+ skipDir: name => skipDirs.has(name),
399
+ onDir: (cur, entries) => {
400
+ const here = { dir: cur, packageJson: null, packageLock: null, yarnLock: null, pnpmLock: null };
401
+ for (const e of entries) {
402
+ if (!e.isFile()) continue;
403
+ const p = path.join(cur, e.name);
404
+ if (e.name === "package.json") here.packageJson = p;
405
+ else if (e.name === "package-lock.json") here.packageLock = p;
406
+ else if (e.name === "yarn.lock") here.yarnLock = p;
407
+ else if (e.name === "pnpm-lock.yaml") here.pnpmLock = p;
408
+ }
409
+ if (here.packageJson || here.packageLock || here.yarnLock || here.pnpmLock) found.push(here);
410
+ },
411
+ });
412
+ return found;
413
+ }
414
+
391
415
  module.exports = {
392
416
  parsePackageJson,
393
417
  parsePackageLock,
@@ -395,4 +419,5 @@ module.exports = {
395
419
  parseYarnBerry,
396
420
  parsePnpmLock,
397
421
  findJsManifests,
422
+ findJsManifestsAsync,
398
423
  };
@@ -49,7 +49,11 @@ module.exports = {
49
49
  recipe: require("./recipes").npm,
50
50
  // collect ramasse TOUTES les deps JS (package-lock + yarn.lock).
51
51
  async collect(dir, opts = {}) {
52
- const deps = collectNpmDeps(dir, opts);
52
+ // Parallel manifest discovery (concurrent readdir) — much faster than the
53
+ // serial walk on a high-latency filesystem; parsing then runs synchronously.
54
+ const { findJsManifestsAsync } = require("./npm/parse");
55
+ const manifestGroups = await findJsManifestsAsync(dir);
56
+ const deps = collectNpmDeps(dir, { ...opts, manifestGroups });
53
57
  return { deps, warnings: deps.warnings || [] };
54
58
  },
55
59
  };
package/lib/core.js CHANGED
@@ -39,6 +39,20 @@ function findPomFiles(dir) {
39
39
  return out;
40
40
  }
41
41
 
42
+ // Parallel equivalent of findPomFiles — same result, but readdir runs concurrently
43
+ // so the walk isn't serialized one round-trip at a time on a high-latency filesystem.
44
+ async function findPomFilesAsync(dir) {
45
+ const { walkDirs } = require("./parallel-walk");
46
+ const out = [];
47
+ await walkDirs(dir, {
48
+ skipDir: name => SKIP_DIRS.has(name),
49
+ onDir: (cur, entries) => {
50
+ for (const e of entries) if (e.isFile() && e.name === "pom.xml") out.push(path.join(cur, e.name));
51
+ },
52
+ });
53
+ return out;
54
+ }
55
+
42
56
  function newMetadataStore() {
43
57
  return {
44
58
  byPath: {},
@@ -346,6 +360,7 @@ async function rewritePoms(pomPath, allPomMetadata, allPropsByPom, opts) {
346
360
  module.exports = {
347
361
  coord,
348
362
  findPomFiles,
363
+ findPomFilesAsync,
349
364
  newMetadataStore,
350
365
  parsePom,
351
366
  resolveParentPath,
package/lib/cve-report.js CHANGED
@@ -17,7 +17,9 @@ const { computePriority, sortByPriority } = require("./priority");
17
17
  // back to the raw id (with a "—" placeholder name where the row asks for one).
18
18
  const CWE_NAMES = (() => {
19
19
  try {
20
- const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "data", "cwe-names.json"), "utf8"));
20
+ // require() (not fs.readFileSync) so bun --compile bundles the data file
21
+ // into the standalone binary instead of reading it off disk at runtime.
22
+ const raw = { ...require("../data/cwe-names.json") };
21
23
  delete raw._comment;
22
24
  return raw;
23
25
  } catch { return {}; }