@socketsecurity/cli-with-sentry 1.10.0 → 1.11.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 (51) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +5 -5
  3. package/dist/cli.js +255 -128
  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 +241 -54
  9. package/dist/manifest-scripts/socket-facts.plugin.scala +224 -38
  10. package/dist/tsconfig.dts.tsbuildinfo +1 -1
  11. package/dist/types/commands/ci/handle-ci.d.mts.map +1 -1
  12. package/dist/types/commands/fix/handle-fix.d.mts.map +1 -1
  13. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts +2 -1
  14. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts.map +1 -1
  15. package/dist/types/commands/manifest/convert-maven-to-facts.d.mts +2 -1
  16. package/dist/types/commands/manifest/convert-maven-to-facts.d.mts.map +1 -1
  17. package/dist/types/commands/manifest/convert-sbt-to-facts.d.mts +2 -1
  18. package/dist/types/commands/manifest/convert-sbt-to-facts.d.mts.map +1 -1
  19. package/dist/types/commands/manifest/generate-recursive-manifests.d.mts.map +1 -1
  20. package/dist/types/commands/manifest/generate_auto_manifest.d.mts.map +1 -1
  21. package/dist/types/commands/manifest/run-manifest-facts.d.mts.map +1 -1
  22. package/dist/types/commands/manifest/scripts/assemble.d.mts.map +1 -1
  23. package/dist/types/commands/manifest/scripts/facts.d.mts +3 -2
  24. package/dist/types/commands/manifest/scripts/facts.d.mts.map +1 -1
  25. package/dist/types/commands/manifest/scripts/records.d.mts +7 -1
  26. package/dist/types/commands/manifest/scripts/records.d.mts.map +1 -1
  27. package/dist/types/commands/manifest/scripts/resolution-report-gradle.d.mts +3 -2
  28. package/dist/types/commands/manifest/scripts/resolution-report-gradle.d.mts.map +1 -1
  29. package/dist/types/commands/manifest/scripts/resolution-report-maven.d.mts.map +1 -1
  30. package/dist/types/commands/manifest/scripts/resolution-report-render.d.mts +9 -4
  31. package/dist/types/commands/manifest/scripts/resolution-report-render.d.mts.map +1 -1
  32. package/dist/types/commands/manifest/scripts/resolution-report.d.mts +7 -2
  33. package/dist/types/commands/manifest/scripts/resolution-report.d.mts.map +1 -1
  34. package/dist/types/commands/manifest/scripts/sidecar.d.mts.map +1 -1
  35. package/dist/types/commands/scan/cmd-scan-create.d.mts.map +1 -1
  36. package/dist/types/commands/scan/cmd-scan-reach.d.mts.map +1 -1
  37. package/dist/types/commands/scan/handle-create-new-scan.d.mts.map +1 -1
  38. package/dist/types/commands/scan/perform-reachability-analysis.d.mts +2 -0
  39. package/dist/types/commands/scan/perform-reachability-analysis.d.mts.map +1 -1
  40. package/dist/types/commands/scan/reach-advisories.d.mts +7 -0
  41. package/dist/types/commands/scan/reach-advisories.d.mts.map +1 -0
  42. package/dist/types/commands/scan/reachability-flags.d.mts.map +1 -1
  43. package/dist/types/constants.d.mts +1 -1
  44. package/dist/types/constants.d.mts.map +1 -1
  45. package/dist/types/utils/advisory-ids.d.mts +14 -0
  46. package/dist/types/utils/advisory-ids.d.mts.map +1 -0
  47. package/dist/types/utils/cve-to-ghsa.d.mts +2 -0
  48. package/dist/types/utils/cve-to-ghsa.d.mts.map +1 -1
  49. package/dist/utils.js +69 -3
  50. package/dist/utils.js.map +1 -1
  51. package/package.json +2 -2
@@ -63,10 +63,26 @@ object SocketFactsPlugin extends AutoPlugin {
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, matcher, failures, unscannable).foreach { case (report, unresolved) =>
69
- foldReport(report, unresolved, 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,6 +154,7 @@ 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
 
@@ -194,17 +214,19 @@ object SocketFactsPlugin extends AutoPlugin {
194
214
  }.toMap
195
215
  }
196
216
 
197
- // When a failure names modules, a lenient (missingOk) retry keeps everything else and, where the
198
- // resolver still places a failed module, its configurations. Failed modules are reported, not emitted.
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.
199
219
  private def runUpdateResilient(
200
220
  taskName: String,
201
221
  ref: ProjectRef,
202
222
  extracted: Extracted,
203
223
  state: State,
224
+ lenient: => State,
204
225
  matcher: String => Boolean,
205
226
  failures: mutable.LinkedHashSet[Failure],
206
- unscannable: mutable.LinkedHashSet[Unscannable]
207
- ): Option[(UpdateReport, Set[String])] = {
227
+ unscannable: mutable.LinkedHashSet[Unscannable],
228
+ resolver: Resolver
229
+ ): Option[(UpdateReport, Set[String], IvyUnfetched, Boolean)] = {
208
230
  val key = TaskKey[UpdateReport](taskName)
209
231
 
210
232
  def runOn(st: State): Either[(Seq[ModuleID], String), UpdateReport] =
@@ -215,14 +237,18 @@ object SocketFactsPlugin extends AutoPlugin {
215
237
  }
216
238
 
217
239
  runOn(state) match {
218
- case Right(rep) => Some((rep, Set.empty[String]))
219
- case Left((failed, detail)) if failed.isEmpty =>
220
- unscannable += Unscannable(detail, ref.project)
221
- None
240
+ case Right(rep) => Some((rep, Set.empty[String], Map.empty: IvyUnfetched, false))
222
241
  case Left((failed, detail)) =>
223
- val lenient = extracted.append(Seq(updateConfiguration.in(ref) ~= (c => withMissingOk(c))), state)
224
- runOn(lenient) match {
225
- case Right(rep) =>
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)) =>
226
252
  val confsByGav = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]]
