From 73d164fa84375ef6a7444675dd7c2360bf7f9787 Mon Sep 17 00:00:00 2001 From: Jozef Koval Date: Tue, 16 Jun 2026 05:54:28 +0200 Subject: [PATCH] [2.x] fix: Substitute [organisation] literally by defaulting Patterns.isMavenCompatible to false (#9344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem** In an Ivy resolver pattern, the [organisation]/[organization] token has its dots rewritten to slashes (org.example → org/example), behaving like Ivy's [orgPath] token rather than being literal. Per the [Apache Ivy spec](http://ant.apache.org/ivy/history/latest-milestone/concept.html), [organisation] should be substituted literally and [orgPath] is the slash-separated form. Root cause: Patterns.isMavenCompatible defaulted to true, which sbt forwards to Apache Ivy as setM2compatible(true); with m2-compatibility on, Ivy rewrites the [organisation] token to slash form. A user supplying a custom Ivy pattern (e.g. for an SFTP/SSH resolver) inherited that default and got the wrong paths, with no obvious indication why. The only workaround was the non-obvious withIsMavenCompatible(false). **Solution** Flip the default of Patterns.isMavenCompatible from true to false, so a hand-written Patterns keeps [organisation] literal by default — matching the Ivy spec. --- .../sbt/librarymanagement/Patterns.scala | 6 +- .../main/contraband/librarymanagement.json | 13 +++- .../sbt/librarymanagement/ResolverExtra.scala | 33 ++++++++-- .../sbt/librarymanagement/ResolverTest.scala | 58 +++++++++++++++++- .../ConvertResolverSpec.scala | 60 +++++++++++++++++++ .../patterns-maven-compatible-default.md | 36 +++++++++++ .../publish-maven-default/build.sbt | 16 +++++ .../publish-maven-default/test | 10 ++++ 8 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 lm-ivy/src/test/scala/sbt/internal/librarymanagement/ConvertResolverSpec.scala create mode 100644 notes/2.0.0/patterns-maven-compatible-default.md create mode 100644 sbt-app/src/sbt-test/dependency-management/publish-maven-default/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/publish-maven-default/test diff --git a/lm-core/src/main/contraband-scala/sbt/librarymanagement/Patterns.scala b/lm-core/src/main/contraband-scala/sbt/librarymanagement/Patterns.scala index 59704ad8c..08ed8f31e 100644 --- a/lm-core/src/main/contraband-scala/sbt/librarymanagement/Patterns.scala +++ b/lm-core/src/main/contraband-scala/sbt/librarymanagement/Patterns.scala @@ -4,6 +4,10 @@ // DO NOT EDIT MANUALLY package sbt.librarymanagement +/** @param isMavenCompatible When false (the default), the [organisation]/[organization] token is substituted literally, +so an organization such as `org.example` stays `org.example` in resolved paths, matching the +Apache Ivy specification. When true, the Ivy engine rewrites the [organisation] token to the +slash-separated form (`org/example`), as in a Maven m2-compatible repository layout. */ final class Patterns private ( val ivyPatterns: Vector[String], val artifactPatterns: Vector[String], @@ -11,7 +15,7 @@ final class Patterns private ( val descriptorOptional: Boolean, val skipConsistencyCheck: Boolean) extends Serializable { - private def this() = this(Vector.empty, Vector.empty, true, false, false) + private def this() = this(Vector.empty, Vector.empty, false, false, false) override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match { case x: Patterns => (this.ivyPatterns == x.ivyPatterns) && (this.artifactPatterns == x.artifactPatterns) && (this.isMavenCompatible == x.isMavenCompatible) && (this.descriptorOptional == x.descriptorOptional) && (this.skipConsistencyCheck == x.skipConsistencyCheck) diff --git a/lm-core/src/main/contraband/librarymanagement.json b/lm-core/src/main/contraband/librarymanagement.json index 87eca394c..5668f0a5d 100644 --- a/lm-core/src/main/contraband/librarymanagement.json +++ b/lm-core/src/main/contraband/librarymanagement.json @@ -532,7 +532,18 @@ "fields": [ { "name": "ivyPatterns", "type": "String*", "default": "Vector.empty", "since": "0.0.1" }, { "name": "artifactPatterns", "type": "String*", "default": "Vector.empty", "since": "0.0.1" }, - { "name": "isMavenCompatible", "type": "boolean", "default": "true", "since": "0.0.1" }, + { + "name": "isMavenCompatible", + "doc": [ + "When false (the default), the [organisation]/[organization] token is substituted literally,", + "so an organization such as `org.example` stays `org.example` in resolved paths, matching the", + "Apache Ivy specification. When true, the Ivy engine rewrites the [organisation] token to the", + "slash-separated form (`org/example`), as in a Maven m2-compatible repository layout." + ], + "type": "boolean", + "default": "false", + "since": "0.0.1" + }, { "name": "descriptorOptional", "type": "boolean", "default": "false", "since": "0.0.1" }, { "name": "skipConsistencyCheck", "type": "boolean", "default": "false", "since": "0.0.1" } ], diff --git a/lm-core/src/main/scala/sbt/librarymanagement/ResolverExtra.scala b/lm-core/src/main/scala/sbt/librarymanagement/ResolverExtra.scala index 89ec3bd48..f1e5b5e73 100644 --- a/lm-core/src/main/scala/sbt/librarymanagement/ResolverExtra.scala +++ b/lm-core/src/main/scala/sbt/librarymanagement/ResolverExtra.scala @@ -36,7 +36,7 @@ private[librarymanagement] abstract class MavenRepositoryFunctions { private[librarymanagement] abstract class PatternsFunctions { implicit def defaultPatterns: Patterns = Resolver.defaultPatterns - def apply(artifactPatterns: String*): Patterns = Patterns(true, artifactPatterns*) + def apply(artifactPatterns: String*): Patterns = Patterns(false, artifactPatterns*) def apply(isMavenCompatible: Boolean, artifactPatterns: String*): Patterns = { val patterns = artifactPatterns.toVector Patterns() @@ -293,13 +293,27 @@ private[librarymanagement] abstract class ResolverFunctions { construct(name, SshConnection(None, hostname, port), resolvePatterns(basePath, basePatterns)) } - /** A factory to construct an interface to an Ivy SSH resolver. */ + /** + * A factory to construct an interface to an Ivy SSH resolver. + * + * The implicit `basePatterns` default to [[mavenStylePatterns]] (`isMavenCompatible = true`), under + * which the `[organisation]` token is rendered in slash-separated form. To keep the organization + * literal (e.g. `org.example`), supply Ivy-style patterns such as a [[Patterns]] built with + * `isMavenCompatible = false` (the [[Patterns]] default). See issue #535. + */ object ssh extends Define[SshRepository] { protected def construct(name: String, connection: SshConnection, patterns: Patterns) = SshRepository(name, connection, patterns, None) } - /** A factory to construct an interface to an Ivy SFTP resolver. */ + /** + * A factory to construct an interface to an Ivy SFTP resolver. + * + * The implicit `basePatterns` default to [[mavenStylePatterns]] (`isMavenCompatible = true`), under + * which the `[organisation]` token is rendered in slash-separated form. To keep the organization + * literal (e.g. `org.example`), supply Ivy-style patterns such as a [[Patterns]] built with + * `isMavenCompatible = false` (the [[Patterns]] default). See issue #535. + */ object sftp extends Define[SftpRepository] { protected def construct(name: String, connection: SshConnection, patterns: Patterns) = SftpRepository(name, connection, patterns) @@ -369,7 +383,18 @@ private[librarymanagement] abstract class ResolverFunctions { else normBase + "/" + pattern } def defaultFileConfiguration = FileConfiguration(true, None) - def mavenStylePatterns = Patterns().withArtifactPatterns(Vector(mavenStyleBasePattern)) + + /** + * Maven-compatible layout (`isMavenCompatible = true`). The `[organisation]`/`[organization]` token + * is rendered in slash-separated form (`org.example` becomes `org/example`) by the Ivy engine. + */ + def mavenStylePatterns = + Patterns().withArtifactPatterns(Vector(mavenStyleBasePattern)).withIsMavenCompatible(true) + + /** + * Ivy-style layout (`isMavenCompatible = false`). The `[organisation]`/`[organization]` token is + * substituted literally, keeping the dots (`org.example` stays `org.example`). + */ def ivyStylePatterns = defaultIvyPatterns // Patterns(Nil, Nil, false) def defaultPatterns = mavenStylePatterns diff --git a/lm-core/src/test/scala/sbt/librarymanagement/ResolverTest.scala b/lm-core/src/test/scala/sbt/librarymanagement/ResolverTest.scala index a29646c96..d4b353dc7 100644 --- a/lm-core/src/test/scala/sbt/librarymanagement/ResolverTest.scala +++ b/lm-core/src/test/scala/sbt/librarymanagement/ResolverTest.scala @@ -4,7 +4,7 @@ import java.net.URI import sbt.internal.librarymanagement.UnitSpec -object ResolverTest extends UnitSpec { +class ResolverTest extends UnitSpec { "Resolver url" should "propagate pattern descriptorOptional and skipConsistencyCheck." in { val pats = Vector("[orgPath]") @@ -27,4 +27,60 @@ object ResolverTest extends UnitSpec { assert(patterns.skipConsistencyCheck) assert(patterns.descriptorOptional) } + + "Patterns" should "default to isMavenCompatible = false (literal [organisation], see #535)." in { + Patterns().isMavenCompatible shouldBe false + } + + it should "default the varargs shorthand to isMavenCompatible = false, like the builder forms." in { + // Patterns("[organisation]/...") must agree with Patterns().withArtifactPatterns(...) so that the + // organization token is treated consistently regardless of how the Patterns is constructed (#535). + Patterns("[organisation]/[module]/[artifact]-[revision].[ext]").isMavenCompatible shouldBe false + } + + "Resolver.mavenStylePatterns" should "stay Maven-compatible (isMavenCompatible = true)." in { + Resolver.mavenStylePatterns.isMavenCompatible shouldBe true + } + + "Resolver.ivyStylePatterns" should "be Ivy-style (isMavenCompatible = false)." in { + Resolver.ivyStylePatterns.isMavenCompatible shouldBe false + } + + "Resolver.sftp" should "keep an Ivy SFTP resolver's custom [organisation] patterns literal by default." in { + // Reproduces the configuration from issue #535: a custom Ivy pattern using [organisation]. + // With the isMavenCompatible = false default, sbt stores the token verbatim and instructs the + // Ivy engine (via setM2compatible(false)) not to rewrite the organization to slash form. + val ivy = "/var/ivy/repo/[organisation]/[module]/ivys/ivy-[revision].xml" + val artifact = "/var/ivy/repo/[organisation]/[module]/[type]s/[artifact]-[revision].[ext]" + val patterns = Resolver + .sftp("repo", "example.org", 22)(using + Patterns().withIvyPatterns(Vector(ivy)).withArtifactPatterns(Vector(artifact)) + ) + .patterns + patterns.ivyPatterns shouldBe Vector(ivy) + patterns.artifactPatterns shouldBe Vector(artifact) + patterns.isMavenCompatible shouldBe false + } + + it should "stay Maven-compatible when constructed with the default mavenStylePatterns." in { + Resolver.sftp("repo", "example.org", 22).patterns.isMavenCompatible shouldBe true + } + + // The historical fluent syntax from issue #535, `Resolver.sftp(...).artifacts(...).ivys(...)`, was + // removed in sbt 2.x; `withPatterns` is the supported replacement. These cases mirror the original + // report's exact patterns (both the `[organization]` and `[organisation]` spellings the reporter + // tried) to lock the behavior down from the client side. + it should "keep the issue #535 patterns literal when set via withPatterns (both org spellings)." in { + val ivys = "/var/ivy/cirque-repo/[organisation]/[module]/ivys/ivy-[revision].xml" + val artifacts = + "/var/ivy/cirque-repo/[organization]/[module]/[type]s/[artifact]-[revision].[ext]" + val repo = Resolver + .sftp("Cirque-ivy-repo", "daisyduck.cirque.dk", 22) + .withPatterns( + Patterns().withIvyPatterns(Vector(ivys)).withArtifactPatterns(Vector(artifacts)) + ) + repo.patterns.ivyPatterns shouldBe Vector(ivys) + repo.patterns.artifactPatterns shouldBe Vector(artifacts) + repo.patterns.isMavenCompatible shouldBe false + } } diff --git a/lm-ivy/src/test/scala/sbt/internal/librarymanagement/ConvertResolverSpec.scala b/lm-ivy/src/test/scala/sbt/internal/librarymanagement/ConvertResolverSpec.scala new file mode 100644 index 000000000..fcdc79159 --- /dev/null +++ b/lm-ivy/src/test/scala/sbt/internal/librarymanagement/ConvertResolverSpec.scala @@ -0,0 +1,60 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.librarymanagement + +import org.apache.ivy.core.settings.IvySettings +import org.apache.ivy.plugins.resolver.AbstractPatternsBasedResolver +import sbt.internal.librarymanagement.ivy.UpdateOptions +import sbt.internal.util.ConsoleLogger +import sbt.librarymanagement.{ Patterns, URLRepository } +import verify.BasicTestSuite + +import scala.jdk.CollectionConverters.* + +// Regression coverage for sbt/sbt#535: the organization token in an Ivy SFTP/SSH/URL resolver pattern +// should be rendered literally unless the resolver is explicitly Maven-compatible. ConvertResolver is the +// boundary sbt owns: it maps Patterns.isMavenCompatible onto Apache Ivy's setM2compatible, which is what +// makes Ivy keep [organisation] literal (false) or rewrite it to slash form (true). +object ConvertResolverSpec extends BasicTestSuite { + private val log = ConsoleLogger() + + // The configuration from issue #535: a custom Ivy pattern using the [organisation] token. + private val orgPatterns = + Patterns() + .withIvyPatterns( + Vector("https://example.org/repo/[organisation]/[module]/ivys/ivy-[revision].xml") + ) + .withArtifactPatterns( + Vector( + "https://example.org/repo/[organisation]/[module]/[type]s/[artifact]-[revision].[ext]" + ) + ) + + private def convert(patterns: Patterns): AbstractPatternsBasedResolver = + ConvertResolver(URLRepository("test-repo", patterns), new IvySettings, UpdateOptions(), log) + .asInstanceOf[AbstractPatternsBasedResolver] + + test("the default Patterns keeps the Ivy resolver non-m2compatible (issue #535)") { + assert(!orgPatterns.isMavenCompatible) + assert(!convert(orgPatterns).isM2compatible) + } + + test("isMavenCompatible = false renders the [organisation] token literally") { + val resolver = convert(orgPatterns) + assert(!resolver.isM2compatible) + // sbt forwards the pattern verbatim; with m2compatible off Ivy does not rewrite the organization. + assert(resolver.getArtifactPatterns.asScala.exists(_.toString.contains("[organisation]"))) + } + + test( + "isMavenCompatible = true makes the Ivy resolver m2compatible (organization rewritten to slash form)" + ) { + assert(convert(orgPatterns.withIsMavenCompatible(true)).isM2compatible) + } +} diff --git a/notes/2.0.0/patterns-maven-compatible-default.md b/notes/2.0.0/patterns-maven-compatible-default.md new file mode 100644 index 000000000..da0673804 --- /dev/null +++ b/notes/2.0.0/patterns-maven-compatible-default.md @@ -0,0 +1,36 @@ +## `Patterns.isMavenCompatible` now defaults to `false` + +The default value of `Patterns.isMavenCompatible` changed from `true` to `false` so that the +`[organisation]`/`[organization]` token in an Ivy pattern is substituted literally, matching the +Apache Ivy specification (see [#535](https://github.com/sbt/sbt/issues/535)). + +### What changed + +```scala +// before: organization "org.example" rendered as "org/example" +// now: organization "org.example" rendered as "org.example" +val p = Patterns() + .withIvyPatterns(Vector("[organisation]/[module]/ivys/ivy-[revision].xml")) + .withArtifactPatterns(Vector("[organisation]/[module]/[type]s/[artifact]-[revision].[ext]")) +``` + +Use the `[orgPath]` token if you want the slash-separated form regardless of the flag, or set +`isMavenCompatible = true` explicitly to opt back into the previous Maven m2-compatible behavior: + +```scala +Patterns(/* ... */).withIsMavenCompatible(true) +``` + +### Who is affected + +- Custom Ivy/SFTP/SSH/URL/file resolvers built with a hand-written `Patterns` that uses the + `[organisation]` token and previously relied on the implicit dot-to-slash rewrite. + - Under the Ivy engine, the organization is now rendered literally for these patterns. + - Under Coursier (the default since sbt 1.3), SFTP/SSH resolvers are ignored entirely, so they + are unaffected. Custom **URL/file** pattern resolvers, however, can still be affected: Coursier + chooses between Maven-base extraction and Ivy-pattern parsing based on `isMavenCompatible`, so a + resolver that relied on the old `true` default may now be parsed as an Ivy-pattern repository. + Set `isMavenCompatible = true` explicitly to keep the Maven handling. +- The built-in `Resolver.mavenStylePatterns` is unchanged: it remains explicitly + `isMavenCompatible = true`, so `Resolver.url`/`Resolver.file`/`Resolver.sftp`/`Resolver.ssh` + constructed with the default patterns continue to use the Maven layout. diff --git a/sbt-app/src/sbt-test/dependency-management/publish-maven-default/build.sbt b/sbt-app/src/sbt-test/dependency-management/publish-maven-default/build.sbt new file mode 100644 index 000000000..4ba0eec0f --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/publish-maven-default/build.sbt @@ -0,0 +1,16 @@ +// Regression guard related to sbt/sbt#535. +// +// Patterns.isMavenCompatible now defaults to false (so a custom Ivy pattern keeps [organisation] +// literal). Resolver.file still uses Resolver.mavenStylePatterns, which is explicitly Maven-compatible, +// so publishing to it must keep producing the standard slash-separated Maven layout. + +ThisBuild / scalaVersion := "2.12.21" + +lazy val lib = project + .settings( + organization := "org.example", + name := "lib", + version := "1.0", + publishMavenStyle := true, + publishTo := Some(Resolver.file("dist", (ThisBuild / baseDirectory).value / "repo")) + ) diff --git a/sbt-app/src/sbt-test/dependency-management/publish-maven-default/test b/sbt-app/src/sbt-test/dependency-management/publish-maven-default/test new file mode 100644 index 000000000..a40335a3b --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/publish-maven-default/test @@ -0,0 +1,10 @@ +# publish under the default (Coursier/ivyless) engine +> lib/publish + +# the organization must be laid out slash-separated (Maven layout) via mavenStylePatterns, +# which stays Maven-compatible even though the Patterns default flipped to false +$ exists repo/org/example/lib_2.12/1.0/lib_2.12-1.0.jar +$ exists repo/org/example/lib_2.12/1.0/lib_2.12-1.0.pom + +# the literal dotted organization directory (Ivy layout) must NOT be produced +-$ exists repo/org.example/lib_2.12/1.0/lib_2.12-1.0.jar