From 2f5e731378feee06457374031e5f98f885c5f310 Mon Sep 17 00:00:00 2001 From: Alexandre Archambault Date: Sun, 22 Nov 2015 23:50:33 +0100 Subject: [PATCH] launch command should be fine --- .../src/main/scala/coursier/Bootstrap.scala | 186 ++++++++++-------- build.sbt | 5 +- .../main/scala/coursier/cli/Coursier.scala | 121 +++++++++--- cli/src/main/scala/coursier/cli/Helper.scala | 19 +- .../scala/coursier/util/ClasspathFilter.scala | 102 ++++++++++ 5 files changed, 312 insertions(+), 121 deletions(-) create mode 100644 core/jvm/src/main/scala/coursier/util/ClasspathFilter.scala diff --git a/bootstrap/src/main/scala/coursier/Bootstrap.scala b/bootstrap/src/main/scala/coursier/Bootstrap.scala index 787d3d12f..a56b248bd 100644 --- a/bootstrap/src/main/scala/coursier/Bootstrap.scala +++ b/bootstrap/src/main/scala/coursier/Bootstrap.scala @@ -48,96 +48,108 @@ object Bootstrap extends App { sys.exit(255) } - args match { + val (prependClasspath, mainClass0, jarDir0, remainingArgs) = args match { + case Array("-B", mainClass0, jarDir0, remainingArgs @ _*) => + (true, mainClass0, jarDir0, remainingArgs) case Array(mainClass0, jarDir0, remainingArgs @ _*) => - val jarDir = new File(jarDir0) - - if (jarDir.exists()) { - if (!jarDir.isDirectory) - exit(s"Error: $jarDir0 is not a directory") - } else if (!jarDir.mkdirs()) - errPrintln(s"Warning: cannot create $jarDir0, continuing anyway.") - - val splitIdx = remainingArgs.indexOf("--") - val (jarStrUrls, userArgs) = - if (splitIdx < 0) - (remainingArgs, Nil) - else - (remainingArgs.take(splitIdx), remainingArgs.drop(splitIdx + 1)) - - val tryUrls = jarStrUrls.map(urlStr => urlStr -> Try(URI.create(urlStr).toURL)) - - val failedUrls = tryUrls.collect { - case (strUrl, Failure(t)) => strUrl -> t - } - if (failedUrls.nonEmpty) - exit( - s"Error parsing ${failedUrls.length} URL(s):\n" + - failedUrls.map { case (s, t) => s"$s: ${t.getMessage}" }.mkString("\n") - ) - - val jarUrls = tryUrls.collect { - case (_, Success(url)) => url - } - - val jarLocalUrlFutures = jarUrls.map { url => - if (url.getProtocol == "file") - Future.successful(url) - else - Future { - val path = url.getPath - val idx = path.lastIndexOf('/') - // FIXME Add other components in path to prevent conflicts? - val fileName = path.drop(idx + 1) - val dest = new File(jarDir, fileName) - - // FIXME If dest exists, do a HEAD request and check that its size or last modified time is OK? - - if (!dest.exists()) { - Console.err.println(s"Downloading $url") - try { - val conn = url.openConnection() - val lastModified = conn.getLastModified - val s = conn.getInputStream - val b = readFullySync(s) - Files.write(dest.toPath, b) - dest.setLastModified(lastModified) - } catch { case e: Exception => - Console.err.println(s"Error while downloading $url: ${e.getMessage}, ignoring it") - } - } - - dest.toURI.toURL - } - } - - val jarLocalUrls = Await.result(Future.sequence(jarLocalUrlFutures), Duration.Inf) - - val thread = Thread.currentThread() - val parentClassLoader = thread.getContextClassLoader - - val classLoader = new URLClassLoader(jarLocalUrls.toArray, parentClassLoader) - - val mainClass = - try classLoader.loadClass(mainClass0) - catch { case e: ClassNotFoundException => - exit(s"Error: class $mainClass0 not found") - } - - val mainMethod = - try mainClass.getMethod("main", classOf[Array[String]]) - catch { case e: NoSuchMethodException => - exit(s"Error: main method not found in class $mainClass0") - } - - thread.setContextClassLoader(classLoader) - try mainMethod.invoke(null, userArgs.toArray) - finally { - thread.setContextClassLoader(parentClassLoader) - } - + (false, mainClass0, jarDir0, remainingArgs) case _ => exit("Usage: bootstrap main-class JAR-directory JAR-URLs...") } + val jarDir = new File(jarDir0) + + if (jarDir.exists()) { + if (!jarDir.isDirectory) + exit(s"Error: $jarDir0 is not a directory") + } else if (!jarDir.mkdirs()) + errPrintln(s"Warning: cannot create $jarDir0, continuing anyway.") + + val splitIdx = remainingArgs.indexOf("--") + val (jarStrUrls, userArgs) = + if (splitIdx < 0) + (remainingArgs, Nil) + else + (remainingArgs.take(splitIdx), remainingArgs.drop(splitIdx + 1)) + + val tryUrls = jarStrUrls.map(urlStr => urlStr -> Try(URI.create(urlStr).toURL)) + + val failedUrls = tryUrls.collect { + case (strUrl, Failure(t)) => strUrl -> t + } + if (failedUrls.nonEmpty) + exit( + s"Error parsing ${failedUrls.length} URL(s):\n" + + failedUrls.map { case (s, t) => s"$s: ${t.getMessage}" }.mkString("\n") + ) + + val jarUrls = tryUrls.collect { + case (_, Success(url)) => url + } + + val jarLocalUrlFutures = jarUrls.map { url => + if (url.getProtocol == "file") + Future.successful(url) + else + Future { + val path = url.getPath + val idx = path.lastIndexOf('/') + // FIXME Add other components in path to prevent conflicts? + val fileName = path.drop(idx + 1) + val dest = new File(jarDir, fileName) + + // FIXME If dest exists, do a HEAD request and check that its size or last modified time is OK? + + if (!dest.exists()) { + Console.err.println(s"Downloading $url") + try { + val conn = url.openConnection() + val lastModified = conn.getLastModified + val s = conn.getInputStream + val b = readFullySync(s) + Files.write(dest.toPath, b) + dest.setLastModified(lastModified) + } catch { case e: Exception => + Console.err.println(s"Error while downloading $url: ${e.getMessage}, ignoring it") + } + } + + dest.toURI.toURL + } + } + + val jarLocalUrls = Await.result(Future.sequence(jarLocalUrlFutures), Duration.Inf) + + val thread = Thread.currentThread() + val parentClassLoader = thread.getContextClassLoader + + val classLoader = new URLClassLoader(jarLocalUrls.toArray, parentClassLoader) + + val mainClass = + try classLoader.loadClass(mainClass0) + catch { case e: ClassNotFoundException => + exit(s"Error: class $mainClass0 not found") + } + + val mainMethod = + try mainClass.getMethod("main", classOf[Array[String]]) + catch { case e: NoSuchMethodException => + exit(s"Error: main method not found in class $mainClass0") + } + + val userArgs0 = + if (prependClasspath) + jarLocalUrls.flatMap { url => + assert(url.getProtocol == "file") + Seq("-B", url.getPath) + } ++ userArgs + else + userArgs + + thread.setContextClassLoader(classLoader) + try mainMethod.invoke(null, userArgs0.toArray) + finally { + thread.setContextClassLoader(parentClassLoader) + } + } \ No newline at end of file diff --git a/build.sbt b/build.sbt index 8ac735cbb..1e1b535ea 100644 --- a/build.sbt +++ b/build.sbt @@ -161,7 +161,10 @@ lazy val bootstrap = project .settings(noPublishSettings) .settings( name := "coursier-bootstrap", - assemblyJarName in assembly := s"bootstrap.jar" + assemblyJarName in assembly := s"bootstrap.jar", + assemblyShadeRules in assembly := Seq( + ShadeRule.rename("scala.**" -> "shadedscala.@1").inAll + ) ) lazy val `coursier` = project.in(file(".")) diff --git a/cli/src/main/scala/coursier/cli/Coursier.scala b/cli/src/main/scala/coursier/cli/Coursier.scala index d67a99f98..9bc06a63b 100644 --- a/cli/src/main/scala/coursier/cli/Coursier.scala +++ b/cli/src/main/scala/coursier/cli/Coursier.scala @@ -4,8 +4,10 @@ package cli import java.io.{ File, IOException } import java.net.URLClassLoader import java.nio.file.{ Files => NIOFiles } +import java.nio.file.attribute.PosixFilePermission import caseapp._ +import coursier.util.ClasspathFilter case class CommonOptions( @HelpMessage("Keep optional dependencies (Maven)") @@ -83,44 +85,49 @@ case class Launch( val files0 = helper.fetch(main = true, sources = false, javadoc = false) - - def printParents(cl: ClassLoader): Unit = - Option(cl.getParent) match { - case None => - case Some(cl0) => - println(cl0.toString) - printParents(cl0) - } - - printParents(Thread.currentThread().getContextClassLoader) - - import scala.collection.JavaConverters._ val cl = new URLClassLoader( files0.map(_.toURI.toURL).toArray, - // setting this to null provokes strange things (wrt terminal, ...) - // but this is far from perfect: this puts all our dependencies along with the user's, - // and with a higher priority - Thread.currentThread().getContextClassLoader + new ClasspathFilter( + Thread.currentThread().getContextClassLoader, + Coursier.baseCp.map(new File(_)).toSet, + exclude = true + ) ) val mainClass0 = - if (mainClass.nonEmpty) - mainClass + if (mainClass.nonEmpty) mainClass else { - val metaInfs = cl.findResources("META-INF/MANIFEST.MF").asScala.toVector - val mainClasses = metaInfs.flatMap { url => - Option(new java.util.jar.Manifest(url.openStream()).getMainAttributes.getValue("Main-Class")) - } + val mainClasses = Helper.mainClasses(cl) - if (mainClasses.isEmpty) { - println(s"No main class found. Specify one with -M or --main.") - sys.exit(255) - } + val mainClass = + if (mainClasses.isEmpty) { + Console.err.println(s"No main class found. Specify one with -M or --main.") + sys.exit(255) + } else if (mainClasses.size == 1) { + val (_, mainClass) = mainClasses.head + mainClass + } else { + // Trying to get the main class of the first artifact + val mainClassOpt = for { + (module, _) <- helper.moduleVersions.headOption + mainClass <- mainClasses.collectFirst { + case ((org, name), mainClass) + if org == module.organization && ( + module.name == name || + module.name.startsWith(name + "_") // Ignore cross version suffix + ) => + mainClass + } + } yield mainClass - if (common.verbose0 >= 0) - println(s"Found ${mainClasses.length} main class(es):\n${mainClasses.map(" " + _).mkString("\n")}") + mainClassOpt.getOrElse { + println(mainClasses) + Console.err.println(s"Cannot find default main class. Specify one with -M or --main.") + sys.exit(255) + } + } - mainClasses.head + mainClass } val cls = @@ -248,15 +255,20 @@ case class Bootstrap( @ExtraName("main") mainClass: String, @ExtraName("o") - output: String, + output: String = "bootstrap", @ExtraName("D") downloadDir: String, @ExtraName("f") force: Boolean, + @HelpMessage(s"Internal use - prepend base classpath options to arguments") + @ExtraName("b") + prependClasspath: Boolean, @Recurse common: CommonOptions ) extends CoursierCommand { + import scala.collection.JavaConverters._ + if (mainClass.isEmpty) { Console.err.println(s"Error: no main class specified. Specify one with -M or --main") sys.exit(255) @@ -306,7 +318,7 @@ case class Bootstrap( val shellPreamble = Seq( "#!/usr/bin/env sh", - "exec java -jar \"$0\" \"" + mainClass + "\" \"" + downloadDir + "\" " + urls.map("\"" + _ + "\"").mkString(" ") + " -- \"$@\"", + "exec java -jar \"$0\" " + (if (prependClasspath) "-B " else "") + "\"" + mainClass + "\" \"" + downloadDir + "\" " + urls.map("\"" + _ + "\"").mkString(" ") + " -- \"$@\"", "" ).mkString("\n") @@ -316,6 +328,51 @@ case class Bootstrap( sys.exit(1) } + try { + val perms = NIOFiles.getPosixFilePermissions(output0.toPath).asScala.toSet + + var newPerms = perms + if (perms(PosixFilePermission.OWNER_READ)) + newPerms += PosixFilePermission.OWNER_EXECUTE + if (perms(PosixFilePermission.GROUP_READ)) + newPerms += PosixFilePermission.GROUP_EXECUTE + if (perms(PosixFilePermission.OTHERS_READ)) + newPerms += PosixFilePermission.OTHERS_EXECUTE + + if (newPerms != perms) + NIOFiles.setPosixFilePermissions( + output0.toPath, + newPerms.asJava + ) + } catch { + case e: UnsupportedOperationException => + // Ignored + case e: IOException => + Console.err.println(s"Error while making $output0 executable: ${e.getMessage}") + sys.exit(1) + } + } -object Coursier extends CommandAppOf[CoursierCommand] +case class BaseCommand( + // FIXME Need a @NoHelp annotation in case-app to hide an option from help message + @HelpMessage("For internal use only - class path used to launch coursier") + @ExtraName("B") + baseCp: List[String] +) extends Command { + Coursier.baseCp = baseCp + + // FIXME Should be in a trait in case-app + override def setCommand(cmd: Option[Either[String, String]]): Unit = { + if (cmd.isEmpty) { + // FIXME Print available commands too? + Console.err.println("Error: no command specified") + sys.exit(255) + } + super.setCommand(cmd) + } +} + +object Coursier extends CommandAppOfWithBase[BaseCommand, CoursierCommand] { + private[coursier] var baseCp = Seq.empty[String] +} diff --git a/cli/src/main/scala/coursier/cli/Helper.scala b/cli/src/main/scala/coursier/cli/Helper.scala index 84593d4bf..e256e2d99 100644 --- a/cli/src/main/scala/coursier/cli/Helper.scala +++ b/cli/src/main/scala/coursier/cli/Helper.scala @@ -74,6 +74,23 @@ object Helper { ) } + def mainClasses(cl: ClassLoader): Map[(String, String), String] = { + import scala.collection.JavaConverters._ + + val metaInfs = cl.getResources("META-INF/MANIFEST.MF").asScala.toVector + + val mainClasses = metaInfs.flatMap { url => + val attributes = new java.util.jar.Manifest(url.openStream()).getMainAttributes + + val vendor = Option(attributes.getValue("Specification-Vendor")).getOrElse("") + val title = Option(attributes.getValue("Specification-Title")).getOrElse("") + val mainClass = Option(attributes.getValue("Main-Class")) + + mainClass.map((vendor, title) -> _) + } + + mainClasses.toMap + } } class Helper( @@ -151,7 +168,7 @@ class Helper( .partition(_.length == 3) if (splitDependencies.isEmpty) { - ??? + Console.err.println(s"Error: no dependencies specified.") // CaseApp.printUsage[Coursier]() sys exit 1 } diff --git a/core/jvm/src/main/scala/coursier/util/ClasspathFilter.scala b/core/jvm/src/main/scala/coursier/util/ClasspathFilter.scala new file mode 100644 index 000000000..4f7cafe4a --- /dev/null +++ b/core/jvm/src/main/scala/coursier/util/ClasspathFilter.scala @@ -0,0 +1,102 @@ +package coursier.util + +// Extracted and adapted from SBT + +import java.io.File +import java.net._ + +/** + * Doesn't load any classes itself, but instead verifies that all classes loaded through `parent` + * come from `classpath`. + * + * If `exclude` is `true`, does the opposite - exclude classes from `classpath`. + */ +class ClasspathFilter(parent: ClassLoader, classpath: Set[File], exclude: Boolean) extends ClassLoader(parent) { + override def toString = s"ClasspathFilter(parent = $parent, cp = $classpath)" + + private val directories = classpath.toSeq.filter(_.isDirectory) + + + private def toFile(url: URL) = + try { new File(url.toURI) } + catch { case _: URISyntaxException => new File(url.getPath) } + + private def uriToFile(uriString: String) = { + val uri = new URI(uriString) + assert(uri.getScheme == "file", s"Expected protocol to be 'file' in URI $uri") + if (uri.getAuthority == null) + new File(uri) + else { + /* https://github.com/sbt/sbt/issues/564 + * http://blogs.msdn.com/b/ie/archive/2006/12/06/file-uris-in-windows.aspx + * http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=5086147 + * The specific problem here is that `uri` will have a defined authority component for UNC names like //foo/bar/some/path.jar + * but the File constructor requires URIs with an undefined authority component. + */ + new File(uri.getSchemeSpecificPart) + } + } + + private def urlAsFile(url: URL) = + url.getProtocol match { + case "file" => Some(toFile(url)) + case "jar" => + val path = url.getPath + val end = path.indexOf('!') + Some(uriToFile(if (end < 0) path else path.substring(0, end))) + case _ => None + } + + private def baseFileString(baseFile: File) = + Some(baseFile).filter(_.isDirectory).map { d => + val cp = d.getAbsolutePath.ensuring(_.nonEmpty) + if (cp.last == File.separatorChar) cp else cp + File.separatorChar + } + + private def relativize(base: File, file: File) = + baseFileString(base) .flatMap { baseString => + Some(file.getAbsolutePath).filter(_ startsWith baseString).map(_ substring baseString.length) + } + + private def fromClasspath(c: Class[_]): Boolean = { + val codeSource = c.getProtectionDomain.getCodeSource + (codeSource eq null) || + onClasspath(codeSource.getLocation) || + // workaround SBT classloader returning the target directory as sourcecode + // make us keep more class than expected + urlAsFile(codeSource.getLocation).exists(_.isDirectory) + } + + private val onClasspath: URL => Boolean = { + if (exclude) + src => + src == null || urlAsFile(src).forall(f => + !classpath(f) && + directories.forall(relativize(_, f).isEmpty) + ) + else + src => + src == null || urlAsFile(src).exists(f => + classpath(f) || + directories.exists(relativize(_, f).isDefined) + ) + } + + + override def loadClass(className: String, resolve: Boolean): Class[_] = { + val c = super.loadClass(className, resolve) + if (fromClasspath(c)) c + else throw new ClassNotFoundException(className) + } + + override def getResource(name: String): URL = { + val res = super.getResource(name) + if (onClasspath(res)) res else null + } + + override def getResources(name: String): java.util.Enumeration[URL] = { + import collection.JavaConverters._ + val res = super.getResources(name) + if (res == null) null else res.asScala.filter(onClasspath).asJavaEnumeration + } +}