fad-checker 2.1.1 → 2.2.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +100 -32
  3. package/fad-checker-report/cve-report.doc +643 -105
  4. package/fad-checker-report/cve-report.html +653 -108
  5. package/fad-checker.js +265 -55
  6. package/lib/codecs/binary/scan.js +71 -0
  7. package/lib/codecs/binary/sniff.js +37 -0
  8. package/lib/codecs/binary.codec.js +54 -0
  9. package/lib/codecs/composer.codec.js +7 -3
  10. package/lib/codecs/go/registry.js +19 -11
  11. package/lib/codecs/go.codec.js +7 -3
  12. package/lib/codecs/index.js +73 -4
  13. package/lib/codecs/maven/jar-scan.js +179 -28
  14. package/lib/codecs/maven.codec.js +4 -2
  15. package/lib/codecs/npm/collect.js +3 -1
  16. package/lib/codecs/npm/parse.js +29 -1
  17. package/lib/codecs/npm/registry.js +29 -18
  18. package/lib/codecs/npm.codec.js +10 -4
  19. package/lib/codecs/nuget.codec.js +7 -3
  20. package/lib/codecs/pypi/registry.js +16 -10
  21. package/lib/codecs/pypi.codec.js +7 -3
  22. package/lib/codecs/recipes.js +9 -1
  23. package/lib/codecs/ruby/registry.js +16 -10
  24. package/lib/codecs/ruby.codec.js +7 -3
  25. package/lib/config.js +34 -23
  26. package/lib/core.js +22 -3
  27. package/lib/cve-match.js +3 -2
  28. package/lib/cve-report.js +59 -6
  29. package/lib/dep-record.js +12 -9
  30. package/lib/hash-id.js +78 -0
  31. package/lib/json-export.js +8 -1
  32. package/lib/license-policy.js +3 -1
  33. package/lib/options-env.js +113 -0
  34. package/lib/outdated.js +3 -0
  35. package/lib/parallel-walk.js +50 -0
  36. package/lib/path-filter.js +58 -0
  37. package/lib/registries.js +112 -0
  38. package/lib/retire.js +66 -0
  39. package/lib/scan-completeness.js +7 -1
  40. package/lib/unmanaged.js +82 -0
  41. package/package.json +1 -1
