All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.joern.x2cpg.utils.dependency.GradleDependencies.scala Maven / Gradle / Ivy

package io.joern.x2cpg.utils.dependency

import better.files._
import org.gradle.tooling.{GradleConnector, ProjectConnection}
import org.gradle.tooling.model.GradleProject
import org.gradle.tooling.model.build.BuildEnvironment
import org.slf4j.LoggerFactory

import java.io.ByteArrayOutputStream
import java.nio.file.{Files, Path}
import java.io.{File => JFile}
import java.util.stream.Collectors
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Random, Success, Try, Using}

case class GradleProjectInfo(gradleVersion: String, tasks: Seq[String], hasAndroidSubproject: Boolean = false) {
  def gradleVersionMajorMinor(): (Int, Int) = {
    def isValidPart(part: String) = part.forall(Character.isDigit)
    val parts                     = gradleVersion.split('.')
    if (parts.length == 1 && isValidPart(parts(0))) {
      (parts(0).toInt, 0)
    } else if (parts.length >= 2 && isValidPart(parts(0)) && isValidPart(parts(1))) {
      (parts(0).toInt, parts(1).toInt)
    } else {
      (-1, -1)
    }
  }
}

object Constants {
  val aarFileExtension            = "aar"
  val gradleAndroidPropertyPrefix = "android."
  val gradlePropertiesTaskName    = "properties"
  val jarInsideAarFileName        = "classes.jar"
}

case class GradleDepsInitScript(contents: String, taskName: String, destinationDir: Path)

object GradleDependencies {
  private val logger           = LoggerFactory.getLogger(getClass)
  private val initScriptPrefix = "x2cpg.init.gradle"
  private val taskNamePrefix   = "x2cpgCopyDeps"
  private val tempDirPrefix    = "x2cpgDependencies"

  // works with Gradle 5.1+ because the script makes use of `task.register`:
  //   https://docs.gradle.org/current/userguide/task_configuration_avoidance.html
  private def gradle5OrLaterAndroidInitScript(
    taskName: String,
    destination: String,
    gradleProjectName: String,
    gradleConfigurationName: String
  ): String = {
    s"""
       |allprojects {
       |  afterEvaluate { project ->
       |    def taskName = "$taskName"
       |    def destinationDir = "${destination.replaceAll("\\\\", "/")}"
       |    def gradleProjectName = "$gradleProjectName"
       |    def gradleConfigurationName = "$gradleConfigurationName"
       |
       |    if (project.name.equals(gradleProjectName)) {
       |      def compileDepsCopyTaskName = taskName + "_compileDeps"
       |      tasks.register(compileDepsCopyTaskName, Copy) {
       |        def selectedConfig = project.configurations.find { it.name.equals(gradleConfigurationName) }
       |        def componentIds = []
       |        if (selectedConfig != null) {
       |          componentIds = selectedConfig.incoming.resolutionResult.allDependencies.collect { it.selected.id }
       |        }
       |        def result = dependencies.createArtifactResolutionQuery()
       |                                 .forComponents(componentIds)
       |                                 .withArtifacts(JvmLibrary, SourcesArtifact)
       |                                 .execute()
       |        duplicatesStrategy = 'include'
       |        into destinationDir
       |        from result.resolvedComponents.collect { it.getArtifacts(SourcesArtifact).collect { it.file } }
       |      }
       |      def androidDepsCopyTaskName = taskName + "_androidDeps"
       |      tasks.register(androidDepsCopyTaskName, Copy) {
       |        duplicatesStrategy = 'include'
       |        into destinationDir
       |        from project.configurations.find { it.name.equals("androidApis") }
       |      }
       |      tasks.register(taskName, Copy) {
       |        dependsOn androidDepsCopyTaskName
       |        dependsOn compileDepsCopyTaskName
       |      }
       |    }
       |  }
       |}
       |""".stripMargin
  }