227
253
  rep.configurations.foreach { cr =>
228
254
  cr.modules.foreach { r =>
@@ -236,7 +262,7 @@ object SocketFactsPlugin extends AutoPlugin {
236
262
  case Some(confs) => confs.filter(matcher).foreach(c => failures += Failure(coordOf(m), detailFor(m, detail), c, ref.project))
237
263
  }
238
264
  }
239
- Some((rep, failed.map(gavKey).toSet))
265
+ Some((rep, failed.map(gavKey).toSet, ivyUnfetched, failed.isEmpty))
240
266
  case Left(_) =>
241
267
  // Even a lenient update failed: the whole project is unresolved, but the modules sbt
242
268
  // named are still reported.
@@ -247,6 +273,83 @@ object SocketFactsPlugin extends AutoPlugin {
247
273
  }
248
274
  }
249
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
+
250
353
  // UpdateConfiguration has `withMissingOk` on sbt 1.x; on 0.13 its only Boolean field is missingOk.
251
354
  private def withMissingOk[T](c: T): T = {
252
355
  val cls = c.getClass
@@ -307,7 +410,13 @@ object SocketFactsPlugin extends AutoPlugin {
307
410
  scannedConfigs: mutable.LinkedHashSet[String],
308
411
  withFiles: Boolean,
309
412
  populateScope: Option[Set[String]],
310
- 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]
311
420
  ): mutable.LinkedHashMap[String, RootTree] = {
312
421
  val perRoot = mutable.LinkedHashMap.empty[String, RootTree]
313
422
  val rootKey = moduleKey(rootIdOf(extracted, ref))
@@ -386,10 +495,18 @@ object SocketFactsPlugin extends AutoPlugin {
386
495
  cr.modules.foreach { m =>
387
496
  if (emittable(m)) {
388
497
  val ids = midToIds.getOrElseUpdate(moduleKey(m.module), mutable.LinkedHashSet.empty[String])
389
- variantsOf(m).foreach { case (coord, fileOpt) =>
390
- val node = nodes.getOrElseUpdate(coord.id, new Node(coord))
391
- ids += coord.id
392
- 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
+ }
393
510
  }
394
511
  }
395
512
  }
@@ -419,22 +536,86 @@ object SocketFactsPlugin extends AutoPlugin {
419
536
  perRoot
420
537
  }
421
538
 
422
- private def variantsOf(m: ModuleReport): Seq[(Coord, Option[File])] = {
539
+ private def variantsOf(m: ModuleReport, resolver: Resolver, ivyMissing: Seq[IvyArtifact]): Seq[Variant] = {
423
540
  val mid = m.module
424
541
  // `mid.revision` is normally set for a resolved module, but normalize null (Coord.id calls
425
542
  // `.nonEmpty`, which NPEs on null) to match the other call sites (gavKey, module records).
426
543
  val ver = if (mid.revision == null) "" else mid.revision
427
- val arts = m.artifacts
428
- if (arts == null || arts.isEmpty)
429
- // No resolved artifact (e.g. a pom-only module): leave it ext-less.
430
- Seq((Coord(mid.organization, mid.name, ver, "", ""), None))
431
- else
432
- arts.toList.map {
433
- case (art, file) =>
434
- (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
+ })
435
576
  }
577
+ }
436
578
  }
437
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
+
438
619
  // ---- config selection ---------------------------------------------------
439
620
 
440
621
  // With no includes the default is ALL configurations (captures build/tooling deps).
@@ -618,8 +799,13 @@ object SocketFactsPlugin extends AutoPlugin {
618
799
  var project = ""
619
800
  // External artifact's resolved jar(s); --with-files only.
620
801
  val targets = mutable.TreeSet.empty[String]
802
+ // An expected artifact was not fetched; --with-files only.
803
+ var unfetched = false
621
804
  }
622
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
+
623
809
  private final class RootTree(
624
810
  val projectKey: String,
625
811
  val config: String,