@@ -0,0 +1,37 @@
1
+ /**
2
+ * lib/codecs/binary/sniff.js — file-type confirmation for the binary codec.
3
+ *
4
+ * Two gates: extKind() (extension allowlist) AND sniffKind() (magic bytes). A
5
+ * candidate is accepted only when both agree, so an image renamed `.so` (PNG
6
+ * magic) is rejected. We never trust the extension alone.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
10
+ */
11
+
12
+ // Magic-byte signatures → family. Mach-O has four (32/64-bit + fat, both endian).
13
+ function sniffKind(buf) {
14
+ if (!buf || buf.length < 4) return null;
15
+ const b0 = buf[0], b1 = buf[1], b2 = buf[2], b3 = buf[3];
16
+ if (b0 === 0x4d && b1 === 0x5a) return "pe"; // MZ
17
+ if (b0 === 0x7f && b1 === 0x45 && b2 === 0x4c && b3 === 0x46) return "elf"; // \x7FELF
18
+ if (b0 === 0x50 && b1 === 0x4b && b2 === 0x03 && b3 === 0x04) return "zip"; // PK\x03\x04
19
+ const be = ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
20
+ const le = ((b3 << 24) | (b2 << 16) | (b1 << 8) | b0) >>> 0;
21
+ const machO = new Set([0xfeedface, 0xfeedfacf, 0xcafebabe, 0xcafebabf]);
22
+ if (machO.has(be) || machO.has(le)) return "macho";
23
+ return null;
24
+ }
25
+
26
+ const EXT_PE = /\.(dll|exe)$/i;
27
+ const EXT_MACHO = /\.dylib$/i;
28
+ const EXT_ELF = /\.so(\.\d+)*$/i; // .so, .so.1, .so.1.2.3
29
+
30
+ function extKind(name) {
31
+ if (EXT_PE.test(name)) return "pe";
32
+ if (EXT_MACHO.test(name)) return "macho";
33
+ if (EXT_ELF.test(name)) return "elf";
34
+ return null;
35
+ }
36
+
37
+ module.exports = { sniffKind, extKind };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * lib/codecs/binary.codec.js — codec for committed NATIVE binaries
3
+ * (.dll/.exe/.so/.dylib) that no package manager governs.
4
+ *
5
+ * Plan 1 scope: discover + hash only. The records carry no resolved coordinate
6
+ * (just a filename); Plan 2's hash-id service fills `identity`, Plan 3 builds the
7
+ * unmanaged inventory + report. Until then the records are `provenance:"binary"`
8
+ * and the CVE/OSV/EOL/outdated stages skip them (no coordinate to query).
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+ const { makeDepRecord } = require("../dep-record");
14
+ const { scanBinaries } = require("./binary/scan");
15
+
16
+ module.exports = {
17
+ id: "binary",
18
+ label: "Binaries",
19
+ osvEcosystem: null, // no OSV ecosystem until identified
20
+ manifestNames: ["*.dll", "*.exe", "*.so", "*.dylib"], // lets detectCodecs include us in auto mode
21
+
22
+ detect(dir) {
23
+ try { return scanBinaries(dir, { onProgress: null }).length > 0; }
24
+ catch { return false; }
25
+ },
26
+
27
+ async collect(dir, opts = {}) {
28
+ const out = new Map();
29
+ const warnings = [];
30
+ let records;
31
+ try { records = scanBinaries(dir, { onProgress: opts.onBinaryProgress, srcRoot: opts.srcRoot || dir, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes }); }
32
+ catch (e) { return { deps: out, warnings: [{ type: "scan-error", message: `binary scan failed: ${e.message}` }] }; }
33
+ for (const r of records) {
34
+ out.set(`binary:${r.path}`, makeDepRecord({
35
+ ecosystem: "binary",
36
+ name: r.declaredName,
37
+ version: null,
38
+ manifestPath: r.path,
39
+ provenance: "binary",
40
+ hashes: { sha1: r.sha1, sha256: r.sha256 },
41
+ declaredName: r.declaredName,
42
+ }));
43
+ }
44
+ return { deps: out, warnings };
45
+ },
46
+
47
+ coordKey(d) { return `binary:${d.manifestPaths?.[0] || d.name}`; },
48
+ formatCoord(d) { return d.declaredName || d.name; },
49
+ osvPackageName() { return null; },
50
+ async checkRegistry() { return { deprecated: [], outdated: [], licensed: [] }; },
51
+ resolveEolProduct() { return null; },
52
+ recipe: require("./recipes").binary,
53
+ nativeScanners: [],
54
+ };
@@ -15,7 +15,7 @@ const { parseComposerLock, parseComposerJson, isConcrete } = require("./composer
15
15
 
16
16
  const SKIP = new Set(["vendor", ".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target"]);
17
17
 
18
- function findComposerManifests(dir) {
18
+ function findComposerManifests(dir, skipDir = (child, name) => SKIP.has(name)) {
19
19
  const groups = [];
20
20
  const stack = [dir];
21
21
  while (stack.length) {
@@ -29,11 +29,15 @@ function findComposerManifests(dir) {
29
29
  composerLock: names.has("composer.lock") ? path.join(cur, "composer.lock") : null,
30
30
  });
31
31
  }
32
- for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
32
+ for (const e of entries) if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
33
33
  }
34
34
  return groups;
35
35
  }
36
36
 
