diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala index fe82db2ac..23239a51d 100644 --- a/main/src/main/scala/sbt/internal/LibraryManagement.scala +++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala @@ -650,6 +650,19 @@ private[sbt] object LibraryManagement { s } + /** + * Picks credentials for a URL. Matches host; when realm is given, prefers credential with matching realm (per Publishing docs). + */ + private def credentialFor( + url: URL, + credentials: Seq[Credentials.DirectCredentials], + realm: Option[String] = None + ): Option[Credentials.DirectCredentials] = + val byHost = credentials.filter(_.host == url.getHost) + realm match + case Some(r) => byHost.find(_.realm == r).orElse(byHost.headOption) + case None => byHost.headOption + /** * HTTP PUT a file to a URL with optional Basic auth. * Uses Gigahorse (Apache HttpClient) per sbt tech stack. @@ -659,22 +672,19 @@ private[sbt] object LibraryManagement { sourceFile: File, credentials: Option[Credentials.DirectCredentials], log: Logger - ): Unit = { + ): Unit = val baseReq = Gigahorse.url(url.toString).put(sourceFile) - val req = credentials.filter(_.host == url.getHost) match { + val req = credentials match case Some(dc) => baseReq.withAuth(dc.userName, dc.passwd, AuthScheme.Basic) case None => baseReq - } val f = sbt.librarymanagement.Http.http.processFull(req) val response = Await.result(f, 5.minutes) - if (response.status < 200 || response.status >= 300) { - val body = response.bodyAsString + val body = response.bodyAsString + if response.status < 200 || response.status >= 300 then throw new IOException( s"PUT $url failed: ${response.status} ${response.statusText}$body" ) - } log.info(s"Published $url") - } /** * Publishes artifacts to a remote Ivy repo (URLRepository) without using Apache Ivy. @@ -738,11 +748,11 @@ private[sbt] object LibraryManagement { artifact.extension ) val url = URI.create(pathPattern).toURL() - httpPut(url, sourceFile, directCreds.find(_.host == url.getHost), log) + httpPut(url, sourceFile, credentialFor(url, directCreds, None), log) val checksums = writeChecksums(sourceFile) checksums.foreach { case (cf, suffix) => val checksumUrl = URI.create(pathPattern + suffix).toURL() - try httpPut(checksumUrl, cf, directCreds.find(_.host == url.getHost), log) + try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log) finally cf.delete() } } @@ -762,16 +772,131 @@ private[sbt] object LibraryManagement { val ivyTmp = File.createTempFile("ivy", ".xml") try { IO.write(ivyTmp, ivyXmlContent) - httpPut(ivyUrl, ivyTmp, directCreds.find(_.host == ivyUrl.getHost), log) + httpPut(ivyUrl, ivyTmp, credentialFor(ivyUrl, directCreds, None), log) val checksums = writeChecksums(ivyTmp) checksums.foreach { case (cf, suffix) => val checksumUrl = URI.create(ivyPathPattern + suffix).toURL() - try httpPut(checksumUrl, cf, directCreds.find(_.host == ivyUrl.getHost), log) + try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log) finally cf.delete() } } finally ivyTmp.delete() } + /** + * Maven layout path: groupId/artifactId/version/artifactId-version[-classifier].ext + */ + private def mavenLayoutPath( + groupId: String, + artifactId: String, + version: String, + artifact: Artifact + ): String = + val groupPath = groupId.replace('.', '/') + val classifierPart = artifact.classifier.map("-" + _).getOrElse("") + val fileName = s"$artifactId-$version$classifierPart.${artifact.extension}" + s"$groupPath/$artifactId/$version/$fileName" + + private def writeChecksumsForFile( + targetFile: File, + algorithms: Vector[String], + log: Logger + ): Unit = + algorithms.foreach: algo => + val digestAlgo = algo.toLowerCase match + case "md5" => sbt.util.Digest.Md5 + case "sha1" => sbt.util.Digest.Sha1 + case other => + throw new IllegalArgumentException(s"Unsupported checksum algorithm: $other") + val digest = sbt.util.Digest(digestAlgo, targetFile.toPath) + val checksumFile = new File(targetFile.getPath + "." + algo.toLowerCase) + IO.write(checksumFile, digest.hashHexString) + log.debug(s"Wrote checksum: $checksumFile") + + /** + * Publishes artifacts to a local Maven repo (Maven layout) without using Apache Ivy. + * Layout: groupId/artifactId/version/artifactId-version[-classifier].ext + */ + def ivylessPublishMavenToFile( + project: CsrProject, + artifacts: Vector[(Artifact, File)], + checksumAlgorithms: Vector[String], + repoBase: File, + overwrite: Boolean, + log: Logger + ): Unit = + if repoBase == null then throw new IllegalArgumentException("repoBase must not be null") + val groupId = project.module.organization.value + val artifactId = project.module.name.value + val version = project.version + val groupPath = groupId.replace('.', '/') + val versionDir = new File(repoBase, s"$groupPath/$artifactId/$version") + log.info(s"Publishing to Maven repo: $versionDir") + + artifacts.foreach: + case (artifact, sourceFile) => + val path = mavenLayoutPath(groupId, artifactId, version, artifact) + val targetFile = new File(repoBase, path.replace('/', File.separatorChar)) + if !targetFile.exists || overwrite then + targetFile.getParentFile.mkdirs() + IO.copyFile(sourceFile, targetFile) + log.info(s"Published $targetFile") + writeChecksumsForFile(targetFile, checksumAlgorithms, log) + else log.warn(s"$targetFile already exists, skipping (overwrite=$overwrite)") + + /** + * Publishes artifacts to a remote Maven repo (HTTP) without using Apache Ivy. + * Same layout as ivylessPublishMavenToFile; uses HTTP PUT with optional Basic auth. + */ + def ivylessPublishMavenToUrl( + project: CsrProject, + artifacts: Vector[(Artifact, File)], + checksumAlgorithms: Vector[String], + baseUrl: String, + credentials: Seq[Credentials], + overwrite: Boolean, + log: Logger + ): Unit = + if baseUrl == null || baseUrl.trim.isEmpty then + throw new IllegalArgumentException("baseUrl must not be null or empty") + val groupId = project.module.organization.value + val artifactId = project.module.name.value + val version = project.version + val directCreds = credentials.collect: + case d: Credentials.DirectCredentials => d + + def writeChecksums(file: File): Vector[(File, String)] = + checksumAlgorithms + .map: algo => + val digestAlgo = algo.toLowerCase match + case "md5" => sbt.util.Digest.Md5 + case "sha1" => sbt.util.Digest.Sha1 + case other => + throw new IllegalArgumentException(s"Unsupported checksum algorithm: $other") + val digest = sbt.util.Digest(digestAlgo, file.toPath) + val content = digest.hashHexString + val suffix = "." + algo.toLowerCase + val tmpFile = File.createTempFile("checksum", suffix) + IO.write(tmpFile, content) + (tmpFile, suffix) + .toVector + + val base = baseUrl.stripSuffix("/") + "/" + artifacts.foreach: + case (artifact, sourceFile) => + val path = mavenLayoutPath(groupId, artifactId, version, artifact) + val url = URI.create(base + path).toURL() + try + httpPut(url, sourceFile, credentialFor(url, directCreds, None), log) + val checksums = writeChecksums(sourceFile) + checksums.foreach: + case (cf, suffix) => + val checksumUrl = URI.create(base + path + suffix).toURL() + try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log) + finally cf.delete() + catch + case e: IOException => + throw new IOException(s"Failed to publish $path: ${e.getMessage}", e) + /** * Publishes artifacts to a local file repo (FileRepository) without using Apache Ivy. * Same layout as ivylessPublishLocal; used for testing without an HTTP server. @@ -869,9 +994,46 @@ private[sbt] object LibraryManagement { config.overwrite, log ) + case mavenCache: sbt.librarymanagement.MavenCache => + ivylessPublishMavenToFile( + project, + artifacts, + config.checksums, + mavenCache.rootFile, + config.overwrite, + log + ) + case mavenRepo: sbt.librarymanagement.MavenRepo => + val root = mavenRepo.root.stripSuffix("/") + if root.startsWith("http://") || root.startsWith("https://") then + val creds = allCredentials.value + ivylessPublishMavenToUrl( + project, + artifacts, + config.checksums, + root, + creds, + config.overwrite, + log + ) + else if root.startsWith("file:") then + val repoBase = new File(URI.create(root)) + ivylessPublishMavenToFile( + project, + artifacts, + config.checksums, + repoBase, + config.overwrite, + log + ) + else + log.warn(s"Ivyless Maven publish: unsupported root '$root'. Falling back to Ivy.") + val conf = publishConfiguration.value + val module = ivyModule.value + publisher.value.publish(module, conf, log) case _ => log.warn( - "Ivyless publish only supports URLRepository (Resolver.url) or FileRepository (Resolver.file). Falling back to Ivy." + "Ivyless publish only supports URLRepository, FileRepository, or MavenRepository. Falling back to Ivy." ) val conf = publishConfiguration.value val module = ivyModule.value diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/a/src/main/scala/Lib.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/a/src/main/scala/Lib.scala new file mode 100644 index 000000000..4cb417e3c --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/a/src/main/scala/Lib.scala @@ -0,0 +1,5 @@ +package com.example + +object Lib { + def hello: String = "Hello from a!" +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/b/src/main/scala/B.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/b/src/main/scala/B.scala new file mode 100644 index 000000000..ee7a90103 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/b/src/main/scala/B.scala @@ -0,0 +1,3 @@ +object B { + def msg: String = com.example.Lib.hello +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/build.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/build.sbt new file mode 100644 index 000000000..d7c8ded1b --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/build.sbt @@ -0,0 +1,97 @@ +ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache" +ThisBuild / organization := "com.example" +ThisBuild / version := "0.1.0-SNAPSHOT" +ThisBuild / scalaVersion := "3.8.1" + +val publishRepoBase = settingKey[File]("Base directory for Maven publish repo (HTTP server writes here)") +ThisBuild / publishRepoBase := (ThisBuild / baseDirectory).value / "repo" +val publishPort = 3031 + +lazy val root = (project in file(".")) + .aggregate(a, b) + .settings( + publish / skip := true, + ) + +lazy val a = project + .settings( + publishMavenStyle := true, + publishTo := Some( + sbt.librarymanagement.MavenRepo("test-repo", s"http://localhost:$publishPort/") + .withAllowInsecureProtocol(true) + ), + useIvy := false, + Compile / packageDoc / publishArtifact := false, + Compile / packageSrc / publishArtifact := false, + ) + +lazy val b = project + .settings( + libraryDependencies += organization.value %% "a" % version.value, + resolvers += sbt.librarymanagement.MavenRepo( + "test-repo", + s"http://localhost:$publishPort/" + ).withAllowInsecureProtocol(true), + ) + +val startPublishServer = taskKey[Unit]("Start HTTP server that accepts PUT to repo directory") +Global / startPublishServer := { + HttpPutServer.start(publishPort, (ThisBuild / publishRepoBase).value) + streams.value.log.info(s"HTTP PUT server started on port $publishPort, writing to ${(ThisBuild / publishRepoBase).value}") +} + +val stopPublishServer = taskKey[Unit]("Stop HTTP server") +Global / stopPublishServer := { + HttpPutServer.stop() + streams.value.log.info("HTTP PUT server stopped") +} + +val checkMavenPublish = taskKey[Unit]("Check that ivyless Maven publish produced the expected files") +Global / checkMavenPublish := { + val log = streams.value.log + val base = (ThisBuild / publishRepoBase).value + val groupId = (ThisBuild / organization).value + val artifactId = "a_3" + val ver = (ThisBuild / version).value + val groupPath = groupId.replace('.', '/') + val versionDir = base / groupPath / artifactId / ver + + log.info(s"Checking published Maven layout in $versionDir") + + def listDir(dir: File, indent: String = ""): Unit = { + if (dir.exists) { + dir.listFiles.foreach { f => + log.info(s"$indent${f.getName}") + if (f.isDirectory) listDir(f, indent + " ") + } + } else { + log.info(s"${indent}Directory does not exist: $dir") + } + } + log.info("Contents of publish repo:") + listDir(base) + + assert(versionDir.exists && versionDir.isDirectory, s"Expected version dir $versionDir to exist") + + val pomFile = versionDir / s"$artifactId-$ver.pom" + assert(pomFile.exists, s"Expected $pomFile to exist") + assert(new File(pomFile.getPath + ".md5").exists, s"Expected pom md5 checksum") + assert(new File(pomFile.getPath + ".sha1").exists, s"Expected pom sha1 checksum") + + val jarFile = versionDir / s"$artifactId-$ver.jar" + assert(jarFile.exists, s"Expected $jarFile to exist") + assert(new File(jarFile.getPath + ".md5").exists, s"Expected jar md5 checksum") + assert(new File(jarFile.getPath + ".sha1").exists, s"Expected jar sha1 checksum") + + val pomContent = IO.read(pomFile) + assert(pomContent.contains(s"$groupId"), s"POM should contain groupId") + assert(pomContent.contains(s"$artifactId"), s"POM should contain artifactId") + assert(pomContent.contains(s"$ver"), s"POM should contain version") + + log.info("All ivyless Maven publish (HTTP) checks passed!") +} + +val cleanPublishRepo = taskKey[Unit]("Clean the publish repo") +Global / cleanPublishRepo := { + IO.delete((ThisBuild / publishRepoBase).value) +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/HttpPutServer.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/HttpPutServer.scala new file mode 100644 index 000000000..ccbc66d57 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/HttpPutServer.scala @@ -0,0 +1,65 @@ +import java.io._ +import java.net.InetSocketAddress +import com.sun.net.httpserver.{ HttpExchange, HttpHandler, HttpServer } + +object HttpPutServer { + private var server: HttpServer = null + + def start(port: Int, baseDir: java.io.File): Unit = { + if (server != null) stop() + server = HttpServer.create(new InetSocketAddress(port), 0) + server.createContext("/", new HttpHandler { + override def handle(ex: HttpExchange): Unit = { + val method = ex.getRequestMethod + val path = ex.getRequestURI.getRawPath + val relativePath = if (path.startsWith("/")) path.substring(1) else path + val targetFile = new File(baseDir, relativePath.replace("/", File.separator)) + + if ("PUT".equalsIgnoreCase(method)) { + targetFile.getParentFile.mkdirs() + val in = ex.getRequestBody + val out = new FileOutputStream(targetFile) + try { + val buf = new Array[Byte](8192) + var n = 0 + while ({ n = in.read(buf); n } != -1) out.write(buf, 0, n) + } finally { + out.close() + in.close() + } + ex.sendResponseHeaders(200, -1) + ex.close() + } else if ("GET".equalsIgnoreCase(method)) { + val baseCanon = baseDir.getCanonicalPath + File.separator + val fileCanon = targetFile.getCanonicalPath + if (!fileCanon.startsWith(baseCanon)) { + ex.sendResponseHeaders(403, -1) + ex.close() + } else if (targetFile.isFile) { + val bytes = java.nio.file.Files.readAllBytes(targetFile.toPath) + ex.sendResponseHeaders(200, bytes.length) + val out = ex.getResponseBody + try out.write(bytes) + finally out.close() + ex.close() + } else { + ex.sendResponseHeaders(404, -1) + ex.close() + } + } else { + ex.sendResponseHeaders(405, -1) + ex.close() + } + } + }) + server.setExecutor(null) + server.start() + } + + def stop(): Unit = { + if (server != null) { + server.stop(0) + server = null + } + } +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/plugins.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/plugins.sbt new file mode 100644 index 000000000..adc5e970b --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/plugins.sbt @@ -0,0 +1 @@ +// empty plugins file diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/test b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/test new file mode 100644 index 000000000..74ec5c082 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/test @@ -0,0 +1,9 @@ +# Test ivyless publish to Maven repo via HTTP - issue #8688 +# a publishes to HTTP server; b consumes a from the same Maven repo (server stays up for resolve) + +> cleanPublishRepo +> startPublishServer +> a/publish +> checkMavenPublish +> b/compile +> stopPublishServer diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/build.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/build.sbt new file mode 100644 index 000000000..b786665db --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/build.sbt @@ -0,0 +1,67 @@ +ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache" + +name := "lib1" +organization := "com.example" +version := "0.1.0-SNAPSHOT" +scalaVersion := "3.8.1" + +publishMavenStyle := true +val publishRepoBase = settingKey[File]("Base directory for Maven publish repo") +publishRepoBase := baseDirectory.value / "repo" + +publishTo := Some(sbt.librarymanagement.MavenCache("local-maven", publishRepoBase.value)) + +useIvy := false + +Compile / packageDoc / publishArtifact := false +Compile / packageSrc / publishArtifact := false + +val checkMavenPublish = taskKey[Unit]("Check that ivyless Maven publish produced the expected files") +checkMavenPublish := { + val log = streams.value.log + val base = publishRepoBase.value + val groupId = organization.value + val artifactId = normalizedName.value + "_3" + val ver = version.value + val groupPath = groupId.replace('.', '/') + val versionDir = base / groupPath / artifactId / ver + + log.info(s"Checking published Maven layout in $versionDir") + + def listDir(dir: File, indent: String = ""): Unit = { + if (dir.exists) { + dir.listFiles.foreach { f => + log.info(s"$indent${f.getName}") + if (f.isDirectory) listDir(f, indent + " ") + } + } else { + log.info(s"${indent}Directory does not exist: $dir") + } + } + log.info("Contents of publish repo:") + listDir(base) + + assert(versionDir.exists && versionDir.isDirectory, s"Expected version dir $versionDir to exist") + + val pomFile = versionDir / s"$artifactId-$ver.pom" + assert(pomFile.exists, s"Expected $pomFile to exist") + assert(new File(pomFile.getPath + ".md5").exists, s"Expected pom md5 checksum") + assert(new File(pomFile.getPath + ".sha1").exists, s"Expected pom sha1 checksum") + + val jarFile = versionDir / s"$artifactId-$ver.jar" + assert(jarFile.exists, s"Expected $jarFile to exist") + assert(new File(jarFile.getPath + ".md5").exists, s"Expected jar md5 checksum") + assert(new File(jarFile.getPath + ".sha1").exists, s"Expected jar sha1 checksum") + + val pomContent = IO.read(pomFile) + assert(pomContent.contains(s"$groupId"), s"POM should contain groupId") + assert(pomContent.contains(s"$artifactId"), s"POM should contain artifactId") + assert(pomContent.contains(s"$ver"), s"POM should contain version") + + log.info("All ivyless Maven publish checks passed!") +} + +val cleanPublishRepo = taskKey[Unit]("Clean the publish repo") +cleanPublishRepo := { + IO.delete(publishRepoBase.value) +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/project/plugins.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/project/plugins.sbt new file mode 100644 index 000000000..adc5e970b --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/project/plugins.sbt @@ -0,0 +1 @@ +// empty plugins file diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/src/main/scala/Lib.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/src/main/scala/Lib.scala new file mode 100644 index 000000000..798d21706 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/src/main/scala/Lib.scala @@ -0,0 +1,5 @@ +package com.example + +object Lib { + def hello: String = "Hello from lib1!" +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/test b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/test new file mode 100644 index 000000000..cc1659d25 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/test @@ -0,0 +1,6 @@ +# Test ivyless publish to Maven repo (file) - issue #8688 +# When useIvy is false and publishTo is MavenCache, publish uses ivyless Maven layout + +> cleanPublishRepo +> publish +> checkMavenPublish