  // this init script _should_ work with Gradle >=4, but has not been tested thoroughly
  // TODO: add test cases for older Gradle versions
  private def gradle5OrLaterInitScript(
    taskName: String,
    destination: String,
    gradleConfigurationName: String
  ): String = {
    val into = destination.replaceAll("\\\\", "/")
    val fromConfigurations =
      Set(s"from configurations.$gradleConfigurationName", "from configurations.runtimeClasspath").mkString("\n")
    s"""
     |allprojects {
     |  apply plugin: 'java'
     |  task $taskName(type: Copy) {
     |    $fromConfigurations
     |    into "$into"
     |  }
     |}
     |""".stripMargin
  }

  private def makeInitScript(
    destinationDir: Path,
    forAndroid: Boolean,
    gradleProjectName: String,
    gradleConfigurationName: String
  ): GradleDepsInitScript = {
    val taskName = taskNamePrefix + "_" + (Random.alphanumeric take 8).toList.mkString
    val content =
      if (forAndroid) {
        gradle5OrLaterAndroidInitScript(taskName, destinationDir.toString, gradleProjectName, gradleConfigurationName)
      } else {
        gradle5OrLaterInitScript(taskName, destinationDir.toString, gradleConfigurationName)
      }
    GradleDepsInitScript(content, taskName, destinationDir)
  }

  private[dependency] def makeConnection(projectDir: JFile): ProjectConnection = {
    GradleConnector.newConnector().forProjectDirectory(projectDir).connect()
  }

  private def getGradleProjectInfo(projectDir: Path, projectName: String): Option[GradleProjectInfo] = {
    Try(makeConnection(projectDir.toFile)) match {
      case Success(gradleConnection) =>
        Using.resource(gradleConnection) { connection =>
          try {
            val buildEnv = connection.getModel[BuildEnvironment](classOf[BuildEnvironment])
            val project  = connection.getModel[GradleProject](classOf[GradleProject])
            val hasAndroidPrefixGradleProperty =
              runGradleTask(connection, Constants.gradlePropertiesTaskName) match {
                case Some(out) =>
                  out.split('\n').exists(_.startsWith(Constants.gradleAndroidPropertyPrefix))
                case None => false
              }
            val info = GradleProjectInfo(
              buildEnv.getGradle.getGradleVersion,
              project.getTasks.asScala.map(_.getName).toSeq,
              hasAndroidPrefixGradleProperty
            )
            if (hasAndroidPrefixGradleProperty) {
              val validProjectNames = List(project.getName) ++ project.getChildren.getAll.asScala.map(_.getName)
              logger.debug(s"Found Gradle projects: ${validProjectNames.mkString(",")}")
              if (!validProjectNames.contains(projectName)) {
                val validProjectNamesStr = validProjectNames.mkString(",")
                logger.warn(
                  s"The provided Gradle project name `$projectName` is is not part of the valid project names: `$validProjectNamesStr`"
                )
                None
              } else {
                Some(info)
              }
            } else {
              Some(info)
            }
          } catch {
            case t: Throwable =>
              logger.warn(s"Caught exception while trying use Gradle connection: ${t.getMessage}")
              logger.debug(s"Full exception: ", t)
              None
          }
        }
      case Failure(t) =>
        logger.warn(s"Caught exception while trying fetch Gradle project information: ${t.getMessage}")
        logger.debug(s"Full exception: ", t)
        None
    }
  }

  private def runGradleTask(connection: ProjectConnection, taskName: String): Option[String] = {
    Using.resource(new ByteArrayOutputStream()) { out =>
      Try(
        connection
          .newBuild()
          .forTasks(taskName)
          .setStandardOutput(out)
          .run()
      ) match {
        case Success(_) => Some(out.toString)
        case Failure(ex) =>
          logger.warn(s"Caught exception while executing Gradle task named `$taskName`:", ex.getMessage)
          logger.debug(s"Full exception: ", ex)
          None
      }
    }
  }

