1 /*
2  * Copyright (C) 2019. Uber Technologies
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 package com.uber.nullaway.jarinfer;
17 
18 import static java.nio.charset.StandardCharsets.UTF_8;
19 
20 import com.google.common.collect.ImmutableSet;
21 import com.google.common.collect.Sets;
22 import java.io.BufferedReader;
23 import java.io.ByteArrayOutputStream;
24 import java.io.IOException;
25 import java.io.InputStream;
26 import java.io.InputStreamReader;
27 import java.io.OutputStream;
28 import java.util.Iterator;
29 import java.util.List;
30 import java.util.Set;
31 import java.util.jar.JarEntry;
32 import java.util.jar.JarFile;
33 import java.util.jar.JarInputStream;
34 import java.util.jar.JarOutputStream;
35 import java.util.zip.ZipEntry;
36 import java.util.zip.ZipFile;
37 import java.util.zip.ZipOutputStream;
38 import org.apache.commons.io.IOUtils;
39 import org.objectweb.asm.ClassReader;
40 import org.objectweb.asm.ClassWriter;
41 import org.objectweb.asm.Opcodes;
42 import org.objectweb.asm.tree.AnnotationNode;
43 import org.objectweb.asm.tree.ClassNode;
44 import org.objectweb.asm.tree.MethodNode;
45 
46 /** Annotates the given methods and method parameters with the specified annotations using ASM. */
47 public final class BytecodeAnnotator {
48   private static boolean debug = false;
49 
LOG(boolean cond, String tag, String msg)50   private static void LOG(boolean cond, String tag, String msg) {
51     if (cond) {
52       System.out.println("[" + tag + "] " + msg);
53     }
54   }
55 
56   public static final String javaxNullableDesc = "Ljavax/annotation/Nullable;";
57   public static final String javaxNonnullDesc = "Ljavax/annotation/Nonnull;";
58   // Consider android.support.annotation.* as a configuration option for older code?
59   public static final String androidNullableDesc = "Landroidx/annotation/Nullable;";
60   public static final String androidNonnullDesc = "Landroidx/annotation/NonNull;";
61 
62   public static final ImmutableSet<String> NULLABLE_ANNOTATIONS =
63       ImmutableSet.of(
64           javaxNullableDesc,
65           androidNullableDesc,
66           // We don't support adding the annotations below, but they would still be redundant,
67           // specially when converted by tools which rewrite these sort of annotation (often
68           // to their androidx.* variant)
69           "Landroid/support/annotation/Nullable;",
70           "Lorg/jetbrains/annotations/Nullable;");
71 
72   public static final ImmutableSet<String> NONNULL_ANNOTATIONS =
73       ImmutableSet.of(
74           javaxNonnullDesc,
75           androidNonnullDesc,
76           // See above
77           "Landroid/support/annotation/NonNull;",
78           "Lorg/jetbrains/annotations/NotNull;");
79 
80   public static final Sets.SetView<String> NULLABILITY_ANNOTATIONS =
81       Sets.union(NULLABLE_ANNOTATIONS, NONNULL_ANNOTATIONS);
82 
83   // Constants used for signed jar processing
84   private static final String SIGNED_JAR_ERROR_MESSAGE =
85       "JarInfer will not process signed jars by default. "
86           + "Please take one of the following actions:\n"
87           + "\t1) Remove the signature from the original jar before passing it to jarinfer,\n"
88           + "\t2) Pass the --strip-jar-signatures flag to JarInfer and the tool will remove signature "
89           + "metadata for you, or\n"
90           + "\t3) Exclude this jar from those being processed by JarInfer.";
91   private static final String BASE64_PATTERN =
92       "(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?";
93   private static final String DIGEST_ENTRY_PATTERN =
94       "Name: [A-Za-z0-9/\\$\\n\\s\\-\\.]+[A-Za-z0-9]\\nSHA-256-Digest: " + BASE64_PATTERN;
95 
annotationsShouldBeVisible(String nullableDesc)96   private static boolean annotationsShouldBeVisible(String nullableDesc) {
97     if (nullableDesc.equals(javaxNullableDesc)) {
98       return true;
99     } else if (nullableDesc.equals(androidNullableDesc)) {
100       return false;
101     } else {
102       throw new Error("Unknown nullness annotation visibility");
103     }
104   }
105 
listHasNullnessAnnotations(List<AnnotationNode> annotationList)106   private static boolean listHasNullnessAnnotations(List<AnnotationNode> annotationList) {
107     if (annotationList != null) {
108       for (AnnotationNode node : annotationList) {
109         if (NULLABILITY_ANNOTATIONS.contains(node.desc)) {
110           return true;
111         }
112       }
113     }
114     return false;
115   }
116 
117   /**
118    * Returns true if any part of this method already has @Nullable/@NonNull annotations, in which
119    * case we skip it, assuming that the developer already captured the desired spec.
120    *
121    * @param method The method node.
122    * @return true iff either the return or any parameter formal has a nullness annotation.
123    */
hasNullnessAnnotations(MethodNode method)124   private static boolean hasNullnessAnnotations(MethodNode method) {
125     if (listHasNullnessAnnotations(method.visibleAnnotations)
126         || listHasNullnessAnnotations(method.invisibleAnnotations)) {
127       return true;
128     }
129     if (method.visibleParameterAnnotations != null) {
130       for (List<AnnotationNode> annotationList : method.visibleParameterAnnotations) {
131         if (listHasNullnessAnnotations(annotationList)) {
132           return true;
133         }
134       }
135     }
136     if (method.invisibleParameterAnnotations != null) {
137       for (List<AnnotationNode> annotationList : method.invisibleParameterAnnotations) {
138         if (listHasNullnessAnnotations(annotationList)) {
139           return true;
140         }
141       }
142     }
143     return false;
144   }
145 
annotateBytecode( InputStream is, OutputStream os, MethodParamAnnotations nonnullParams, MethodReturnAnnotations nullableReturns, String nullableDesc, String nonnullDesc)146   private static void annotateBytecode(
147       InputStream is,
148       OutputStream os,
149       MethodParamAnnotations nonnullParams,
150       MethodReturnAnnotations nullableReturns,
151       String nullableDesc,
152       String nonnullDesc)
153       throws IOException {
154     ClassReader cr = new ClassReader(is);
155     ClassWriter cw = new ClassWriter(0);
156     ClassNode cn = new ClassNode(Opcodes.ASM9);
157     cr.accept(cn, 0);
158 
159     String className = cn.name.replace('/', '.');
160     List<MethodNode> methods = cn.methods;
161     for (MethodNode method : methods) {
162       // Skip methods that already have nullability annotations anywhere in their signature
163       if (hasNullnessAnnotations(method)) {
164         continue;
165       }
166       boolean visible = annotationsShouldBeVisible(nullableDesc);
167       String methodSignature = className + "." + method.name + method.desc;
168       if (nullableReturns.contains(methodSignature)) {
169         // Add a @Nullable annotation on this method to indicate that the method can return null.
170         method.visitAnnotation(nullableDesc, visible);
171         LOG(debug, "DEBUG", "Added nullable return annotation for " + methodSignature);
172       }
173       Set<Integer> params = nonnullParams.get(methodSignature);
174       if (params != null) {
175         boolean isStatic = (method.access & Opcodes.ACC_STATIC) != 0;
176         for (Integer param : params) {
177           int paramNum = isStatic ? param : param - 1;
178           // Add a @Nonnull annotation on this parameter.
179           method.visitParameterAnnotation(paramNum, nonnullDesc, visible);
180           LOG(
181               debug,
182               "DEBUG",
183               "Added nonnull parameter annotation for #" + param + " in " + methodSignature);
184         }
185       }
186     }
187 
188     cn.accept(cw);
189     os.write(cw.toByteArray());
190   }
191 
192   /**
193    * Annotates the methods and method parameters in the given class with the specified annotations.
194    *
195    * @param is InputStream for the input class.
196    * @param os OutputStream for the output class.
197    * @param nonnullParams Map from methods to their nonnull params.
198    * @param nullableReturns List of methods that return nullable.
199    * @param debug flag to output debug logs.
200    * @throws IOException if an error happens when reading or writing to class streams.
201    */
annotateBytecodeInClass( InputStream is, OutputStream os, MethodParamAnnotations nonnullParams, MethodReturnAnnotations nullableReturns, boolean debug)202   public static void annotateBytecodeInClass(
203       InputStream is,
204       OutputStream os,
205       MethodParamAnnotations nonnullParams,
206       MethodReturnAnnotations nullableReturns,
207       boolean debug)
208       throws IOException {
209     BytecodeAnnotator.debug = debug;
210     LOG(debug, "DEBUG", "nullableReturns: " + nullableReturns);
211     LOG(debug, "DEBUG", "nonnullParams: " + nonnullParams);
212     annotateBytecode(is, os, nonnullParams, nullableReturns, javaxNullableDesc, javaxNonnullDesc);
213   }
214 
215   /**
216    * Create a zip entry with creation time of 0 to ensure that jars always have the same checksum.
217    *
218    * @param name of the zip entry.
219    * @return the zip entry.
220    */
createZipEntry(String name)221   private static ZipEntry createZipEntry(String name) {
222     ZipEntry entry = new ZipEntry(name);
223     entry.setTime(0);
224     return entry;
225   }
226 
copyAndAnnotateJarEntry( JarEntry jarEntry, InputStream is, JarOutputStream jarOS, MethodParamAnnotations nonnullParams, MethodReturnAnnotations nullableReturns, String nullableDesc, String nonnullDesc, boolean stripJarSignatures)227   private static void copyAndAnnotateJarEntry(
228       JarEntry jarEntry,
229       InputStream is,
230       JarOutputStream jarOS,
231       MethodParamAnnotations nonnullParams,
232       MethodReturnAnnotations nullableReturns,
233       String nullableDesc,
234       String nonnullDesc,
235       boolean stripJarSignatures)
236       throws IOException {
237     String entryName = jarEntry.getName();
238     if (entryName.endsWith(".class")) {
239       jarOS.putNextEntry(createZipEntry(jarEntry.getName()));
240       annotateBytecode(is, jarOS, nonnullParams, nullableReturns, nullableDesc, nonnullDesc);
241     } else if (entryName.equals("META-INF/MANIFEST.MF")) {
242       // Read full file
243       StringBuilder stringBuilder = new StringBuilder();
244       BufferedReader br = new BufferedReader(new InputStreamReader(is, UTF_8));
245       String currentLine;
246       while ((currentLine = br.readLine()) != null) {
247         stringBuilder.append(currentLine + "\n");
248       }
249       String manifestText = stringBuilder.toString();
250       // Check for evidence of jar signing, note that lines can be split if too long so regex
251       // matching line by line will have false negatives.
252       String manifestMinusDigests = manifestText.replaceAll(DIGEST_ENTRY_PATTERN, "");
253       if (!manifestText.equals(manifestMinusDigests) && !stripJarSignatures) {
254         throw new SignedJarException(SIGNED_JAR_ERROR_MESSAGE);
255       }
256       jarOS.putNextEntry(createZipEntry(jarEntry.getName()));
257       jarOS.write(manifestMinusDigests.getBytes(UTF_8));
258     } else if (entryName.startsWith("META-INF/")
259         && (entryName.endsWith(".DSA")
260             || entryName.endsWith(".RSA")
261             || entryName.endsWith(".SF"))) {
262       if (!stripJarSignatures) {
263         throw new SignedJarException(SIGNED_JAR_ERROR_MESSAGE);
264       } // the case where stripJarSignatures==true is handled by default by skipping these files
265     } else {
266       jarOS.putNextEntry(createZipEntry(jarEntry.getName()));
267       jarOS.write(IOUtils.toByteArray(is));
268     }
269     jarOS.closeEntry();
270   }
271 
272   /**
273    * Annotates the methods and method parameters in the classes in the given jar with the specified
274    * annotations.
275    *
276    * @param inputJar JarFile to annotate.
277    * @param jarOS OutputStream of the output jar file.
278    * @param nonnullParams Map from methods to their nonnull params.
279    * @param nullableReturns List of methods that return nullable.
280    * @param debug flag to output debug logs.
281    * @throws IOException if an error happens when reading or writing to jar or class streams.
282    */
annotateBytecodeInJar( JarFile inputJar, JarOutputStream jarOS, MethodParamAnnotations nonnullParams, MethodReturnAnnotations nullableReturns, boolean stripJarSignatures, boolean debug)283   public static void annotateBytecodeInJar(
284       JarFile inputJar,
285       JarOutputStream jarOS,
286       MethodParamAnnotations nonnullParams,
287       MethodReturnAnnotations nullableReturns,
288       boolean stripJarSignatures,
289       boolean debug)
290       throws IOException {
291     BytecodeAnnotator.debug = debug;
292     LOG(debug, "DEBUG", "nullableReturns: " + nullableReturns);
293     LOG(debug, "DEBUG", "nonnullParams: " + nonnullParams);
294     // Do not use JarInputStream in place of JarFile/JarEntry. JarInputStream misses MANIFEST.MF
295     // while iterating over the entries in the stream.
296     // Reference: https://bugs.openjdk.java.net/browse/JDK-8215788
297     // Note: we can't just put the code below inside stream().forach(), because it can throw
298     // IOException.
299     for (JarEntry jarEntry : (Iterable<JarEntry>) inputJar.stream()::iterator) {
300       InputStream is = inputJar.getInputStream(jarEntry);
301       copyAndAnnotateJarEntry(
302           jarEntry,
303           is,
304           jarOS,
305           nonnullParams,
306           nullableReturns,
307           javaxNullableDesc,
308           javaxNonnullDesc,
309           stripJarSignatures);
310     }
311   }
312 
313   /**
314    * Annotates the methods and method parameters in the classes in "classes.jar" in the given aar
315    * file with the specified annotations.
316    *
317    * @param inputZip AarFile to annotate.
318    * @param zipOS OutputStream of the output aar file.
319    * @param nonnullParams Map from methods to their nonnull params.
320    * @param nullableReturns List of methods that return nullable.
321    * @param debug flag to output debug logs.
322    * @throws IOException if an error happens when reading or writing to AAR/JAR/class streams.
323    */
annotateBytecodeInAar( ZipFile inputZip, ZipOutputStream zipOS, MethodParamAnnotations nonnullParams, MethodReturnAnnotations nullableReturns, boolean stripJarSignatures, boolean debug)324   public static void annotateBytecodeInAar(
325       ZipFile inputZip,
326       ZipOutputStream zipOS,
327       MethodParamAnnotations nonnullParams,
328       MethodReturnAnnotations nullableReturns,
329       boolean stripJarSignatures,
330       boolean debug)
331       throws IOException {
332     BytecodeAnnotator.debug = debug;
333     LOG(debug, "DEBUG", "nullableReturns: " + nullableReturns);
334     LOG(debug, "DEBUG", "nonnullParams: " + nonnullParams);
335     // Error Prone doesn't like usages of the old Java Enumerator APIs. ZipFile does not implement
336     // Iterable, and likely never will (see  https://bugs.openjdk.java.net/browse/JDK-6581715).
337     // Additionally, inputZip.stream() returns a Stream<? extends ZipEntry>, and a for-each loop
338     // has trouble handling the corresponding ::iterator  method reference. So this seems like the
339     // best remaining way:
340     Iterator<? extends ZipEntry> zipIterator = inputZip.stream().iterator();
341     while (zipIterator.hasNext()) {
342       ZipEntry zipEntry = zipIterator.next();
343       InputStream is = inputZip.getInputStream(zipEntry);
344       zipOS.putNextEntry(createZipEntry(zipEntry.getName()));
345       if (zipEntry.getName().equals("classes.jar")) {
346         JarInputStream jarIS = new JarInputStream(is);
347         JarEntry inputJarEntry = jarIS.getNextJarEntry();
348 
349         ByteArrayOutputStream byteArrayOS = new ByteArrayOutputStream();
350         JarOutputStream jarOS = new JarOutputStream(byteArrayOS);
351         while (inputJarEntry != null) {
352           copyAndAnnotateJarEntry(
353               inputJarEntry,
354               jarIS,
355               jarOS,
356               nonnullParams,
357               nullableReturns,
358               androidNullableDesc,
359               androidNonnullDesc,
360               stripJarSignatures);
361           inputJarEntry = jarIS.getNextJarEntry();
362         }
363         jarOS.flush();
364         jarOS.close();
365         zipOS.write(byteArrayOS.toByteArray());
366       } else {
367         zipOS.write(IOUtils.toByteArray(is));
368       }
369       zipOS.closeEntry();
370     }
371   }
372 }
373