diff options
author | Jon Bratseth <bratseth@oath.com> | 2018-11-30 12:47:48 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-11-30 12:47:48 -0800 |
commit | c0513ac34d2c438e9f97e699659855029e1f06e8 (patch) | |
tree | 8171d8c9a6414e2e16e60c1f67b1162d0bea5133 | |
parent | 7bf98f903237df5e14660bdb08f8518993dc4afd (diff) | |
parent | a9820cd241dfdaf900a3b819d0aa9afab244cf4c (diff) |
Merge pull request #7829 from vespa-engine/iruotsalainen/abi-check-plugin
Add maven plugin for ABI stability checking
20 files changed, 1174 insertions, 0 deletions
diff --git a/abi-check-plugin/README.md b/abi-check-plugin/README.md new file mode 100644 index 00000000000..23a68a94783 --- /dev/null +++ b/abi-check-plugin/README.md @@ -0,0 +1,56 @@ +# abi-check-plugin + +Maven plugin for ensuring project ABI stability. + +## Theory of operation + +The project artifact JAR is scanned for class files. The resulting package tree is scanned for +packages annotated with configured annotation denoting a package that is considered public ABI. +Classes in those packages are scanned for their visible ABI and the result is compared against +expected ABI (stored in a JSON file) and possible discrepancies are reported. + +## What is considered visible ABI + +Visible ABI is considered to be classes, methods and fields that are accessible from other JAR +files without use of reflection or other tricks. + +## Setup + +### Add plugin to build + +Add the plugin to `<plugins>` in the project `pom.xml`, with an execution of `abicheck` goal. This +goal has to be executed in a phase where the project artifact JAR is available, the recommended +phase is `package`. + +Example: +``` +<plugin> + <groupId>com.yahoo.vespa</groupId> + <artifactId>abi-check-plugin</artifactId> + <configuration> + <publicApiAnnotation>com.yahoo.api.annotations.PublicApi</publicApiAnnotation> + </configuration> + <executions> + <execution> + <phase>package</phase> + <goals> + <goal>abicheck</goal> + </goals> + </execution> + </executions> +</plugin> +``` + +### Configuration parameters + + * **publicApiAnnotation** (required) + Fully qualified class name of the annotation that denotes a public API package. + * **specFileName** (optional, default: `abi-spec.json`) + File name relative to project root from which to read the expected ABI spec from. + +## Updating the expected ABI spec + +To automatically generate the expected ABI spec from the current ABI of the project, define +property `abicheck.writeSpec` when running the relevant phase. + +Example: `mvn package -Dabicheck.writeSpec` diff --git a/abi-check-plugin/pom.xml b/abi-check-plugin/pom.xml new file mode 100644 index 00000000000..db7149ccdb2 --- /dev/null +++ b/abi-check-plugin/pom.xml @@ -0,0 +1,84 @@ +<?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</groupId> + <artifactId>maven-core</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.ow2.asm</groupId> + <artifactId>asm</artifactId> + <version>7.0</version> + </dependency> + <dependency> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> + <version>24.0-jre</version> + </dependency> + <dependency> + <groupId>com.google.code.gson</groupId> + <artifactId>gson</artifactId> + <version>2.8.5</version> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-api</artifactId> + <version>5.3.1</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.hamcrest</groupId> + <artifactId>hamcrest-all</artifactId> + <version>1.3</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-all</artifactId> + <version>1.10.19</version> + <scope>test</scope> + </dependency> + </dependencies> + <build> + <finalName>${project.artifactId}</finalName> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-plugin-plugin</artifactId> + </plugin> + <plugin> + <groupId>org.openclover</groupId> + <artifactId>clover-maven-plugin</artifactId> + <version>4.3.1</version> + </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..c7863d85a3c --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java @@ -0,0 +1,129 @@ +package com.yahoo.abicheck.classtree; + +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(JarFile jarFile) { + Map<String, Package> rootPackages = new HashMap<>(); + + 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 ClassFile(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 void close() throws IOException; + + public abstract Collection<Package> getRootPackages(); + + public static abstract class ClassFile { + + private final Package parent; + private final String name; + + private ClassFile(Package parent, String name) { + this.parent = parent; + this.name = name; + } + + public abstract InputStream getInputStream() throws IOException; + + public String getName() { + return name; + } + + // CLOVER:OFF + // Testing debug methods is not necessary + @Override + public String toString() { + return "ClassFile(" + parent.getFullyQualifiedName() + "." + name + ")"; + } + // CLOVER:ON + } + + public static class Package { + + private final Package parent; + private final String name; + private final Map<String, Package> subPackages = new HashMap<>(); + private final Set<ClassFile> classFiles = 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(ClassFile klazz) { + classFiles.add(klazz); + } + + public String getFullyQualifiedName() { + if (parent == null) { + return name; + } else { + return parent.getFullyQualifiedName() + "." + name; + } + } + + public Collection<Package> getSubPackages() { + return subPackages.values(); + } + + public Collection<ClassFile> getClassFiles() { + return classFiles; + } + + // CLOVER:OFF + // Testing debug methods is not necessary + @Override + public String toString() { + return "Package(" + getFullyQualifiedName() + ")"; + } + // CLOVER:ON + } +} 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..d90b3c1a8dd --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java @@ -0,0 +1,115 @@ +package com.yahoo.abicheck.collector; + +import com.yahoo.abicheck.signature.JavaClassSignature; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.FieldVisitor; +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 String currentSuper; + private Set<String> currentInterfaces; + private int currentAccess; + private Set<String> currentMethods; + private Set<String> currentFields; + + public PublicSignatureCollector() { + super(Opcodes.ASM6); + } + + private static boolean testBit(long access, long mask) { + return (access & mask) != 0; + } + + private static String describeMethod(String name, int access, String returnType, + List<String> argumentTypes) { + return String.format("%s %s %s(%s)", describeAccess(access, Util.methodFlags), returnType, name, + String.join(", ", argumentTypes)); + } + + private static String describeAccess(int access, List<Util.AccessFlag> possibleFlags) { + return String.join(" ", Util.convertAccess(access, possibleFlags)); + } + + private static String describeField(String name, int access, String type) { + return String.format("%s %s %s", describeAccess(access, Util.fieldFlags), type, name); + } + + private static String internalNameToClassName(String superName) { + return Type.getObjectType(superName).getClassName(); + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, + String[] interfaces) { + currentName = internalNameToClassName(name); + currentSuper = internalNameToClassName(superName); + currentInterfaces = Arrays.stream(interfaces) + .map(PublicSignatureCollector::internalNameToClassName) + .collect(Collectors.toCollection(LinkedHashSet::new)); + currentAccess = access; + currentMethods = new LinkedHashSet<>(); + currentFields = new LinkedHashSet<>(); + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + if (isVisibleMember(access)) { + Type method = Type.getMethodType(descriptor); + List<String> argumentTypes = Arrays.stream(method.getArgumentTypes()).map(Type::getClassName) + .collect(Collectors.toList()); + currentMethods + .add(describeMethod(name, access, method.getReturnType().getClassName(), argumentTypes)); + } + return null; + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, + Object value) { + if (isVisibleMember(access)) { + currentFields.add(describeField(name, access, Type.getType(descriptor).getClassName())); + } + return null; + } + + private boolean isVisibleMember(int access) { + // Public members are visible + if (testBit(access, Opcodes.ACC_PUBLIC)) { + return true; + } + // Protected members are visible if the class is not final (can be accessed from + // extending classes) + if (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(currentSuper, currentInterfaces, + Util.convertAccess(currentAccess, Util.classFlags), currentMethods, currentFields)); + } + } + + 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..f308ad42db5 --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java @@ -0,0 +1,87 @@ +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.ignored(Opcodes.ACC_SUPER), // 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") + AccessFlag.ignored(Opcodes.ACC_DEPRECATED) + ); + + 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? + AccessFlag.ignored(Opcodes.ACC_DEPRECATED) + ); + + public static final List<AccessFlag> fieldFlags = 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_VOLATILE, "volatile"), + AccessFlag.make(Opcodes.ACC_TRANSIENT, "transient"), + AccessFlag.make(Opcodes.ACC_SYNTHETIC, "synthetic"), // FIXME: Do we want this? + AccessFlag.make(Opcodes.ACC_ENUM, "enum"), + AccessFlag.ignored(Opcodes.ACC_DEPRECATED) + ); + + 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; + } + + public 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); + } + + private static AccessFlag ignored(int bit) { + return new AccessFlag(bit, null); + } + } +} 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..06d2628228c --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java @@ -0,0 +1,188 @@ +package com.yahoo.abicheck.mojo; + +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.ClassFile; +import com.yahoo.abicheck.classtree.ClassFileTree.Package; +import com.yahoo.abicheck.collector.AnnotationCollector; +import com.yahoo.abicheck.collector.PublicSignatureCollector; +import com.yahoo.abicheck.setmatcher.SetMatcher; +import com.yahoo.abicheck.signature.JavaClassSignature; +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.jar.JarFile; +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.plugin.logging.Log; +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 { + + public static final String PACKAGE_INFO_CLASS_FILE_NAME = "package-info.class"; + private static final String DEFAULT_SPEC_FILE = "abi-spec.json"; + private static final String WRITE_SPEC_PROPERTY = "abicheck.writeSpec"; + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project = null; + + @Parameter(required = true) + private String publicApiAnnotation = null; + + @Parameter + private String specFileName = DEFAULT_SPEC_FILE; + + // CLOVER:OFF + // Testing that Gson can read JSON files is not very useful + private static Map<String, JavaClassSignature> readSpec(String fileName) throws IOException { + try (FileReader reader = new FileReader(fileName)) { + TypeToken<Map<String, JavaClassSignature>> typeToken = + new TypeToken<Map<String, JavaClassSignature>>() { + }; + Gson gson = new GsonBuilder().create(); + return gson.fromJson(reader, typeToken.getType()); + } + } + // CLOVER:ON + + // CLOVER:OFF + // Testing that Gson can write JSON files is not very useful + private static void writeSpec(Map<String, JavaClassSignature> signatures, String fileName) + throws IOException { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + try (FileWriter writer = new FileWriter(fileName)) { + gson.toJson(signatures, writer); + } + } + // CLOVER:ON + + private static boolean matchingClasses(String className, JavaClassSignature expected, + JavaClassSignature actual, Log log) { + boolean match = true; + if (!expected.superClass.equals(actual.superClass)) { + match = false; + log.error(String + .format("Class %s: Expected superclass %s, found %s", className, expected.superClass, + actual.superClass)); + } + if (!SetMatcher.compare(expected.interfaces, actual.interfaces, + item -> true, + item -> log.error(String.format("Class %s: Missing interface %s", className, item)), + item -> log.error(String.format("Class %s: Extra interface %s", className, item)))) { + match = false; + } + if (!SetMatcher + .compare(new HashSet<>(expected.attributes), new HashSet<>(actual.attributes), + item -> true, + item -> log.error(String.format("Class %s: Missing attribute %s", className, item)), + item -> log.error(String.format("Class %s: Extra attribute %s", className, item)))) { + match = false; + } + if (!SetMatcher.compare(expected.methods, actual.methods, + item -> true, + item -> log.error(String.format("Class %s: Missing method %s", className, item)), + item -> log.error(String.format("Class %s: Extra method %s", className, item)))) { + match = false; + } + if (!SetMatcher.compare(expected.fields, actual.fields, + item -> true, + item -> log.error(String.format("Class %s: Missing field %s", className, item)), + item -> log.error(String.format("Class %s: Extra field %s", className, item)))) { + match = false; + } + return match; + } + + private static boolean isPublicAbiPackage(ClassFileTree.Package pkg, String publicApiAnnotation) + throws IOException { + Optional<ClassFile> pkgInfo = pkg.getClassFiles().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); + } + } + + static Map<String, JavaClassSignature> collectPublicAbiSignatures(Package pkg, + String publicApiAnnotation) throws IOException { + Map<String, JavaClassSignature> signatures = new LinkedHashMap<>(); + if (isPublicAbiPackage(pkg, publicApiAnnotation)) { + PublicSignatureCollector collector = new PublicSignatureCollector(); + for (ClassFile klazz : pkg.getClassFiles()) { + 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, publicApiAnnotation)); + } + return signatures; + } + + static boolean compareSignatures(Map<String, JavaClassSignature> expected, + Map<String, JavaClassSignature> actual, Log log) { + return SetMatcher.compare(expected.keySet(), actual.keySet(), + item -> matchingClasses(item, expected.get(item), actual.get(item), log), + item -> log.error(String.format("Missing class: %s", item)), + item -> log.error(String.format("Extra class: %s", item))); + } + + // CLOVER:OFF + // The main entry point is tedious to unit test + @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 (JarFile jarFile = new JarFile(mainArtifact.getFile())) { + ClassFileTree tree = ClassFileTree.fromJar(jarFile); + Map<String, JavaClassSignature> signatures = new LinkedHashMap<>(); + for (ClassFileTree.Package pkg : tree.getRootPackages()) { + signatures.putAll(collectPublicAbiSignatures(pkg, publicApiAnnotation)); + } + if (System.getProperty(WRITE_SPEC_PROPERTY) != null) { + getLog().info("Writing ABI specs to " + specFileName); + writeSpec(signatures, specFileName); + } else { + Map<String, JavaClassSignature> abiSpec = readSpec(specFileName); + if (!compareSignatures(abiSpec, signatures, getLog())) { + throw new MojoFailureException("ABI spec mismatch"); + } + } + } catch (IOException e) { + throw new MojoExecutionException("Error processing class signatures", e); + } + } + // CLOVER:ON +} diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/setmatcher/SetMatcher.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/setmatcher/SetMatcher.java new file mode 100644 index 00000000000..56b08bef74f --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/setmatcher/SetMatcher.java @@ -0,0 +1,31 @@ +package com.yahoo.abicheck.setmatcher; + +import com.google.common.collect.Sets; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; + +public class SetMatcher { + + public static <T> boolean compare(Set<T> expected, Set<T> actual, Predicate<T> itemsMatch, + Consumer<T> onMissing, Consumer<T> onExtra) { + boolean mismatch = false; + Set<T> missing = Sets.difference(expected, actual); + for (T item : missing) { + mismatch = true; + onMissing.accept(item); + } + Set<T> extra = Sets.difference(actual, expected); + for (T item : extra) { + mismatch = true; + onExtra.accept(item); + } + Set<T> both = Sets.intersection(actual, expected); + for (T item : both) { + if (!itemsMatch.test(item)) { + mismatch = true; + } + } + return !mismatch; + } +} 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..5e748d1b7e2 --- /dev/null +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java @@ -0,0 +1,22 @@ +package com.yahoo.abicheck.signature; + +import java.util.List; +import java.util.Set; + +public class JavaClassSignature { + + public final String superClass; + public final Set<String> interfaces; + public final List<String> attributes; + public final Set<String> methods; + public final Set<String> fields; + + public JavaClassSignature(String superClass, Set<String> interfaces, List<String> attributes, + Set<String> methods, Set<String> fields) { + this.superClass = superClass; + this.interfaces = interfaces; + this.attributes = attributes; + this.methods = methods; + this.fields = fields; + } +} diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/AccessConversionTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AccessConversionTest.java new file mode 100644 index 00000000000..eaa49789f2f --- /dev/null +++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AccessConversionTest.java @@ -0,0 +1,48 @@ +package com.yahoo.abicheck; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.yahoo.abicheck.collector.Util; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.objectweb.asm.Opcodes; + +public class AccessConversionTest { + + @Test + public void testClassFlags() { + // ACC_SUPER should be ignored + assertEquals( + Arrays.asList("public", "abstract"), + Util.convertAccess( + Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER | Opcodes.ACC_ABSTRACT, + Util.classFlags)); + } + + @Test + public void testMethodFlags() { + // ACC_DEPRECATED should be ignored + assertEquals( + Arrays.asList("protected", "varargs"), + Util.convertAccess( + Opcodes.ACC_PROTECTED | Opcodes.ACC_VARARGS | Opcodes.ACC_DEPRECATED, + Util.methodFlags)); + } + + @Test + public void testFieldFlags() { + assertEquals( + Arrays.asList("private", "volatile"), + Util.convertAccess( + Opcodes.ACC_PRIVATE | Opcodes.ACC_VOLATILE, + Util.fieldFlags)); + } + + @Test + public void testUnsupportedFlags() { + // ACC_MODULE is not a valid flag for fields + assertThrows(IllegalArgumentException.class, + () -> Util.convertAccess(Opcodes.ACC_MODULE, Util.fieldFlags)); + } +} diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/AnnotationCollectorTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AnnotationCollectorTest.java new file mode 100644 index 00000000000..3104ce794cd --- /dev/null +++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AnnotationCollectorTest.java @@ -0,0 +1,28 @@ +package com.yahoo.abicheck; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; + +import com.yahoo.abicheck.collector.AnnotationCollector; +import java.io.IOException; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassReader; + +public class AnnotationCollectorTest { + + @Test + public void testCollection() throws IOException { + ClassReader r = new ClassReader( + "com.yahoo.abicheck.AnnotationCollectorTest$ClassWithAnnotations"); + AnnotationCollector collector = new AnnotationCollector(); + r.accept(collector, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + + assertThat(collector.getAnnotations(), containsInAnyOrder(Disabled.class.getCanonicalName())); + } + + @Disabled // Any RetentionPolicy.RUNTIME annotation is fine for testing + private static class ClassWithAnnotations { + + } +} diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/ClassFileTreeTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/ClassFileTreeTest.java new file mode 100644 index 00000000000..5aa8ed2d770 --- /dev/null +++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/ClassFileTreeTest.java @@ -0,0 +1,44 @@ +package com.yahoo.abicheck; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.sameInstance; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.Iterables; +import com.yahoo.abicheck.classtree.ClassFileTree; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import org.junit.jupiter.api.Test; + +public class ClassFileTreeTest { + + @Test + public void testJarParsing() throws IOException { + JarFile jarFile = mock(JarFile.class); + JarEntry classJarEntry = new JarEntry("com/yahoo/Test.class"); + JarEntry dirJarEntry = new JarEntry("com/yahoo/"); + InputStream jarEntryStream = mock(InputStream.class); + when(jarFile.entries()) + .thenReturn(Collections.enumeration(Arrays.asList(dirJarEntry, classJarEntry))); + when(jarFile.getInputStream(classJarEntry)).thenReturn(jarEntryStream); + + try (ClassFileTree cft = ClassFileTree.fromJar(jarFile)) { + ClassFileTree.Package com = Iterables.getOnlyElement(cft.getRootPackages()); + assertThat(com.getFullyQualifiedName(), equalTo("com")); + assertThat(com.getClassFiles(), empty()); + ClassFileTree.Package yahoo = Iterables.getOnlyElement(com.getSubPackages()); + assertThat(yahoo.getFullyQualifiedName(), equalTo("com.yahoo")); + assertThat(yahoo.getSubPackages(), empty()); + ClassFileTree.ClassFile testClassFile = Iterables.getOnlyElement(yahoo.getClassFiles()); + assertThat(testClassFile.getName(), equalTo("Test.class")); + assertThat(testClassFile.getInputStream(), sameInstance(jarEntryStream)); + } + } +} diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/Public.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/Public.java new file mode 100644 index 00000000000..8e7410e032d --- /dev/null +++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/Public.java @@ -0,0 +1,12 @@ +package com.yahoo.abicheck; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PACKAGE}) +public @interface Public { + +} diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/PublicSignatureCollectorTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/PublicSignatureCollectorTest.java new file mode 100644 index 00000000000..784a81de50c --- /dev/null +++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/PublicSignatureCollectorTest.java @@ -0,0 +1,92 @@ +package com.yahoo.abicheck; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; + +import com.yahoo.abicheck.collector.PublicSignatureCollector; +import com.yahoo.abicheck.signature.JavaClassSignature; +import java.io.IOException; +import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassReader; + +public class PublicSignatureCollectorTest { + + @Test + public void testCollection() throws IOException { + String testClassName = "com.yahoo.abicheck.PublicSignatureCollectorTest$TestClass"; + ClassReader r = new ClassReader(testClassName); + PublicSignatureCollector collector = new PublicSignatureCollector(); + r.accept(collector, 0); + + Map<String, JavaClassSignature> signatures = collector.getClassSignatures(); + assertThat(signatures.size(), equalTo(1)); + JavaClassSignature s = signatures.get(testClassName); + assertThat(s.superClass, equalTo("java.lang.Object")); + assertThat(s.interfaces, + containsInAnyOrder("java.lang.Runnable", "java.util.function.Function")); + assertThat(s.attributes, contains("public")); + assertThat(s.fields, containsInAnyOrder( + "public static int staticPublicField", + "protected static int staticProtectedField", + "public boolean publicField", + "protected boolean protectedField")); + assertThat(s.methods, containsInAnyOrder( + "public void <init>()", + "public static java.lang.String staticPublicMethod()", + "protected static java.lang.String staticProtectedMethod()", + "public java.lang.Object publicMethod()", + "protected java.lang.Object protectedMethod()", + "public void run()", + "public java.lang.Void apply(java.lang.Void)", + "public bridge synthetic java.lang.Object apply(java.lang.Object)" + )); + } + + public static class TestClass implements Runnable, Function<Void, Void> { + + public static int staticPublicField = 1; + protected static int staticProtectedField = 2; + private static int staticPrivateField = 3; + public boolean publicField = true; + protected boolean protectedField = true; + private boolean privateField = true; + + public static String staticPublicMethod() { + return ""; + } + + protected static String staticProtectedMethod() { + return ""; + } + + private static String staticPrivateMethod() { + return ""; + } + + public Object publicMethod() { + return null; + } + + protected Object protectedMethod() { + return null; + } + + private Object privateMethod() { + return null; + } + + @Override + public void run() { + + } + + @Override + public Void apply(Void aVoid) { + return null; + } + } +} diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/SetMatcherTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/SetMatcherTest.java new file mode 100644 index 00000000000..37755147fb2 --- /dev/null +++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/SetMatcherTest.java @@ -0,0 +1,89 @@ +package com.yahoo.abicheck; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableSet; +import com.yahoo.abicheck.setmatcher.SetMatcher; +import java.util.Collections; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; + +public class SetMatcherTest { + + @Test + public void testMissing() { + Set<String> a = ImmutableSet.of("a", "b"); + + @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class); + @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class); + @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class); + + when(itemsMatch.test("a")).thenReturn(true); + + assertThat(SetMatcher.compare(a, Collections.singleton("a"), itemsMatch, missing, extra), + equalTo(false)); + + verify(missing, times(1)).accept("b"); + verify(extra, never()).accept(any()); + verify(itemsMatch, times(1)).test("a"); + } + + @Test + public void testExtra() { + Set<String> a = ImmutableSet.of("a"); + + @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class); + @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class); + @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class); + + when(itemsMatch.test("a")).thenReturn(true); + + assertThat(SetMatcher.compare(a, ImmutableSet.of("a", "b"), itemsMatch, missing, extra), + equalTo(false)); + + verify(missing, never()).accept(any()); + verify(extra, times(1)).accept("b"); + verify(itemsMatch, times(1)).test("a"); + } + + @Test + public void testItemsMatch() { + @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class); + @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class); + @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class); + + when(itemsMatch.test("a")).thenReturn(false); + + assertThat(SetMatcher + .compare(Collections.singleton("a"), Collections.singleton("a"), itemsMatch, missing, + extra), equalTo(false)); + + verify(itemsMatch, times(1)).test("a"); + } + + @Test + public void testCompleteMatch() { + @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class); + @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class); + @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class); + + when(itemsMatch.test("a")).thenReturn(true); + + assertThat(SetMatcher + .compare(Collections.singleton("a"), Collections.singleton("a"), itemsMatch, missing, + extra), equalTo(true)); + + verify(missing, never()).accept(any()); + verify(extra, never()).accept(any()); + verify(itemsMatch, times(1)).test("a"); + } +} diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/mojo/AbiCheckTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/mojo/AbiCheckTest.java new file mode 100644 index 00000000000..4de6f186800 --- /dev/null +++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/mojo/AbiCheckTest.java @@ -0,0 +1,107 @@ +package com.yahoo.abicheck.mojo; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import com.yahoo.abicheck.Public; +import com.yahoo.abicheck.classtree.ClassFileTree; +import com.yahoo.abicheck.signature.JavaClassSignature; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import org.apache.maven.plugin.logging.Log; +import org.junit.jupiter.api.Test; +import root.Root; +import root.sub.Sub; + +public class AbiCheckTest { + + private static ClassFileTree.Package buildClassFileTree() throws IOException { + ClassFileTree.Package rootPkg = mock(ClassFileTree.Package.class); + ClassFileTree.Package subPkg = mock(ClassFileTree.Package.class); + + ClassFileTree.ClassFile rootPkgInfoClass = mock(ClassFileTree.ClassFile.class); + ClassFileTree.ClassFile rootPkgClass = mock(ClassFileTree.ClassFile.class); + ClassFileTree.ClassFile subPkgClass = mock(ClassFileTree.ClassFile.class); + + when(rootPkg.getSubPackages()).thenReturn(Collections.singleton(subPkg)); + when(rootPkg.getClassFiles()).thenReturn(Arrays.asList(rootPkgClass, rootPkgInfoClass)); + when(subPkg.getClassFiles()).thenReturn(Collections.singleton(subPkgClass)); + + when(rootPkgInfoClass.getName()).thenReturn("package-info.class"); + when(rootPkgInfoClass.getInputStream()) + .thenAnswer(invocation -> Root.class.getResourceAsStream("package-info.class")); + + when(rootPkgClass.getName()).thenReturn("Root.class"); + when(rootPkgClass.getInputStream()) + .thenAnswer(invocation -> Root.class.getResourceAsStream("Root.class")); + + when(subPkgClass.getName()).thenReturn("Sub.class"); + when(subPkgClass.getInputStream()) + .thenAnswer(invocation -> Sub.class.getResourceAsStream("Sub.class")); + + return rootPkg; + } + + @Test + public void testCollectPublicAbiSignatures() throws IOException { + ClassFileTree.Package rootPkg = buildClassFileTree(); + + Map<String, JavaClassSignature> signatures = AbiCheck + .collectPublicAbiSignatures(rootPkg, Public.class.getCanonicalName()); + + assertThat(signatures.size(), equalTo(1)); + JavaClassSignature rootSignature = signatures.get("root.Root"); + + // PublicSignatureCollectorTest verifies actual signatures, no need to duplicate here + } + + @Test + public void testCompareSignatures() { + Log log = mock(Log.class); + + JavaClassSignature signatureA = new JavaClassSignature( + "java.lang.Object", + Collections.emptySet(), + Collections.singletonList("public"), + Collections.singleton("public void foo()"), + Collections.singleton("public int bar")); + JavaClassSignature signatureB = new JavaClassSignature( + "java.lang.Exception", + Collections.singleton("java.lang.Runnable"), + Collections.singletonList("protected"), + Collections.singleton("public void foo(int)"), + Collections.singleton("public boolean bar")); + + Map<String, JavaClassSignature> expected = ImmutableMap.<String, JavaClassSignature>builder() + .put("test.Missing", signatureA) + .put("test.A", signatureA) + .put("test.B", signatureB) + .build(); + + Map<String, JavaClassSignature> actual = ImmutableMap.<String, JavaClassSignature>builder() + .put("test.A", signatureA) + .put("test.Extra", signatureA) + .put("test.B", signatureA) + .build(); + + assertThat(AbiCheck.compareSignatures(expected, actual, log), equalTo(false)); + + verify(log).error("Missing class: test.Missing"); + verify(log).error("Extra class: test.Extra"); + verify(log) + .error("Class test.B: Expected superclass java.lang.Exception, found java.lang.Object"); + verify(log).error("Class test.B: Missing interface java.lang.Runnable"); + verify(log).error("Class test.B: Missing attribute protected"); + verify(log).error("Class test.B: Extra attribute public"); + verify(log).error("Class test.B: Missing method public void foo(int)"); + verify(log).error("Class test.B: Extra method public void foo()"); + verify(log).error("Class test.B: Missing field public boolean bar"); + verify(log).error("Class test.B: Extra field public int bar"); + } +} diff --git a/abi-check-plugin/src/test/java/root/Root.java b/abi-check-plugin/src/test/java/root/Root.java new file mode 100644 index 00000000000..58fd210159f --- /dev/null +++ b/abi-check-plugin/src/test/java/root/Root.java @@ -0,0 +1,5 @@ +package root; + +public class Root { + +} diff --git a/abi-check-plugin/src/test/java/root/package-info.java b/abi-check-plugin/src/test/java/root/package-info.java new file mode 100644 index 00000000000..26f0f5c5a6f --- /dev/null +++ b/abi-check-plugin/src/test/java/root/package-info.java @@ -0,0 +1,4 @@ +@Public +package root; + +import com.yahoo.abicheck.Public;
\ No newline at end of file diff --git a/abi-check-plugin/src/test/java/root/sub/Sub.java b/abi-check-plugin/src/test/java/root/sub/Sub.java new file mode 100644 index 00000000000..c4f330644e9 --- /dev/null +++ b/abi-check-plugin/src/test/java/root/sub/Sub.java @@ -0,0 +1,5 @@ +package root.sub; + +public class Sub { + +} diff --git a/maven-plugins/pom.xml b/maven-plugins/pom.xml index 546dfdafcd9..be03926499f 100644 --- a/maven-plugins/pom.xml +++ b/maven-plugins/pom.xml @@ -23,5 +23,6 @@ <module>../config-class-plugin</module> <module>../application-deploy-plugin</module> <module>../vespa-application-maven-plugin</module> + <module>../abi-check-plugin</module> </modules> </project> |