  private def runGradleTask(
    connection: ProjectConnection,
    initScript: GradleDepsInitScript,
    initScriptPath: String
  ): Option[collection.Seq[String]] = {
    Using.resources(new ByteArrayOutputStream, new ByteArrayOutputStream) { case (stdoutStream, stderrStream) =>
      logger.info(s"Executing gradle task '${initScript.taskName}'...")
      Try(
        connection
          .newBuild()
          .forTasks(initScript.taskName)
          .withArguments("--init-script", initScriptPath)
          .setStandardOutput(stdoutStream)
          .setStandardError(stderrStream)
          .run()
      ) match {
        case Success(_) =>
          val result =
            Files
              .list(initScript.destinationDir)
              .collect(Collectors.toList[Path])
              .asScala
              .map(_.toAbsolutePath.toString)
          logger.info(s"Resolved `${result.size}` dependency files.")
          Some(result)
        case Failure(ex) =>
          logger.warn(s"Caught exception while executing Gradle task: ${ex.getMessage}")
          logger.debug(s"Gradle task execution stdout: \n$stdoutStream")
          logger.debug(s"Gradle task execution stderr: \n$stderrStream")
          None
      }
    }
  }

  private def extractClassesJarFromAar(aar: File): Option[Path] = {
    val newPath           = aar.path.toString.replaceFirst(Constants.aarFileExtension + "$", "jar")
    val aarUnzipDirSuffix = ".unzipped"
    val outDir            = File(aar.path.toString + aarUnzipDirSuffix)
    aar.unzipTo(outDir, _.getName == Constants.jarInsideAarFileName)
    val outFile = File(newPath)
    val classesJarEntries =
      outDir.listRecursively
        .filter(_.path.getFileName.toString == Constants.jarInsideAarFileName)
        .toList
    if (classesJarEntries.size != 1) {
      logger.warn(s"Found aar file without `classes.jar` inside at path ${aar.path}")
      outDir.delete()
      None
    } else {
      val classesJar = classesJarEntries.head
      logger.trace(s"Copying `classes.jar` for aar at `${aar.path.toString}` into `$newPath`")
      classesJar.copyTo(outFile)
      outDir.delete()
      aar.delete()
      Some(outFile.path)
    }
  }

  // fetch the gradle project information first, then invoke a newly-defined gradle task to copy the necessary jars into
  // a destination directory.
  private[dependency] def get(
    projectDir: Path,
    projectName: String,
    configurationName: String
  ): Option[collection.Seq[String]] = {
    logger.info(s"Fetching Gradle project information at path `$projectDir` with project name `$projectName`.")
    getGradleProjectInfo(projectDir, projectName) match {
      case Some(projectInfo) if projectInfo.gradleVersionMajorMinor()._1 < 5 =>
        logger.warn(s"Unsupported Gradle version `${projectInfo.gradleVersion}`")
        None
      case Some(projectInfo) =>
        Try(File.newTemporaryDirectory(tempDirPrefix).deleteOnExit()) match {
          case Success(destinationDir) =>
            Try(File.newTemporaryFile(initScriptPrefix).deleteOnExit()) match {
              case Success(initScriptFile) =>
                val initScript =
                  makeInitScript(destinationDir.path, projectInfo.hasAndroidSubproject, projectName, configurationName)
                initScriptFile.write(initScript.contents)

                logger.info(
                  s"Downloading dependencies for configuration `$configurationName` of project `$projectName` at `$projectDir` into `$destinationDir`..."
                )
                Try(makeConnection(projectDir.toFile)) match {
                  case Success(connection) =>
                    Using.resource(connection) { c =>
                      runGradleTask(c, initScript, initScriptFile.pathAsString) match {
                        case Some(deps) =>
                          Some(deps.map { d =>
                            if (!d.endsWith(Constants.aarFileExtension)) d
                            else
                              extractClassesJarFromAar(File(d)) match {
                                case Some(path) => path.toString
                                case None       => d
                              }
                          })
                        case None => None
                      }
                    }
                  case Failure(ex) =>
                    logger.warn(s"Caught exception while trying to establish a Gradle connection: ${ex.getMessage}")
                    logger.debug(s"Full exception: ", ex)
                    None
                }
              case Failure(ex) =>
                logger.warn(s"Could not create temporary file for Gradle init script: ${ex.getMessage}")
                logger.debug(s"Full exception: ", ex)
                None
            }
          case Failure(ex) =>
            logger.warn(s"Could not create temporary directory for saving dependency files: ${ex.getMessage}")
            logger.debug("Full exception: ", ex)
            None
        }
      case None =>
        logger.warn("Could not fetch Gradle project information")
        None
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy