1 /*
2  * Copyright 2023 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 package androidx.build.lint
18 
19 import com.android.SdkConstants.ATTR_VALUE
20 import com.android.tools.lint.detector.api.AnnotationInfo
21 import com.android.tools.lint.detector.api.AnnotationUsageInfo
22 import com.android.tools.lint.detector.api.AnnotationUsageType
23 import com.android.tools.lint.detector.api.AnnotationUsageType.ASSIGNMENT_LHS
24 import com.android.tools.lint.detector.api.AnnotationUsageType.ASSIGNMENT_RHS
25 import com.android.tools.lint.detector.api.Category
26 import com.android.tools.lint.detector.api.Implementation
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.detector.api.SourceCodeScanner
32 import com.android.tools.lint.detector.api.isKotlin
33 import com.android.tools.lint.model.DefaultLintModelMavenName
34 import com.android.tools.lint.model.LintModelLibrary
35 import com.android.tools.lint.model.LintModelMavenName
36 import com.intellij.lang.jvm.annotation.JvmAnnotationConstantValue
37 import com.intellij.psi.PsiClass
38 import com.intellij.psi.PsiCompiledElement
39 import com.intellij.psi.PsiField
40 import com.intellij.psi.PsiMember
41 import com.intellij.psi.PsiMethod
42 import com.intellij.psi.impl.compiled.ClsAnnotationImpl
43 import com.intellij.psi.util.PsiTypesUtil
44 import org.jetbrains.uast.UAnnotated
45 import org.jetbrains.uast.UAnnotation
46 import org.jetbrains.uast.UCallExpression
47 import org.jetbrains.uast.UClass
48 import org.jetbrains.uast.UElement
49 import org.jetbrains.uast.UExpression
50 import org.jetbrains.uast.UReferenceExpression
51 import org.jetbrains.uast.UTypeReferenceExpression
52 import org.jetbrains.uast.UastEmptyExpression
53 import org.jetbrains.uast.getParentOfType
54 import org.jetbrains.uast.util.isArrayInitializer
55 
56 /** Adapted from com/android/tools/lint/checks/RestrictToDetector.kt in Android Studio repo. */
57 class RestrictToDetector : AbstractAnnotationDetector(), SourceCodeScanner {
applicableAnnotationsnull58     override fun applicableAnnotations(): List<String> = listOf(RESTRICT_TO_ANNOTATION)
59 
60     override fun inheritAnnotation(annotation: String): Boolean {
61         // Require restriction annotations to be annotated everywhere
62         return false
63     }
64 
isApplicableAnnotationUsagenull65     override fun isApplicableAnnotationUsage(type: AnnotationUsageType): Boolean {
66         return type != ASSIGNMENT_LHS &&
67             type != ASSIGNMENT_RHS &&
68             super.isApplicableAnnotationUsage(type)
69     }
70 
visitAnnotationUsagenull71     override fun visitAnnotationUsage(
72         context: JavaContext,
73         element: UElement,
74         annotationInfo: AnnotationInfo,
75         usageInfo: AnnotationUsageInfo
76     ) {
77         val type = usageInfo.type
78         if (type == AnnotationUsageType.EXTENDS && element is UTypeReferenceExpression) {
79             // If it's a constructor reference we don't need to also check the type
80             // reference. Ideally we'd do a "parent is KtConstructorCalleeExpression"
81             // here, but that points to impl classes in its hierarchy which leads to
82             // class loading trouble.
83             val sourcePsi = element.sourcePsi
84             if (
85                 sourcePsi != null &&
86                     isKotlin(sourcePsi.language) &&
87                     sourcePsi.parent?.toString() == "CONSTRUCTOR_CALLEE"
88             ) {
89                 return
90             }
91         }
92 
93         val member = usageInfo.referenced as? PsiMember
94         val annotation = annotationInfo.annotation
95         val qualifiedName = annotationInfo.qualifiedName
96         if (RESTRICT_TO_ANNOTATION == qualifiedName) {
97             checkRestrictTo(context, element, member, annotation, usageInfo)
98         }
99     }
100 
isTestContextnull101     private fun isTestContext(context: JavaContext, element: UElement): Boolean {
102         var current = element
103         // (1) Is this compilation unit in a test source path?
104         if (context.isTestSource) {
105             return true
106         }
107 
108         // (2) Is this AST node surrounded by a test-only annotation?
109         while (true) {
110             val owner = current.getParentOfType<UAnnotated>(true) ?: break
111 
112             //noinspection AndroidLintExternalAnnotations
113             for (annotation in owner.uAnnotations) {
114                 val name = annotation.qualifiedName ?: continue
115                 when {
116                     RESTRICT_TO_ANNOTATION == name -> {
117                         val restrictionScope = getRestrictionScope(annotation)
118                         if (restrictionScope and RESTRICT_TO_TESTS != 0) {
119                             return true
120                         }
121                     }
122                     name.endsWith(VISIBLE_FOR_TESTING_SUFFIX) -> return true
123                     name.endsWith(".TestOnly") -> return true
124                 }
125             }
126 
127             current = owner
128         }
129 
130         return false
131     }
132 
133     // TODO: Test XML access of restricted classes
checkRestrictTonull134     private fun checkRestrictTo(
135         context: JavaContext,
136         node: UElement,
137         method: PsiMember?,
138         annotation: UAnnotation,
139         usageInfo: AnnotationUsageInfo
140     ) {
141         val scope = getRestrictionScope(annotation)
142         if (scope != 0) {
143             checkRestrictTo(context, node, method, usageInfo, scope)
144         }
145     }
146 
checkRestrictTonull147     private fun checkRestrictTo(
148         context: JavaContext,
149         node: UElement,
150         member: PsiMember?,
151         usageInfo: AnnotationUsageInfo,
152         scope: Int
153     ) {
154 
155         val containingClass =
156             when {
157                 node is UTypeReferenceExpression -> PsiTypesUtil.getPsiClass(node.type)
158                 member != null -> member.containingClass
159                 node is UCallExpression -> node.classReference?.resolve() as? PsiClass?
160                 node is PsiClass -> node
161                 else -> null
162             }
163 
164         containingClass ?: return
165 
166         if (usageInfo.anyCloser { it.qualifiedName == RESTRICT_TO_ANNOTATION }) {
167             return
168         }
169 
170         if (scope and RESTRICT_TO_LIBRARY_GROUP != 0 && member != null) {
171             val evaluator = context.evaluator
172             val thisCoordinates = evaluator.getLibrary(node) ?: context.project.mavenCoordinate
173             val methodCoordinates = context.findMavenCoordinate(member)
174             val thisGroup = thisCoordinates?.groupId
175             val methodGroup = methodCoordinates?.groupId
176             if (thisGroup != methodGroup && methodGroup != null) {
177                 val thisGroupDisplayText = thisGroup ?: "<unknown>"
178                 val where =
179                     "from within the same library group (referenced groupId=`$methodGroup` from " +
180                         "groupId=`$thisGroupDisplayText`)"
181                 reportRestriction(where, containingClass, member, context, node, usageInfo)
182             }
183         } else if (scope and RESTRICT_TO_LIBRARY_GROUP_PREFIX != 0 && member != null) {
184             val evaluator = context.evaluator
185             val thisCoordinates = evaluator.getLibrary(node) ?: context.project.mavenCoordinate
186             val methodCoordinates =
187                 evaluator.getLibrary(member)
188                     ?: run {
189                         if (thisCoordinates != null && member !is PsiCompiledElement) {
190                             // Local source?
191                             context.evaluator.getProject(member)?.mavenCoordinate
192                         } else {
193                             null
194                         }
195                     }
196             val thisGroup = thisCoordinates?.groupId
197             val methodGroup = methodCoordinates?.groupId
198             if (
199                 methodGroup != null &&
200                     (thisGroup == null || !sameLibraryGroupPrefix(thisGroup, methodGroup))
201             ) {
202                 val expectedPrefix =
203                     methodGroup.lastIndexOf('.').let {
204                         if (it < 0) {
205                             "\"\""
206                         } else {
207                             methodGroup.substring(0, it)
208                         }
209                     }
210                 val where =
211                     "from within the same library group prefix (referenced " +
212                         "groupId=`$methodGroup` with prefix $expectedPrefix" +
213                         "${if (thisGroup != null) " from groupId=`$thisGroup`" else ""})"
214                 reportRestriction(where, containingClass, member, context, node, usageInfo)
215             }
216         } else if (scope and RESTRICT_TO_LIBRARY != 0 && member != null) {
217             val evaluator = context.evaluator
218             val thisCoordinates = evaluator.getLibrary(node) ?: context.project.mavenCoordinate
219             val methodCoordinates = context.findMavenCoordinate(member)
220             val thisGroup = thisCoordinates?.groupId
221             val methodGroup = methodCoordinates?.groupId
222             if (thisGroup != methodGroup && methodGroup != null) {
223                 val thisArtifact = thisCoordinates?.artifactId
224                 val methodArtifact = methodCoordinates.artifactId
225                 if (thisArtifact != methodArtifact) {
226                     val name =
227                         if (methodGroup == "__local_aars__") {
228                             "missing Maven coordinate due to repackaging"
229                         } else {
230                             "$methodGroup:$methodArtifact"
231                         }
232                     val where = "from within the same library ($name)"
233                     reportRestriction(where, containingClass, member, context, node, usageInfo)
234                 }
235             } else if (member !is PsiCompiledElement) {
236                 // If the resolved method is source, make sure they're part
237                 // of the same Gradle project
238                 val project = context.evaluator.getProject(member)
239                 if (project != null && project != context.project) {
240                     val coordinates = project.mavenCoordinate
241                     val name =
242                         if (coordinates != null) {
243                             "${coordinates.groupId}:${coordinates.artifactId}"
244                         } else {
245                             project.name
246                         }
247                     val where = "from within the same library ($name)"
248                     reportRestriction(where, containingClass, member, context, node, usageInfo)
249                 }
250             }
251         }
252 
253         if (scope and RESTRICT_TO_TESTS != 0) {
254             if (!isTestContext(context, node)) {
255                 reportRestriction("from tests", containingClass, member, context, node, usageInfo)
256             }
257         }
258 
259         if (scope and RESTRICT_TO_SUBCLASSES != 0) {
260             val qualifiedName = containingClass.qualifiedName
261             if (qualifiedName != null) {
262                 val evaluator = context.evaluator
263 
264                 var outer: UClass?
265                 var isSubClass = false
266                 var prev = node
267 
268                 while (true) {
269                     outer = prev.getParentOfType(UClass::class.java, true)
270                     if (outer == null) {
271                         break
272                     }
273                     if (evaluator.inheritsFrom(outer, qualifiedName, false)) {
274                         isSubClass = true
275                         break
276                     }
277 
278                     if (evaluator.isStatic(outer)) {
279                         break
280                     }
281                     prev = outer
282                 }
283 
284                 if (!isSubClass) {
285                     reportRestriction(
286                         "from subclasses",
287                         containingClass,
288                         member,
289                         context,
290                         node,
291                         usageInfo
292                     )
293                 }
294             }
295         }
296     }
297 
reportRestrictionnull298     private fun reportRestriction(
299         where: String?,
300         containingClass: PsiClass,
301         member: PsiMember?,
302         context: JavaContext,
303         node: UElement,
304         usageInfo: AnnotationUsageInfo
305     ) {
306         var api: String
307         api =
308             if (member == null || member is PsiMethod && member.isConstructor) {
309                 member?.name ?: (containingClass.name + " constructor")
310             } else
311             //noinspection LintImplPsiEquals
312             if (containingClass == member) {
313                 member.name ?: "class"
314             } else {
315                 containingClass.name + "." + member.name
316             }
317 
318         var locationNode = node
319         if (node is UCallExpression) {
320             val nameElement = node.methodIdentifier
321             if (nameElement != null) {
322                 locationNode = nameElement
323             }
324 
325             // If the annotation was reported on the class, and the left hand side
326             // expression is that class, use it as the name node?
327             val annotation = usageInfo.annotations[usageInfo.index]
328             val annotated = annotation.annotated
329             //noinspection LintImplPsiEquals
330             if (where == null && annotated is PsiClass && annotated != usageInfo.referenced) {
331                 val qualifier = node.receiver
332                 val className = annotated.name
333                 if (
334                     qualifier != null &&
335                         className != null &&
336                         qualifier.asSourceString() == className
337                 ) {
338                     locationNode = qualifier
339                     api = className
340                 }
341             }
342         }
343 
344         // If this error message changes, you need to also update
345         // ResourceTypeInspection#guessLintIssue
346         var message: String
347         if (where == null) {
348             message = "$api is marked as internal and should not be accessed from apps"
349         } else {
350             val refType = if (member is PsiMethod) "called" else "accessed"
351             message = "$api can only be $refType $where"
352 
353             // Most users will encounter this for the support library; let's have a clearer error
354             // message
355             // for that specific scenario
356             if (where == "from within the same library (groupId=com.android.support)") {
357                 // If this error message changes, you need to also update
358                 // ResourceTypeInspection#guessLintIssue
359                 message =
360                     "This API is marked as internal to the support library and should not be " +
361                         "accessed from apps"
362             }
363         }
364 
365         val location =
366             if (locationNode is UCallExpression) {
367                 context.getCallLocation(
368                     locationNode,
369                     includeReceiver = false,
370                     includeArguments = false
371                 )
372             } else {
373                 context.getLocation(locationNode)
374             }
375         report(context, RESTRICTED, node, location, message, null)
376     }
377 
378     companion object {
379         private val IMPLEMENTATION =
380             Implementation(RestrictToDetector::class.java, Scope.JAVA_FILE_SCOPE)
381 
382         private const val RESTRICT_TO_ANNOTATION = "androidx.annotation.RestrictTo"
383         private const val VISIBLE_FOR_TESTING_SUFFIX = ".VisibleForTesting"
384 
385         /** `RestrictTo(RestrictTo.Scope.GROUP_ID` */
386         private const val RESTRICT_TO_LIBRARY_GROUP = 1 shl 0
387 
388         /** `RestrictTo(RestrictTo.Scope.GROUP_ID` */
389         private const val RESTRICT_TO_LIBRARY = 1 shl 1
390 
391         /** `RestrictTo(RestrictTo.Scope.GROUP_ID` */
392         private const val RESTRICT_TO_LIBRARY_GROUP_PREFIX = 1 shl 2
393 
394         /** `RestrictTo(RestrictTo.Scope.TESTS` */
395         private const val RESTRICT_TO_TESTS = 1 shl 3
396 
397         /** `RestrictTo(RestrictTo.Scope.SUBCLASSES` */
398         private const val RESTRICT_TO_SUBCLASSES = 1 shl 4
399 
getRestrictionScopenull400         private fun getRestrictionScope(annotation: UAnnotation): Int {
401             val value = annotation.findDeclaredAttributeValue(ATTR_VALUE)
402             if (value != null) {
403                 return getRestrictionScope(value, annotation)
404             }
405             return 0
406         }
407 
408         @Suppress("UnstableApiUsage") // JvmAnnotation.findAttribute()
getRestrictionScopenull409         private fun getRestrictionScope(expression: UExpression?, annotation: UAnnotation): Int {
410             var scope = 0
411             if (expression != null) {
412                 if (expression.isArrayInitializer()) {
413                     val initializerExpression = expression as UCallExpression?
414                     val initializers = initializerExpression!!.valueArguments
415                     for (initializer in initializers) {
416                         scope = scope or getRestrictionScope(initializer, annotation)
417                     }
418                 } else if (expression is UReferenceExpression) {
419                     val resolved = expression.resolve()
420                     if (resolved is PsiField) {
421                         val name = resolved.name
422                         scope =
423                             when (name) {
424                                 "GROUP_ID",
425                                 "LIBRARY_GROUP" -> RESTRICT_TO_LIBRARY_GROUP
426                                 "SUBCLASSES" -> RESTRICT_TO_SUBCLASSES
427                                 "TESTS" -> RESTRICT_TO_TESTS
428                                 "LIBRARY" -> RESTRICT_TO_LIBRARY
429                                 "LIBRARY_GROUP_PREFIX" -> RESTRICT_TO_LIBRARY_GROUP_PREFIX
430                                 else -> 0
431                             }
432                     }
433                 } else if (expression is UastEmptyExpression) {
434                     // See JavaUAnnotation.findDeclaredAttributeValue
435                     val psi = annotation.sourcePsi
436                     if (psi is ClsAnnotationImpl) {
437                         val otherwise = psi.findAttribute(ATTR_VALUE)
438                         val v = otherwise?.attributeValue
439                         if (v is JvmAnnotationConstantValue) {
440                             val constant = v.constantValue
441                             if (constant is Number) {
442                                 scope = constant.toInt()
443                             }
444                         }
445                     }
446                 }
447             }
448 
449             return scope
450         }
451 
452         /**
453          * Implements the group prefix equality that is described in the documentation for the
454          * RestrictTo.Scope.LIBRARY_GROUP_PREFIX enum constant.
455          */
sameLibraryGroupPrefixnull456         fun sameLibraryGroupPrefix(group1: String, group2: String): Boolean {
457             // TODO: Allow group1.startsWith(group2) || group2.startsWith(group1) ?
458 
459             if (group1 == group2) {
460                 return true
461             }
462 
463             // Implementation for AndroidX differs from the standard RestrictToDetector, since we
464             // treat LIBRARY_GROUP_PREFIX as anything in the androidx.* package. See b/297047524.
465             if (group1.startsWith(ANDROIDX_PREFIX) && group2.startsWith(ANDROIDX_PREFIX)) {
466                 return true
467             }
468 
469             val i1 = group1.lastIndexOf('.')
470             val i2 = group2.lastIndexOf('.')
471             if (i2 != i1 || i1 == -1) {
472                 return false
473             }
474 
475             return group1.regionMatches(0, group2, 0, i1)
476         }
477 
478         private const val ANDROIDX_PREFIX = "androidx."
479 
480         /** Using a restricted API. */
481         @JvmField
482         val RESTRICTED =
483             Issue.create(
484                 id = "RestrictedApiAndroidX",
485                 briefDescription = "Restricted API",
486                 explanation =
487                     """
488                 This API has been flagged with a restriction that has not been met.
489 
490                 Examples of API restrictions:
491                 * Method can only be invoked by a subclass
492                 * Method can only be accessed from within the same library (defined by the Gradle library group id)
493                 * Method can only be accessed from tests.
494 
495                 You can add your own API restrictions with the `@RestrictTo` annotation.""",
496                 category = Category.CORRECTNESS,
497                 priority = 4,
498                 severity = Severity.ERROR,
499                 implementation = IMPLEMENTATION
500             )
501     }
502 }
503 
504 /** Attempts to parse an unversioned Maven name from the library identifier. */
getMavenNameFromIdentifiernull505 internal fun LintModelLibrary.getMavenNameFromIdentifier(): LintModelMavenName? {
506     val indexOfSentinel = identifier.indexOf(":@@:")
507     if (indexOfSentinel < 0) return null
508 
509     // May be suffixed with something like ::debug.
510     val project = identifier.substring(indexOfSentinel + 4).substringBefore("::")
511     val indexOfLastSeparator = project.lastIndexOf(':')
512     if (indexOfLastSeparator < 0) return null
513 
514     val groupId = project.substring(0, indexOfLastSeparator).replace(':', '.')
515     val artifactId = project.substring(indexOfLastSeparator + 1)
516     return DefaultLintModelMavenName("androidx.$groupId", artifactId)
517 }
518