blob: 5150ca17ab50cd18ed566501095f499499aa8fb0 [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.lint.checks
import com.android.SdkConstants.ATTR_VALUE
import com.android.tools.lint.checks.AnnotationDetector.GMS_HIDE_ANNOTATION
import com.android.tools.lint.checks.AnnotationDetector.GUAVA_VISIBLE_FOR_TESTING
import com.android.tools.lint.checks.AnnotationDetector.RESTRICT_TO_ANNOTATION
import com.android.tools.lint.checks.AnnotationDetector.VISIBLE_FOR_TESTING_ANNOTATION
import com.android.tools.lint.detector.api.AnnotationUsageType
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Location
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import com.android.tools.lint.detector.api.SourceCodeScanner
import com.android.tools.lint.detector.api.UastLintUtils
import com.android.tools.lint.detector.api.UastLintUtils.Companion.containsAnnotation
import com.android.tools.lint.detector.api.isKotlin
import com.intellij.lang.jvm.annotation.JvmAnnotationConstantValue
import com.intellij.psi.PsiClass
import com.intellij.psi.PsiCompiledElement
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiField
import com.intellij.psi.PsiMember
import com.intellij.psi.PsiMethod
import com.intellij.psi.impl.compiled.ClsAnnotationImpl
import com.intellij.psi.util.PsiTypesUtil
import org.jetbrains.uast.UAnnotated
import org.jetbrains.uast.UAnnotation
import org.jetbrains.uast.UCallExpression
import org.jetbrains.uast.UClass
import org.jetbrains.uast.UElement
import org.jetbrains.uast.UExpression
import org.jetbrains.uast.ULiteralExpression
import org.jetbrains.uast.UReferenceExpression
import org.jetbrains.uast.UTypeReferenceExpression
import org.jetbrains.uast.UastEmptyExpression
import org.jetbrains.uast.getContainingUFile
import org.jetbrains.uast.getParentOfType
import org.jetbrains.uast.util.isArrayInitializer
class RestrictToDetector : AbstractAnnotationDetector(), SourceCodeScanner {
override fun applicableAnnotations(): List<String> = listOf(
RESTRICT_TO_ANNOTATION.oldName(),
RESTRICT_TO_ANNOTATION.newName(),
GMS_HIDE_ANNOTATION,
VISIBLE_FOR_TESTING_ANNOTATION.oldName(),
VISIBLE_FOR_TESTING_ANNOTATION.newName(),
GUAVA_VISIBLE_FOR_TESTING
)
override fun inheritAnnotation(annotation: String): Boolean {
// Require restriction annotations to be annotated everywhere
return false
}
override fun visitAnnotationUsage(
context: JavaContext,
usage: UElement,
type: AnnotationUsageType,
annotation: UAnnotation,
qualifiedName: String,
method: PsiMethod?,
referenced: PsiElement?,
annotations: List<UAnnotation>,
allMemberAnnotations: List<UAnnotation>,
allClassAnnotations: List<UAnnotation>,
allPackageAnnotations: List<UAnnotation>
) {
if (type == AnnotationUsageType.EXTENDS && usage is UTypeReferenceExpression) {
// If it's a constructor reference we don't need to also check the type
// reference. Ideally we'd do a "parent is KtConstructorCalleeExpression"
// here, but that points to impl classes in its hierarchy which leads to
// class loading trouble.
val sourcePsi = usage.sourcePsi
if (isKotlin(sourcePsi) && sourcePsi?.parent?.toString() == "CONSTRUCTOR_CALLEE") {
return
}
}
val member = method ?: referenced as? PsiMember
when (qualifiedName) {
RESTRICT_TO_ANNOTATION.oldName(), RESTRICT_TO_ANNOTATION.newName() -> {
checkRestrictTo(
context, usage, member, annotation, allMemberAnnotations,
allClassAnnotations, true
)
}
GMS_HIDE_ANNOTATION -> {
val isConstructor = method == null || method.isConstructor
val isStatic = if (method == null) false else context.evaluator.isStatic(method)
checkRestrictTo(
context, usage, method, annotation, allMemberAnnotations,
allClassAnnotations, isConstructor || isStatic
)
}
VISIBLE_FOR_TESTING_ANNOTATION.oldName(), VISIBLE_FOR_TESTING_ANNOTATION.newName(),
GUAVA_VISIBLE_FOR_TESTING -> {
if (member != null) {
checkVisibleForTesting(
context,
usage,
member,
annotation,
allMemberAnnotations,
allClassAnnotations
)
}
}
}
}
// Checks whether the client code is in the GMS codebase; if so, allow @Hide calls
// there
private fun isGmsContext(
context: JavaContext,
element: UElement
): Boolean {
val evaluator = context.evaluator
val pkg = evaluator.getPackage(element) ?: return false
val qualifiedName = pkg.qualifiedName
if (!qualifiedName.startsWith("com.google.")) {
return false
}
return qualifiedName.startsWith("com.google.firebase") ||
qualifiedName.startsWith("com.google.android.gms") ||
qualifiedName.startsWith("com.google.ads") ||
qualifiedName.startsWith("com.google.mlkit")
}
private fun isTestContext(
context: JavaContext,
element: UElement
): Boolean {
var current = element
// (1) Is this compilation unit in a test source path?
if (context.isTestSource) {
return true
}
// (2) Is this AST node surrounded by a test-only annotation?
while (true) {
val owner = current.getParentOfType<UAnnotated>(true) ?: break
//noinspection AndroidLintExternalAnnotations
for (annotation in owner.uAnnotations) {
val name = annotation.qualifiedName
if (RESTRICT_TO_ANNOTATION.isEquals(name)) {
val restrictionScope = getRestrictionScope(annotation)
if (restrictionScope and RESTRICT_TO_TESTS != 0) {
return true
}
} else if (VISIBLE_FOR_TESTING_ANNOTATION.isEquals(name)) {
return true
}
}
current = owner
}
return false
}
private fun checkVisibleForTesting(
context: JavaContext,
node: UElement,
method: PsiMember,
annotation: UAnnotation,
allMethodAnnotations: List<UAnnotation>,
allClassAnnotations: List<UAnnotation>
) {
val visibility = getVisibilityForTesting(annotation)
if (visibility == VISIBILITY_NONE) { // not the default
checkRestrictTo(
context, node, method, annotation, allMethodAnnotations,
allClassAnnotations, RESTRICT_TO_TESTS
)
} else {
// Check that the target method is available
// (1) private is available in the same compilation unit
// (2) package private is available in the same package
// (3) protected is available either from subclasses or in same package
val uFile = node.getContainingUFile()
val containingFile1 = UastLintUtils.getPsiFile(uFile)
val containingFile2 = UastLintUtils.getContainingFile(method)
if (containingFile1 == containingFile2 || containingFile2 == null) {
// Same compilation unit
return
}
// Sanity check (since Kotlin UAST creates several light classes around
// PSI files that sometimes fail equality tests)
if (containingFile1?.virtualFile == containingFile2.virtualFile) {
return
}
if (visibility == VISIBILITY_PRIVATE) {
if (!isTestContext(context, node)) {
reportVisibilityError(context, node, "private")
}
return
}
val evaluator = context.evaluator
val pkg = evaluator.getPackage(node)
val methodPackage = evaluator.getPackage(method)
// can't compare pkg == methodPackage because PsiPackageImpl#equals only returns
// true for other instances of the exact same class
if (pkg?.qualifiedName == methodPackage?.qualifiedName) {
// Same package
return
}
if (visibility == VISIBILITY_PACKAGE_PRIVATE) {
if (!isTestContext(context, node)) {
reportVisibilityError(context, node, "package private")
}
return
}
assert(visibility == VISIBILITY_PROTECTED)
val methodClass = method.containingClass
val thisClass = node.getParentOfType<UClass>(UClass::class.java, true)
if (thisClass == null || methodClass == null) {
return
}
val qualifiedName = methodClass.qualifiedName
if (qualifiedName == null || evaluator.inheritsFrom(
thisClass,
qualifiedName,
false
)
) {
return
}
if (!isTestContext(context, node)) {
reportVisibilityError(context, node, "protected")
}
}
}
private fun reportVisibilityError(
context: JavaContext,
node: UElement,
desc: String
) {
val message = "This method should only be accessed from tests or within $desc scope"
val location: Location = if (node is UCallExpression) {
context.getCallLocation(node, false, false)
} else {
context.getLocation(node)
}
report(context, TEST_VISIBILITY, node, location, message)
}
// TODO: Test XML access of restricted classes
private fun checkRestrictTo(
context: JavaContext,
node: UElement,
method: PsiMember?,
annotation: UAnnotation,
allMethodAnnotations: List<UAnnotation>,
allClassAnnotations: List<UAnnotation>,
applyClassAnnotationsToMembers: Boolean = true
) {
val scope = getRestrictionScope(annotation)
if (scope != 0) {
checkRestrictTo(
context, node, method, annotation, allMethodAnnotations,
allClassAnnotations, scope, applyClassAnnotationsToMembers
)
}
}
private fun checkRestrictTo(
context: JavaContext,
node: UElement,
member: PsiMember?,
annotation: UAnnotation,
allMethodAnnotations: List<UAnnotation>,
allClassAnnotations: List<UAnnotation>,
scope: Int,
applyClassAnnotationsToMembers: Boolean = true
) {
val containingClass = when {
node is UTypeReferenceExpression -> PsiTypesUtil.getPsiClass(node.type)
member != null -> member.containingClass
node is UCallExpression -> node.classReference?.resolve() as PsiClass?
node is PsiClass -> node
else -> null
}
containingClass ?: return
var isClassAnnotation = false
if (containsAnnotation(allMethodAnnotations, annotation)) {
// Make sure that the annotation is *not* inherited.
// For example, NavigationView (a public, exposed class) extends ScrimInsetsFrameLayout, which
// is a restricted class. We don't want to make all uses of NavigationView to suddenly be
// treated as Restricted just because it inherits code from a restricted API.
if (member != null && context.evaluator.isInherited(annotation, member)) {
return
}
} else if (applyClassAnnotationsToMembers) {
// Found restriction or class or package: make sure we only check on the most
// specific scope, otherwise we report the same error multiple times
// or report errors on restrictions that have been redefined
if (containsRestrictionAnnotation(allMethodAnnotations)) {
return
}
isClassAnnotation = containsAnnotation(allClassAnnotations, annotation)
if (isClassAnnotation) {
if (context.evaluator.isInherited(annotation, containingClass)) {
return
}
} else if (containsRestrictionAnnotation(allClassAnnotations)) {
return
}
} else { // not in member annotations and applyClassAnnotationToMembers is false.
return
}
if (scope and RESTRICT_TO_LIBRARY_GROUP != 0 && member != null) {
val evaluator = context.evaluator
val thisCoordinates = evaluator.getLibrary(node) ?: context.project.mavenCoordinate
val methodCoordinates = evaluator.getLibrary(member) ?: run {
if (thisCoordinates != null && member !is PsiCompiledElement) {
// Local source?
context.evaluator.getProject(member)?.mavenCoordinate
} else {
null
}
}
val thisGroup = thisCoordinates?.groupId
val methodGroup = methodCoordinates?.groupId
if (thisGroup != methodGroup && methodGroup != null) {
val where = "from within the same library group (groupId=$methodGroup)"
reportRestriction(
where, containingClass, member, context,
node, isClassAnnotation
)
}
} else if (scope and RESTRICT_TO_LIBRARY_GROUP_PREFIX != 0 && member != null) {
val evaluator = context.evaluator
val thisCoordinates = evaluator.getLibrary(node) ?: context.project.mavenCoordinate
val methodCoordinates = evaluator.getLibrary(member) ?: run {
if (thisCoordinates != null && member !is PsiCompiledElement) {
// Local source?
context.evaluator.getProject(member)?.mavenCoordinate
} else {
null
}
}
val thisGroup = thisCoordinates?.groupId
val methodGroup = methodCoordinates?.groupId
if (methodGroup != null &&
(thisGroup == null || !sameLibraryGroupPrefix(thisGroup, methodGroup))
) {
val expectedPrefix = methodGroup.substring(0, methodGroup.lastIndexOf('.'))
val where =
"from within the same library group prefix (referenced groupId=`$methodGroup` with prefix $expectedPrefix${if (thisGroup != null) " from groupId=`$thisGroup`" else ""})"
reportRestriction(
where, containingClass, member, context,
node, isClassAnnotation
)
}
} else if (scope and RESTRICT_TO_LIBRARY != 0 && member != null) {
val evaluator = context.evaluator
val thisCoordinates = evaluator.getLibrary(node) ?: context.project.mavenCoordinate
val methodCoordinates = evaluator.getLibrary(member)
val thisGroup = thisCoordinates?.groupId
val methodGroup = methodCoordinates?.groupId
if (thisGroup != methodGroup && methodGroup != null) {
val thisArtifact = thisCoordinates?.artifactId
val methodArtifact = methodCoordinates.artifactId
if (thisArtifact != methodArtifact) {
val where = "from within the same library ($methodGroup:$methodArtifact)"
reportRestriction(
where, containingClass, member, context,
node, isClassAnnotation
)
}
} else if (member !is PsiCompiledElement) {
// If the resolved method is source, make sure they're part
// of the same Gradle project
val project = context.evaluator.getProject(member)
if (project != null && project != context.project) {
val coordinates = project.mavenCoordinate
val name = if (coordinates != null) {
"${coordinates.groupId}:${coordinates.artifactId}"
} else {
project.name
}
val where = "from within the same library ($name)"
reportRestriction(
where, containingClass, member, context,
node, isClassAnnotation
)
}
}
}
if (scope and RESTRICT_TO_TESTS != 0) {
if (!isTestContext(context, node)) {
reportRestriction(
"from tests", containingClass, member, context,
node, isClassAnnotation
)
}
}
if (scope and RESTRICT_TO_ALL != 0) {
if (!isGmsContext(context, node)) {
reportRestriction(
null, containingClass, member, context,
node, isClassAnnotation
)
}
}
if (scope and RESTRICT_TO_SUBCLASSES != 0) {
val qualifiedName = containingClass.qualifiedName
if (qualifiedName != null) {
val evaluator = context.evaluator
var outer: UClass?
var isSubClass = false
var prev = node
while (true) {
outer = prev.getParentOfType(UClass::class.java, true)
if (outer == null) {
break
}
if (evaluator.inheritsFrom(outer, qualifiedName, false)) {
isSubClass = true
break
}
if (evaluator.isStatic(outer)) {
break
}
prev = outer
}
if (!isSubClass) {
reportRestriction(
"from subclasses", containingClass, member,
context, node, isClassAnnotation
)
}
}
}
}
private fun reportRestriction(
where: String?,
containingClass: PsiClass,
member: PsiMember?,
context: JavaContext,
node: UElement,
isClassAnnotation: Boolean
) {
var api: String
api = if (member == null || member is PsiMethod && member.isConstructor) {
member?.name ?: containingClass.name + " constructor"
} else if (containingClass == member) {
member.name ?: "class"
} else {
containingClass.name + "." + member.name
}
var locationNode = node
if (node is UCallExpression) {
val nameElement = node.methodIdentifier
if (nameElement != null) {
locationNode = nameElement
}
// If the annotation was reported on the class, and the left hand side
// expression is that class, use it as the name node?
if (isClassAnnotation) {
val qualifier = node.receiver
val className = containingClass.name
if (qualifier != null && className != null && qualifier.asSourceString() == className) {
locationNode = qualifier
api = className
}
}
}
// If this error message changes, you need to also update ResourceTypeInspection#guessLintIssue
var message: String
if (where == null) {
message = "$api is marked as internal and should not be accessed from apps"
} else {
val refType = if (member is PsiMethod) "called" else "accessed"
message = "$api can only be $refType $where"
// Most users will encounter this for the support library; let's have a clearer error message
// for that specific scenario
if (where == "from within the same library (groupId=com.android.support)") {
// If this error message changes, you need to also update ResourceTypeInspection#guessLintIssue
message =
"This API is marked as internal to the support library and should not be accessed from apps"
}
}
val location: Location
location = if (locationNode is UCallExpression) {
context.getCallLocation(locationNode, false, false)
} else {
context.getLocation(locationNode)
}
report(context, RESTRICTED, node, location, message, null)
}
private fun containsRestrictionAnnotation(list: List<UAnnotation>): Boolean {
return containsAnnotation(
list,
RESTRICT_TO_ANNOTATION.oldName()
) || containsAnnotation(list, RESTRICT_TO_ANNOTATION.newName())
}
companion object {
private val IMPLEMENTATION = Implementation(
RestrictToDetector::class.java,
Scope.JAVA_FILE_SCOPE
)
private const val ATTR_OTHERWISE = "otherwise"
private const val ATTR_PRODUCTION_VISIBILITY = "productionVisibility"
// Must match constants in @VisibleForTesting:
private const val VISIBILITY_PRIVATE = 2
private const val VISIBILITY_PACKAGE_PRIVATE = 3
private const val VISIBILITY_PROTECTED = 4
private const val VISIBILITY_NONE = 5
// TODO: Kotlin "module" visibility
private fun getVisibilityForTesting(annotation: UAnnotation): Int {
val value = annotation.findDeclaredAttributeValue(ATTR_OTHERWISE)
// Guava within Google3:
?: annotation.findDeclaredAttributeValue(ATTR_PRODUCTION_VISIBILITY)
if (value is ULiteralExpression) {
val v = value.value
if (v is Int) {
return (v as Int?)!!
}
} else if (value is UReferenceExpression) {
// Not compiled; this is unlikely (but can happen when editing the support
// library project itself)
val name = value.resolvedName
when (name) {
"NONE" -> return VISIBILITY_NONE
"PRIVATE" -> return VISIBILITY_PRIVATE
"PROTECTED" -> return VISIBILITY_PROTECTED
"PACKAGE_PRIVATE" -> return VISIBILITY_PACKAGE_PRIVATE
}
} else if (value is UastEmptyExpression) {
// Some kind of error in UAST; try harder. JavaUAnnotation is used to wrap
// class file annotations and in findDeclaredAttributeValue it returns
// UastEmptyExpression if it cannot convert it to UAST.
val psi = annotation.sourcePsi
if (psi is ClsAnnotationImpl) {
val otherwise = psi.findAttribute(ATTR_OTHERWISE)
?: psi.findAttribute(ATTR_PRODUCTION_VISIBILITY)
val v = otherwise?.attributeValue
if (v is JvmAnnotationConstantValue) {
val constant = v.constantValue
if (constant is Number) {
return constant.toInt()
}
}
}
}
return VISIBILITY_PRIVATE // the default
}
/** `RestrictTo(RestrictTo.Scope.GROUP_ID` */
private const val RESTRICT_TO_LIBRARY_GROUP = 1 shl 0
/** `RestrictTo(RestrictTo.Scope.GROUP_ID` */
private const val RESTRICT_TO_LIBRARY = 1 shl 1
/** `RestrictTo(RestrictTo.Scope.GROUP_ID` */
private const val RESTRICT_TO_LIBRARY_GROUP_PREFIX = 1 shl 2
/** `RestrictTo(RestrictTo.Scope.TESTS` */
private const val RESTRICT_TO_TESTS = 1 shl 3
/** `RestrictTo(RestrictTo.Scope.SUBCLASSES` */
private const val RESTRICT_TO_SUBCLASSES = 1 shl 4
private const val RESTRICT_TO_ALL = 1 shl 5
private fun getRestrictionScope(annotation: UAnnotation): Int {
val value = annotation.findDeclaredAttributeValue(ATTR_VALUE)
if (value != null) {
return getRestrictionScope(value, annotation)
} else if (GMS_HIDE_ANNOTATION == annotation.qualifiedName) {
return RESTRICT_TO_ALL
}
return 0
}
private fun getRestrictionScope(expression: UExpression?, annotation: UAnnotation): Int {
var scope = 0
if (expression != null) {
if (expression.isArrayInitializer()) {
val initializerExpression = expression as UCallExpression?
val initializers = initializerExpression!!.valueArguments
for (initializer in initializers) {
scope = scope or getRestrictionScope(initializer, annotation)
}
} else if (expression is UReferenceExpression) {
val resolved = expression.resolve()
if (resolved is PsiField) {
val name = resolved.name
if ("GROUP_ID" == name || "LIBRARY_GROUP" == name) {
scope = scope or RESTRICT_TO_LIBRARY_GROUP
} else if ("SUBCLASSES" == name) {
scope = scope or RESTRICT_TO_SUBCLASSES
} else if ("TESTS" == name) {
scope = scope or RESTRICT_TO_TESTS
} else if ("LIBRARY" == name) {
scope = scope or RESTRICT_TO_LIBRARY
} else if ("LIBRARY_GROUP_PREFIX" == name) {
scope = scope or RESTRICT_TO_LIBRARY_GROUP_PREFIX
}
}
} else if (expression is UastEmptyExpression) {
// See JavaUAnnotation.findDeclaredAttributeValue
val psi = annotation.sourcePsi
if (psi is ClsAnnotationImpl) {
val otherwise = psi.findAttribute(ATTR_VALUE)
val v = otherwise?.attributeValue
if (v is JvmAnnotationConstantValue) {
val constant = v.constantValue
if (constant is Number) {
scope = scope or constant.toInt()
}
}
}
}
}
return scope
}
/**
* Implements the group prefix equality that is described in the documentation
* for the RestrictTo.Scope.LIBRARY_GROUP_PREFIX enum constant
*/
fun sameLibraryGroupPrefix(group1: String, group2: String): Boolean {
// TODO: Allow group1.startsWith(group2) || group2.startsWith(group1) ?
if (group1 == group2) {
return true
}
val i1 = group1.lastIndexOf('.')
val i2 = group2.lastIndexOf('.')
if (i2 != i1 || i1 == -1) {
return false
}
return group1.regionMatches(0, group2, 0, i1)
}
/** Using a restricted API */
@JvmField
val RESTRICTED = Issue.create(
id = "RestrictedApi",
briefDescription = "Restricted API",
explanation = """
This API has been flagged with a restriction that has not been met.
Examples of API restrictions:
* Method can only be invoked by a subclass
* Method can only be accessed from within the same library (defined by the Gradle library group id)
* Method can only be accessed from tests.
You can add your own API restrictions with the `@RestrictTo` annotation.""",
category = Category.CORRECTNESS,
priority = 4,
severity = Severity.ERROR,
implementation = IMPLEMENTATION
)
/** Using an intended-for-tests API */
@JvmField
val TEST_VISIBILITY = Issue.create(
id = "VisibleForTests",
briefDescription = "Visible Only For Tests",
explanation = """
With the `@VisibleForTesting` annotation you can specify an `otherwise=` \
attribute which specifies the intended visibility if the method had not \
been made more widely visible for the tests.
This check looks for accesses from production code (e.g. not tests) where \
the access would not have been allowed with the intended production \
visibility.""",
category = Category.CORRECTNESS,
priority = 4,
severity = Severity.WARNING,
implementation = IMPLEMENTATION
)
}
}