@socketsecurity/cli-with-sentry 1.9.1 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +5 -5
  3. package/dist/cli.js +379 -127
  4. package/dist/cli.js.map +1 -1
  5. package/dist/constants.js +4 -4
  6. package/dist/constants.js.map +1 -1
  7. package/dist/manifest-scripts/maven-extension/coana-maven-extension.jar +0 -0
  8. package/dist/manifest-scripts/socket-facts.init.gradle +244 -56
  9. package/dist/manifest-scripts/socket-facts.plugin.scala +290 -73
  10. package/dist/tsconfig.dts.tsbuildinfo +1 -1
  11. package/dist/types/commands/manifest/cmd-manifest-dynamic-sbom-inference.d.mts.map +1 -1
  12. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts +5 -2
  13. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts.map +1 -1
  14. package/dist/types/commands/manifest/convert-maven-to-facts.d.mts +5 -2
  15. package/dist/types/commands/manifest/convert-maven-to-facts.d.mts.map +1 -1
  16. package/dist/types/commands/manifest/convert-sbt-to-facts.d.mts +5 -2
  17. package/dist/types/commands/manifest/convert-sbt-to-facts.d.mts.map +1 -1
  18. package/dist/types/commands/manifest/enumerate-workspaces.d.mts.map +1 -1
  19. package/dist/types/commands/manifest/generate-recursive-manifests.d.mts +8 -1
  20. package/dist/types/commands/manifest/generate-recursive-manifests.d.mts.map +1 -1
  21. package/dist/types/commands/manifest/generate_auto_manifest.d.mts.map +1 -1
  22. package/dist/types/commands/manifest/handle-manifest-dynamic-sbom-inference.d.mts +2 -1
  23. package/dist/types/commands/manifest/handle-manifest-dynamic-sbom-inference.d.mts.map +1 -1
  24. package/dist/types/commands/manifest/run-manifest-facts.d.mts +4 -1
  25. package/dist/types/commands/manifest/run-manifest-facts.d.mts.map +1 -1
  26. package/dist/types/commands/manifest/scripts/assemble.d.mts.map +1 -1
  27. package/dist/types/commands/manifest/scripts/facts.d.mts +3 -2
  28. package/dist/types/commands/manifest/scripts/facts.d.mts.map +1 -1
  29. package/dist/types/commands/manifest/scripts/records.d.mts +10 -4
  30. package/dist/types/commands/manifest/scripts/records.d.mts.map +1 -1
  31. package/dist/types/commands/manifest/scripts/resolution-report-gradle.d.mts +3 -2
  32. package/dist/types/commands/manifest/scripts/resolution-report-gradle.d.mts.map +1 -1
  33. package/dist/types/commands/manifest/scripts/resolution-report-maven.d.mts +3 -3
  34. package/dist/types/commands/manifest/scripts/resolution-report-maven.d.mts.map +1 -1
  35. package/dist/types/commands/manifest/scripts/resolution-report-render.d.mts +29 -8
  36. package/dist/types/commands/manifest/scripts/resolution-report-render.d.mts.map +1 -1
  37. package/dist/types/commands/manifest/scripts/resolution-report.d.mts +14 -3
  38. package/dist/types/commands/manifest/scripts/resolution-report.d.mts.map +1 -1
  39. package/dist/types/commands/manifest/scripts/run.d.mts +2 -1
  40. package/dist/types/commands/manifest/scripts/run.d.mts.map +1 -1
  41. package/dist/types/commands/manifest/scripts/sidecar.d.mts.map +1 -1
  42. package/dist/types/commands/scan/cmd-scan-create.d.mts.map +1 -1
  43. package/dist/types/commands/scan/cmd-scan-reach.d.mts.map +1 -1
  44. package/dist/types/commands/scan/handle-create-new-scan.d.mts.map +1 -1
  45. package/dist/types/commands/scan/warn-socket-json-only-flags.d.mts +2 -0
  46. package/dist/types/commands/scan/warn-socket-json-only-flags.d.mts.map +1 -0
  47. package/dist/types/constants.d.mts +1 -1
  48. package/dist/utils.js +2 -1
  49. package/dist/utils.js.map +1 -1
  50. package/package.json +2 -2
@@ -4,7 +4,6 @@ import sbt._
4
4
  import sbt.Keys._
5
5
 
