View Javadoc
1   /*
2    * Licensed under the Apache License, Version 2.0 (the "License");
3    * you may not use this file except in compliance with the License.
4    * You may obtain a copy of the License at
5    *
6    * http://www.apache.org/licenses/LICENSE-2.0
7    *
8    * Unless required by applicable law or agreed to in writing, software
9    * distributed under the License is distributed on an "AS IS" BASIS,
10   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11   * See the License for the specific language governing permissions and
12   * limitations under the License.
13   */
14  package org.basepom.mojo.duplicatefinder.classpath;
15  
16  import java.io.File;
17  import java.io.IOException;
18  import java.io.InputStream;
19  import java.util.Arrays;
20  import java.util.Collection;
21  import java.util.List;
22  import java.util.Map.Entry;
23  import java.util.Optional;
24  import java.util.concurrent.ConcurrentHashMap;
25  import java.util.concurrent.ConcurrentMap;
26  import java.util.function.Predicate;
27  import java.util.regex.Pattern;
28  import java.util.regex.PatternSyntaxException;
29  import java.util.zip.ZipEntry;
30  import java.util.zip.ZipInputStream;
31  
32  import javax.lang.model.SourceVersion;
33  
34  import com.google.common.annotations.VisibleForTesting;
35  import com.google.common.base.MoreObjects;
36  import com.google.common.base.Splitter;
37  import com.google.common.collect.ImmutableList;
38  import com.google.common.collect.ImmutableList.Builder;
39  import com.google.common.collect.ImmutableMap;
40  import com.google.common.collect.ImmutableMultimap;
41  import com.google.common.collect.Multimap;
42  import com.google.common.collect.MultimapBuilder;
43  import com.google.common.io.Files;
44  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
45  import org.apache.maven.artifact.Artifact;
46  import org.apache.maven.artifact.versioning.InvalidVersionSpecificationException;
47  import org.apache.maven.plugin.MojoExecutionException;
48  import org.apache.maven.project.MavenProject;
49  import org.basepom.mojo.duplicatefinder.ConflictType;
50  import org.basepom.mojo.duplicatefinder.artifact.MavenCoordinates;
51  import org.slf4j.Logger;
52  import org.slf4j.LoggerFactory;
53  
54  import static java.lang.String.format;
55  
56  import static com.google.common.base.Preconditions.checkNotNull;
57  import static com.google.common.base.Preconditions.checkState;
58  
59  @SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
60  public class ClasspathDescriptor {
61  
62      private static final Logger LOG = LoggerFactory.getLogger(ClasspathDescriptor.class);
63  
64      private static final MatchPatternPredicate DEFAULT_IGNORED_RESOURCES_PREDICATE = new MatchPatternPredicate(Arrays.asList(
65              // Standard jar folders
66              "^META-INF/.*",
67              "^OSGI-INF/.*",
68              // directory name that shows up all the time
69              "^licenses/.*",
70              // file names that show up all the time
71              ".*license(\\.txt)?$",
72              ".*notice(\\.txt)?$",
73              ".*readme(\\.txt)?$",
74              ".*changelog(\\.txt)?$",
75              ".*third-party(\\.txt)?$",
76              // HTML stuff from javadocs.
77              ".*package\\.html$",
78              ".*overview\\.html$"));
79  
80      @VisibleForTesting
81      static final MatchPatternPredicate DEFAULT_IGNORED_CLASS_PREDICATE = new MatchPatternPredicate(Arrays.asList(
82  
83              "^(.*\\.)?.*\\$.*$",      // matches inner classes in any package
84              "^(.*\\.)?package-info$", // matches package-info in any package
85              "^(.*\\.)?module-info$"   // matches module-info in any package
86      ));
87  
88      private static final MatchPatternPredicate DEFAULT_IGNORED_LOCAL_DIRECTORIES = new MatchPatternPredicate(Arrays.asList(
89              "^.git$",
90              "^.svn$",
91              "^.hg$",
92              "^.bzr$"));
93  
94      /**
95       * This is a global, static cache which can be reused through multiple runs of the plugin in the same VM,
96       * e.g. for a multi-module build.
97       */
98      private static final ConcurrentMap<File, ClasspathCacheElement> CACHED_BY_FILE = new ConcurrentHashMap<>();
99  
100     private final Multimap<String, File> classesWithElements = MultimapBuilder.treeKeys().hashSetValues().build();
101     private final Multimap<String, File> resourcesWithElements = MultimapBuilder.treeKeys().hashSetValues().build();
102 
103     private final Predicate<String> resourcesPredicate;
104     private final Predicate<String> classPredicate;
105 
106     private final ImmutableList<Pattern> ignoredResourcePatterns;
107     private final ImmutableList<Pattern> ignoredClassPatterns;
108 
109     public static ClasspathDescriptor createClasspathDescriptor(final MavenProject project,
110             final Multimap<File, Artifact> fileToArtifactMap,
111             final Collection<String> ignoredResourcePatterns,
112             final Collection<String> ignoredClassPatterns,
113             final Collection<MavenCoordinates> ignoredDependencies,
114             final boolean useDefaultResourceIgnoreList,
115             final boolean useDefaultClassIgnoreList,
116             final File[] projectFolders) throws MojoExecutionException, InvalidVersionSpecificationException {
117         checkNotNull(project, "project is null");
118         checkNotNull(fileToArtifactMap, "fileToArtifactMap is null");
119         checkNotNull(ignoredResourcePatterns, "ignoredResourcePatterns is null");
120         checkNotNull(ignoredClassPatterns, "ignoredClassPatterns is null");
121         checkNotNull(ignoredDependencies, "ignoredDependencies is null");
122         checkNotNull(projectFolders, "projectFolders is null");
123 
124         final ClasspathDescriptor classpathDescriptor = new ClasspathDescriptor(useDefaultResourceIgnoreList, ignoredResourcePatterns,
125                 useDefaultClassIgnoreList, ignoredClassPatterns);
126 
127         File file = null;
128 
129         final MatchArtifactPredicate matchArtifactPredicate = new MatchArtifactPredicate(ignoredDependencies);
130 
131         Artifact artifact = null;
132 
133         try {
134             // any entry is either a jar in the repo or a folder in the target folder of a referenced
135             // project. Add the elements that are not ignored by the ignoredDependencies predicate to
136             // the classpath descriptor.
137             for (final Entry<File, Artifact> entry : fileToArtifactMap.entries()) {
138                 artifact = entry.getValue();
139                 file = entry.getKey();
140 
141                 if (file.exists()) {
142                     // Add to the classpath if the artifact predicate does not apply (then it is not in the ignoredDependencies list).
143                     if (!matchArtifactPredicate.apply(artifact)) {
144                         classpathDescriptor.addClasspathElement(file);
145                     }
146                 } else {
147                     // e.g. when running the goal explicitly on a cleaned multi-module project, referenced
148                     // projects will try to use the output folders of a referenced project but these do not
149                     // exist. Obviously, in this case the plugin might return incorrect results (unfortunately
150                     // false negatives, but there is not much it can do here (besides fail the build here with a
151                     // cryptic error message. Maybe add a flag?).
152                     LOG.debug(format("Classpath element '%s' does not exist.", file.getAbsolutePath()));
153                 }
154             }
155         } catch (final IOException ex) {
156             throw new MojoExecutionException(format("Error trying to access file '%s' for artifact '%s'", file, artifact), ex);
157         }
158 
159         try {
160             // Add project folders unconditionally.
161             for (final File projectFile : projectFolders) {
162                 file = projectFile;
163                 if (projectFile.exists()) {
164                     classpathDescriptor.addClasspathElement(file);
165                 } else {
166                     // See above. This may happen in the project has been cleaned before running the goal directly.
167                     LOG.debug(format("Project folder '%s' does not exist.", file.getAbsolutePath()));
168                 }
169             }
170         } catch (final IOException ex) {
171             throw new MojoExecutionException(format("Error trying to access project folder '%s'", file), ex);
172         }
173 
174         return classpathDescriptor;
175     }
176 
177     private ClasspathDescriptor(final boolean useDefaultResourceIgnoreList,
178             final Collection<String> ignoredResourcePatterns,
179             final boolean useDefaultClassIgnoreList,
180             final Collection<String> ignoredClassPatterns)
181             throws MojoExecutionException {
182         final Builder<Pattern> ignoredResourcePatternsBuilder = ImmutableList.builder();
183 
184         // build resource predicate...
185         Predicate<String> resourcesPredicate = s -> false;
186 
187         // predicate matching the default ignores
188         if (useDefaultResourceIgnoreList) {
189             resourcesPredicate = resourcesPredicate.or(DEFAULT_IGNORED_RESOURCES_PREDICATE);
190             ignoredResourcePatternsBuilder.addAll(DEFAULT_IGNORED_RESOURCES_PREDICATE.getPatterns());
191         }
192 
193         if (!ignoredResourcePatterns.isEmpty()) {
194             try {
195                 // predicate matching the user ignores
196                 MatchPatternPredicate ignoredResourcesPredicate = new MatchPatternPredicate(ignoredResourcePatterns);
197                 resourcesPredicate = resourcesPredicate.or(ignoredResourcesPredicate);
198                 ignoredResourcePatternsBuilder.addAll(ignoredResourcesPredicate.getPatterns());
199             } catch (final PatternSyntaxException pse) {
200                 throw new MojoExecutionException("Error compiling resourceIgnore pattern: " + pse.getMessage());
201             }
202         }
203 
204         this.resourcesPredicate = resourcesPredicate;
205         this.ignoredResourcePatterns = ignoredResourcePatternsBuilder.build();
206 
207         final Builder<Pattern> ignoredClassPatternsBuilder = ImmutableList.builder();
208 
209         // build class predicate.
210         Predicate<String> classPredicate = s -> false;
211 
212         // predicate matching the default ignores
213         if (useDefaultClassIgnoreList) {
214             classPredicate = classPredicate.or(DEFAULT_IGNORED_CLASS_PREDICATE);
215             ignoredClassPatternsBuilder.addAll(DEFAULT_IGNORED_CLASS_PREDICATE.getPatterns());
216         }
217 
218         if (!ignoredClassPatterns.isEmpty()) {
219             try {
220                 // predicate matching the user ignores
221                 MatchPatternPredicate ignoredPackagePredicate = new MatchPatternPredicate(ignoredClassPatterns);
222                 classPredicate = classPredicate.or(ignoredPackagePredicate);
223                 ignoredClassPatternsBuilder.addAll(ignoredPackagePredicate.getPatterns());
224             } catch (final PatternSyntaxException pse) {
225                 throw new MojoExecutionException("Error compiling classIgnore pattern: " + pse.getMessage());
226             }
227         }
228 
229         this.classPredicate = classPredicate;
230         this.ignoredClassPatterns = ignoredClassPatternsBuilder.build();
231     }
232 
233     public ImmutableMap<String, Collection<File>> getClasspathElementLocations(final ConflictType type) {
234         checkNotNull(type, "type is null");
235         switch (type) {
236             case CLASS:
237                 return ImmutableMultimap.copyOf(classesWithElements).asMap();
238             case RESOURCE:
239                 return ImmutableMultimap.copyOf(resourcesWithElements).asMap();
240             default:
241                 throw new IllegalStateException("Type '" + type + "' unknown!");
242         }
243     }
244 
245     public ImmutableList<Pattern> getIgnoredResourcePatterns() {
246         return ignoredResourcePatterns;
247     }
248 
249     public ImmutableList<Pattern> getIgnoredClassPatterns() {
250         return ignoredClassPatterns;
251     }
252 
253     public ImmutableList<Pattern> getIgnoredDirectoryPatterns() {
254         return DEFAULT_IGNORED_LOCAL_DIRECTORIES.getPatterns();
255     }
256 
257 
258     private void addClasspathElement(final File element) throws IOException {
259         checkState(element.exists(), "Path '%s' does not exist!", element.getAbsolutePath());
260 
261         ClasspathCacheElement cached = CACHED_BY_FILE.get(element);
262 
263         if (cached == null) {
264             final ClasspathCacheElement.Builder cacheBuilder = ClasspathCacheElement.builder(element);
265             if (element.isDirectory()) {
266                 addDirectory(cacheBuilder, element, new PackageNameHolder());
267             } else {
268                 addArchive(cacheBuilder, element);
269             }
270             final ClasspathCacheElement newCached = cacheBuilder.build();
271             final ClasspathCacheElement oldCached = CACHED_BY_FILE.putIfAbsent(element, newCached);
272             cached = MoreObjects.firstNonNull(oldCached, newCached);
273         } else {
274             LOG.debug(format("Cache hit for '%s'", element.getAbsolutePath()));
275         }
276 
277         cached.putResources(resourcesWithElements, resourcesPredicate);
278         cached.putClasses(classesWithElements, classPredicate);
279 
280     }
281 
282     private void addDirectory(final ClasspathCacheElement.Builder cacheBuilder, final File workDir, final PackageNameHolder packageName) {
283         final File[] files = workDir.listFiles();
284 
285         if (files != null) {
286             for (final File file : files) {
287                 if (file.isDirectory()) {
288                     if (DEFAULT_IGNORED_LOCAL_DIRECTORIES.apply(file.getName())) {
289                         LOG.debug(format("Ignoring local directory '%s'", file.getAbsolutePath()));
290                     } else {
291                         addDirectory(cacheBuilder, file, packageName.getChildPackage(file.getName()));
292                     }
293 
294                 } else if (file.isFile()) {
295                     if ("class".equals(Files.getFileExtension(file.getName()))) {
296                         final String className = packageName.getQualifiedName(Files.getNameWithoutExtension(file.getName()));
297                         cacheBuilder.addClass(className);
298                     } else {
299                         final String resourcePath = packageName.getQualifiedPath(file.getName());
300                         cacheBuilder.addResource(resourcePath);
301                     }
302                 } else {
303                     LOG.warn(format("Ignoring unknown file type for '%s'", file.getAbsolutePath()));
304                 }
305             }
306         }
307     }
308 
309     private void addArchive(final ClasspathCacheElement.Builder cacheBuilder, final File element) throws IOException {
310 
311         try (
312             final InputStream input = element.toURI().toURL().openStream();
313             final ZipInputStream zipInput = new ZipInputStream(input)) {
314 
315             ZipEntry entry;
316 
317             while ((entry = zipInput.getNextEntry()) != null) {
318                 if (!entry.isDirectory()) {
319                     final String name = entry.getName();
320                     Optional<List<String>> validatedElements = validateClassName(name);
321                     if (validatedElements.isPresent()) {
322                         List<String> nameElements = validatedElements.get();
323                         final PackageNameHolder packageName = new PackageNameHolder(nameElements.subList(0, nameElements.size() - 1));
324                         final String className = packageName.getQualifiedName(Files.getNameWithoutExtension(name));
325                         cacheBuilder.addClass(className);
326                     } else {
327                         final String resourcePath = name.replace('\\', File.separatorChar);
328                         cacheBuilder.addResource(resourcePath);
329                     }
330                 }
331             }
332         }
333     }
334 
335     @VisibleForTesting
336     static Optional<List<String>> validateClassName(String fullClassPath) {
337         if (fullClassPath == null) {
338             return Optional.empty();
339         }
340 
341         // ZIP/Jars always use "/" as separators
342         final List<String> nameElements = ImmutableList.copyOf(Splitter.on("/").splitToList(fullClassPath));
343         if (nameElements.isEmpty()) {
344             LOG.warn(format("ZIP entry '%s' split into empty list!", fullClassPath));
345             return Optional.empty();
346         }
347         String classFileName = nameElements.get(nameElements.size() - 1);
348 
349         if (!"class".equals(Files.getFileExtension(classFileName))) {
350             LOG.debug(format("Ignoring %s, %s is not a class file", fullClassPath, classFileName));
351             return Optional.empty();
352         }
353         for (int i = 0; i < nameElements.size() - 1; i++) {
354             if (!SourceVersion.isIdentifier(nameElements.get(i))) {
355                 LOG.debug(format("Ignoring %s, %s is not a valid package element", fullClassPath, nameElements.get(i)));
356                 return Optional.empty();
357             }
358         }
359 
360         String className = Files.getNameWithoutExtension(classFileName);
361         if (!(SourceVersion.isIdentifier(className)
362                 || "module-info".equals(className)
363                 || "package-info".equals(className))) {
364             LOG.debug(format("Ignoring %s, %s is not a valid class identifier", fullClassPath, className));
365             return Optional.empty();
366         }
367 
368         return Optional.of(nameElements);
369     }
370 }