blob: 69311992477213ab2057fe5335bb357b85fee228 [file] [log] [blame]
/*
* Copyright (C) 2019. Uber Technologies
*
* 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.uber.nullaway.jarinfer;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import org.apache.commons.io.IOUtils;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AnnotationNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.MethodNode;
/** Annotates the given methods and method parameters with the specified annotations using ASM. */
public final class BytecodeAnnotator {
private static boolean debug = false;
private static void LOG(boolean cond, String tag, String msg) {
if (cond) {
System.out.println("[" + tag + "] " + msg);
}
}
public static final String javaxNullableDesc = "Ljavax/annotation/Nullable;";
public static final String javaxNonnullDesc = "Ljavax/annotation/Nonnull;";
// Consider android.support.annotation.* as a configuration option for older code?
public static final String androidNullableDesc = "Landroidx/annotation/Nullable;";
public static final String androidNonnullDesc = "Landroidx/annotation/NonNull;";
public static final ImmutableSet<String> NULLABLE_ANNOTATIONS =
ImmutableSet.of(
javaxNullableDesc,
androidNullableDesc,
// We don't support adding the annotations below, but they would still be redundant,
// specially when converted by tools which rewrite these sort of annotation (often
// to their androidx.* variant)
"Landroid/support/annotation/Nullable;",
"Lorg/jetbrains/annotations/Nullable;");
public static final ImmutableSet<String> NONNULL_ANNOTATIONS =
ImmutableSet.of(
javaxNonnullDesc,
androidNonnullDesc,
// See above
"Landroid/support/annotation/NonNull;",
"Lorg/jetbrains/annotations/NotNull;");
public static final Sets.SetView<String> NULLABILITY_ANNOTATIONS =
Sets.union(NULLABLE_ANNOTATIONS, NONNULL_ANNOTATIONS);
// Constants used for signed jar processing
private static final String SIGNED_JAR_ERROR_MESSAGE =
"JarInfer will not process signed jars by default. "
+ "Please take one of the following actions:\n"
+ "\t1) Remove the signature from the original jar before passing it to jarinfer,\n"
+ "\t2) Pass the --strip-jar-signatures flag to JarInfer and the tool will remove signature "
+ "metadata for you, or\n"
+ "\t3) Exclude this jar from those being processed by JarInfer.";
private static final String BASE64_PATTERN =
"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?";
private static final String DIGEST_ENTRY_PATTERN =
"Name: [A-Za-z0-9/\\$\\n\\s\\-\\.]+[A-Za-z0-9]\\nSHA-256-Digest: " + BASE64_PATTERN;
private static boolean annotationsShouldBeVisible(String nullableDesc) {
if (nullableDesc.equals(javaxNullableDesc)) {
return true;
} else if (nullableDesc.equals(androidNullableDesc)) {
return false;
} else {
throw new Error("Unknown nullness annotation visibility");
}
}
private static boolean listHasNullnessAnnotations(List<AnnotationNode> annotationList) {
if (annotationList != null) {
for (AnnotationNode node : annotationList) {
if (NULLABILITY_ANNOTATIONS.contains(node.desc)) {
return true;
}
}
}
return false;
}
/**
* Returns true if any part of this method already has @Nullable/@NonNull annotations, in which
* case we skip it, assuming that the developer already captured the desired spec.
*
* @param method The method node.
* @return true iff either the return or any parameter formal has a nullness annotation.
*/
private static boolean hasNullnessAnnotations(MethodNode method) {
if (listHasNullnessAnnotations(method.visibleAnnotations)
|| listHasNullnessAnnotations(method.invisibleAnnotations)) {
return true;
}
if (method.visibleParameterAnnotations != null) {
for (List<AnnotationNode> annotationList : method.visibleParameterAnnotations) {
if (listHasNullnessAnnotations(annotationList)) {
return true;
}
}
}
if (method.invisibleParameterAnnotations != null) {
for (List<AnnotationNode> annotationList : method.invisibleParameterAnnotations) {
if (listHasNullnessAnnotations(annotationList)) {
return true;
}
}
}
return false;
}
private static void annotateBytecode(
InputStream is,
OutputStream os,
MethodParamAnnotations nonnullParams,
MethodReturnAnnotations nullableReturns,
String nullableDesc,
String nonnullDesc)
throws IOException {
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(0);
ClassNode cn = new ClassNode(Opcodes.ASM7);
cr.accept(cn, 0);
String className = cn.name.replace('/', '.');
List<MethodNode> methods = cn.methods;
for (MethodNode method : methods) {
// Skip methods that already have nullability annotations anywhere in their signature
if (hasNullnessAnnotations(method)) {
continue;
}
boolean visible = annotationsShouldBeVisible(nullableDesc);
String methodSignature = className + "." + method.name + method.desc;
if (nullableReturns.contains(methodSignature)) {
// Add a @Nullable annotation on this method to indicate that the method can return null.
method.visitAnnotation(nullableDesc, visible);
LOG(debug, "DEBUG", "Added nullable return annotation for " + methodSignature);
}
Set<Integer> params = nonnullParams.get(methodSignature);
if (params != null) {
boolean isStatic = (method.access & Opcodes.ACC_STATIC) != 0;
for (Integer param : params) {
int paramNum = isStatic ? param : param - 1;
// Add a @Nonnull annotation on this parameter.
method.visitParameterAnnotation(paramNum, nonnullDesc, visible);
LOG(
debug,
"DEBUG",
"Added nonnull parameter annotation for #" + param + " in " + methodSignature);
}
}
}
cn.accept(cw);
os.write(cw.toByteArray());
}
/**
* Annotates the methods and method parameters in the given class with the specified annotations.
*
* @param is InputStream for the input class.
* @param os OutputStream for the output class.
* @param nonnullParams Map from methods to their nonnull params.
* @param nullableReturns List of methods that return nullable.
* @param debug flag to output debug logs.
* @throws IOException if an error happens when reading or writing to class streams.
*/
public static void annotateBytecodeInClass(
InputStream is,
OutputStream os,
MethodParamAnnotations nonnullParams,
MethodReturnAnnotations nullableReturns,
boolean debug)
throws IOException {
BytecodeAnnotator.debug = debug;
LOG(debug, "DEBUG", "nullableReturns: " + nullableReturns);
LOG(debug, "DEBUG", "nonnullParams: " + nonnullParams);
annotateBytecode(is, os, nonnullParams, nullableReturns, javaxNullableDesc, javaxNonnullDesc);
}
private static void copyAndAnnotateJarEntry(
JarEntry jarEntry,
InputStream is,
JarOutputStream jarOS,
MethodParamAnnotations nonnullParams,
MethodReturnAnnotations nullableReturns,
String nullableDesc,
String nonnullDesc,
boolean stripJarSignatures)
throws IOException {
String entryName = jarEntry.getName();
if (entryName.endsWith(".class")) {
jarOS.putNextEntry(new ZipEntry(jarEntry.getName()));
annotateBytecode(is, jarOS, nonnullParams, nullableReturns, nullableDesc, nonnullDesc);
} else if (entryName.equals("META-INF/MANIFEST.MF")) {
// Read full file
StringBuilder stringBuilder = new StringBuilder();
BufferedReader br = new BufferedReader(new InputStreamReader(is, UTF_8));
String currentLine;
while ((currentLine = br.readLine()) != null) {
stringBuilder.append(currentLine + "\n");
}
String manifestText = stringBuilder.toString();
// Check for evidence of jar signing, note that lines can be split if too long so regex
// matching line by line will have false negatives.
String manifestMinusDigests = manifestText.replaceAll(DIGEST_ENTRY_PATTERN, "");
if (!manifestText.equals(manifestMinusDigests) && !stripJarSignatures) {
throw new SignedJarException(SIGNED_JAR_ERROR_MESSAGE);
}
jarOS.putNextEntry(new ZipEntry(jarEntry.getName()));
jarOS.write(manifestMinusDigests.getBytes(UTF_8));
} else if (entryName.startsWith("META-INF/")
&& (entryName.endsWith(".DSA")
|| entryName.endsWith(".RSA")
|| entryName.endsWith(".SF"))) {
if (!stripJarSignatures) {
throw new SignedJarException(SIGNED_JAR_ERROR_MESSAGE);
} // the case where stripJarSignatures==true is handled by default by skipping these files
} else {
jarOS.putNextEntry(new ZipEntry(jarEntry.getName()));
jarOS.write(IOUtils.toByteArray(is));
}
jarOS.closeEntry();
}
/**
* Annotates the methods and method parameters in the classes in the given jar with the specified
* annotations.
*
* @param inputJar JarFile to annotate.
* @param jarOS OutputStream of the output jar file.
* @param nonnullParams Map from methods to their nonnull params.
* @param nullableReturns List of methods that return nullable.
* @param debug flag to output debug logs.
* @throws IOException if an error happens when reading or writing to jar or class streams.
*/
public static void annotateBytecodeInJar(
JarFile inputJar,
JarOutputStream jarOS,
MethodParamAnnotations nonnullParams,
MethodReturnAnnotations nullableReturns,
boolean stripJarSignatures,
boolean debug)
throws IOException {
BytecodeAnnotator.debug = debug;
LOG(debug, "DEBUG", "nullableReturns: " + nullableReturns);
LOG(debug, "DEBUG", "nonnullParams: " + nonnullParams);
// Do not use JarInputStream in place of JarFile/JarEntry. JarInputStream misses MANIFEST.MF
// while iterating over the entries in the stream.
// Reference: https://bugs.openjdk.java.net/browse/JDK-8215788
// Note: we can't just put the code below inside stream().forach(), because it can throw
// IOException.
for (JarEntry jarEntry : (Iterable<JarEntry>) inputJar.stream()::iterator) {
InputStream is = inputJar.getInputStream(jarEntry);
copyAndAnnotateJarEntry(
jarEntry,
is,
jarOS,
nonnullParams,
nullableReturns,
javaxNullableDesc,
javaxNonnullDesc,
stripJarSignatures);
}
}
/**
* Annotates the methods and method parameters in the classes in "classes.jar" in the given aar
* file with the specified annotations.
*
* @param inputZip AarFile to annotate.
* @param zipOS OutputStream of the output aar file.
* @param nonnullParams Map from methods to their nonnull params.
* @param nullableReturns List of methods that return nullable.
* @param debug flag to output debug logs.
* @throws IOException if an error happens when reading or writing to AAR/JAR/class streams.
*/
public static void annotateBytecodeInAar(
ZipFile inputZip,
ZipOutputStream zipOS,
MethodParamAnnotations nonnullParams,
MethodReturnAnnotations nullableReturns,
boolean stripJarSignatures,
boolean debug)
throws IOException {
BytecodeAnnotator.debug = debug;
LOG(debug, "DEBUG", "nullableReturns: " + nullableReturns);
LOG(debug, "DEBUG", "nonnullParams: " + nonnullParams);
// Error Prone doesn't like usages of the old Java Enumerator APIs. ZipFile does not implement
// Iterable, and likely never will (see https://bugs.openjdk.java.net/browse/JDK-6581715).
// Additionally, inputZip.stream() returns a Stream<? extends ZipEntry>, and a for-each loop
// has trouble handling the corresponding ::iterator method reference. So this seems like the
// best remaining way:
Iterator<? extends ZipEntry> zipIterator = inputZip.stream().iterator();
while (zipIterator.hasNext()) {
ZipEntry zipEntry = zipIterator.next();
InputStream is = inputZip.getInputStream(zipEntry);
zipOS.putNextEntry(new ZipEntry(zipEntry.getName()));
if (zipEntry.getName().equals("classes.jar")) {
JarInputStream jarIS = new JarInputStream(is);
JarEntry inputJarEntry = jarIS.getNextJarEntry();
ByteArrayOutputStream byteArrayOS = new ByteArrayOutputStream();
JarOutputStream jarOS = new JarOutputStream(byteArrayOS);
while (inputJarEntry != null) {
copyAndAnnotateJarEntry(
inputJarEntry,
jarIS,
jarOS,
nonnullParams,
nullableReturns,
androidNullableDesc,
androidNonnullDesc,
stripJarSignatures);
inputJarEntry = jarIS.getNextJarEntry();
}
jarOS.flush();
jarOS.close();
zipOS.write(byteArrayOS.toByteArray());
} else {
zipOS.write(IOUtils.toByteArray(is));
}
zipOS.closeEntry();
}
}
}