6
6
  import scala.collection.mutable
7
- import scala.reflect.ClassTag
8
7
 
9
8
  /**
10
9
  * Emits a flat line-protocol RECORDS file at the build root (NOT the final
@@ -16,7 +15,7 @@ import scala.reflect.ClassTag
16
15
  *
17
16
  * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x (compiled by the sbt
18
17
  * meta-build), hence string-named TaskKeys and reflection for version-specific
19
- * ExclusionRule / ResolveException / ConfigRef shapes.
18
+ * UpdateConfiguration / ResolveException / ConfigRef shapes.
20
19
  */
21
20
  object SocketFactsPlugin extends AutoPlugin {
22
21
  override def trigger = allRequirements
@@ -57,16 +56,33 @@ object SocketFactsPlugin extends AutoPlugin {
57
56
  // One tree per resolution root (project, config); the TS assembler merges them path-sensitively.
58
57
  val perSub = mutable.LinkedHashMap.empty[String, RootTree]
59
58
  val failures = mutable.LinkedHashSet.empty[Failure]
59
+ val unscannable = mutable.LinkedHashSet.empty[Unscannable]
60
60
  val scannedConfigs = mutable.LinkedHashSet.empty[String]
61
61
  val matcher = buildConfigMatcher()
62
62
 
63
63
  // Real artifact ext per build module, so an ext-less inter-project dep gets its true coordinate.
64
64
  val moduleExts = buildModuleExts(allRefs, extracted)
65
65
 
66
+ // Every project: `update` also runs each upstream project's update, which would fail strictly.
67
+ lazy val lenientState = extracted.append(allRefs.map(r => updateConfiguration.in(r) ~= (c => withMissingOk(c))), st)
68
+ val pomsByCache = mutable.HashMap.empty[Option[File], PomPackaging]
69
+
66
70
  allRefs.foreach { ref =>
67
71
  if (!isExcludedRef(ref)) {
68
- runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report =>
69
- foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach {
72
+ // Built-in lm-coursier (sbt 1.3+) and the legacy sbt-coursier plugin name their caches differently.
73
+ val legacyCoursierCache = extracted.getOpt(SettingKey[File]("coursierCache").in(ref))
74
+ val csrCache = extracted.getOpt(SettingKey[File]("csrCacheDirectory").in(ref)).orElse(legacyCoursierCache)
75
+ val poms = pomsByCache.getOrElseUpdate(csrCache, new PomPackaging(csrCache))
76
+ // sbt 0.13 has no `useCoursier`: it resolves with Ivy unless the legacy plugin replaces it.
77
+ val ivy = extracted.getOpt(SettingKey[Boolean]("useCoursier").in(ref)) match {
78
+ case Some(useCoursier) => !useCoursier
79
+ case None => legacyCoursierCache.isEmpty
80
+ }
81
+ val resolver = new Resolver(ivy, poms)
82
+ runUpdateResilient(updateTaskName, ref, extracted, st, lenientState, matcher, failures, unscannable, resolver)
83
+ .foreach { case (report, unresolved, ivyUnfetched, updateFailed) =>
84
+ foldReport(report, unresolved, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts,
85
+ resolver, ivyUnfetched, updateFailed, failures).foreach {
70
86
  case (rootKey, tree) => perSub(rootKey) = tree
71
87
  }
72
88
  }
@@ -101,21 +117,24 @@ object SocketFactsPlugin extends AutoPlugin {
101
117
  rec("buildRoot", rootCanonPath.toString)
102
118
 
103
119
  // One `project` record per build module (sources/targets only with --with-files). Excluded
104
- // subprojects are omitted (they were also skipped during resolution above).
120
+ // subprojects get none (they were also skipped during resolution above), but keep their paths so
121
+ // a kept project depending on one is analysed with its code.
105
122
  allRefs.foreach { ref =>
106
- if (!isExcludedRef(ref)) {
107
- val mid = rootIdOf(extracted, ref)
108
- val ver = if (mid.revision == null) "" else mid.revision
123
+ val mid = rootIdOf(extracted, ref)
124
+ val ver = if (mid.revision == null) "" else mid.revision
125
+ if (isExcludedRef(ref)) {
126
+ if (withFiles) rec("excludedProject", ref.project)
127
+ } else {
109
128
  val baseDir = extracted.get(baseDirectory.in(ref))
110
129
  rec("project", ref.project, mid.organization, mid.name, ver, relOf(baseDir))
111
130
  (buildFilesByRef.getOrElse(ref, Nil) ++ baseBuildSbt(baseDir, rootCanonPath).map(relOf)).distinct.sorted
112
131
  .foreach(f => rec("projectBuild", ref.project, f))
113
- if (withFiles) {
114
- moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach {
115
- case (sources, targets) =>
116
- sources.foreach(s => rec("projectSrc", ref.project, s))
117
- targets.foreach(t => rec("projectTgt", ref.project, t))
118
- }
132
+ }
133
+ if (withFiles) {
134
+ moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach {
135
+ case (sources, targets) =>
136
+ sources.foreach(s => rec("projectSrc", ref.project, s))
137
+ targets.foreach(t => rec("projectTgt", ref.project, t))
119
138
  }
120
139
  }
121
140
  }
@@ -135,12 +154,14 @@ object SocketFactsPlugin extends AutoPlugin {
135
154
  node.project)
136
155
  node.children.foreach(ch => rec("edge", rootId, coordId, ch))
137
156
  node.targets.foreach(p => rec("file", rootId, coordId, p))
157
+ if (node.unfetched) rec("unfetched", rootId, coordId)
138
158
  }
139
159
  }
140
160
 
141
161
  // Scanned configs + raw failures (Coana owns the abort/warn policy and report rendering).
142
162
  scannedConfigs.toList.sorted.foreach(c => rec("scanned", c))
143
- failures.foreach(f => rec("failure", f.coord, f.detail, f.config))
163
+ failures.foreach(f => rec("failure", f.coord, f.detail, f.config, f.project))
164
+ unscannable.foreach(u => rec("unscannable", "", u.detail, u.project))
144
165
 
145
166
  val recordsFile = sys.props.get("socket.recordsFile").filter(_.nonEmpty) match {
146
167
  case Some(p) => new File(p)
@@ -193,38 +214,173 @@ object SocketFactsPlugin extends AutoPlugin {
193
214
  }.toMap
194
215
  }
195
216
 
196
- // On a hard resolve failure, record the failed modules and retry once with them excluded. Never throws.
217
+ // A failure naming no module is an artifact that could not be fetched, which only the lenient
218
+ // report's missing artifacts reveal; with neither, the project is unscannable.
197
219
  private def runUpdateResilient(
198
220
  taskName: String,
199
221
  ref: ProjectRef,
200
222
  extracted: Extracted,
201
223
  state: State,
202
- failures: mutable.LinkedHashSet[Failure]
203
- ): Option[UpdateReport] = {
224
+ lenient: => State,
225
+ matcher: String => Boolean,
226
+ failures: mutable.LinkedHashSet[Failure],
227
+ unscannable: mutable.LinkedHashSet[Unscannable],
228
+ resolver: Resolver
229
+ ): Option[(UpdateReport, Set[String], IvyUnfetched, Boolean)] = {
204
230
  val key = TaskKey[UpdateReport](taskName)
205
231
 
206
- def runOn(st: State): Either[Seq[ModuleID], UpdateReport] =
232
+ def runOn(st: State): Either[(Seq[ModuleID], String), UpdateReport] =
207
233
  Project.runTask(key.in(ref).scopedKey, st) match {
208
234
  case Some((_, sbt.Value(rep))) => Right(rep)
209
- case Some((_, sbt.Inc(inc))) => Left(extractFailedModules(inc))
210
- case _ => Left(Nil)
235
+ case Some((_, sbt.Inc(inc))) => Left((extractFailedModules(inc), incompleteMessage(inc)))
236
+ case _ => Left((Nil, s"$taskName did not run"))
211
237
  }
212
238
 
213
239
  runOn(state) match {
214
- case Right(rep) => Some(rep)
215
- case Left(failed) =>
216
- failed.foreach { m =>
217
- failures += Failure(coordOf(m), "unresolved dependency", taskName)
240
+ case Right(rep) => Some((rep, Set.empty[String], Map.empty: IvyUnfetched, false))
241
+ case Left((failed, detail)) =>
242
+ val lenientRun = runOn(lenient).right.map { rep =>
243
+ (rep, if (resolver.ivy) ivyUnfetchedOf(rep, extracted.get(target.in(ref))) else Map.empty: IvyUnfetched)
244
+ }
245
+ lenientRun match {
246
+ case Right((rep, ivyUnfetched))
247
+ if failed.isEmpty && ivyUnfetched.isEmpty &&
248
+ !rep.configurations.exists(_.modules.exists(m => !m.evicted && resolver.missingOf(m).nonEmpty)) =>
249
+ unscannable += Unscannable(detail, ref.project)
250
+ None
251
+ case Right((rep, ivyUnfetched)) =>
252
+ val confsByGav = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]]
253
+ rep.configurations.foreach { cr =>
254
+ cr.modules.foreach { r =>
255
+ if (!r.evicted) confsByGav.getOrElseUpdate(gavKey(r.module), mutable.LinkedHashSet.empty[String]) += confName(cr)
256
+ }
257
+ }
258
+ failed.foreach { m =>
259
+ // Absent from the graph: no configuration. Present only in excluded ones: not reported.
260
+ confsByGav.get(gavKey(m)) match {
261
+ case None => failures += Failure(coordOf(m), detailFor(m, detail), "", ref.project)
262
+ case Some(confs) => confs.filter(matcher).foreach(c => failures += Failure(coordOf(m), detailFor(m, detail), c, ref.project))
263
+ }
264
+ }
265
+ Some((rep, failed.map(gavKey).toSet, ivyUnfetched, failed.isEmpty))
266
+ case Left(_) =>
267
+ // Even a lenient update failed: the whole project is unresolved, but the modules sbt
268
+ // named are still reported.
269
+ failed.foreach(m => failures += Failure(coordOf(m), detailFor(m, detail), "", ref.project))
270
+ unscannable += Unscannable(detail, ref.project)
271
+ None
218
272
  }
219
- if (failed.isEmpty) None
220
- else
221
- runOn(extracted.append(exclusionSettings(extracted, failed), state)) match {
222
- case Right(rep) => Some(rep)
223
- case Left(_) => None
224
- }
225
273
  }
226
274
  }
227
275
 
276
+ // config -> "org:name:rev" -> artifacts Ivy failed to download.
277
+ private type IvyUnfetched = Map[String, Map[String, Seq[IvyArtifact]]]
278
+
279
+ private final case class IvyArtifact(ext: String, classifier: String, detail: String)
280
+
281
+ // sbt 0.13's UpdateReport omits artifacts Ivy failed to download; Ivy's own report per configuration,
282
+ // `<resolution cache>/reports/<organisation>-<module>-<conf>.xml`, keeps them.
283
+ private def ivyUnfetchedOf(rep: UpdateReport, projectTarget: File): IvyUnfetched = {
284
+ val descriptor = Option(rep.cachedDescriptor).filter(_.isFile)
285
+ val bound = projectTarget.getCanonicalFile.toPath
286
+ def reportsDirs(f: File): Stream[File] =
287
+ Option(f.getParentFile).filter(_.getCanonicalFile.toPath.startsWith(bound)).toStream
288
+ .flatMap(p => new File(p, "reports") #:: reportsDirs(p))
289
+ (for {
290
+ d <- descriptor.toSeq
291
+ id <- ivyResolveId(d).toSeq
292
+ cr <- rep.configurations
293
+ conf = confName(cr)
294
+ report <- reportsDirs(d).map(new File(_, id + "-" + conf + ".xml")).find(_.isFile).toSeq
295
+ arts = ivyFailedArtifacts(report)
296
+ if arts.nonEmpty
297
+ } yield conf -> arts).toMap
298
+ }
299
+
300
+ private def parseXml(f: File): org.w3c.dom.Document = {
301
+ // A POM may declare a DOCTYPE, so only what it could pull in from outside is refused.
302
+ val dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance()
303
+ dbf.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true)
304
+ dbf.setFeature("http://xml.org/sax/features/external-general-entities", false)
305
+ dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false)
306
+ dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
307
+ dbf.setXIncludeAware(false)
308
+ dbf.setExpandEntityReferences(false)
309
+ dbf.newDocumentBuilder().parse(f)
310
+ }
311
+
312
+ // Ivy's ResolveOptions.getDefaultResolveId: `<organisation>-<module>` of the resolved descriptor.
313
+ private def ivyResolveId(descriptor: File): Option[String] =
314
+ try {
315
+ val info = parseXml(descriptor).getElementsByTagName("info")
316
+ if (info.getLength == 0) None
317
+ else {
318
+ val e = info.item(0).asInstanceOf[org.w3c.dom.Element]
319
+ Some(e.getAttribute("organisation") + "-" + e.getAttribute("module"))
320
+ }
321
+ } catch { case _: Exception => None }
322
+
323
+ private def ivyFailedArtifacts(report: File): Map[String, Seq[IvyArtifact]] =
324
+ try {
325
+ val modules = parseXml(report).getElementsByTagName("module")
326
+ val out = for {
327
+ mi <- 0 until modules.getLength
328
+ module = modules.item(mi).asInstanceOf[org.w3c.dom.Element]
329
+ revisions = module.getElementsByTagName("revision")
330
+ ri <- 0 until revisions.getLength
331
+ revision = revisions.item(ri).asInstanceOf[org.w3c.dom.Element]
332
+ if revision.getAttribute("evicted").isEmpty
333
+ artifacts = revision.getElementsByTagName("artifact")
334
+ ai <- 0 until artifacts.getLength
335
+ a = artifacts.item(ai).asInstanceOf[org.w3c.dom.Element]
336
+ if a.getAttribute("status") == "failed"
337
+ } yield {
338
+ val (org, name, rev) = (module.getAttribute("organisation"), module.getAttribute("name"), revision.getAttribute("name"))
339
+ val classifier = a.getAttribute("extra-classifier")
340
+ val file = a.getAttribute("name") + (if (classifier.isEmpty) "" else "-" + classifier) + "." + a.getAttribute("ext")
341
+ // An absent artifact is "missing artifact" (empty under sbt 0.13's Ivy); one the resolver chain
342
+ // failed itself has "null"; anything else is a transfer failure's message.
343
+ val details = a.getAttribute("details")
344
+ val cause =
345
+ if (details.isEmpty || details == "missing artifact") "not found"
346
+ else if (details == "null") "download failed"
347
+ else "download failed (" + details + ")"
348
+ (org + ":" + name + ":" + rev) -> IvyArtifact(a.getAttribute("ext"), classifier, cause + ": " + org + "#" + name + ";" + rev + "!" + file)
349
+ }
350
+ out.groupBy(_._1).map { case (gav, pairs) => gav -> pairs.map(_._2).distinct }
351
+ } catch { case _: Exception => Map.empty }
352
+
353
+ // UpdateConfiguration has `withMissingOk` on sbt 1.x; on 0.13 its only Boolean field is missingOk.
354
+ private def withMissingOk[T](c: T): T = {
355
+ val cls = c.getClass
356
+ try cls.getMethod("withMissingOk", classOf[Boolean]).invoke(c, java.lang.Boolean.TRUE).asInstanceOf[T]
357
+ catch {
358
+ case _: NoSuchMethodException =>
359
+ val copy = cls.getMethods.find(_.getName == "copy").get
360
+ val args = copy.getParameterTypes.zipWithIndex.map { case (t, i) =>
361
+ if (t == classOf[Boolean]) java.lang.Boolean.TRUE
362
+ else cls.getMethod("copy$default$" + (i + 1)).invoke(c)
363
+ }
364
+ copy.invoke(c, args: _*).asInstanceOf[T]
365
+ }
366
+ }
367
+
368
+ // One module's part of a batch resolve message, so records grow with the failure count, not its
369
+ // square: Coursier writes an indented "Error downloading org:name:rev" block per module, Ivy a line.
370
+ private def detailFor(m: ModuleID, batch: String): String = {
371
+ val lines = batch.split("\n").toList
372
+ val keys = Seq(coordOf(m), s"${orEmpty(m.organization)}#${orEmpty(m.name)};${orEmpty(m.revision)}")
373
+ val start = lines.indexWhere(l => keys.exists(k => l.contains(k)))
374
+ if (start < 0) lines.map(_.trim).find(_.nonEmpty).getOrElse(batch)
375
+ else (lines(start) :: lines.drop(start + 1).takeWhile(l => l.startsWith(" ") || l.startsWith("\t"))).mkString("\n").trim
376
+ }
377
+
378
+ private def incompleteMessage(inc: sbt.Incomplete): String = {
379
+ def first(i: sbt.Incomplete): Option[String] =
380
+ i.directCause.flatMap(ex => Option(ex.getMessage)).orElse(i.causes.view.flatMap(first).headOption)
381
+ first(inc).map(_.trim).filter(_.nonEmpty).getOrElse("unresolved dependency")
382
+ }
383
+
228
384
  // Reflection: ResolveException's package and the `failed` accessor differ between sbt 0.13 and 1.x.
229
385
  private def extractFailedModules(inc: sbt.Incomplete): Seq[ModuleID] = {
230
386
  val out = mutable.ListBuffer.empty[ModuleID]
@@ -244,42 +400,23 @@ object SocketFactsPlugin extends AutoPlugin {
244
400
  out.toList.distinct
245
401
  }
246
402
 
247
- // ExclusionRule constructor shape differs between sbt 0.13 (`sbt.ExclusionRule`) and 1.x (`sbt.SbtExclusionRule`).
248
- private def exclusionSettings(extracted: Extracted, failed: Seq[ModuleID]): Seq[Setting[_]] = {
249
- val cls: Class[_] =
250
- try Class.forName("sbt.SbtExclusionRule")
251
- catch { case _: ClassNotFoundException => classOf[sbt.ExclusionRule] }
252
-
253
- def rule(m: ModuleID): Any =
254
- if (cls.getName.contains("SbtExclusionRule")) {
255
- cls.getConstructors.find(_.getParameterCount == 5) match {
256
- case Some(ctor) =>
257
- ctor.newInstance(m.organization, m.name, "*", Seq(), sbt.CrossVersion.Disabled)
258
- case None =>
259
- throw new IllegalStateException("No suitable SbtExclusionRule constructor")
260
- }
261
- } else ExclusionRule(organization = m.organization, name = m.name)
262
-
263
- def castSeq[T](xs: Seq[Any])(implicit ct: ClassTag[T]): Seq[T] = xs.map(_.asInstanceOf[T])
264
-
265
- extracted.structure.allProjectRefs.map { ref =>
266
- excludeDependencies.in(ref) := {
267
- val original = excludeDependencies.in(ref).value
268
- castSeq(original ++ failed.map(rule))(ClassTag(cls))
269
- }
270
- }
271
- }
272
-
273
403
  // Fold one project's UpdateReport into one tree per configuration (rootKey -> tree), keyed by coordinate.
274
404
  private def foldReport(
275
405
  report: UpdateReport,
406
+ unresolved: Set[String],
276
407
  ref: ProjectRef,
277
408
  extracted: Extracted,
278
409
  matcher: String => Boolean,
279
410
  scannedConfigs: mutable.LinkedHashSet[String],
280
411
  withFiles: Boolean,
281
412
  populateScope: Option[Set[String]],
282
- moduleExts: Map[String, String]
413
+ moduleExts: Map[String, String],
414
+ resolver: Resolver,
415
+ ivyUnfetched: IvyUnfetched,
416
+ // sbt's own update failed on artifacts alone, so one it could not fetch is a failure even
417
+ // without files.
418
+ updateFailed: Boolean,
419
+ failures: mutable.LinkedHashSet[Failure]
283
420
  ): mutable.LinkedHashMap[String, RootTree] = {
284
421
  val perRoot = mutable.LinkedHashMap.empty[String, RootTree]
285
422
  val rootKey = moduleKey(rootIdOf(extracted, ref))
@@ -287,7 +424,7 @@ object SocketFactsPlugin extends AutoPlugin {
287
424
  // Build modules come from the build structure instead: lm-coursier lists every inter-project
288
425
  // dependency in every configuration, and cross-versioning can merge distinct ones.
289
426
  def emittable(m: ModuleReport): Boolean =
290
- !m.evicted && !moduleExts.contains(gavKey(m.module))
427
+ !m.evicted && !moduleExts.contains(gavKey(m.module)) && !unresolved.contains(gavKey(m.module))
291
428
 
292
429
  def confsOf(r: ProjectRef): Seq[Configuration] = extracted.getOpt(ivyConfigurations.in(r)).getOrElse(Nil)
293
430
  val buildDeps = extracted.get(buildDependencies)
@@ -358,10 +495,18 @@ object SocketFactsPlugin extends AutoPlugin {
358
495
  cr.modules.foreach { m =>
359
496
  if (emittable(m)) {
360
497
  val ids = midToIds.getOrElseUpdate(moduleKey(m.module), mutable.LinkedHashSet.empty[String])
361
- variantsOf(m).foreach { case (coord, fileOpt) =>
362
- val node = nodes.getOrElseUpdate(coord.id, new Node(coord))
363
- ids += coord.id
364
- if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath)
498
+ val ivyMissing =
499
+ if (ivyUnfetched.isEmpty) Nil
500
+ else ivyUnfetched.getOrElse(cfg, Map.empty[String, Seq[IvyArtifact]]).getOrElse(gavKey(m.module), Nil)
501
+ val recordFiles = withFiles && inScope(m.module)
502
+ variantsOf(m, resolver, ivyMissing).foreach { v =>
503
+ val node = nodes.getOrElseUpdate(v.coord.id, new Node(v.coord))
504
+ ids += v.coord.id
505
+ if (recordFiles) v.file.foreach(f => node.targets += f.getAbsolutePath)
506
+ v.unfetched.foreach { url =>
507
+ if (recordFiles) node.unfetched = true
508
+ if (recordFiles || updateFailed) failures += Failure(coordOf(m.module), url, cfg, ref.project)
509
+ }
365
510
  }
366
511
  }
367
512
  }
@@ -391,22 +536,86 @@ object SocketFactsPlugin extends AutoPlugin {
391
536
  perRoot
392
537
  }
393
538
 
394
- private def variantsOf(m: ModuleReport): Seq[(Coord, Option[File])] = {
539
+ private def variantsOf(m: ModuleReport, resolver: Resolver, ivyMissing: Seq[IvyArtifact]): Seq[Variant] = {
395
540
  val mid = m.module
396
541
  // `mid.revision` is normally set for a resolved module, but normalize null (Coord.id calls
397
542
  // `.nonEmpty`, which NPEs on null) to match the other call sites (gavKey, module records).
398
543
  val ver = if (mid.revision == null) "" else mid.revision
399
- val arts = m.artifacts
400
- if (arts == null || arts.isEmpty)
401
- // No resolved artifact (e.g. a pom-only module): leave it ext-less.
402
- Seq((Coord(mid.organization, mid.name, ver, "", ""), None))
403
- else
404
- arts.toList.map {
405
- case (art, file) =>
406
- (Coord(mid.organization, mid.name, ver, extOf(art), classifierOf(art)), Option(file))
544
+ def coordFor(art: Artifact) = Coord(mid.organization, mid.name, ver, extOf(art), classifierOf(art))
545
+ val fetched = Option(m.artifacts).toList.flatten.map { case (art, file) => Variant(coordFor(art), Option(file), None) }
546
+ val missing = resolver.missingOf(m).map { art =>
547
+ // The update report does not say why: a 401 or a timeout is as likely as an absent file.
548
+ Variant(coordFor(art), None, Some("could not be fetched: " + art.url.map(_.toString).getOrElse(coordFor(art).id)))
549
+ } ++ ivyMissing.map(a => Variant(Coord(mid.organization, mid.name, ver, a.ext, a.classifier), None, Some(a.detail)))
550
+ if (fetched.isEmpty && missing.isEmpty)
551
+ // No artifact expected (a pom-only module): leave it ext-less.
552
+ Seq(Variant(Coord(mid.organization, mid.name, ver, "", ""), None, None))
553
+ else fetched ++ missing
554
+ }
555
+
556
+ // Artifacts the resolver expected and did not fetch. Under Ivy they come from its resolution report
557
+ // instead (see ivyUnfetchedOf).
558
+ private final class Resolver(val ivy: Boolean, poms: PomPackaging) {
559
+ def missingOf(m: ModuleReport): List[Artifact] =
560
+ if (ivy) Nil else Option(m.missingArtifacts).toList.flatten.filterNot(poms.isUnexpectedDefault)
561
+ }
562
+
563
+ // Coursier requests the default jar of every Maven module and skips it silently when absent unless
564
+ // the POM declares a jar packaging. Only the module's own POM tells whether its artifact is a jar
565
+ // (packaging defaults to jar, so a parent's never applies); an unreadable POM counts as one.
566
+ private final class PomPackaging(csrCacheDir: Option[File]) {
567
+ private val memo = mutable.HashMap.empty[String, Boolean]
568
+
569
+ def isUnexpectedDefault(a: Artifact): Boolean = {
570
+ val ext = extOf(a)
571
+ a.classifier.isEmpty && ext == "jar" && a.url.map(_.toString).exists { url =>
572
+ url.endsWith("." + ext) && memo.getOrElseUpdate(url, {
573
+ val pomUrl = url.dropRight(ext.length) + "pom"
574
+ coursierLocalFiles(pomUrl, csrCacheDir).find(_.isFile).flatMap(pomPackaging).exists(p => !JarPackagings(p))
575
+ })
407
576
  }
577
+ }
408
578
  }
409
579
 
580
+ // coursier.maven.MavenAttributes.typeExtensions (2.1.24, which adds ejb): the packagings whose
581
+ // artifact is a `.jar`. Any other packaging (pom, aar, war, ...) publishes its own artifact.
582
+ private val JarPackagings = Set("eclipse-plugin", "maven-plugin", "hk2-jar", "orbit", "scala-jar", "jar", "bundle",
583
+ "doc", "src", "test-jar", "ejb-client", "ejb")
584
+
585
+ // coursier.paths.CachePath.localFile. A repository with credentials is cached under `user@host`,
586
+ // and the update report does not carry the user.
587
+ private def coursierLocalFiles(url: String, csrCacheDir: Option[File]): Seq[File] =
588
+ if (url.startsWith("file:/"))
589
+ try Seq(new File(new java.net.URI(url)))
590
+ catch { case _: Exception => Nil }
591
+ else {
592
+ val i = url.indexOf(':')
593
+ val protocol = if (i < 0) "" else url.substring(0, i)
594
+ val rest = if (i < 0) "" else url.substring(i + 1).dropWhile(_ == '/')
595
+ csrCacheDir.toSeq.flatMap { dir =>
596
+ val protocolDir = new File(dir, coursierEscape(protocol))
597
+ val host = coursierEscape(rest.takeWhile(_ != '/'))
598
+ val path = coursierEscape(rest.dropWhile(_ != '/').drop(1))
599
+ val withUser = Option(protocolDir.listFiles).toSeq.flatten.filter(_.getName.endsWith("%40" + host))
600
+ (new File(protocolDir, host) +: withUser).map(new File(_, path))
601
+ }
602
+ }
603
+
604
+ private def coursierEscape(s: String): String =
605
+ s.flatMap { ch =>
606
+ if (ch > 128 || " %$&+,:;=?@<>#".indexOf(ch) >= 0) "%" + "%02X".format(ch.toInt) else ch.toString
607
+ }
608
+
609
+ // Maven's default packaging is jar.
610
+ private def pomPackaging(pom: File): Option[String] =
611
+ try {
612
+ val kids = parseXml(pom).getDocumentElement.getChildNodes
613
+ val declared = (0 until kids.getLength).map(kids.item).collectFirst {
614
+ case e: org.w3c.dom.Element if e.getNodeName.split(':').last == "packaging" => e.getTextContent.trim
615
+ }
616
+ Some(declared.filter(_.nonEmpty).getOrElse("jar"))
617
+ } catch { case _: Exception => None }
618
+
410
619
  // ---- config selection ---------------------------------------------------
411
620
 
412
621
  // With no includes the default is ALL configurations (captures build/tooling deps).
@@ -568,7 +777,10 @@ object SocketFactsPlugin extends AutoPlugin {
568
777
  else v.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r")
569
778
  }
570
779
 
571
- private final case class Failure(coord: String, detail: String, config: String)
780
+ // Empty `config`: the failure belongs to no configuration.
781
+ private final case class Failure(coord: String, detail: String, config: String, project: String)
782
+
783
+ private final case class Unscannable(detail: String, project: String)
572
784
 
573
785
  private final case class Coord(
574
786
  org: String,
@@ -587,8 +799,13 @@ object SocketFactsPlugin extends AutoPlugin {
587
799
  var project = ""
588
800
  // External artifact's resolved jar(s); --with-files only.
589
801
  val targets = mutable.TreeSet.empty[String]
802
+ // An expected artifact was not fetched; --with-files only.
803
+ var unfetched = false
590
804
  }
591
805
 
806
+ // `unfetched`: why the expected artifact could not be fetched.
807
+ private final case class Variant(coord: Coord, file: Option[File], unfetched: Option[String])
808
+
592
809
  private final class RootTree(
593
810
  val projectKey: String,
594
811
  val config: String,