diff options
8 files changed, 592 insertions, 0 deletions
diff --git a/abi-check-plugin/pom.xml b/abi-check-plugin/pom.xml new file mode 100644 index 00000000000..5e3a13629f2 --- /dev/null +++ b/abi-check-plugin/pom.xml @@ -0,0 +1,67 @@ +<?xml version="1.0"?> +<!-- Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. --> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>com.yahoo.vespa</groupId> + <artifactId>parent</artifactId> + <version>6-SNAPSHOT</version> + <relativePath>../parent/pom.xml</relativePath> + </parent> + <artifactId>abi-check-plugin</artifactId> + <version>6-SNAPSHOT</version> + <packaging>maven-plugin</packaging> + <name>${project.artifactId}</name> + <description>Maven Plugin for ensuring ABI stability.</description> + <prerequisites> + <maven>2.2.1</maven> + </prerequisites> + <dependencies> + <dependency> + <groupId>org.apache.maven</groupId> + <artifactId>maven-plugin-api</artifactId> + <version>3.5.0</version> + </dependency> + <dependency> + <groupId>org.apache.maven.plugin-tools</groupId> + <artifactId>maven-plugin-annotations</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.apache.maven.reporting</groupId> + <artifactId>maven-reporting-impl</artifactId> + <version>3.0.0</version> + </dependency> + <dependency> + <groupId>org.apache.maven.reporting</groupId> + <artifactId>maven-reporting-api</artifactId> + <version>3.0</version> + </dependency> + <dependency> + <groupId>org.ow2.asm</groupId> + <artifactId>asm</artifactId> + <version>7.0</version> + </dependency> + <dependency> + <groupId>com.google.code.gson</groupId> + <artifactId>gson</artifactId> + <version>2.8.5</version> + </dependency> + </dependencies> + <build> + <finalName>${project.artifactId}</finalName> + <resources> + <resource> + <directory>src/main/resources</directory> + <filtering>true</filtering> + </resource> + </resources> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-plugin-plugin</artifactId> + </plugin> + </plugins> + </build> +</project> diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java new file mode 100644 index 00000000000..8a2e6d609de --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java @@ -0,0 +1,123 @@ +package com.yahoo.abicheck.classtree; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Collection; +import java.util.Deque; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +public abstract class ClassFileTree implements AutoCloseable { + + public static ClassFileTree fromJar(File file) throws IOException { + Map<String, Package> rootPackages = new HashMap<>(); + JarFile jarFile = new JarFile(file); + + Enumeration<JarEntry> jarEntries = jarFile.entries(); + while (jarEntries.hasMoreElements()) { + JarEntry entry = jarEntries.nextElement(); + if (!entry.isDirectory() && entry.getName().endsWith(".class")) { + Deque<String> parts = new ArrayDeque<>(Arrays.asList(entry.getName().split("/"))); + String className = parts.removeLast(); + Package pkg = rootPackages + .computeIfAbsent(parts.removeFirst(), name -> new Package(null, name)); + for (String part : parts) { + pkg = pkg.getOrCreateSubPackage(part); + } + pkg.addClass(new Class(pkg, className) { + + @Override + public InputStream getInputStream() throws IOException { + return jarFile.getInputStream(entry); + } + }); + } + } + + return new ClassFileTree() { + @Override + public Collection<Package> getRootPackages() { + return rootPackages.values(); + } + + @Override + public void close() throws IOException { + jarFile.close(); + } + }; + } + + public abstract Collection<Package> getRootPackages(); + + public static abstract class Class { + + private final Package parent; + private final String name; + + private Class(Package parent, String name) { + this.parent = parent; + this.name = name; + } + + public abstract InputStream getInputStream() throws IOException; + + public String getName() { + return name; + } + + @Override + public String toString() { + return "Class(" + parent.getFullyQualifiedName() + "." + name + ")"; + } + } + + public static class Package { + + private final Package parent; + private final String name; + private final Map<String, Package> subPackages = new HashMap<>(); + private final Set<Class> classes = new HashSet<>(); + + private Package(Package parent, String name) { + this.parent = parent; + this.name = name; + } + + private Package getOrCreateSubPackage(String name) { + return subPackages.computeIfAbsent(name, n -> new Package(this, n)); + } + + private void addClass(Class klazz) { + classes.add(klazz); + } + + public String getFullyQualifiedName() { + if (parent == null) { + return name; + } else { + return parent.getFullyQualifiedName() + "." + name; + } + } + + public Collection<Package> getSubPackages() { + return subPackages.values(); + } + + public Collection<Class> getClasses() { + return classes; + } + + @Override + public String toString() { + return "Package(" + getFullyQualifiedName() + ")"; + } + } +} diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/AnnotationCollector.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/AnnotationCollector.java new file mode 100644 index 00000000000..5be66a4fbdd --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/AnnotationCollector.java @@ -0,0 +1,27 @@ +package com.yahoo.abicheck.collector; + +import java.util.HashSet; +import java.util.Set; +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +public class AnnotationCollector extends ClassVisitor { + + private final Set<String> annotations = new HashSet<>(); + + public AnnotationCollector() { + super(Opcodes.ASM6); + } + + @Override + public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + annotations.add(Type.getType(descriptor).getClassName()); + return null; + } + + public Set<String> getAnnotations() { + return annotations; + } +} diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java new file mode 100644 index 00000000000..4fee85c823a --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java @@ -0,0 +1,85 @@ +package com.yahoo.abicheck.collector; + +import com.yahoo.abicheck.signature.JavaClassSignature; +import com.yahoo.abicheck.signature.JavaMethodSignature; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +public class PublicSignatureCollector extends ClassVisitor { + + private final Map<String, JavaClassSignature> classSignatures = new LinkedHashMap<>(); + + private String currentName; + private int currentAccess; + private Map<String, JavaMethodSignature> currentMethods; + + public PublicSignatureCollector() { + super(Opcodes.ASM6); + } + + private static String methodNameWithArguments(String name, List<String> argumentTypes) { + return String.format("%s(%s)", name, String.join(", ", argumentTypes)); + } + + private static boolean testBit(long access, long mask) { + return (access & mask) != 0; + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, + String[] interfaces) { + currentName = Type.getObjectType(name).getClassName(); + currentAccess = access; + currentMethods = new LinkedHashMap<>(); + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + if (isVisibleMethod(access)) { + Type method = Type.getMethodType(descriptor); + List<String> argumentTypes = Arrays.stream(method.getArgumentTypes()).map(Type::getClassName) + .collect(Collectors.toList()); + name = methodNameWithArguments(name, argumentTypes); + currentMethods.put(name, + new JavaMethodSignature(Util.convertAccess(access, Util.methodFlags), + method.getReturnType().getClassName())); + } + return null; + } + + private boolean isVisibleMethod(int access) { + // Public methods are visible + if (testBit(access, Opcodes.ACC_PUBLIC)) { + return true; + } + // Protected non-static methods are visible if the class is not final (can be called from + // extending classes) + if (!testBit(access, Opcodes.ACC_STATIC) && testBit(access, Opcodes.ACC_PROTECTED) && !testBit( + currentAccess, Opcodes.ACC_FINAL)) { + return true; + } + // Otherwise not visible + return false; + } + + @Override + public void visitEnd() { + if ((currentAccess & Opcodes.ACC_PUBLIC) != 0) { + classSignatures.put(currentName, + new JavaClassSignature(Util.convertAccess(currentAccess, Util.classFlags), + currentMethods)); + } + } + + public Map<String, JavaClassSignature> getClassSignatures() { + return classSignatures; + } +} diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java new file mode 100644 index 00000000000..24c1b52aa04 --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java @@ -0,0 +1,68 @@ +package com.yahoo.abicheck.collector; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.objectweb.asm.Opcodes; + +public class Util { + + public static final List<AccessFlag> classFlags = Arrays.asList( + AccessFlag.make(Opcodes.ACC_PUBLIC, "public"), + AccessFlag.make(Opcodes.ACC_PRIVATE, "private"), + AccessFlag.make(Opcodes.ACC_PROTECTED, "protected"), + AccessFlag.make(Opcodes.ACC_FINAL, "final"), + AccessFlag.make(Opcodes.ACC_SUPER, null), // Ignored, always set by modern Java + AccessFlag.make(Opcodes.ACC_INTERFACE, "interface"), + AccessFlag.make(Opcodes.ACC_ABSTRACT, "abstract"), + AccessFlag.make(Opcodes.ACC_SYNTHETIC, "synthetic"), // FIXME: Do we want this? + AccessFlag.make(Opcodes.ACC_ANNOTATION, "annotation"), + AccessFlag.make(Opcodes.ACC_ENUM, "enum") +// FIXME: Module support +// AccessFlag.make(Opcodes.ACC_MODULE, "module") + ); + + public static final List<AccessFlag> methodFlags = Arrays.asList( + AccessFlag.make(Opcodes.ACC_PUBLIC, "public"), + AccessFlag.make(Opcodes.ACC_PRIVATE, "private"), + AccessFlag.make(Opcodes.ACC_PROTECTED, "protected"), + AccessFlag.make(Opcodes.ACC_STATIC, "static"), + AccessFlag.make(Opcodes.ACC_FINAL, "final"), + AccessFlag.make(Opcodes.ACC_SYNCHRONIZED, "synchronized"), + AccessFlag.make(Opcodes.ACC_BRIDGE, "bridge"), + AccessFlag.make(Opcodes.ACC_VARARGS, "varargs"), // FIXME: Do we want this? + AccessFlag.make(Opcodes.ACC_NATIVE, "native"), + AccessFlag.make(Opcodes.ACC_ABSTRACT, "abstract"), + AccessFlag.make(Opcodes.ACC_STRICT, "strict"), // FIXME: Do we want this? + AccessFlag.make(Opcodes.ACC_SYNTHETIC, "synthetic") // FIXME: Do we want this? + ); + + public static List<String> convertAccess(int access, List<AccessFlag> flags) { + List<String> result = new ArrayList<>(); + for (AccessFlag flag : flags) { + if ((access & flag.bit) != 0 && flag.attribute != null) { + result.add(flag.attribute); + } + access &= ~flag.bit; + } + if (access != 0) { + throw new IllegalArgumentException(String.format("Unexpected access bits: 0x%x", access)); + } + return result; + } + + private static class AccessFlag { + + public final int bit; + public final String attribute; + + private AccessFlag(int bit, String attribute) { + this.bit = bit; + this.attribute = attribute; + } + + private static AccessFlag make(int bit, String attribute) { + return new AccessFlag(bit, attribute); + } + } +} diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java new file mode 100644 index 00000000000..d1707f4bf28 --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java @@ -0,0 +1,191 @@ +package com.yahoo.abicheck.mojo; + +import com.google.common.collect.Sets; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.yahoo.abicheck.classtree.ClassFileTree; +import com.yahoo.abicheck.classtree.ClassFileTree.Class; +import com.yahoo.abicheck.classtree.ClassFileTree.Package; +import com.yahoo.abicheck.collector.AnnotationCollector; +import com.yahoo.abicheck.collector.PublicSignatureCollector; +import com.yahoo.abicheck.signature.JavaClassSignature; +import com.yahoo.abicheck.signature.JavaMethodSignature; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import org.objectweb.asm.ClassReader; + +@Mojo( + name = "abicheck", + defaultPhase = LifecyclePhase.PACKAGE, + requiresDependencyResolution = ResolutionScope.RUNTIME +) +public class AbiCheck extends AbstractMojo { + + private static final String DEFAULT_SPEC_FILE = "abi-spec.json"; + private static final String WRITE_SPEC_PROPERTY = "abicheck.writeSpec"; + public static final String PACKAGE_INFO_CLASS_FILE_NAME = "package-info.class"; + + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project = null; + + @Parameter(required = true) + private String publicApiAnnotation = null; + + @Parameter + private String specFileName = DEFAULT_SPEC_FILE; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + Artifact mainArtifact = project.getArtifact(); + if (mainArtifact.getFile() == null) { + throw new MojoExecutionException("Missing project artifact file"); + } else if (!mainArtifact.getType().equals("jar")) { + throw new MojoExecutionException("Project artifact is not a JAR"); + } + + getLog().debug("Analyzing " + mainArtifact.getFile()); + + try { + ClassFileTree tree = ClassFileTree.fromJar(mainArtifact.getFile()); + Map<String, JavaClassSignature> signatures = new LinkedHashMap<>(); + for (ClassFileTree.Package pkg : tree.getRootPackages()) { + signatures.putAll(collectPublicAbiSignatures(pkg)); + } + if (System.getProperty(WRITE_SPEC_PROPERTY) != null) { + getLog().info("Writing ABI specs to " + specFileName); + writeSpec(signatures); + } else { + Gson gson = new GsonBuilder().create(); + try (FileReader reader = new FileReader(specFileName)) { + TypeToken<Map<String, JavaClassSignature>> typeToken = + new TypeToken<Map<String, JavaClassSignature>>() { + }; + Map<String, JavaClassSignature> abiSpec = gson + .fromJson(reader, typeToken.getType()); + if (!matchingItemSets(abiSpec.keySet(), signatures.keySet(), + item -> matchingClasses(item, abiSpec.get(item), signatures.get(item)), + (item, error) -> getLog() + .error(String.format("%s class: %s", capitalizeFirst(error), item)))) { + throw new MojoFailureException("ABI spec mismatch"); + } + } + } + } catch (IOException e) { + throw new MojoExecutionException("Error processing class signatures", e); + } + } + + private static String capitalizeFirst(String s) { + return s.substring(0, 1).toUpperCase() + s.substring(1); + } + + private void writeSpec(Map<String, JavaClassSignature> publicAbiSignatures) throws IOException { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + try (FileWriter writer = new FileWriter(specFileName)) { + gson.toJson(publicAbiSignatures, writer); + } + } + + private static <T> boolean matchingItemSets(Set<T> expected, Set<T> actual, + Predicate<T> itemsMatch, BiConsumer<T, String> onError) { + boolean mismatch = false; + Set<T> missing = Sets.difference(expected, actual); + for (T name : missing) { + mismatch = true; + onError.accept(name, "missing"); + } + Set<T> extra = Sets.difference(actual, expected); + for (T name : extra) { + mismatch = true; + onError.accept(name, "extra"); + } + Set<T> both = Sets.intersection(actual, expected); + for (T name : both) { + if (!itemsMatch.test(name)) { + mismatch = true; + } + } + return !mismatch; + } + + private boolean matchingClasses(String className, JavaClassSignature expected, + JavaClassSignature actual) { + boolean match = true; + if (!matchingItemSets(new HashSet<>(expected.attributes), new HashSet<>(actual.attributes), + item -> true, (item, error) -> getLog().error(String + .format("Class %s: %s attribute %s", className, capitalizeFirst(error), item)))) { + match = false; + } + if (!matchingItemSets(expected.methods.keySet(), actual.methods.keySet(), + item -> matchingMethods(className + "." + item, expected.methods.get(item), + actual.methods.get(item)), + (item, error) -> getLog().error( + String.format("Class %s: %s method %s", className, capitalizeFirst(error), item)))) { + match = false; + } + return match; + } + + private boolean matchingMethods(String methodName, JavaMethodSignature expected, + JavaMethodSignature actual) { + boolean match = true; + if (!expected.returnType.equals(actual.returnType)) { + match = false; + getLog().error(String + .format("Method %s: Expected return type %s, found %s", methodName, expected.returnType, + actual.returnType)); + } + return match; + } + + private boolean isPublicAbiPackage(ClassFileTree.Package pkg) throws IOException { + Optional<Class> pkgInfo = pkg.getClasses().stream() + .filter(klazz -> klazz.getName().equals(PACKAGE_INFO_CLASS_FILE_NAME)).findFirst(); + if (!pkgInfo.isPresent()) { + return false; + } + try (InputStream is = pkgInfo.get().getInputStream()) { + AnnotationCollector visitor = new AnnotationCollector(); + new ClassReader(is).accept(visitor, + ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return visitor.getAnnotations().contains(publicApiAnnotation); + } + } + + private Map<String, JavaClassSignature> collectPublicAbiSignatures(Package pkg) + throws IOException { + Map<String, JavaClassSignature> signatures = new LinkedHashMap<>(); + if (isPublicAbiPackage(pkg)) { + PublicSignatureCollector collector = new PublicSignatureCollector(); + for (ClassFileTree.Class klazz : pkg.getClasses()) { + try (InputStream is = klazz.getInputStream()) { + new ClassReader(is).accept(collector, 0); + } + } + signatures.putAll(collector.getClassSignatures()); + } + for (ClassFileTree.Package subPkg : pkg.getSubPackages()) { + signatures.putAll(collectPublicAbiSignatures(subPkg)); + } + return signatures; + } +} diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java new file mode 100644 index 00000000000..774792c0365 --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java @@ -0,0 +1,17 @@ +package com.yahoo.abicheck.signature; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class JavaClassSignature { + + public final List<String> attributes; + public final Map<String, JavaMethodSignature> methods; + + public JavaClassSignature(List<String> attributes, Map<String, JavaMethodSignature> methods) { + this.attributes = attributes; + this.methods = methods; + } +} diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaMethodSignature.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaMethodSignature.java new file mode 100644 index 00000000000..0d562d0acb1 --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaMethodSignature.java @@ -0,0 +1,14 @@ +package com.yahoo.abicheck.signature; + +import java.util.List; + +public class JavaMethodSignature { + + public final List<String> attributes; + public final String returnType; + + public <T> JavaMethodSignature(List<String> attributes, String returnType) { + this.attributes = attributes; + this.returnType = returnType; + } +} |