diff --git a/.gitignore b/.gitignore index d6bb54053c..3dd4c3ebf5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,12 @@ test/fixtures/commands/fix/e2e-test-py-temp-* /src/commands/manifest/scripts/test/sbt-compat/project/localrepo/ /src/commands/manifest/scripts/test/sbt-compat/project/records.tsv /src/commands/manifest/scripts/test/sbt-compat/project/target/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/*/target/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/localrepo/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/project/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/records.tsv +/src/commands/manifest/scripts/test/sbt-compat/relocation/scala-version.sbt +/src/commands/manifest/scripts/test/sbt-compat/relocation/target/ # Generated when the dynamic SBOM inference tests walk their fixtures # (test/fixtures/commands/manifest/dynamic-sbom-inference). Those fixtures are diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2fd26301..1e80891d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `--exclude-paths` patterns are now matched relative to the scan root in every build root. They now also exclude modules a build reaches outside its own directory (Maven `../` modules, relocated Gradle projects) and modules matched by wildcards that span a build-root boundary, such as `*/core`. - `--exclude-paths` patterns written with Windows `\` separators now exclude JVM build modules in single-root runs too. - Maven SBOMs keep the dependencies a module reaches through a sibling module's test-jar. +- sbt SBOMs now generate for builds with relocated Maven dependencies, such as `mysql:mysql-connector-java`. ## [1.12.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.2) - 2026-10-30 diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 4d9d7a5b16..6a736865d4 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -186,11 +186,11 @@ object SocketFactsPlugin extends AutoPlugin { // ---- resolution --------------------------------------------------------- - private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = { - val sv = extracted.get(scalaVersion.in(ref)) - val sbv = extracted.get(scalaBinaryVersion.in(ref)) - CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref))) - } + private def crossOf(extracted: Extracted, ref: ProjectRef): ModuleID => ModuleID = + CrossVersion.apply(extracted.get(scalaVersion.in(ref)), extracted.get(scalaBinaryVersion.in(ref))) + + private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = + crossOf(extracted, ref)(extracted.get(projectID.in(ref))) // GAV -> a build module's real artifact extension (jar for a normal project). coursier reports an // inter-project dependency with no resolved artifact, so it would otherwise be ext-less and not @@ -346,6 +346,47 @@ object SocketFactsPlugin extends AutoPlugin { dbf.newDocumentBuilder().parse(f) } + // Child elements by local name; "*" for all. + private def pomKids(e: org.w3c.dom.Element, name: String): Seq[org.w3c.dom.Element] = { + val ns = e.getChildNodes + (0 until ns.getLength).map(ns.item).collect { + case c: org.w3c.dom.Element if name == "*" || c.getNodeName.split(':').last == name => c + } + } + + private def pomText(e: org.w3c.dom.Element, name: String): Option[String] = + pomKids(e, name).headOption.map(_.getTextContent.trim).filter(_.nonEmpty) + + private def pomGroupId(e: org.w3c.dom.Element): String = + pomText(e, "groupId").orElse(pomKids(e, "parent").headOption.flatMap(pomText(_, "groupId"))).getOrElse("") + + private val PropertyRef = "\\$\\{([^}]+)\\}".r + + // A POM after inheritance: its dependencies (groupId:artifactId -> declared scope, optional), its + // managed scopes, the BOMs it imports, and its properties. + private final case class PomModel( + deps: Map[String, (String, Boolean)], + managed: Map[String, String], + imports: Seq[(String, String, String)], + props: Map[String, String]) + + private object PomModel { + val empty = PomModel(Map.empty, Map.empty, Nil, Map.empty) + } + + // Until nothing changes, as a property's value may refer to another. + private def interpolate(s: String, props: Map[String, String]): String = { + var cur = s + var prev = "" + var rounds = 0 + while (cur != prev && rounds < 16) { + prev = cur + cur = PropertyRef.replaceAllIn(cur, m => java.util.regex.Matcher.quoteReplacement(props.getOrElse(m.group(1), m.matched))) + rounds += 1 + } + cur + } + // Ivy's ResolveOptions.getDefaultResolveId: `-` of the resolved descriptor. private def ivyResolveId(descriptor: File): Option[String] = try { @@ -464,10 +505,21 @@ object SocketFactsPlugin extends AutoPlugin { !m.evicted && !moduleExts.contains(gavKey(m.module)) && !unresolved.contains(gavKey(m.module)) def confsOf(r: ProjectRef): Seq[Configuration] = extracted.getOpt(ivyConfigurations.in(r)).getOrElse(Nil) + lazy val refByProject: Map[String, ProjectRef] = extracted.structure.allProjectRefs.map(r => r.project -> r).toMap + // Whether build project `r` declares module `c` in one of `confs`. A declaration without a + // configuration goes to Compile; the report names the module cross-versioned. + def declares(r: ProjectRef, c: ModuleID, confs: Set[String]): Boolean = { + val cross = crossOf(extracted, r) + extracted.getOpt(libraryDependencies.in(r)).getOrElse(Nil).exists { d => + val masters = d.configurations.getOrElse("compile").split(';').flatMap(_.split("->").head.split(',')).map(_.trim) + d.organization == c.organization && cross(d).name == c.name && masters.exists(m => m == "*" || confs(m)) + } + } val buildDeps = extracted.get(buildDependencies) - // Classpaths.interSort, recording the (project -> dependency project) edges it follows. - def interProjectEdges(conf: Configuration): mutable.LinkedHashSet[(ProjectRef, ProjectRef)] = { + // Classpaths.interSort, recording the (project -> dependency project) edges it follows and the + // configurations of each project it reaches. + def interProjectEdges(conf: Configuration): (mutable.LinkedHashSet[(ProjectRef, ProjectRef)], Map[String, Set[String]]) = { val masterConfs = confsOf(ref).map(_.name) val visited = mutable.HashSet.empty[(ProjectRef, String)] val edges = mutable.LinkedHashSet.empty[(ProjectRef, ProjectRef)] @@ -488,7 +540,7 @@ object SocketFactsPlugin extends AutoPlugin { } } visit(ref, conf) - edges + (edges, visited.toSeq.groupBy(_._1.project).map { case (p, vs) => p -> vs.map(_._2).toSet }) } def buildModuleCoord(r: ProjectRef): Coord = { @@ -510,6 +562,7 @@ object SocketFactsPlugin extends AutoPlugin { val nodes = mutable.LinkedHashMap.empty[String, Node] // org:name -> component ids (caller edges are module-level). val midToIds = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]] + var reachedConfs = Map.empty[String, Set[String]] confsOf(ref).find(_.name == cfg).foreach { conf => // Keyed by project id, as projects need not have distinct coordinates. @@ -522,7 +575,9 @@ object SocketFactsPlugin extends AutoPlugin { }) r.project } - interProjectEdges(conf).foreach { case (p, d) => + val (edges, reached) = interProjectEdges(conf) + reachedConfs = reached + edges.foreach { case (p, d) => val child = buildModuleNode(d) if (p == ref) nodes(child).direct = true else nodes(buildModuleNode(p)).children += child @@ -549,19 +604,29 @@ object SocketFactsPlugin extends AutoPlugin { } // Caller edges within this config: a root caller marks the child direct, any other becomes its parent. - cr.modules.foreach { m => - if (emittable(m)) { - midToIds.get(moduleKey(m.module)).foreach { childIds => - // lm-coursier gives a declared caller its declared revision, not the resolved one, so - // callers match by org:name (a configuration resolves one version per module). - m.callers.foreach { c => - val callerKey = moduleKey(c.caller) - if (callerKey == rootKey) childIds.foreach(cid => nodes(cid).direct = true) - else - midToIds.get(callerKey).foreach { parentIds => - // Drop self-edges (test → main resolving to the same coordinate), matching gradle. - parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid)) - } + // Coursier resolves a Maven relocation's target in place of the relocating module, but reports + // neither the relocating module nor who declared it, so that caller is missing from the report. + // Its declarers are read off the build's own declarations and the POMs instead. + val candidates = cr.modules.filter(emittable) + // Drops self-edges (test → main resolving to the same coordinate), matching gradle. + def link(parentIds: Iterable[String], childIds: Iterable[String]): Unit = + parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid)) + def declaredBy(p: String, c: ModuleID): Boolean = + refByProject.get(p).exists(declares(_, c, reachedConfs.getOrElse(p, Set.empty))) + candidates.foreach { m => + midToIds.get(moduleKey(m.module)).foreach { childIds => + // lm-coursier gives a declared caller its declared revision, not the resolved one, so + // callers match by org:name (a configuration resolves one version per module). + m.callers.foreach { c => + val callerKey = moduleKey(c.caller) + if (callerKey == rootKey) childIds.foreach(cid => nodes(cid).direct = true) + else midToIds.get(callerKey) match { + case Some(parentIds) => link(parentIds, childIds) + case None if !resolver.ivy && resolver.poms.relocates(c.caller, m) => + if (declaredBy(ref.project, c.caller)) childIds.foreach(cid => nodes(cid).direct = true) + link(nodes.collect { case (nid, n) if n.project.nonEmpty && declaredBy(n.project, c.caller) => nid }, childIds) + resolver.poms.declaringModules(c.caller, candidates).foreach(p => midToIds.get(moduleKey(p)).foreach(link(_, childIds))) + case None => } } } @@ -592,7 +657,7 @@ object SocketFactsPlugin extends AutoPlugin { // Artifacts the resolver expected and did not fetch. Under Ivy they come from its resolution report // instead (see ivyUnfetchedOf). - private final class Resolver(val ivy: Boolean, poms: PomPackaging) { + private final class Resolver(val ivy: Boolean, val poms: PomPackaging) { def missingOf(m: ModuleReport): List[Artifact] = if (ivy) Nil else Option(m.missingArtifacts).toList.flatten.filterNot(poms.isUnexpectedDefault) } @@ -601,15 +666,126 @@ object SocketFactsPlugin extends AutoPlugin { // the POM declares a jar packaging. Only the module's own POM tells whether its artifact is a jar // (packaging defaults to jar, so a parent's never applies); an unreadable POM counts as one. private final class PomPackaging(csrCacheDir: Option[File]) { - private val memo = mutable.HashMap.empty[String, Boolean] + private val pomMemo = mutable.HashMap.empty[String, Option[org.w3c.dom.Element]] + + private def pom(url: String): Option[org.w3c.dom.Element] = + pomMemo.getOrElseUpdate(url, coursierLocalFiles(url, csrCacheDir).find(_.isFile).flatMap { f => + try Some(parseXml(f).getDocumentElement) + catch { case _: Exception => None } + }) + + // Maven repository layout. + private def moduleDir(base: String, org: String, name: String): String = + base + org.replace('.', '/') + "/" + name + "/" + + private def pomUrl(base: String, org: String, name: String, rev: String): String = + moduleDir(base, org, name) + rev + "/" + name + "-" + rev + ".pom" + + // The repository root a module's artifacts were fetched from. + private def repoBase(m: ModuleReport): Option[String] = { + val mid = m.module + val marker = moduleDir("/", mid.organization, mid.name) + orEmpty(mid.revision) + "/" + val urls = Option(m.artifacts).toList.flatten.flatMap(_._1.url) ++ + Option(m.missingArtifacts).toList.flatten.flatMap(_.url) + urls.map(_.toString).collectFirst { case u if u.contains(marker) => u.substring(0, u.indexOf(marker) + 1) } + } + + private val modelMemo = mutable.HashMap.empty[String, PomModel] + + // Maven model inheritance and interpolation of a POM, as far as its dependencies' scopes need. + private def model(base: String, org: String, name: String, rev: String, depth: Int): PomModel = { + val url = pomUrl(base, org, name, rev) + modelMemo.getOrElseUpdate(url, pom(url) match { + case None => PomModel.empty + case Some(e) => + val parent = pomKids(e, "parent").headOption + val pm = parent match { + case Some(p) if depth < 16 => + model(base, pomText(p, "groupId").getOrElse(""), pomText(p, "artifactId").getOrElse(""), + pomText(p, "version").getOrElse(""), depth + 1) + case _ => PomModel.empty + } + val groupId = pomGroupId(e) + val artifactId = pomText(e, "artifactId").getOrElse("") + val version = pomText(e, "version").orElse(parent.flatMap(pomText(_, "version"))).getOrElse("") + val own = pomKids(e, "properties").flatMap(ps => pomKids(ps, "*")).map(p => p.getNodeName.split(':').last -> p.getTextContent.trim) + val props = pm.props ++ own ++ Seq( + "project.groupId" -> groupId, "pom.groupId" -> groupId, "groupId" -> groupId, + "project.artifactId" -> artifactId, "pom.artifactId" -> artifactId, "artifactId" -> artifactId, + "project.version" -> version, "pom.version" -> version, "version" -> version + ) ++ parent.toSeq.flatMap { p => + Seq("project.parent.groupId" -> pomText(p, "groupId").getOrElse(""), + "project.parent.version" -> pomText(p, "version").getOrElse("")) + } + def field(d: _root_.org.w3c.dom.Element, name: String) = interpolate(pomText(d, name).getOrElse(""), props) + def ga(d: _root_.org.w3c.dom.Element) = field(d, "groupId") + ":" + field(d, "artifactId") + val deps = pomKids(e, "dependencies").flatMap(ds => pomKids(ds, "dependency")) + val managed = pomKids(e, "dependencyManagement").flatMap(pomKids(_, "dependencies")).flatMap(pomKids(_, "dependency")) + val (imports, scoped) = managed.partition(d => field(d, "scope") == "import" && field(d, "type") == "pom") + PomModel( + pm.deps ++ deps.map(d => ga(d) -> ((field(d, "scope"), field(d, "optional") == "true"))), + pm.managed ++ scoped.map(d => ga(d) -> field(d, "scope")), + imports.map(d => (field(d, "groupId"), field(d, "artifactId"), field(d, "version"))) ++ pm.imports, + props) + }) + } + + private val managedMemo = mutable.HashMap.empty[String, Map[String, String]] + + // Managed scopes after BOM imports: the POM's own and inherited entries win, then the first import. + private def managedScopes(base: String, org: String, name: String, rev: String, depth: Int): Map[String, String] = + managedMemo.getOrElseUpdate(pomUrl(base, org, name, rev), { + val m = model(base, org, name, rev, 0) + m.imports.reverse.foldLeft(Map.empty[String, String]) { case (acc, (g, a, v)) => + if (depth < 16) acc ++ managedScopes(base, g, a, v, depth + 1) else acc + } ++ m.managed + }) + + // org:name of the modules each relocating module's cached POMs relocate to. + private val relocationsMemo = mutable.HashMap.empty[(String, String), Set[String]] + + // Whether `relocating` is a POM relocated to `target`'s module. The report gives the relocating + // module its declared revision, which may be a range, so every cached version is tried; the + // target may have resolved to another version than the relocation names. + def relocates(relocating: ModuleID, target: ModuleReport): Boolean = + repoBase(target).exists { base => + relocationsMemo.getOrElseUpdate((base, gavKey(relocating)), { + val dir = moduleDir(base, relocating.organization, relocating.name) + val versions = orEmpty(relocating.revision) +: coursierLocalFiles(dir, csrCacheDir) + .flatMap(d => Option(d.listFiles).toSeq.flatten).filter(_.isDirectory).map(_.getName) + versions.distinct.flatMap { v => + pom(pomUrl(base, relocating.organization, relocating.name, v)).toSeq.flatMap { e => + pomKids(e, "distributionManagement").flatMap(pomKids(_, "relocation")).headOption.map { r => + pomText(r, "groupId").getOrElse(pomGroupId(e)) + ":" + + pomText(r, "artifactId").orElse(pomText(e, "artifactId")).getOrElse("") + } + } + }.toSet + })(moduleKey(target.module)) + } + + // The modules among `candidates` whose POM passes `declared` on to their dependents: compile or + // runtime scope, declared or managed, and not optional. + def declaringModules(declared: ModuleID, candidates: Seq[ModuleReport]): Seq[ModuleID] = { + val key = moduleKey(declared) + candidates.filter { p => + repoBase(p).exists { base => + val m = model(base, p.module.organization, p.module.name, orEmpty(p.module.revision), 0) + m.deps.get(key).exists { case (scope, optional) => + val effective = + if (scope.nonEmpty) scope + else managedScopes(base, p.module.organization, p.module.name, orEmpty(p.module.revision), 0).getOrElse(key, "") + Set("", "compile", "runtime")(effective) && !optional + } + } + }.map(_.module) + } def isUnexpectedDefault(a: Artifact): Boolean = { val ext = extOf(a) a.classifier.isEmpty && ext == "jar" && a.url.map(_.toString).exists { url => - url.endsWith("." + ext) && memo.getOrElseUpdate(url, { - val pomUrl = url.dropRight(ext.length) + "pom" - coursierLocalFiles(pomUrl, csrCacheDir).find(_.isFile).flatMap(pomPackaging).exists(p => !JarPackagings(p)) - }) + url.endsWith("." + ext) && + pom(url.dropRight(ext.length) + "pom").exists(e => !JarPackagings(pomText(e, "packaging").getOrElse("jar"))) } } } @@ -643,16 +819,6 @@ object SocketFactsPlugin extends AutoPlugin { if (ch > 128 || " %$&+,:;=?@<>#".indexOf(ch) >= 0) "%" + "%02X".format(ch.toInt) else ch.toString } - // Maven's default packaging is jar. - private def pomPackaging(pom: File): Option[String] = - try { - val kids = parseXml(pom).getDocumentElement.getChildNodes - val declared = (0 until kids.getLength).map(kids.item).collectFirst { - case e: org.w3c.dom.Element if e.getNodeName.split(':').last == "packaging" => e.getTextContent.trim - } - Some(declared.filter(_.nonEmpty).getOrElse("jar")) - } catch { case _: Exception => None } - // ---- config selection --------------------------------------------------- // With no includes the default is ALL configurations (captures build/tooling deps). diff --git a/src/commands/manifest/scripts/test/make-stub-repo.sh b/src/commands/manifest/scripts/test/make-stub-repo.sh index fed643bd0e..f6ef753024 100755 --- a/src/commands/manifest/scripts/test/make-stub-repo.sh +++ b/src/commands/manifest/scripts/test/make-stub-repo.sh @@ -10,7 +10,9 @@ # Usage: make-stub-repo.sh ... # spec = group:artifact:version[+dep-group:dep-artifact:dep-version[,...]] # Deps listed after `+` are written as compile-scope dependencies of that stub, so a consumer that -# depends on the stub also pulls them transitively. +# depends on the stub also pulls them transitively; `dep@scope` writes that scope instead. +# spec = group:artifact:version>new-group:new-artifact:new-version writes a POM that relocates the +# stub to the new coordinate, and no jar. set -euo pipefail REPO="${1:?usage: make-stub-repo.sh ...}" @@ -28,6 +30,32 @@ EMPTY="$(mktemp -d)" trap 'rm -rf "$EMPTY"' EXIT for spec in "$@"; do + if [[ "$spec" == *">"* ]]; then + IFS=: read -r group art ver <<<"${spec%%>*}" + IFS=: read -r ngroup nart nver <<<"${spec#*>}" + if [ -z "${group:-}" ] || [ -z "${art:-}" ] || [ -z "${ver:-}" ] || [ -z "${ngroup:-}" ] || [ -z "${nart:-}" ] || [ -z "${nver:-}" ]; then + echo "make-stub-repo.sh: malformed relocation '$spec' (want group:artifact:version>group:artifact:version)" >&2 + exit 1 + fi + dir="$REPO/${group//.//}/$art/$ver" + mkdir -p "$dir" + cat >"$dir/$art-$ver.pom" < + 4.0.0 + $group + $art + $ver + + + $ngroup + $nart + $nver + + + +POM + continue + fi coord="${spec%%+*}" deps="" [ "$coord" = "$spec" ] || deps="${spec#*+}" @@ -51,7 +79,9 @@ for spec in "$@"; do echo ' ' IFS=, read -r -a dep_list <<<"$deps" for dep in "${dep_list[@]}"; do - IFS=: read -r dgroup dart dver <<<"$dep" + dscope="" + [ "${dep%@*}" = "$dep" ] || dscope="${dep#*@}" + IFS=: read -r dgroup dart dver <<<"${dep%@*}" if [ -z "${dgroup:-}" ] || [ -z "${dart:-}" ] || [ -z "${dver:-}" ]; then echo "make-stub-repo.sh: malformed dependency '$dep' in spec '$spec'" >&2 exit 1 @@ -60,6 +90,7 @@ for spec in "$@"; do echo " $dgroup" echo " $dart" echo " $dver" + [ -z "$dscope" ] || echo " $dscope" echo ' ' done echo ' ' @@ -71,4 +102,22 @@ for spec in "$@"; do ( cd "$EMPTY" && jar cf "$dir/$art-$ver.jar" . ) done +# A version list per module, so a version range can resolve against the repo. +find "$REPO" -name '*.pom' | while read -r pom; do + dir="$(dirname "$(dirname "$pom")")" + art="$(basename "$dir")" + group="$(dirname "${dir#"$REPO"/}")" + { + echo '' + echo " ${group//\//.}" + echo " $art" + echo ' ' + echo ' ' + for v in "$dir"/*/; do echo " $(basename "$v")"; done + echo ' ' + echo ' ' + echo '' + } >"$dir/maven-metadata.xml" +done + echo "built stub repo at $REPO" diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index 6b2b0ab2a0..eaa8d75125 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -115,6 +115,7 @@ run_sbt() { bash "$HERE/sbt-compat/smoke-test-exclude-base.sh" "$ver" "$scala" fi bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" ${resolver:+"$resolver"} + bash "$HERE/sbt-compat/smoke-test-relocation.sh" "$ver" "$scala" ${resolver:+"$resolver"} done } diff --git a/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt index ec2e92f71e..f72bace27e 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt +++ b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt @@ -1,5 +1,6 @@ -// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves one prod dep and one -// test dep so the smoke test can assert the prod/dev split and --with-files materialization. Both are +// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves prod deps (two through +// a Maven relocation) and one test dep so the smoke test can assert the prod/dev split, relocations +// and --with-files materialization. All are // stub artifacts generated into `localrepo` at test time (../../make-stub-repo.sh): plain Java // artifacts needing no Scala cross-version, never compiled against, so they can never age into a CVE // alert or a version bump. Uses the `in ThisBuild` setting form (not the `/` slash form) so it parses @@ -20,5 +21,7 @@ lazy val root = (project in file(".")) "Maven Central" at "https://repo1.maven.org/maven2/" ) ++ resolvers.value, libraryDependencies += "demo.ext" % "tool" % "1.0", + // Relocated to demo.ext:moved; tool depends on demo.old:legacy, relocated to demo.ext:legacy. + libraryDependencies += "demo.old" % "moved" % "1.0", libraryDependencies += "demo.ext" % "harness" % "1.0" % Test ) diff --git a/src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt new file mode 100644 index 0000000000..ebdb848d52 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt @@ -0,0 +1,21 @@ +// Relocation fixture for ../../socket-facts.plugin.scala. core declares demo.old:moved, which +// relocates to demo.ext:moved, by a version range; app gets demo.ext:moved only through core, and +// overrides its version to one the relocation does not name. The +// demo.ext:wrapper POM declares demo.old:moved with the test scope, which it does not pass on, so it +// must not become demo.ext:moved's parent. Stub artifacts come from `localrepo` +// (../../make-stub-repo.sh). Uses the `in ThisBuild` setting form so it parses on sbt 0.13 and 1.x. +organization in ThisBuild := "demo" +version in ThisBuild := "0.1.0" + +lazy val stubs = Seq( + resolvers := Seq( + "Socket Stubs" at s"file://${(baseDirectory in ThisBuild).value.getAbsolutePath}/localrepo", + "Maven Central" at "https://repo1.maven.org/maven2/" + ) ++ resolvers.value +) + +lazy val core = (project in file("core")).settings(stubs, libraryDependencies += "demo.old" % "moved" % "[1.0,2.0)") + +lazy val app = (project in file("app")) + .dependsOn(core) + .settings(stubs, libraryDependencies += "demo.ext" % "wrapper" % "1.0", dependencyOverrides += "demo.ext" % "moved" % "1.1") diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh new file mode 100755 index 0000000000..ae4b43a7c2 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Run socket-facts.plugin.scala on the relocation fixture and assert a Maven relocation's target keeps +# the parent that declared the relocating module: the project itself (direct) or a build project it +# depends on, never a library that declares it with a scope it does not pass on. Coursier reports the +# target with a caller it leaves out of the report, and gives that caller its declared revision, here +# a version range; an override can move the target off the version the relocation names. +# +# Usage: smoke-test-relocation.sh [coursier|ivy] (ivy: sbt 1.x only) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SBT_VERSION="${1:?usage: smoke-test-relocation.sh }" +SCALA_VERSION="${2:?usage: smoke-test-relocation.sh }" +RESOLVER="${3:-coursier}" +PLUGIN="$HERE/../../socket-facts.plugin.scala" +PROJECT="$HERE/relocation" +RECORDS="$PROJECT/records.tsv" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +IVY="$SOCKET_COMPAT_CACHE/ivy2" +export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier" + +bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \ + 'demo.old:moved:1.0>demo.ext:moved:1.0' \ + 'demo.ext:moved:1.0' \ + 'demo.ext:moved:1.1' \ + 'demo.ext:wrapper:1.0+demo.old:moved:1.0@test' +rm -rf "$IVY/cache/demo.ext" "$IVY/cache/demo.old" + +GB="$(mktemp -d)/global-base" +mkdir -p "$GB/plugins" +cp "$PLUGIN" "$GB/plugins/SocketFactsPlugin.scala" +mkdir -p "$PROJECT/project" +echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties" +echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt" +if [ "$RESOLVER" = ivy ]; then + echo 'useCoursier in ThisBuild := false' >> "$PROJECT/scala-version.sbt" +fi +rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" "$PROJECT"/*/target + +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (relocation, $RESOLVER)" +( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsbt.ivy.home="$IVY" -Dsocket.recordsFile="$RECORDS" --batch socketFacts ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +root_of = {r[1]: (r[2], r[3]) for r in rows if r[0] == 'root'} +# Coursier leaves the relocating module out, so the target hangs under its declarer; Ivy keeps it as +# an artifact-less node between the two, which counts as the declarer's edge. +moved = {'core': 'demo.ext:moved:jar:1.0', 'app': 'demo.ext:moved:jar:1.1'} +relocating, wrapper = 'demo.old:moved:1.0', 'demo.ext:wrapper:jar:1.0' +errors = [] +for rid, (project, config) in root_of.items(): + nodes = {r[2]: r for r in rows if r[0] == 'node' and r[1] == rid} + target = moved.get(project) + if target not in nodes: + continue + parents_of = lambda c: {r[2] for r in rows if r[0] == 'edge' and r[1] == rid and r[3] == c} + parents, direct = parents_of(target), nodes[target][8] == '1' + if relocating in parents: + parents = (parents - {relocating}) | parents_of(relocating) + direct = direct or nodes[relocating][8] == '1' + if project == 'core' and not direct: + errors.append(f"core/{config}: {target} not direct") + if project == 'app' and parents != {'core'}: + errors.append(f"app/{config}: {target} parents {sorted(parents)} != ['core']") + if wrapper in parents: + errors.append(f"{project}/{config}: {wrapper} made a parent of {target}") +for project, target in moved.items(): + if not any(r[0] == 'node' and r[2] == target and root_of[r[1]][0] == project for r in rows): + errors.append(f"{target} missing from {project}") +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS (relocation): demo.ext:moved direct in core, under core in app (overridden), never under {wrapper}") +PY + +python3 "$HERE/../assert-reachability.py" "$RECORDS" diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh index 064f35784c..801d831c9f 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh @@ -8,6 +8,8 @@ # - tool appears in a prod root, harness only in non-prod roots -> the assembler's dev flag; # - every component is reachable from a direct dependency of its own root (no orphaned components); # - both get an on-disk jar `file` record under -Dsocket.withFiles; +# - a Maven relocation's target, declared directly (demo.old:moved) or through a dependency +# (demo.old:legacy, from tool), stays in the graph although coursier leaves its caller out of the report; # - a source in the base dir is a `projectSrc` file, never the base dir itself. # # Both deps are stubs generated into project/localrepo at test time (../make-stub-repo.sh, shared with @@ -27,7 +29,13 @@ RECORDS="$PROJECT/records.tsv" IVY="$SOCKET_COMPAT_CACHE/ivy2" export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier" -bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" 'demo.ext:tool:1.0' 'demo.ext:harness:1.0' +bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \ + 'demo.ext:tool:1.0+demo.old:legacy:1.0' \ + 'demo.old:legacy:1.0>demo.ext:legacy:1.0' \ + 'demo.ext:legacy:1.0' \ + 'demo.old:moved:1.0>demo.ext:moved:1.0' \ + 'demo.ext:moved:1.0' \ + 'demo.ext:harness:1.0' # scala-library and friends stay cached between runs; the stubs never do, so every run has to # resolve them from the repo just generated. Only Ivy caches them — coursier reads a `file:` # repository in place. @@ -72,6 +80,9 @@ if harness in nodes and any(roots.get(rid) for rid in nodes[harness]): def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) for cid in (tool, harness): if not has_jar(cid): errors.append(f"{cid} missing materialized jar under --with-files: {files.get(cid)}") +for cid in ('demo.ext:legacy:jar:1.0', 'demo.ext:moved:jar:1.0'): + if cid not in nodes: errors.append(f"relocation target missing: {cid}") + elif not any(roots.get(rid) for rid in nodes[cid]): errors.append(f"relocation target {cid} not in a prod root") if os.path.join(base, 'Base.scala') not in srcs: errors.append(f"base-dir source Base.scala not a projectSrc: {sorted(srcs)}") if base in srcs: errors.append("base dir emitted as a projectSrc root")