diff --git a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/GradleProjectResolver.kt b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/GradleProjectResolver.kt index 7d4d0a051..1a76eeba4 100644 --- a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/GradleProjectResolver.kt +++ b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/GradleProjectResolver.kt @@ -9,7 +9,6 @@ import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.absolutePathString import kotlin.io.path.createDirectories -import kotlin.io.path.div import kotlin.io.path.exists import kotlin.io.path.extension import kotlin.io.path.isDirectory @@ -20,12 +19,25 @@ import kotlin.io.path.readText import kotlin.io.path.walk import kotlin.io.path.writeText +fun parseResolutionTable(reportDir: Path): Map { + val json = Json { ignoreUnknownKeys = true } + val table = HashMap() + reportDir.walk().filter { it.extension == "json" && it.name.startsWith("table-") }.forEach { f -> + json.decodeFromString>(f.readText()).forEach { e -> + table.putIfAbsent(e.coord, Path(e.file)) + } + } + return table +} + class GradleProjectResolver( private val resolverDir: Path, override val projectSourceRoot: Path ) : ProjectResolver { private val resolvedModules = mutableListOf() - private val resolvedProjectDependencies = mutableListOf() + private val resolvedProjectDependencies = mutableListOf() + + private var resolutionTable: Map = emptyMap() private val json = Json { ignoreUnknownKeys = true } @@ -107,6 +119,23 @@ class GradleProjectResolver( } } + private val tableResolverInitScript: Path by lazy { + resolverDir.resolve("resolution-table.gradle").apply { writeText(GRADLE_RESOLUTION_TABLE_INIT_SCRIPT) } + } + + private fun resolveResolutionTable() { + val outDir = resolverDir.resolve("table-out").createDirectories() + val gradleExecutable = resolveGradleExecutable(projectSourceRoot) + val args = listOf(gradleExecutable) + gradleBuildFlags + listOf( + "--init-script", tableResolverInitScript.absolutePathString(), + "-D$TABLE_REPORT_DIR_PROPERTY=${outDir.absolutePathString()}", + RESOLVE_TABLE_TASK, + ) + val status = ProjectResolver.runCommand(projectSourceRoot, args, javaToolchain) + if (status != 0) logger.warn { "Gradle resolution-table returned $status for: $projectSourceRoot" } + resolutionTable = parseResolutionTable(outDir) + } + private fun resolveDependencies(): Boolean { val depGraphOutFolder = resolverDir.resolve("dg-out").createDirectories() @@ -120,6 +149,8 @@ class GradleProjectResolver( return false } + resolveResolutionTable() + logger.info { "Gradle resolution table size: ${resolutionTable.size} for: $projectSourceRoot" } resolveDependenciesFromGraph(depGraphOutFolder) return true @@ -134,7 +165,7 @@ class GradleProjectResolver( dependencyResolver.addDependencies(deps) } - resolvedProjectDependencies += dependencyResolver.resolveDependenciesJars() + resolvedProjectDependencies += dependencyResolver.resolveDependencies(resolutionTable) } private class GradleDependencyResolver { @@ -154,30 +185,14 @@ class GradleProjectResolver( } } - fun resolveDependenciesJars(): List { - val allDependenciesInfo = dependenciesInfo.entries.sortedBy { it.key } - - val resolvedDirectDependencies = allDependenciesInfo - .filter { it.key in directDependencies } - .mapNotNull { resolveJarPath(it.value) } - - val resolvedIndirectDependencies = allDependenciesInfo - .filter { it.key !in directDependencies } - .mapNotNull { resolveJarPath(it.value) } - - return resolvedDirectDependencies + resolvedIndirectDependencies - } - - private fun resolveJarPath(dependency: GradleDependencyInfo): Path? { - val gradlePath = gradleLocalRepoPath.resolve(dependency.gradleArtifactDir) - if (gradlePath.isDirectory()) { - gradlePath.walk().firstOrNull { it.name == dependency.artifactJarName }?.let { return it } - } - - val mavenPath = mavenLocalRepoPath.resolve(dependency.mavenArtifactDir).resolve(dependency.artifactJarName) - if (mavenPath.isRegularFile()) return mavenPath - - return null + fun resolveDependencies(table: Map): List { + val all = dependenciesInfo.entries.sortedBy { it.key } + val direct = all.filter { it.key in directDependencies } + val indirect = all.filterNot { it.key in directDependencies } + fun resolve(d: GradleDependencyInfo): ResolvedDependency? = + table["${d.groupId}:${d.artifactId}:${d.version}"] + ?.let { ResolvedDependency(it, mavenPurl(d.groupId, d.artifactId, d.version)) } + return (direct + indirect).mapNotNull { resolve(it.value) } } } @@ -187,6 +202,9 @@ class GradleProjectResolver( val classDirs: List = emptyList() ) + @Serializable + data class TableEntry(val coord: String, val file: String) + @Serializable data class GradleDependencies( val manifests: Map? = null @@ -228,25 +246,9 @@ class GradleProjectResolver( } } - data class GradleDependencyInfo( - val groupId: String, - val artifactId: String, - val version: String, - ) { - val artifactJarName: String by lazy { "${artifactId}-${version}.jar" } - val mavenArtifactDir: List by lazy { groupId.split(".") + listOf(artifactId, version) } - val gradleArtifactDir: List by lazy { listOf(groupId, artifactId, version) } - } + data class GradleDependencyInfo(val groupId: String, val artifactId: String, val version: String) companion object { - private val mavenLocalRepoPath by lazy { - Path(System.getProperty("user.home")) / ".m2" / "repository" - } - - private val gradleLocalRepoPath by lazy { - Path(System.getProperty("user.home")) / ".gradle" / "caches" / "modules-2" / "files-2.1" - } - private const val GRADLE_SETTINGS_FILE = "settings.gradle" private const val GRADLE_SETTINGS_KTS_FILE = "$GRADLE_SETTINGS_FILE.kts" private const val GRADLE_BUILD_FILE = "build.gradle" @@ -293,6 +295,10 @@ class GradleProjectResolver( private const val CLASSES_REPORT_DIR_PROPERTY = "OPENTAINT_CLASSES_REPORT_DIR" + private const val RESOLVE_TABLE_TASK = "opentaintResolveTable" + + private const val TABLE_REPORT_DIR_PROPERTY = "opentaint.table.dir" + private val GRADLE_CLASSES_INIT_SCRIPT = """ import groovy.json.JsonOutput @@ -375,6 +381,43 @@ class GradleProjectResolver( apply plugin: GitHubDependencyGraphPlugin """.trimIndent() + private val GRADLE_RESOLUTION_TABLE_INIT_SCRIPT = """ + import groovy.json.JsonOutput + import org.gradle.api.artifacts.component.ModuleComponentIdentifier + + def reportDir = new File(System.getProperty("$TABLE_REPORT_DIR_PROPERTY")) + reportDir.mkdirs() + + gradle.rootProject { root -> root.tasks.register("$RESOLVE_TABLE_TASK") } + + allprojects { p -> + p.afterEvaluate { + def reportTask = p.tasks.register("opentaintReportTable") { + doLast { + def seen = [] as Set + def out = [] + p.configurations.findAll { it.canBeResolved }.each { cfg -> + try { + cfg.incoming.artifactView { v -> v.lenient = true }.artifacts.each { art -> + def id = art.id.componentIdentifier + if (!(id instanceof ModuleComponentIdentifier)) return + def coord = id.group + ":" + id.module + ":" + id.version + if (!seen.add(coord)) return + out.add([coord: coord, file: art.file.absolutePath]) + } + } catch (Exception e) { + println "opentaint: failed to resolve " + cfg.name + " in " + p.path + ": " + e + } + } + def name = "table-" + p.path.replace(":", "_") + ".json" + new File(reportDir, name).text = JsonOutput.toJson(out) + } + } + p.rootProject.tasks.named("$RESOLVE_TABLE_TASK").configure { dependsOn reportTask } + } + } + """.trimIndent() + private fun resolveGradleDependencyCmdArgs(workDir: Path, initScript: Path, reportDir: Path): List = listOf( "-Dorg.gradle.configureondemand=false", diff --git a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/MavenProjectResolver.kt b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/MavenProjectResolver.kt index 864a3595f..3930ac02f 100644 --- a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/MavenProjectResolver.kt +++ b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/MavenProjectResolver.kt @@ -24,7 +24,7 @@ class MavenProjectResolver( private val resolverDir: Path, override val projectSourceRoot: Path ) : ProjectResolver { - private val resolvedProjectDependencies = mutableListOf() + private val resolvedProjectDependencies = mutableListOf() private val resolvedModules = mutableListOf() private var executableFound = true @@ -129,7 +129,7 @@ class MavenProjectResolver( dependencyResolver.addDependencies(deps) } - resolvedProjectDependencies += dependencyResolver.resolveDependenciesJars() + resolvedProjectDependencies += dependencyResolver.resolveDependencies() } class MavenDependencyGraphResolver { @@ -153,11 +153,13 @@ class MavenProjectResolver( } } - fun resolveDependenciesJars(): List { + fun resolveDependencies( + resolvePath: (MavenArtifact) -> Path? = { mavenLocalRepoPath.resolve(it.artifactJarPath).takeIf { p -> p.exists() } }, + ): List { val buildArtifactsNames = buildArtifacts.mapTo(mutableSetOf()) { artifacts.getValue(it).artifactName } return artifacts.values - .filter { artifact -> artifact.artifactName !in buildArtifactsNames } - .mapNotNull { artifact -> mavenLocalRepoPath.resolve(artifact.artifactJarPath).takeIf { it.exists() } } + .filter { it.artifactName !in buildArtifactsNames } + .mapNotNull { a -> resolvePath(a)?.let { ResolvedDependency(it, mavenPurl(a.groupId, a.artifactId, a.version)) } } } } diff --git a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/PortableProjectCreator.kt b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/PortableProjectCreator.kt index 8575c604c..6a168f9dd 100644 --- a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/PortableProjectCreator.kt +++ b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/PortableProjectCreator.kt @@ -139,7 +139,7 @@ class PortableProjectCreator( sourceRoot = project.sourceRoot?.let { copySources(ctx, project, it) }, javaToolchain = project.javaToolchain?.let { copyToolchain(ctx, it) }, modules = project.modules.map { create(ctx, project, it) }, - dependencies = project.dependencies.map { copyDependency(ctx, it) }, + dependencies = project.dependencies.map { it.copy(path = copyDependency(ctx, it.path)) }, subProjects = project.subProjects.map { create(ctx, it) } ) diff --git a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/ProjectFromCPResolver.kt b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/ProjectFromCPResolver.kt index 736d991cf..de0fb2f5c 100644 --- a/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/ProjectFromCPResolver.kt +++ b/core/opentaint-jvm-autobuilder/src/main/kotlin/org/opentaint/project/ProjectFromCPResolver.kt @@ -20,7 +20,7 @@ class ProjectFromCPResolver { val resolvedCp = projectCp.cp.flatMap { resolveProjectCp(it, resolverWorkDir) } val module = ProjectModuleClasses(rootDir, projectCp.pkg, resolvedCp) - return JavaProject(rootDir, projectCp.toolchain, listOf(module), projectCp.dependency) + return JavaProject(rootDir, projectCp.toolchain, listOf(module), projectCp.dependency.map { ResolvedDependency(it) }) } private var unpackId = 0 diff --git a/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/GradleResolutionTableTest.kt b/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/GradleResolutionTableTest.kt new file mode 100644 index 000000000..0f099834e --- /dev/null +++ b/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/GradleResolutionTableTest.kt @@ -0,0 +1,28 @@ +package org.opentaint.project + +import kotlin.io.path.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import java.nio.file.Files + +class GradleResolutionTableTest { + @Test + fun `folds per-project reports into one coordinate-keyed table`() { + val dir = Files.createTempDirectory("table").also { it.createDirectories() } + dir.resolve("table-_os-v2.json").writeText( + """[{"coord":"org.opensearch.client:opensearch-rest-client:2.18.0","file":"/g/os2/opensearch-rest-client-2.18.0.jar"}]""") + dir.resolve("table-_os-v3.json").writeText( + """[{"coord":"org.opensearch.client:opensearch-rest-client:3.5.0","file":"/g/os3/opensearch-rest-client-3.5.0.jar"}]""") + + val table = parseResolutionTable(dir) + + assertEquals(Path("/g/os2/opensearch-rest-client-2.18.0.jar"), + table["org.opensearch.client:opensearch-rest-client:2.18.0"]) + assertEquals(Path("/g/os3/opensearch-rest-client-3.5.0.jar"), + table["org.opensearch.client:opensearch-rest-client:3.5.0"]) + assertNull(table["org.opensearch.client:opensearch-rest-client:1.0.0"]) + } +} diff --git a/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/GradleTaggedResolutionIT.kt b/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/GradleTaggedResolutionIT.kt new file mode 100644 index 000000000..f15624bdf --- /dev/null +++ b/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/GradleTaggedResolutionIT.kt @@ -0,0 +1,76 @@ +package org.opentaint.project + +import java.nio.file.Files +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteRecursively +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile +import kotlin.io.path.pathString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Runs the real autobuilder Gradle pipeline against a small project with a versioned Maven Central + * dependency and checks the emitted model. Shells out to Gradle and needs network access (Maven + * Central + the Gradle Plugin Portal). + */ +@OptIn(ExperimentalPathApi::class) +class GradleTaggedResolutionIT { + @Test + fun `gradle project dependencies come out tagged with coordinates and resolved to real files`() { + val fixtureResource = javaClass.classLoader.getResource("gradle-fixture-project") + ?: fail("gradle-fixture-project test resource not found on classpath") + + val workRoot = Files.createTempDirectory("gradle-tagged-resolution-it") + try { + val projectDir = workRoot.resolve("project").createDirectories() + java.nio.file.Paths.get(fixtureResource.toURI()).copyDirRecursivelyTo(projectDir) + + val buildDir = workRoot.resolve("build").createDirectories() + val resolved = ProjectResolver.resolveProject(projectDir, buildDir) + ?: fail("Autobuilder failed to resolve the fixture Gradle project") + + val modelPath = workRoot.resolve("model.yaml") + resolved.dump(modelPath) + + // Re-parse from the emitted YAML, like a real consumer would. + val loaded = JavaProject.load(modelPath) + + assertTrue(loaded.dependencies.isNotEmpty(), "Expected at least one resolved dependency") + + // Invariant 1: every dependency is tagged with a purl, not a bare path. + loaded.dependencies.forEach { dep -> + assertNotNull(dep.purl, "Untagged dependency (missing purl): $dep") + } + + // Invariant 2: every dependency path resolves to a real file on disk. + loaded.dependencies.forEach { dep -> + assertTrue( + dep.path.exists() && dep.path.isRegularFile(), + "Dependency path does not exist on disk: ${dep.path}" + ) + } + + // Invariant 3: no dependency path leaks a project/module build output directory. + loaded.dependencies.forEach { dep -> + assertFalse( + dep.path.pathString.contains("build/classes"), + "Dependency path leaks a project class directory: ${dep.path}" + ) + } + + // Invariant 4: the resolved version reflects what the build declares, not a stray cached one. + val commonsLang3 = loaded.dependencies.singleOrNull { + it.purl == "pkg:maven/org.apache.commons/commons-lang3@3.14.0" + } ?: fail("Expected a resolved commons-lang3 dependency, got: ${loaded.dependencies}") + assertEquals("pkg:maven/org.apache.commons/commons-lang3@3.14.0", commonsLang3.purl) + } finally { + workRoot.deleteRecursively() + } + } +} diff --git a/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/MavenResolvedDependencyTest.kt b/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/MavenResolvedDependencyTest.kt new file mode 100644 index 000000000..249093e24 --- /dev/null +++ b/core/opentaint-jvm-autobuilder/src/test/kotlin/org/opentaint/project/MavenResolvedDependencyTest.kt @@ -0,0 +1,34 @@ +package org.opentaint.project + +import kotlin.io.path.Path +import kotlin.test.Test +import kotlin.test.assertEquals + +class MavenResolvedDependencyTest { + @Test + fun `resolved maven artifacts carry a purl`() { + val r = MavenProjectResolver.MavenDependencyGraphResolver() + r.addDependencies(mavenGraphWith(group = "com.fasterxml.jackson.core", + artifact = "jackson-core", version = "2.17.3")) + val deps = r.resolveDependencies(resolvePath = { Path("/m2/${it.artifactId}-${it.version}.jar") }) + val jc = deps.single { it.purl == "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.17.3" } + assertEquals(Path("/m2/jackson-core-2.17.3.jar"), jc.path) + } + + private fun mavenGraphWith(group: String, artifact: String, version: String): MavenProjectResolver.MavenDependencies { + val id = "$group:$artifact:jar:$version" + return MavenProjectResolver.MavenDependencies( + artifacts = listOf( + MavenProjectResolver.MavenArtifact( + id = id, + groupId = group, + artifactId = artifact, + version = version, + ) + ), + dependencies = listOf( + MavenProjectResolver.MavenDependency(from = "root", to = id), + ), + ) + } +} diff --git a/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/build.gradle b/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/build.gradle new file mode 100644 index 000000000..e723bf0d0 --- /dev/null +++ b/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly 'org.apache.commons:commons-lang3:3.14.0' +} diff --git a/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/settings.gradle b/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/settings.gradle new file mode 100644 index 000000000..4e36d107f --- /dev/null +++ b/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'gradle-tagged-resolution-fixture' diff --git a/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/src/main/java/com/example/Fixture.java b/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/src/main/java/com/example/Fixture.java new file mode 100644 index 000000000..1762232de --- /dev/null +++ b/core/opentaint-jvm-autobuilder/src/test/resources/gradle-fixture-project/src/main/java/com/example/Fixture.java @@ -0,0 +1,4 @@ +package com.example; + +public class Fixture { +} diff --git a/core/opentaint-project-model/src/main/kotlin/org/opentaint/project/Project.kt b/core/opentaint-project-model/src/main/kotlin/org/opentaint/project/Project.kt index 1fc94c7ee..85492d51f 100644 --- a/core/opentaint-project-model/src/main/kotlin/org/opentaint/project/Project.kt +++ b/core/opentaint-project-model/src/main/kotlin/org/opentaint/project/Project.kt @@ -3,12 +3,17 @@ package org.opentaint.project import com.charleskorn.kaml.SingleLineStringStyle import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration +import com.charleskorn.kaml.YamlInput +import com.charleskorn.kaml.YamlScalar import com.charleskorn.kaml.decodeFromStream import com.charleskorn.kaml.encodeToStream +import kotlinx.serialization.ContextualSerializer +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.nio.file.Path @@ -22,13 +27,61 @@ sealed interface CommonProject { fun sourceRoot(): Path? } +/** + * Builds a Maven package-URL of the form `pkg:maven//@`. Maven + * group/artifact/version are simple tokens, so no percent-encoding is applied. + */ +fun mavenPurl(group: String, artifact: String, version: String): String = + "pkg:maven/$group/$artifact@$version" + +@Serializable(with = ResolvedDependencySerializer::class) +data class ResolvedDependency( + val path: Path, + val purl: String? = null, +) { + fun relativeTo(base: Path): ResolvedDependency = copy(path = path.relativeTo(base)) + fun resolve(base: Path): ResolvedDependency = copy(path = base.resolve(path)) +} + +@Serializable +private data class ResolvedDependencySurrogate( + val path: @Serializable(with = PathAsStringSerializer::class) Path, + val purl: String? = null, +) + +/** + * Decodes the tagged mapping via a private surrogate, or a bare scalar (legacy `- /path.jar`) as a + * path-only dependency. The descriptor is CONTEXTUAL so kaml routes scalar nodes here instead of + * rejecting them before [deserialize] runs. + */ +object ResolvedDependencySerializer : KSerializer { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = + ContextualSerializer(ResolvedDependency::class).descriptor + + override fun serialize(encoder: Encoder, value: ResolvedDependency) { + encoder.encodeSerializableValue( + ResolvedDependencySurrogate.serializer(), + ResolvedDependencySurrogate(value.path, value.purl), + ) + } + + override fun deserialize(decoder: Decoder): ResolvedDependency { + if (decoder is YamlInput && decoder.node is YamlScalar) { + return ResolvedDependency(path = Path(decoder.decodeString())) + } + val surrogate = decoder.decodeSerializableValue(ResolvedDependencySurrogate.serializer()) + return ResolvedDependency(surrogate.path, surrogate.purl) + } +} + @Suppress("DEPRECATION") @Serializable data class JavaProject( val sourceRoot: @Serializable(with = PathAsStringSerializer::class) Path? = null, val javaToolchain: @Serializable(with = PathAsStringSerializer::class) Path? = null, val modules: List = emptyList(), - val dependencies: List<@Serializable(with = PathAsStringSerializer::class) Path> = emptyList(), + val dependencies: List = emptyList(), @Deprecated("Use top-level Project.javaProjects instead") val subProjects: List = emptyList(), ): CommonProject { @@ -46,7 +99,7 @@ data class JavaProject( sourceRoot?.let { base.resolve(it) }, javaToolchain?.let { base.resolve(it) }, modules.map { it.resolve(base) }, - dependencies.map { base.resolve(it) }, + dependencies.map { it.resolve(base) }, subProjects.map { it.resolve(base) } ) diff --git a/core/opentaint-project-model/src/test/kotlin/org/opentaint/project/ResolvedDependencyTest.kt b/core/opentaint-project-model/src/test/kotlin/org/opentaint/project/ResolvedDependencyTest.kt new file mode 100644 index 000000000..1b3918e8e --- /dev/null +++ b/core/opentaint-project-model/src/test/kotlin/org/opentaint/project/ResolvedDependencyTest.kt @@ -0,0 +1,105 @@ +package org.opentaint.project + +import kotlin.io.path.Path +import kotlin.io.path.createTempFile +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ResolvedDependencyTest { + @Test + fun `mavenPurl formats a pkg-maven package-URL`() { + assertEquals( + "pkg:maven/org.opensearch.client/opensearch-rest-client@2.18.0", + mavenPurl("org.opensearch.client", "opensearch-rest-client", "2.18.0"), + ) + } + + @Test + fun `relativeTo then resolve round-trips the path and keeps the purl`() { + val abs = ResolvedDependency( + path = Path("/repo/model/deps/opensearch-rest-client-2.18.0.jar"), + purl = mavenPurl("org.opensearch.client", "opensearch-rest-client", "2.18.0"), + ) + val rel = abs.relativeTo(Path("/repo/model")) + assertEquals(Path("deps/opensearch-rest-client-2.18.0.jar"), rel.path) + assertEquals("pkg:maven/org.opensearch.client/opensearch-rest-client@2.18.0", rel.purl) + assertEquals(abs.path, rel.resolve(Path("/repo/model")).path) + } + + @Test + fun `purl is optional`() { + val d = ResolvedDependency(path = Path("/x/lib.jar")) + assertNull(d.purl) + } + + @Test + fun `a JavaProject serializes purl-tagged dependencies and loads them back`() { + val jp = JavaProject( + sourceRoot = Path("/p"), + dependencies = listOf( + ResolvedDependency(Path("/d/a.jar"), mavenPurl("g", "a", "1.0")), + ), + ) + val tmp = createTempFile(suffix = ".yaml") + jp.dump(tmp) + val loaded = JavaProject.load(tmp) + val dep = loaded.dependencies.single() + assertEquals(Path("/d/a.jar"), dep.path) + assertEquals("pkg:maven/g/a@1.0", dep.purl) + } + + @Test + fun `a dependency without a purl round-trips`() { + val jp = JavaProject( + sourceRoot = Path("/p"), + dependencies = listOf(ResolvedDependency(Path("/d/plain.jar"))), + ) + val tmp = createTempFile(suffix = ".yaml") + jp.dump(tmp) + val loaded = JavaProject.load(tmp) + val dep = loaded.dependencies.single() + assertEquals(Path("/d/plain.jar"), dep.path) + assertNull(dep.purl) + } + + private val legacyJavaProjectYaml = """ + sourceRoot: /p + dependencies: + - /path/to/a.jar + - /path/to/b.jar + """.trimIndent() + + private val legacyProjectYaml = """ + projectRoot: /p + javaProjects: + - sourceRoot: /p + dependencies: + - /some/bare/path.jar + """.trimIndent() + + @Test + fun `JavaProject load accepts a legacy bare-string dependency as a path-only dependency`() { + val tmp = createTempFile(suffix = ".yaml") + tmp.writeText(legacyJavaProjectYaml) + val loaded = JavaProject.load(tmp) + assertEquals( + listOf(Path("/path/to/a.jar"), Path("/path/to/b.jar")), + loaded.dependencies.map { it.path }, + ) + loaded.dependencies.forEach { dep -> + assertNull(dep.purl) + } + } + + @Test + fun `Project load accepts a Project-shaped legacy bare-string dependency as a path-only dependency`() { + val tmp = createTempFile(suffix = ".yaml") + tmp.writeText(legacyProjectYaml) + val loaded = Project.load(tmp) + val dep = loaded.javaProjects.single().dependencies.single() + assertEquals(Path("/some/bare/path.jar"), dep.path) + assertNull(dep.purl) + } +} diff --git a/core/src/main/kotlin/org/opentaint/jvm/sast/project/ProjectAnalysisContext.kt b/core/src/main/kotlin/org/opentaint/jvm/sast/project/ProjectAnalysisContext.kt index 7e8c4704c..dc72e429b 100644 --- a/core/src/main/kotlin/org/opentaint/jvm/sast/project/ProjectAnalysisContext.kt +++ b/core/src/main/kotlin/org/opentaint/jvm/sast/project/ProjectAnalysisContext.kt @@ -55,7 +55,7 @@ private fun initializeProjectAnalysisContextUtil( options: ProjectAnalysisOptions, createAnalysisContext: AnalysisContextBuilder.() -> T ): T { - val dependencyFiles = project.dependencies.map { it.toFile() } + val dependencyFiles = project.dependencies.map { it.path.toFile() } val projectModulesFiles = run { val moduleFiles = mutableMapOf() for (module in project.modules) { diff --git a/core/src/test/kotlin/org/opentaint/jvm/sast/ast/BasicTestUtils.kt b/core/src/test/kotlin/org/opentaint/jvm/sast/ast/BasicTestUtils.kt index 5998505d3..69bc9e15b 100644 --- a/core/src/test/kotlin/org/opentaint/jvm/sast/ast/BasicTestUtils.kt +++ b/core/src/test/kotlin/org/opentaint/jvm/sast/ast/BasicTestUtils.kt @@ -22,6 +22,7 @@ import org.opentaint.common.sast.sarif.TracePathNodeEntry import org.opentaint.common.sast.sarif.TracePathNodeKind import org.opentaint.project.JavaProject import org.opentaint.project.ProjectModuleClasses +import org.opentaint.project.ResolvedDependency import java.io.File import java.nio.file.Path import java.util.jar.JarFile @@ -65,7 +66,7 @@ abstract class BasicTestUtils { moduleClasses = listOf(samplesJar) ) ), - dependencies = dependencyJars + dependencies = dependencyJars.map { ResolvedDependency(it) } ) val options = ProjectAnalysisOptions()