37
+ function dirFilter(dir, opts) {
38
+ return require("../path-filter").makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
39
+ }
40
+
37
41
  module.exports = {
38
42
  id: "composer",
39
43
  label: "Composer",
@@ -46,7 +50,7 @@ module.exports = {
46
50
  const { ignoreTest, deps2Exclude } = opts;
47
51
  const out = new Map();
48
52
  const warnings = [];
49
- for (const g of findComposerManifests(dir)) {
53
+ for (const g of findComposerManifests(dir, dirFilter(dir, opts))) {
50
54
  if (g.composerLock) {
51
55
  let parsed;
52
56
  try { parsed = parseComposerLock(g.composerLock); }
@@ -13,11 +13,11 @@ const fs = require("fs");
13
13
  const path = require("path");
14
14
  const os = require("os");
15
15
  let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
16
+ const { withPublic, authHeaderFor, normalise } = require("../../registries");
16
17
 
17
18
  const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
18
19
  const CACHE_PATH = path.join(CACHE_DIR, "go-proxy-cache.json");
19
20
  const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
20
- const PROXY = "https://proxy.golang.org";
21
21
 
22
22
  function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
23
23
  function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
@@ -34,18 +34,26 @@ function cmp(a, b) {
34
34
  return 0;
35
35
  }
36
36
 
37
- async function fetchLatest(mod, { offline }) {
37
+ // Custom GOPROXY first (same protocol: <base>/<escaped-module>/@latest), then proxy.golang.org.
38
+ async function fetchLatest(mod, { offline, registries = [], fetcher = globalThis.fetch } = {}) {
38
39
  if (offline) return null;
39
- try {
40
- const res = await fetch(`${PROXY}/${escapeModule(mod)}/@latest`, { headers: { "User-Agent": "fad-checker-go" } });
41
- if (!res.ok) return { error: `HTTP ${res.status}` };
42
- const j = await res.json();
43
- return { latest: (j.Version || "").replace(/^v/, "") || null };
44
- } catch (e) { return { error: e.message }; }
40
+ const bases = withPublic("go", registries);
41
+ let lastErr = null;
42
+ for (const base of bases) {
43
+ const url = normalise(base.url) + `${escapeModule(mod)}/@latest`;
44
+ const headers = { "User-Agent": "fad-checker-go", Accept: "application/json" };
45
+ const ah = authHeaderFor(base); if (ah) headers.Authorization = ah;
46
+ try {
47
+ const res = await fetcher(url, { headers });
48
+ if (res.ok) { const j = await res.json(); return { latest: (j.Version || "").replace(/^v/, "") || null }; }
49
+ lastErr = `HTTP ${res.status}`;
50
+ } catch (e) { lastErr = e.message; }
51
+ }
52
+ return { error: lastErr || "no data" };
45
53
  }
46
54
 
47
55
  async function checkGoRegistryDeps(deps, opts = {}) {
48
- const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
56
+ const { verbose, offline, allLibs = true, concurrency = 8, registries = [] } = opts;
49
57
  const targets = [...deps.values()].filter(d => d.ecosystem === "go" && d.version);
50
58
  const result = { deprecated: [], outdated: [], licensed: [] };
51
59
  if (!targets.length || !allLibs) return result;
@@ -57,7 +65,7 @@ async function checkGoRegistryDeps(deps, opts = {}) {
57
65
  const key = `${t.name}@${t.version}`;
58
66
  let ex = cache.entries[key];
59
67
  if (!ex) {
60
- const r = await fetchLatest(t.name, { offline });
68
+ const r = await fetchLatest(t.name, { offline, registries });
61
69
  ex = (r && !r.error) ? { latest: r.latest } : { latest: null };
62
70
  if (r && !r.error) cache.entries[key] = ex;
63
71
  }
@@ -71,4 +79,4 @@ async function checkGoRegistryDeps(deps, opts = {}) {
71
79
  return result;
72
80
  }
73
81
 
74
- module.exports = { checkGoRegistryDeps, escapeModule };
82
+ module.exports = { checkGoRegistryDeps, escapeModule, fetchLatest };
@@ -15,7 +15,7 @@ const G = require("./go/parse");
15
15
 
16
16
  const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "vendor", "testdata"]);
17
17
 
18
- function findGoDirs(dir) {
18
+ function findGoDirs(dir, skipDir = (child, name) => SKIP.has(name)) {
19
19
  const groups = [];
20
20
  const stack = [dir];
21
21
  while (stack.length) {
@@ -23,11 +23,15 @@ function findGoDirs(dir) {
23
23
  let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
24
24
  const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
25
25
  if (names.has("go.mod") || names.has("go.sum")) groups.push({ dir: cur, names });
26
- for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
26
+ for (const e of entries) if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
27
27
  }
28
28
  return groups;
29
29
  }
30
30
 
31
+ function dirFilter(dir, opts) {
32
+ return require("../path-filter").makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
33
+ }
34
+
31
35
  module.exports = {
32
36
  id: "go",
33
37
  label: "Go",
@@ -44,7 +48,7 @@ module.exports = {
44
48
  if (deps2Exclude && deps2Exclude.test(d.name)) return;
45
49
  out.set(coordKeyFor("go", "", d.name), makeDepRecord({ ecosystem: "go", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: false }));
46
50
  };
47
- for (const g of findGoDirs(dir)) {
51
+ for (const g of findGoDirs(dir, dirFilter(dir, opts))) {
48
52
  if (g.names.has("go.mod")) {
49
53
  const fp = path.join(g.dir, "go.mod");
50
54
  try {
@@ -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");
@@ -17,12 +19,14 @@ const pypi = require("./pypi.codec");
17
19
  const nuget = require("./nuget.codec");
18
20
  const go = require("./go.codec");
19
21
  const ruby = require("./ruby.codec");
22
+ const binary = require("./binary.codec");
20
23
 
21
24
  // Ordre stable pour le report (maven, npm, yarn, puis les nouveaux écosystèmes).
22
- const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"];
25
+ // "binary" (committed native libs, no manifest) reste en dernier.
26
+ const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby", "binary"];
23
27
 
24
28
  const REGISTRY = new Map();
25
- for (const c of [maven, npm, yarn, composer, pypi, nuget, go, ruby]) {
29
+ for (const c of [maven, npm, yarn, composer, pypi, nuget, go, ruby, binary]) {
26
30
  assertCodecShape(c);
27
31
  REGISTRY.set(c.id, c);
28
32
  }
@@ -33,10 +37,75 @@ function allCodecs() {
33
37
  return [...REGISTRY.values()].sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
34
38
  }
35
39
 
40
+ // Dirs skipped during detection. Starts from the INTERSECTION of every codec's own
41
+ // skip set (a dir skipped here only if ALL codecs skip it → detection never misses a
42
+ // manifest a codec would have found) plus well-known dependency/cache/tooling dirs
43
+ // that are NEVER a project root (all dot-dirs or vendored-dep caches), so detection
44
+ // can prune them safely. NOTE: "build" / "vendor" / "bin" / "obj" are deliberately
45
+ // NOT here — maven keeps build/ for BOMs and several codecs scan vendor/.
46
+ const DETECT_SKIP = new Set([
47
+ "node_modules", ".git", ".idea", ".vscode", "target", "dist", "out", // intersection
48
+ ".svn", ".hg", ".gradle", ".mvn", ".cache", ".m2", "bower_components", // never a project root
49
+ "jspm_packages", "__pycache__", ".venv", ".tox", ".mypy_cache", "coverage", ".next", ".nuxt",
50
+ ]);
51
+
52
+ const DETECT_CONCURRENCY = 48;
53
+
36
54
  // yarn est détecté via le même arbre JS que npm ; on ne le renvoie pas en
37
55
  // 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));
56
+ //
57
+ // ONE shared walk instead of one full-tree walk per codec: we descend the tree once
58
+ // and, per file, test it against each codec's manifestNames (supporting "*.ext"
59
+ // globs). Short-circuits as soon as every codec has matched. readdir runs with bounded
60
+ // concurrency — on a high-latency filesystem (WSL/9p, network mounts) the walk is
61
+ // latency-bound, so issuing many readdirs at once is far faster than a serial walk.
62
+ async function detectCodecs(dir, opts = {}) {
63
+ const { makeDirFilter } = require("../path-filter");
64
+ const skipDir = makeDirFilter({ srcRoot: dir, defaultSkip: DETECT_SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
65
+ const codecs = allCodecs().filter(c => c.id !== "yarn");
66
+ const matchers = codecs.map(c => {
67
+ const exact = new Set();
68
+ const exts = [];
69
+ for (const n of c.manifestNames || []) {
70
+ if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
71
+ else exact.add(n);
72
+ }
73
+ return { codec: c, exact, exts, found: false };
74
+ });
75
+ let remaining = matchers.length;
76
+ const queue = [dir];
77
+ let active = 0;
78
+
79
+ await new Promise(resolve => {
80
+ let settled = false;
81
+ const finish = () => { if (!settled) { settled = true; resolve(); } };
82
+ const pump = () => {
83
+ if (settled) return;
84
+ if (remaining === 0) return finish();
85
+ if (queue.length === 0 && active === 0) return finish();
86
+ while (active < DETECT_CONCURRENCY && queue.length && remaining > 0) {
87
+ const cur = queue.pop();
88
+ active++;
89
+ fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
90
+ for (const e of entries) {
91
+ if (e.isDirectory()) {
92
+ const child = path.join(cur, e.name);
93
+ if (!skipDir(child, e.name)) queue.push(child);
94
+ continue;
95
+ }
96
+ if (!e.isFile()) continue;
97
+ const name = e.name;
98
+ for (const m of matchers) {
99
+ if (m.found) continue;
100
+ if (m.exact.has(name) || m.exts.some(ext => name.endsWith(ext))) { m.found = true; remaining--; }
101
+ }
102
+ }
103
+ }).catch(() => { /* unreadable dir → skip */ }).finally(() => { active--; pump(); });
104
+ }
105
+ };
106
+ pump();
107
+ });
108
+ return matchers.filter(m => m.found).map(m => m.codec);
40
109
  }
41
110
 
42
111
  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,54 @@ 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, skipDir) {
293
+ const { walkDirs } = require("../../parallel-walk");
294
+ const skip = skipDir || ((child, name) => SKIP_DIRS.has(name) || name.startsWith("."));
295
+ const acc = [];
296
+ await walkDirs(dir, {
297
+ skipDir: skip,
298
+ onDir: (cur, entries) => {
299
+ for (const e of entries) if (e.isFile() && ARCHIVE_RE.test(e.name)) acc.push(path.join(cur, e.name));
300
+ },
301
+ });
302
+ return acc;
303
+ }
304
+
173
305
  /**
174
306
  * Scan `rootDir` for embedded JAR/WAR/EAR coordinates.
307
+ *
308
+ * Reading + unzipping is synchronous and can be slow on a tree with many or large
309
+ * fat-jars, so `opts.onProgress` is invoked around the work — for EVERY archive,
310
+ * nested ones included — so the caller can tell the user what's happening (it would
311
+ * otherwise block silently, especially while recursing through a fat-jar's libs):
312
+ * onProgress({ phase: "start", total }) once; total = top-level archive count
313
+ * onProgress({ phase: "scan", scanned, total, path }) before each archive is read (top-level + nested)
314
+ * onProgress({ phase: "done", total, found, scanned }) once, when finished
315
+ *
175
316
  * @returns { deps: depRecord[], warnings: [{type,message}] }
176
317
  */
177
- function scanEmbeddedJars(rootDir, opts = {}) {
178
- const out = [];
179
- const warnings = [];
180
- const archives = findArchives(rootDir);
318
+ async function scanEmbeddedJars(rootDir, opts = {}) {
319
+ const { makeDirFilter } = require("../../path-filter");
320
+ const useDefaults = opts.defaultExcludes !== false;
321
+ const base = makeDirFilter({ srcRoot: opts.srcRoot || rootDir, defaultSkip: SKIP_DIRS, excludePath: opts.excludePath, useDefaults });
322
+ const skipDir = (child, name) => base(child, name) || (useDefaults && name.startsWith("."));
323
+ const archives = await findArchivesAsync(rootDir, skipDir);
324
+ const ctx = {
325
+ out: [],
326
+ warnings: [],
327
+ onProgress: typeof opts.onProgress === "function" ? opts.onProgress : null,
328
+ scanned: 0,
329
+ total: archives.length,
330
+ };
331
+ ctx.onProgress?.({ phase: "start", total: archives.length });
181
332
  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);
333
+ const rel = (opts.srcRoot ? path.relative(opts.srcRoot, abs) : abs).split(path.sep).join("/");
334
+ scanArchiveFile(abs, rel, path.basename(abs), ctx);
187
335
  }
188
- return { deps: out, warnings };
336
+ ctx.onProgress?.({ phase: "done", total: archives.length, found: ctx.out.length, scanned: ctx.scanned });
337
+ return { deps: ctx.out, warnings: ctx.warnings };
189
338
  }
190
339
 
191
340
  module.exports = {
@@ -196,4 +345,6 @@ module.exports = {
196
345
  coordFromFilename,
197
346
  coordForArchive,
198
347
  scanArchiveBuffer,
348
+ readZipEntriesFromFd,
349
+ findArchivesAsync,
199
350
  };
@@ -40,7 +40,9 @@ 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 { makeDirFilter } = require("../path-filter");
44
+ const skipDir = makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: core.SKIP_DIRS, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
45
+ const pomFiles = await core.findPomFilesAsync(dir, skipDir);
44
46
  const store = core.newMetadataStore();
45
47
  const propsByPom = {};
46
48
  for (const pom of pomFiles) {
@@ -58,7 +60,7 @@ module.exports = {
58
60
  if (opts.scanJars !== false) {
59
61
  try {
60
62
  const { scanEmbeddedJars } = require("./maven/jar-scan");
61
- const { deps: embedded, warnings: jarWarnings } = scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir });
63
+ const { deps: embedded, warnings: jarWarnings } = await scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes, onProgress: opts.onJarProgress });
62
64
  for (const rec of embedded) deps.set(rec.coordKey, rec);
63
65
  warnings.push(...jarWarnings);
64
66
  } 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) {