/*
* (C) Copyright Uwe Schindler (Generics Policeman) and others.
* Parts of this work are licensed to the Apache Software Foundation (ASF)
* under one or more contributor license agreements.
*
* 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 de.thetaphi.forbiddenapis;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.Method;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.File;
import java.io.StringReader;
import java.net.JarURLConnection;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NavigableSet;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.lang.annotation.Annotation;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
/**
* Forbidden APIs checker class.
*/
public final class Checker implements RelatedClassLookup, Constants {
public static enum Option {
FAIL_ON_MISSING_CLASSES,
FAIL_ON_VIOLATION,
FAIL_ON_UNRESOLVABLE_SIGNATURES,
DISABLE_CLASSLOADING_CACHE
}
public final boolean isSupportedJDK;
private final long start;
private final NavigableSet<String> runtimePaths;
final Logger logger;
final ClassLoader loader;
final java.lang.reflect.Method method_Class_getModule, method_Module_getName;
final EnumSet<Option> options;
// key is the binary name (dotted):
final Map<String,ClassSignature> classesToCheck = new HashMap<String,ClassSignature>();
// key is the binary name (dotted):
final Map<String,ClassSignature> classpathClassCache = new HashMap<String,ClassSignature>();
// if enabled, the bundled signature to enable heuristics for detection of non-portable runtime calls is used:
private boolean forbidNonPortableRuntime = false;
// key is the internal name (slashed), followed by \000 and the field name:
final Map<String,String> forbiddenFields = new HashMap<String,String>();
// key is the internal name (slashed), followed by \000 and the method signature:
final Map<String,String> forbiddenMethods = new HashMap<String,String>();
// key is the internal name (slashed):
final Map<String,String> forbiddenClasses = new HashMap<String,String>();
// set of patterns of forbidden classes:
final Set<ClassPatternRule> forbiddenClassPatterns = new LinkedHashSet<ClassPatternRule>();
// descriptors (not internal names) of all annotations that suppress:
final Set<String> suppressAnnotations = new LinkedHashSet<String>();
private static enum UnresolvableReporting {
FAIL() {
@Override
public void parseFailed(Logger logger, String message, String signature) throws ParseException {
throw new ParseException(String.format(Locale.ENGLISH, "%s while parsing signature: %s", message, signature));
}
},
WARNING() {
@Override
public void parseFailed(Logger logger, String message, String signature) throws ParseException {
logger.warn(String.format(Locale.ENGLISH, "%s while parsing signature: %s [signature ignored]", message, signature));
}
},
SILENT() {
@Override
public void parseFailed(Logger logger, String message, String signature) throws ParseException {
// keep silent
}
};
public abstract void parseFailed(Logger logger, String message, String signature) throws ParseException;
}
public Checker(Logger logger, ClassLoader loader, Option... options) {
this(logger, loader, (options.length == 0) ? EnumSet.noneOf(Option.class) : EnumSet.copyOf(Arrays.asList(options)));
}
public Checker(Logger logger, ClassLoader loader, EnumSet<Option> options) {
this.logger = logger;
this.loader = loader;
this.options = options;
this.start = System.currentTimeMillis();
// default (always available)
addSuppressAnnotation(SuppressForbidden.class);
boolean isSupportedJDK = false;
// Try to figure out entry points to Java 9 module system (Jigsaw)
// Please note: This code is not guaranteed to work with final Java 9 version. This is just for testing!
java.lang.reflect.Method method_Class_getModule, method_Module_getName;
try {
method_Class_getModule = Class.class.getMethod("getModule");
method_Module_getName = method_Class_getModule
.getReturnType().getMethod("getName");
isSupportedJDK = true;
} catch (NoSuchMethodException e) {
method_Class_getModule = method_Module_getName = null;
}
this.method_Class_getModule = method_Class_getModule;
this.method_Module_getName = method_Module_getName;
final NavigableSet<String> runtimePaths = new TreeSet<String>();
// fall back to legacy behavior:
if (!isSupportedJDK) {
try {
final URL objectClassURL = loader.getResource(AsmUtils.getClassResourceName(Object.class.getName()));
if (objectClassURL != null && "jrt".equalsIgnoreCase(objectClassURL.getProtocol())) {
// this is Java 9 allowing direct access to .class file resources - we do not need to deal with modules!
isSupportedJDK = true;
} else {
String javaHome = System.getProperty("java.home");
if (javaHome != null) {
javaHome = new File(javaHome).getCanonicalPath();
if (!javaHome.endsWith(File.separator)) {
javaHome += File.separator;
}
runtimePaths.add(javaHome);
}
// Scan the runtime's bootclasspath, too! This is needed because
// Apple's JDK 1.6 has the main rt.jar outside ${java.home}!
final RuntimeMXBean rb = ManagementFactory.getRuntimeMXBean();
if (rb.isBootClassPathSupported()) {
final String cp = rb.getBootClassPath();
final StringTokenizer st = new StringTokenizer(cp, File.pathSeparator);
while (st.hasMoreTokens()) {
File f = new File(st.nextToken().trim());
if (f.isFile()) {
f = f.getParentFile();
}
if (f.exists()) {
String fp = f.getCanonicalPath();
if (!fp.endsWith(File.separator)) {
fp += File.separator;
}
runtimePaths.add(fp);
}
}
}
isSupportedJDK = !runtimePaths.isEmpty();
if (!isSupportedJDK) {
logger.warn("Boot classpath appears to be empty or ${java.home} not defined; marking runtime as not suppported.");
}
}
} catch (IOException ioe) {
logger.warn("Cannot scan boot classpath and ${java.home} due to IO exception; marking runtime as not suppported: " + ioe);
isSupportedJDK = false;
runtimePaths.clear();
}
}
this.runtimePaths = runtimePaths;
// logger.info("Runtime paths: " + runtimePaths);
if (isSupportedJDK) {
try {
isSupportedJDK = getClassFromClassLoader(Object.class.getName()).isRuntimeClass;
if (!isSupportedJDK) {
logger.warn("Bytecode of java.lang.Object does not seem to come from runtime library; marking runtime as not suppported.");
}
} catch (IllegalArgumentException iae) {
logger.warn("Bundled version of ASM cannot parse bytecode of java.lang.Object class; marking runtime as not suppported.");
isSupportedJDK = false;
} catch (ClassNotFoundException cnfe) {
logger.warn("Bytecode or Class<?> instance of java.lang.Object not found; marking runtime as not suppported.");
isSupportedJDK = false;
} catch (IOException ioe) {
logger.warn("IOException while loading java.lang.Object class from classloader; marking runtime as not suppported: " + ioe);
isSupportedJDK = false;
}
}
// finally set the latest value to final field:
this.isSupportedJDK = isSupportedJDK;
}
/** Loads the class from Java9's module system and uses reflection to get methods and fields.
* <p>
* This code is not guaranteed to work with final Java 9 version.
* This is just for testing!
**/
private ClassSignature loadClassFromJigsaw(String classname) throws IOException {
if (method_Class_getModule == null || method_Module_getName == null) {
return null; // not Java 9 JIGSAW
}
final Class<?> clazz;
final String moduleName;
try {
clazz = Class.forName(classname, false, loader);
final Object module = method_Class_getModule.invoke(clazz);
moduleName = (String) method_Module_getName.invoke(module);
} catch (Exception e) {
return null; // not found
}
return new ClassSignature(clazz, AsmUtils.isRuntimeModule(moduleName));
}
private boolean isRuntimePath(URL url) throws IOException {
if (!"file".equalsIgnoreCase(url.getProtocol())) {
return false;
}
try {
final String path = new File(url.toURI()).getCanonicalPath();
final String lookup = runtimePaths.floor(path);
return lookup != null && path.startsWith(lookup);
} catch (URISyntaxException e) {
// should not happen, but if it's happening, it's definitely not a below our paths
return false;
}
}
private boolean isRuntimeClass(URLConnection conn) throws IOException {
final URL url = conn.getURL();
if (isRuntimePath(url)) {
return true;
} else if ("jar".equalsIgnoreCase(url.getProtocol()) && conn instanceof JarURLConnection) {
final URL jarUrl = ((JarURLConnection) conn).getJarFileURL();
return isRuntimePath(jarUrl);
} else if ("jrt".equalsIgnoreCase(url.getProtocol())) {
// all 'jrt:' URLs refer to a module in the Java 9+ runtime (see http://openjdk.java.net/jeps/220)
// This may still be different with module system. We support both variants for now.
// Please note: This code is not guaranteed to work with final Java 9 version. This is just for testing!
return AsmUtils.isRuntimeModule(AsmUtils.getModuleName(url));
}
return false;
}
/** Reads a class (binary name) from the given {@link ClassLoader}. If not found there, falls back to the list of classes to be checked. */
private ClassSignature getClassFromClassLoader(final String clazz) throws ClassNotFoundException,IOException {
final ClassSignature c;
if (classpathClassCache.containsKey(clazz)) {
c = classpathClassCache.get(clazz);
if (c == null) {
throw new ClassNotFoundException(clazz);
}
return c;
} else {
final URL url = loader.getResource(AsmUtils.getClassResourceName(clazz));
if (url != null) {
final URLConnection conn = url.openConnection();
final boolean isRuntimeClass = isRuntimeClass(conn);
if (!isRuntimeClass && options.contains(Option.DISABLE_CLASSLOADING_CACHE)) {
conn.setUseCaches(false);
}
final InputStream in = conn.getInputStream();
try {
classpathClassCache.put(clazz, c = new ClassSignature(AsmUtils.readAndPatchClass(in), isRuntimeClass, false));
} finally {
in.close();
}
return c;
} else {
final ClassSignature jigsawCl = loadClassFromJigsaw(clazz);
if (jigsawCl != null) {
classpathClassCache.put(clazz, c = jigsawCl);
return c;
}
}
// try to get class from our list of classes we are checking:
c = classesToCheck.get(clazz);
if (c != null) {
classpathClassCache.put(clazz, c);
return c;
}
// all failed => the class does not exist!
classpathClassCache.put(clazz, null);
throw new ClassNotFoundException(clazz);
}
}
@Override
public ClassSignature lookupRelatedClass(String internalName) {
final Type type = Type.getObjectType(internalName);
if (type.getSort() != Type.OBJECT) {
return null;
}
try {
// use binary name, so we need to convert:
return getClassFromClassLoader(type.getClassName());
} catch (ClassNotFoundException cnfe) {
if (options.contains(Option.FAIL_ON_MISSING_CLASSES)) {
throw new WrapperRuntimeException(cnfe);
} else {
logger.warn(String.format(Locale.ENGLISH,
"The referenced class '%s' cannot be loaded. Please fix the classpath!",
type.getClassName()
));
return null;
}
} catch (IOException ioe) {
throw new WrapperRuntimeException(ioe);
}
}
/** Adds the method signature to the list of disallowed methods. The Signature is checked against the given ClassLoader. */
private void addSignature(final String line, final String defaultMessage, final UnresolvableReporting report) throws ParseException,IOException {
final String clazz, field, signature;
String message = null;
final Method method;
int p = line.indexOf('@');
if (p >= 0) {
signature = line.substring(0, p).trim();
message = line.substring(p + 1).trim();
} else {
signature = line;
message = defaultMessage;
}
p = signature.indexOf('#');
if (p >= 0) {
clazz = signature.substring(0, p);
final String s = signature.substring(p + 1);
p = s.indexOf('(');
if (p >= 0) {
if (p == 0) {
throw new ParseException("Invalid method signature (method name missing): " + signature);
}
// we ignore the return type, its just to match easier (so return type is void):
try {
method = Method.getMethod("void " + s, true);
} catch (IllegalArgumentException iae) {
throw new ParseException("Invalid method signature: " + signature);
}
field = null;
} else {
field = s;
method = null;
}
} else {
clazz = signature;
method = null;
field = null;
}
if (message != null && message.isEmpty()) {
message = null;
}
// create printout message:
final String printout = (message != null) ? (signature + " [" + message + "]") : signature;
// check class & method/field signature, if it is really existent (in classpath), but we don't really load the class into JVM:
if (AsmUtils.isGlob(clazz)) {
if (method != null || field != null) {
throw new ParseException(String.format(Locale.ENGLISH, "Class level glob pattern cannot be combined with methods/fields: %s", signature));
}
forbiddenClassPatterns.add(new ClassPatternRule(clazz, message));
} else {
final ClassSignature c;
try {
c = getClassFromClassLoader(clazz);
} catch (ClassNotFoundException cnfe) {
report.parseFailed(logger, String.format(Locale.ENGLISH, "Class '%s' not found on classpath", cnfe.getMessage()), signature);
return;
}
if (method != null) {
assert field == null;
// list all methods with this signature:
boolean found = false;
for (final Method m : c.methods) {
if (m.getName().equals(method.getName()) && Arrays.equals(m.getArgumentTypes(), method.getArgumentTypes())) {
found = true;
forbiddenMethods.put(c.className + '\000' + m, printout);
// don't break when found, as there may be more covariant overrides!
}
}
if (!found) {
report.parseFailed(logger, "Method not found", signature);
return;
}
} else if (field != null) {
assert method == null;
if (!c.fields.contains(field)) {
report.parseFailed(logger, "Field not found", signature);
return;
}
forbiddenFields.put(c.className + '\000' + field, printout);
} else {
assert field == null && method == null;
// only add the signature as class name
forbiddenClasses.put(c.className, printout);
}
}
}
/** Reads a list of bundled API signatures from classpath. */
public void addBundledSignatures(String name, String jdkTargetVersion) throws IOException,ParseException {
addBundledSignatures(name, jdkTargetVersion, true);
}
public static String fixTargetVersion(String name) throws ParseException {
final Matcher m = JDK_SIG_PATTERN.matcher(name);
if (m.matches()) {
if (m.group(4) == null) {
// rewrite version number if it does not start with "1"
if ("1".equals(m.group(2)) && m.group(3) != null) {
return name;
} else {
if (".0".equals(m.group(3)) || m.group(3) == null) {
return m.group(1) + "1." + m.group(2);
}
}
}
throw new ParseException("Invalid bundled signature reference (JDK version is invalid): " + name);
}
return name;
}
private void addBundledSignatures(String name, String jdkTargetVersion, boolean logging) throws IOException,ParseException {
if (!name.matches("[A-Za-z0-9\\-\\.]+")) {
throw new ParseException("Invalid bundled signature reference: " + name);
}
if (BS_JDK_NONPORTABLE.equals(name)) {
if (logging) logger.info("Reading bundled API signatures: " + name);
forbidNonPortableRuntime = true;
return;
}
name = fixTargetVersion(name);
// use Checker.class hardcoded (not getClass) so we have a fixed package name:
InputStream in = Checker.class.getResourceAsStream("signatures/" + name + ".txt");
// automatically expand the compiler version in here (for jdk-* signatures without version):
if (in == null && jdkTargetVersion != null && name.startsWith("jdk-") && !name.matches(".*?\\-\\d\\.\\d")) {
name = name + "-" + jdkTargetVersion;
name = fixTargetVersion(name);
in = Checker.class.getResourceAsStream("signatures/" + name + ".txt");
}
if (in == null) {
throw new FileNotFoundException("Bundled signatures resource not found: " + name);
}
if (logging) logger.info("Reading bundled API signatures: " + name);
parseSignaturesFile(in, true);
}
/** Reads a list of API signatures. Closes the Reader when done (on Exception, too)! */
public void parseSignaturesFile(InputStream in, String name) throws IOException,ParseException {
logger.info("Reading API signatures: " + name);
parseSignaturesFile(in, false);
}
/** Reads a list of API signatures from the given URL. */
public void parseSignaturesFile(URL url) throws IOException,ParseException {
parseSignaturesFile(url.openStream(), url.toString());
}
/** Reads a list of API signatures from the given file. */
public void parseSignaturesFile(File f) throws IOException,ParseException {
parseSignaturesFile(new FileInputStream(f), f.toString());
}
/** Reads a list of API signatures from a String. */
public void parseSignaturesString(String signatures) throws IOException,ParseException {
logger.info("Reading inline API signatures...");
parseSignaturesFile(new StringReader(signatures), false);
}
private void parseSignaturesFile(InputStream in, boolean allowBundled) throws IOException,ParseException {
parseSignaturesFile(new InputStreamReader(in, "UTF-8"), allowBundled);
}
private static final String BUNDLED_PREFIX = "@includeBundled ";
private static final String DEFAULT_MESSAGE_PREFIX = "@defaultMessage ";
private static final String IGNORE_UNRESOLVABLE_LINE = "@ignoreUnresolvable";
private void parseSignaturesFile(Reader reader, boolean isBundled) throws IOException,ParseException {
final BufferedReader r = new BufferedReader(reader);
try {
String line, defaultMessage = null;
UnresolvableReporting reporter = options.contains(Option.FAIL_ON_UNRESOLVABLE_SIGNATURES) ? UnresolvableReporting.FAIL : UnresolvableReporting.WARNING;
while ((line = r.readLine()) != null) {
line = line.trim();
if (line.length() == 0 || line.startsWith("#"))
continue;
if (line.startsWith("@")) {
if (isBundled && line.startsWith(BUNDLED_PREFIX)) {
final String name = line.substring(BUNDLED_PREFIX.length()).trim();
addBundledSignatures(name, null, false);
} else if (line.startsWith(DEFAULT_MESSAGE_PREFIX)) {
defaultMessage = line.substring(DEFAULT_MESSAGE_PREFIX.length()).trim();
if (defaultMessage.length() == 0) defaultMessage = null;
} else if (line.equals(IGNORE_UNRESOLVABLE_LINE)) {
reporter = isBundled ? UnresolvableReporting.SILENT : UnresolvableReporting.WARNING;
} else {
throw new ParseException("Invalid line in signature file: " + line);
}
} else {
addSignature(line, defaultMessage, reporter);
}
}
} finally {
r.close();
}
}
/** Parses and adds a class from the given stream to the list of classes to check. Closes the stream when parsed (on Exception, too)! Does not log anything. */
public void addClassToCheck(final InputStream in) throws IOException {
final ClassReader reader;
try {
reader = AsmUtils.readAndPatchClass(in);
} finally {
in.close();
}
final String binaryName = Type.getObjectType(reader.getClassName()).getClassName();
classesToCheck.put(binaryName, new ClassSignature(reader, false, true));
}
/** Parses and adds a class from the given file to the list of classes to check. Does not log anything. */
public void addClassToCheck(File f) throws IOException {
addClassToCheck(new FileInputStream(f));
}
/** Parses and adds a multiple class files. */
public void addClassesToCheck(Iterable<File> files) throws IOException {
logger.info("Loading classes to check...");
for (final File f : files) {
addClassToCheck(f);
}
}
/** Parses and adds a multiple class files. */
public void addClassesToCheck(File... files) throws IOException {
addClassesToCheck(Arrays.asList(files));
}
/** Parses and adds a multiple class files. */
public void addClassesToCheck(File basedir, Iterable<String> relativeNames) throws IOException {
logger.info("Loading classes to check...");
for (final String f : relativeNames) {
addClassToCheck(new File(basedir, f));
}
}
/** Parses and adds a multiple class files. */
public void addClassesToCheck(File basedir, String... relativeNames) throws IOException {
addClassesToCheck(basedir, Arrays.asList(relativeNames));
}
public boolean hasNoSignatures() {
return 0 == forbiddenMethods.size() +
forbiddenFields.size() +
forbiddenClasses.size() +
forbiddenClassPatterns.size() +
(forbidNonPortableRuntime ? 1 : 0);
}
/** Adds the given annotation class for suppressing errors. */
public void addSuppressAnnotation(Class<? extends Annotation> anno) {
suppressAnnotations.add(anno.getName());
}
/** Adds suppressing annotation name in binary form (dotted). It may also be a glob pattern. The class name is not checked for existence. */
public void addSuppressAnnotation(String annoName) {
suppressAnnotations.add(annoName);
}
/** Parses a class and checks for valid method invocations */
private int checkClass(final ClassReader reader, Pattern suppressAnnotationsPattern) {
final String className = Type.getObjectType(reader.getClassName()).getClassName();
final ClassScanner scanner = new ClassScanner(this, forbiddenClasses, forbiddenClassPatterns, forbiddenMethods, forbiddenFields, suppressAnnotationsPattern, forbidNonPortableRuntime);
reader.accept(scanner, ClassReader.SKIP_FRAMES);
final List<ForbiddenViolation> violations = scanner.getSortedViolations();
final Pattern splitter = Pattern.compile(Pattern.quote(ForbiddenViolation.SEPARATOR));
for (final ForbiddenViolation v : violations) {
for (final String line : splitter.split(v.format(className, scanner.getSourceFile()))) {
logger.error(line);
}
}
return violations.size();
}
public void run() throws ForbiddenApiException {
logger.info("Scanning classes for violations...");
int errors = 0;
final Pattern suppressAnnotationsPattern = AsmUtils.glob2Pattern(suppressAnnotations.toArray(new String[suppressAnnotations.size()]));
try {
for (final ClassSignature c : classesToCheck.values()) {
errors += checkClass(c.getReader(), suppressAnnotationsPattern);
}
} catch (WrapperRuntimeException wre) {
final Throwable cause = wre.getCause();
if (cause != null) {
throw new ForbiddenApiException("Check for forbidden API calls failed: " + cause.toString(), cause);
} else {
throw new ForbiddenApiException("Check for forbidden API calls failed.");
}
}
final String message = String.format(Locale.ENGLISH,
"Scanned %d class file(s) for forbidden API invocations (in %.2fs), %d error(s).",
classesToCheck.size(), (System.currentTimeMillis() - start) / 1000.0, errors);
if (options.contains(Option.FAIL_ON_VIOLATION) && errors > 0) {
logger.error(message);
throw new ForbiddenApiException("Check for forbidden API calls failed, see log.");
} else {
logger.info(message);
}
}
}