-
Notifications
You must be signed in to change notification settings - Fork 135
Plugin to validate all classes on the classpath are uniquely named #267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 42 commits
77d0413
8c38f0c
d94f939
7941840
19b30c2
5ce2ee8
c9c3b55
0919961
9d0745b
a81b6b3
b66f48c
a1275df
ed4425e
5ea80f6
a1b2156
982e92d
bcf86e8
cfa7aec
830af37
7784164
9e9ab49
4591774
ea7cd0b
67264ff
27b793c
8692c46
e676bb2
94c6b38
4f1f8c8
f6b4ebd
9f4963d
c17aa36
73bf75f
3d5c250
778c4fa
2bdcc50
087003f
2ac6526
f3e98d4
452a0b6
4526967
4640a4d
caaa580
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| /* | ||
| * (c) Copyright 2018 Palantir Technologies Inc. All rights reserved. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.palantir.baseline.plugins; | ||
|
|
||
| import com.palantir.baseline.tasks.CheckClassUniquenessTask; | ||
| import org.gradle.api.Project; | ||
|
|
||
| /** | ||
| * This plugin is similar to https://github.com/nebula-plugins/gradle-lint-plugin/wiki/Duplicate-Classes-Rule | ||
| * but goes one step further and actually hashes any identically named classfiles to figure out if they're | ||
| * <i>completely</i> identical (and therefore safely interchangeable). | ||
| * | ||
| * The task only fails if it finds classes which have the same name but different implementations. | ||
| */ | ||
| public class BaselineClassUniquenessPlugin extends AbstractBaselinePlugin { | ||
|
|
||
| @Override | ||
| public final void apply(Project project) { | ||
| project.getPlugins().withId("java", plugin -> { | ||
| project.getTasks().create("checkClassUniqueness", CheckClassUniquenessTask.class, task -> { | ||
| task.setConfiguration(project.getConfigurations().getByName("runtime")); | ||
| project.getTasks().getByName("check").dependsOn(task); | ||
| }); | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| /* | ||
| * (c) Copyright 2018 Palantir Technologies Inc. All rights reserved. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.palantir.baseline.tasks; | ||
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Paths; | ||
| import java.util.Collection; | ||
| import java.util.Comparator; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
| import org.gradle.api.DefaultTask; | ||
| import org.gradle.api.artifacts.Configuration; | ||
| import org.gradle.api.artifacts.ModuleVersionIdentifier; | ||
| import org.gradle.api.tasks.Input; | ||
| import org.gradle.api.tasks.OutputFile; | ||
| import org.gradle.api.tasks.TaskAction; | ||
|
|
||
| public class CheckClassUniquenessTask extends DefaultTask { | ||
|
|
||
| private Configuration configuration; | ||
|
|
||
| public CheckClassUniquenessTask() { | ||
| setGroup("Verification"); | ||
| setDescription("Checks that the given configuration contains no identically named classes."); | ||
| } | ||
|
|
||
| @Input | ||
| public final Configuration getConfiguration() { | ||
| return configuration; | ||
| } | ||
|
|
||
| public final void setConfiguration(Configuration configuration) { | ||
| this.configuration = configuration; | ||
| } | ||
|
|
||
| @TaskAction | ||
| public final void checkForDuplicateClasses() { | ||
| ClassUniquenessAnalyzer analyzer = new ClassUniquenessAnalyzer(getLogger()); | ||
| analyzer.analyzeConfiguration(getConfiguration()); | ||
| boolean success = analyzer.getDifferingProblemJars().isEmpty(); | ||
| writeResultFile(success); | ||
|
|
||
| if (!success) { | ||
| analyzer.getDifferingProblemJars().forEach((problemJars) -> { | ||
| Set<String> differingClasses = analyzer.getDifferingSharedClassesInProblemJars(problemJars); | ||
| getLogger().error("{} Identically named classes with differing impls found in {}: {}", | ||
| differingClasses.size(), problemJars, differingClasses); | ||
| }); | ||
|
|
||
| throw new IllegalStateException(String.format( | ||
| "'%s' contains multiple copies of identically named classes - " | ||
| + "this may cause different runtime behaviour depending on classpath ordering.\n" | ||
| + "To resolve this, try excluding one of the following jars:\n\n%s", | ||
| configuration.getName(), | ||
| formatSummary(analyzer) | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| private static String formatSummary(ClassUniquenessAnalyzer summary) { | ||
| Collection<Set<ModuleVersionIdentifier>> allProblemJars = summary.getDifferingProblemJars(); | ||
|
|
||
| int maxLength = allProblemJars.stream().flatMap(Set::stream) | ||
| .map(ModuleVersionIdentifier::toString) | ||
| .map(String::length) | ||
| .max(Comparator.naturalOrder()).get(); | ||
| String format = "%-" + (maxLength + 1) + "s"; | ||
|
|
||
| StringBuilder builder = new StringBuilder(); | ||
|
|
||
| allProblemJars.forEach(problemJars -> { | ||
| int count = summary.getDifferingSharedClassesInProblemJars(problemJars).size(); | ||
| String countColumn = String.format("\t%-14s", "(" + count + " classes) "); | ||
| builder.append(countColumn); | ||
|
|
||
| String jars = problemJars.stream().map(jar -> String.format(format, jar)).collect(Collectors.joining()); | ||
| builder.append(jars); | ||
|
|
||
| builder.append('\n'); | ||
| }); | ||
|
|
||
| return builder.toString(); | ||
| } | ||
|
|
||
| /** | ||
| * This only exists to convince gradle this task is incremental. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. don't think this comment is strictly necessary -- I feel this is pretty standard/well understood for people who write gradle plugins
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's helpful to reassure readers of this code that the file we write really is inconsequential and not magically wired to something elsewhere in the project. |
||
| */ | ||
| @OutputFile | ||
| public final File getResultFile() { | ||
| return getProject().getBuildDir().toPath() | ||
| .resolve(Paths.get("uniqueClassNames", configuration.getName())) | ||
| .toFile(); | ||
| } | ||
|
|
||
| private void writeResultFile(boolean success) { | ||
| try { | ||
| File result = getResultFile(); | ||
| Files.createDirectories(result.toPath().getParent()); | ||
| Files.write(result.toPath(), Boolean.toString(success).getBytes(StandardCharsets.UTF_8)); | ||
| } catch (IOException e) { | ||
| throw new RuntimeException("Unable to write boolean result file", e); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| /* | ||
| * (c) Copyright 2018 Palantir Technologies Inc. All rights reserved. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.palantir.baseline.tasks; | ||
|
|
||
| import static java.util.stream.Collectors.toSet; | ||
|
|
||
| import com.google.common.hash.HashCode; | ||
| import com.google.common.hash.Hashing; | ||
| import com.google.common.hash.HashingInputStream; | ||
| import com.google.common.io.ByteStreams; | ||
| import java.io.File; | ||
| import java.io.FileInputStream; | ||
| import java.io.IOException; | ||
| import java.time.Duration; | ||
| import java.time.Instant; | ||
| import java.util.Collection; | ||
| 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.JarInputStream; | ||
| import java.util.stream.Collectors; | ||
| import org.gradle.api.artifacts.Configuration; | ||
| import org.gradle.api.artifacts.ModuleVersionIdentifier; | ||
| import org.gradle.api.artifacts.ResolvedArtifact; | ||
| import org.slf4j.Logger; | ||
|
|
||
| public final class ClassUniquenessAnalyzer { | ||
|
|
||
| private final Map<Set<ModuleVersionIdentifier>, Set<String>> jarsToClasses = new HashMap<>(); | ||
| private final Map<String, Set<HashCode>> classToHashCodes = new HashMap<>(); | ||
| private final Logger log; | ||
|
|
||
| public ClassUniquenessAnalyzer(Logger log) { | ||
| this.log = log; | ||
| } | ||
|
|
||
| public void analyzeConfiguration(Configuration configuration) { | ||
| Instant before = Instant.now(); | ||
| Set<ResolvedArtifact> dependencies = configuration | ||
| .getResolvedConfiguration() | ||
| .getResolvedArtifacts(); | ||
|
|
||
| // we use these temporary maps to accumulate information as we process each jar, | ||
| // so they may include singletons which we filter out later | ||
| Map<String, Set<ModuleVersionIdentifier>> classToJars = new HashMap<>(); | ||
| Map<String, Set<HashCode>> tempClassToHashCodes = new HashMap<>(); | ||
|
|
||
| dependencies.stream().forEach(resolvedArtifact -> { | ||
| File file = resolvedArtifact.getFile(); | ||
| if (!file.exists()) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probably wanna filter out non-jars too to avoid spurious errors in line 92
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TBH I'd kinda like to just get an MVP released and get a few repos to pick it up and see what we encounter! The |
||
| log.info("Skipping non-existent jar {}: {}", resolvedArtifact, file); | ||
| return; | ||
| } | ||
|
|
||
| try (FileInputStream fileInputStream = new FileInputStream(file); | ||
| JarInputStream jarInputStream = new JarInputStream(fileInputStream)) { | ||
| JarEntry entry; | ||
| while ((entry = jarInputStream.getNextJarEntry()) != null) { | ||
| if (entry.isDirectory() || !entry.getName().endsWith(".class")) { | ||
| continue; | ||
| } | ||
|
|
||
| String className = entry.getName().replaceAll("/", ".").replaceAll(".class", ""); | ||
| HashingInputStream inputStream = new HashingInputStream(Hashing.sha256(), jarInputStream); | ||
| ByteStreams.exhaust(inputStream); | ||
|
|
||
| multiMapPut(classToJars, | ||
| className, | ||
| resolvedArtifact.getModuleVersion().getId()); | ||
|
|
||
| multiMapPut(tempClassToHashCodes, | ||
| className, | ||
| inputStream.hash()); | ||
| } | ||
| } catch (IOException e) { | ||
| log.error("Failed to read JarFile {}", resolvedArtifact, e); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the resolvedArtifact might not be a jar -- we should probs filter those out first, a la https://github.com/nebula-plugins/gradle-lint-plugin/blob/master/src/main/groovy/com/netflix/nebula/lint/rule/dependency/DependencyService.groovy#L92-L94 |
||
| throw new RuntimeException(e); | ||
| } | ||
| }); | ||
|
|
||
| // discard all the classes that only come from one jar - these are completely safe! | ||
| classToJars.entrySet().stream() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. don't think we need this collection -- finding which entries of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we delete People need to know which are the offending jars!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh whoops I didn't read this closely enough |
||
| .filter(entry -> entry.getValue().size() > 1) | ||
| .forEach(entry -> multiMapPut(jarsToClasses, entry.getValue(), entry.getKey())); | ||
|
|
||
| // figure out which classes have differing hashes | ||
| tempClassToHashCodes.entrySet().stream() | ||
| .filter(entry -> entry.getValue().size() > 1) | ||
| .forEach(entry -> | ||
| entry.getValue().forEach(value -> multiMapPut(classToHashCodes, entry.getKey(), value))); | ||
|
|
||
| Instant after = Instant.now(); | ||
| log.info("Checked {} classes from {} dependencies for uniqueness ({}ms)", | ||
| classToJars.size(), dependencies.size(), Duration.between(before, after).toMillis()); | ||
| } | ||
|
|
||
| /** | ||
| * Any groups jars that all contain some identically named classes. | ||
| * Note: may contain non-scary duplicates - class files which are 100% identical, so their | ||
| * clashing name doesn't have any effect. | ||
| */ | ||
| public Collection<Set<ModuleVersionIdentifier>> getProblemJars() { | ||
| return jarsToClasses.keySet(); | ||
| } | ||
|
|
||
| /** | ||
| * Class names that appear in all of the given jars. | ||
| */ | ||
| public Set<String> getSharedClassesInProblemJars(Collection<ModuleVersionIdentifier> problemJars) { | ||
| return jarsToClasses.get(problemJars); | ||
| } | ||
|
|
||
| /** | ||
| * Jars which contain identically named classes with non-identical implementations. | ||
| */ | ||
| public Collection<Set<ModuleVersionIdentifier>> getDifferingProblemJars() { | ||
| return getProblemJars() | ||
| .stream() | ||
| .filter(jars -> getDifferingSharedClassesInProblemJars(jars).size() > 0) | ||
| .collect(Collectors.toSet()); | ||
| } | ||
|
|
||
| /** | ||
| * Class names which appear in all of the given jars and also have non-identical implementations. | ||
| */ | ||
| public Set<String> getDifferingSharedClassesInProblemJars(Collection<ModuleVersionIdentifier> problemJars) { | ||
| return getSharedClassesInProblemJars(problemJars).stream() | ||
| .filter(classToHashCodes::containsKey) | ||
| .collect(toSet()); | ||
| } | ||
|
|
||
| private static <K, V> void multiMapPut(Map<K, Set<V>> map, K key, V value) { | ||
| map.compute(key, (unused, collection) -> { | ||
| Set<V> newCollection = collection != null ? collection : new HashSet<>(); | ||
| newCollection.add(value); | ||
| return newCollection; | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| implementation-class=com.palantir.baseline.plugins.BaselineClassUniquenessPlugin |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's remove the testCompile dep in line 17