1 /*
<lambda>null2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 @file:Suppress("UnstableApiUsage")
18 
19 package androidx.build.lint
20 
21 import com.android.tools.lint.client.api.JavaEvaluator
22 import com.android.tools.lint.client.api.UElementHandler
23 import com.android.tools.lint.detector.api.Category
24 import com.android.tools.lint.detector.api.Detector
25 import com.android.tools.lint.detector.api.Implementation
26 import com.android.tools.lint.detector.api.Incident
27 import com.android.tools.lint.detector.api.Issue
28 import com.android.tools.lint.detector.api.JavaContext
29 import com.android.tools.lint.detector.api.Scope
30 import com.android.tools.lint.detector.api.Severity
31 import com.android.tools.lint.model.DefaultLintModelMavenName
32 import com.android.tools.lint.model.LintModelMavenName
33 import com.intellij.psi.PsiCompiledElement
34 import java.io.File
35 import java.io.FileNotFoundException
36 import org.jetbrains.uast.UAnnotated
37 import org.jetbrains.uast.UAnnotation
38 import org.jetbrains.uast.UCallExpression
39 import org.jetbrains.uast.UClass
40 import org.jetbrains.uast.UClassLiteralExpression
41 import org.jetbrains.uast.UElement
42 import org.jetbrains.uast.UExpression
43 import org.jetbrains.uast.UastCallKind
44 import org.jetbrains.uast.resolveToUElement
45 import org.jetbrains.uast.toUElement
46 
47 /** Prevents usage of experimental annotations outside the groups in which they were defined. */
48 class BanInappropriateExperimentalUsage : Detector(), Detector.UastScanner {
49 
50     override fun getApplicableUastTypes() = listOf(UAnnotation::class.java)
51 
52     override fun createUastHandler(context: JavaContext): UElementHandler {
53         return AnnotationChecker(context)
54     }
55 
56     private inner class AnnotationChecker(val context: JavaContext) : UElementHandler() {
57         val atomicGroupList: List<String> by lazy { loadAtomicLibraryGroupList() }
58 
59         override fun visitAnnotation(node: UAnnotation) {
60             val signature = node.qualifiedName
61 
62             if (DEBUG) {
63                 if (APPLICABLE_ANNOTATIONS.contains(signature) && node.sourcePsi != null) {
64                     (node.uastParent as? UClass)?.let { annotation ->
65                         println(
66                             "${context.driver.mode}: declared ${annotation.qualifiedName} in " +
67                                 "${context.project}"
68                         )
69                     }
70                 }
71             }
72 
73             /**
74              * If the annotation under evaluation is a form of @OptIn, extract and evaluate the
75              * annotation(s) referenced by @OptIn - denoted by its markerClass.
76              */
77             if (signature != null && APPLICATION_OPT_IN_ANNOTATIONS.contains(signature)) {
78                 if (DEBUG) {
79                     println("Found an @OptIn annotation. Attempting to find markerClass element(s)")
80                 }
81 
82                 val markerClass: UExpression? = node.findAttributeValue("markerClass")
83                 if (markerClass != null) {
84                     val markerClasses = getUElementsFromOptInMarkerClass(markerClass)
85 
86                     if (DEBUG && markerClasses.isNotEmpty()) {
87                         println("Found ${markerClasses.size} markerClass(es): ")
88                     }
89 
90                     markerClasses.forEach { uElement ->
91                         if (DEBUG) {
92                             println(
93                                 "Inspecting markerClass annotation " + uElement.getQualifiedName()
94                             )
95                         }
96                         inspectAnnotation(uElement, node)
97                     }
98                 }
99 
100                 /**
101                  * @OptIn has no effect if its markerClass isn't provided. Similarly, if
102                  *   [getUElementsFromOptInMarkerClass] returns an empty list then there isn't
103                  *   anything more to inspect.
104                  *
105                  * In both of these cases we can stop processing here.
106                  */
107                 return
108             }
109 
110             inspectAnnotation(node.resolveToUElement(), node)
111         }
112 
113         private fun getUElementsFromOptInMarkerClass(markerClass: UExpression): List<UElement> {
114             val elements = ArrayList<UElement?>()
115 
116             when {
117                 markerClass is UClassLiteralExpression -> {
118                     // opting in to single annotation
119                     elements.add(markerClass.toUElement())
120                 }
121                 markerClass is UCallExpression &&
122                     markerClass.kind == UastCallKind.NESTED_ARRAY_INITIALIZER -> {
123                     // opting in to multiple annotations
124                     val expressions: List<UExpression> = markerClass.valueArguments
125                     for (expression in expressions) {
126                         val uElement = (expression as UClassLiteralExpression).toUElement()
127                         elements.add(uElement)
128                     }
129                 }
130                 else -> {
131                     // do nothing
132                 }
133             }
134 
135             return elements.filterNotNull()
136         }
137 
138         private fun UClassLiteralExpression.toUElement(): UElement? {
139             val psiType = this.type
140             val psiClass = context.evaluator.getTypeClass(psiType)
141             return psiClass.toUElement()
142         }
143 
144         // If we find an usage of an experimentally-declared annotation, check it.
145         private fun inspectAnnotation(annotation: UElement?, node: UAnnotation) {
146             if (annotation is UAnnotated) {
147                 val annotations = context.evaluator.getAllAnnotations(annotation, false)
148                 if (annotations.any { APPLICABLE_ANNOTATIONS.contains(it.qualifiedName) }) {
149                     if (DEBUG) {
150                         println(
151                             "${context.driver.mode}: used ${annotation.getQualifiedName()} in " +
152                                 context.project.mavenCoordinate.groupId
153                         )
154                     }
155                     verifyUsageOfElementIsWithinSameGroup(
156                         context,
157                         node,
158                         annotation,
159                         ISSUE,
160                         atomicGroupList
161                     )
162                 }
163             }
164         }
165 
166         private fun loadAtomicLibraryGroupList(): List<String> {
167             val fileStream =
168                 this::class.java.classLoader.getResourceAsStream(ATOMIC_LIBRARY_GROUPS_FILENAME)
169                     ?: throw FileNotFoundException(
170                         "Couldn't find atomic library group file $ATOMIC_LIBRARY_GROUPS_FILENAME" +
171                             " within lint-checks.jar"
172                     )
173 
174             val atomicLibraryGroupsString = fileStream.bufferedReader().use { it.readText() }
175             if (atomicLibraryGroupsString.isEmpty()) {
176                 throw RuntimeException("Atomic library group file should not be empty")
177             }
178 
179             return atomicLibraryGroupsString.split("\n")
180         }
181     }
182 
183     fun verifyUsageOfElementIsWithinSameGroup(
184         context: JavaContext,
185         usage: UElement,
186         annotation: UElement,
187         issue: Issue,
188         atomicGroupList: List<String>,
189     ) {
190 
191         // Experimental annotations are permitted if they are in the allowlist
192         val annotationQualifiedName = annotation.getQualifiedName()
193         if (annotationQualifiedName != null && isAnnotationAlwaysAllowed(annotationQualifiedName)) {
194             return
195         }
196 
197         val evaluator = context.evaluator
198 
199         // The location where the annotation is used
200         val usageCoordinates = evaluator.getLibrary(usage) ?: context.project.mavenCoordinate
201         val usageGroupId = usageCoordinates?.groupId
202 
203         // The location where the annotation is declared
204         val annotationCoordinates = evaluator.getLibraryLocalMode(annotation)
205 
206         // This should not happen; generate a lint report if it does
207         if (annotationCoordinates == null) {
208             Incident(context)
209                 .issue(NULL_ANNOTATION_GROUP_ISSUE)
210                 .at(usage)
211                 .message(
212                     "Could not find associated group for annotation " +
213                         "${annotation.getQualifiedName()}, which is used in " +
214                         "${context.project.mavenCoordinate.groupId}."
215                 )
216                 .report()
217             return
218         }
219 
220         val annotationGroupId = annotationCoordinates.groupId
221 
222         val isUsedInAlpha = usageCoordinates.version.contains("-alpha")
223         val isUsedInSameGroup = usageCoordinates.groupId == annotationCoordinates.groupId
224         val isUsedInSameArtifact = usageCoordinates.artifactId == annotationCoordinates.artifactId
225         val isAtomic = atomicGroupList.contains(usageGroupId)
226 
227         /**
228          * Usage of experimental APIs is allowed in either of the following conditions:
229          * - The usage is in an alpha library
230          * - Both the group ID and artifact ID in `usageCoordinates` and `annotationCoordinates`
231          *   match
232          * - The group IDs match, and that group ID is atomic
233          */
234         if (
235             isUsedInAlpha ||
236                 (isUsedInSameGroup && isUsedInSameArtifact) ||
237                 (isUsedInSameGroup && isAtomic)
238         )
239             return
240 
241         // Log inappropriate experimental usage
242         if (DEBUG) {
243             println("${context.driver.mode}: report usage of $annotationGroupId in $usageGroupId")
244         }
245         Incident(context)
246             .issue(issue)
247             .at(usage)
248             .message(
249                 "`Experimental` and `RequiresOptIn` APIs may only be used within the " +
250                     "same-version group where they were defined."
251             )
252             .report()
253     }
254 
255     /**
256      * An implementation of [JavaEvaluator.getLibrary] that attempts to use the JAR path when we
257      * can't find the project from the sourcePsi, even if the element isn't a compiled element.
258      */
259     private fun JavaEvaluator.getLibraryLocalMode(element: UElement): LintModelMavenName? {
260         if (element !is PsiCompiledElement) {
261             val coord = element.sourcePsi?.let { psi -> getProject(psi)?.mavenCoordinate }
262             if (coord != null) {
263                 return coord
264             }
265         }
266         val findJarPath = findJarPath(element)
267         return if (findJarPath != null) {
268             val file = File(findJarPath)
269             getLibrary(file) ?: getMavenCoordinatesFromPath(file.path)
270         } else {
271             null
272         }
273     }
274 
275     private fun UElement.getQualifiedName() = (this as UClass).qualifiedName
276 
277     companion object {
278         private const val DEBUG = false
279 
280         /**
281          * Even though Kotlin's [Experimental] annotation is deprecated in favor of [RequiresOptIn],
282          * we still want to check for its use in Lint.
283          */
284         private const val KOTLIN_EXPERIMENTAL_ANNOTATION = "kotlin.Experimental"
285 
286         private const val KOTLIN_REQUIRES_OPT_IN_ANNOTATION = "kotlin.RequiresOptIn"
287         private const val JAVA_EXPERIMENTAL_ANNOTATION =
288             "androidx.annotation.experimental.Experimental"
289         private const val JAVA_REQUIRES_OPT_IN_ANNOTATION = "androidx.annotation.RequiresOptIn"
290 
291         val APPLICABLE_ANNOTATIONS =
292             listOf(
293                 JAVA_EXPERIMENTAL_ANNOTATION,
294                 KOTLIN_EXPERIMENTAL_ANNOTATION,
295                 JAVA_REQUIRES_OPT_IN_ANNOTATION,
296                 KOTLIN_REQUIRES_OPT_IN_ANNOTATION,
297             )
298 
299         private val APPLICATION_OPT_IN_ANNOTATIONS =
300             listOf(
301                 "androidx.annotation.OptIn",
302                 "kotlin.OptIn",
303             )
304 
305         // This must match the definition in ExportAtomicLibraryGroupsToTextTask
306         const val ATOMIC_LIBRARY_GROUPS_FILENAME = "atomic-library-groups.txt"
307 
308         val ISSUE =
309             Issue.create(
310                 id = "IllegalExperimentalApiUsage",
311                 briefDescription = "Using experimental API from separately versioned library",
312                 explanation =
313                     "Annotations meta-annotated with `@RequiresOptIn` or `@Experimental` " +
314                         "may only be referenced from within the same-version group in which they were " +
315                         "defined.",
316                 category = Category.CORRECTNESS,
317                 priority = 5,
318                 severity = Severity.ERROR,
319                 implementation =
320                     Implementation(
321                         BanInappropriateExperimentalUsage::class.java,
322                         Scope.JAVA_FILE_SCOPE,
323                     ),
324             )
325 
326         val NULL_ANNOTATION_GROUP_ISSUE =
327             Issue.create(
328                 id = "NullAnnotationGroup",
329                 briefDescription = "Maven group associated with an annotation could not be found",
330                 explanation =
331                     "An annotation's group could not be found using `getProject` or " +
332                         "`getLibrary`.",
333                 category = Category.CORRECTNESS,
334                 priority = 5,
335                 severity = Severity.ERROR,
336                 implementation =
337                     Implementation(
338                         BanInappropriateExperimentalUsage::class.java,
339                         Scope.JAVA_FILE_SCOPE,
340                     ),
341             )
342 
343         /** Checks to see if the given annotation is always allowed for use in @OptIn. */
344         internal fun isAnnotationAlwaysAllowed(annotation: String): Boolean {
345             val allowedExperimentalAnnotations =
346                 listOf(
347                     Regex("com\\.google\\.devtools\\.ksp\\.KspExperimental"),
348                     Regex("kotlin\\..*"),
349                     Regex("kotlinx\\..*"),
350                     Regex("org.jetbrains.kotlin\\..*"),
351                 )
352             return allowedExperimentalAnnotations.any { annotation.matches(it) }
353         }
354 
355         /**
356          * Extracts the Maven coordinates from a given JAR path
357          *
358          * For example: given `<checkout
359          * root>/androidx/compose/ui/ui-test/build/libs/ui-test-jvmstubs-1.8.0-beta01.jar`, this
360          * method will return a:
361          * - `groupId` of `androidx.compose.ui`
362          * - `artifactId` of `ui-test`
363          * - `version` of `jvmstubs-1.8.0-beta01`
364          *
365          * @param jarFilePath the path to the JAR file
366          * @return a [LintModelMavenName] with the groupId, artifactId, and version parsed from the
367          *   path, or `null` if [jarFilePath] doesn't contain the strings "androidx" and "build".
368          */
369         internal fun getMavenCoordinatesFromPath(jarFilePath: String): LintModelMavenName? {
370             val pathParts = jarFilePath.split("/")
371             val androidxIndex = pathParts.indexOf("androidx")
372             val buildIndex = pathParts.indexOf("build")
373             if (androidxIndex == -1 || buildIndex == -1) return null
374 
375             val groupId = pathParts.subList(androidxIndex, buildIndex - 1).joinToString(".")
376             val artifactId = pathParts[buildIndex - 1]
377 
378             val filename = pathParts.last()
379             val version = filename.removePrefix("$artifactId-").removeSuffix(".jar")
380 
381             return DefaultLintModelMavenName(groupId, artifactId, version)
382         }
383     }
384 }
385