1   package com.atlassian.maven.plugins.amps.osgi;
2   
3   import org.apache.maven.plugin.logging.Log;
4   import org.apache.maven.project.MavenProject;
5   import org.apache.maven.plugin.MojoFailureException;
6   import org.apache.maven.artifact.Artifact;
7   import org.apache.commons.io.IOUtils;
8   
9   import java.util.*;
10  import java.util.zip.ZipInputStream;
11  import java.util.zip.ZipEntry;
12  import java.io.File;
13  import java.io.FileInputStream;
14  import java.io.IOException;
15  
16  import aQute.libg.header.OSGiHeader;
17  
18  /**
19   * Validates the package imports in a manifest contain proper versions
20   *
21   * @since 3.0
22   */
23  public class PackageImportVersionValidator
24  {
25      private final MavenProject project;
26      private final Log log;
27      private final String productName;
28      private final Map<String,Set<String>> jarPackageCache = new HashMap<String,Set<String>>();
29      private static final int MIN_PACKAGES_FOR_WILDCARD = 4;
30  
31      public PackageImportVersionValidator(MavenProject project, Log log, String productName)
32      {
33          this.project = project;
34          this.log = log;
35          this.productName = productName;
36      }
37  
38      /**
39       * Validates the package imports.
40       * @param imports The package imports from the manifest
41       * @throws MojoFailureException If the validation fails.  Will contain user-friendly error message trying to guess
42       * a desirable bnd configuration for package imports.
43       */
44      public void validate(String imports)
45      {
46          if (imports != null)
47          {
48              Map<String,String> foundPackages = new HashMap<String,String>();
49              boolean validationFailed = false;
50  
51              Map<String,Map<String,String>> pkgImports = OSGiHeader.parseHeader(imports);
52              for (Map.Entry<String,Map<String,String>> pkgImport : pkgImports.entrySet())
53              {
54                  String pkg = pkgImport.getKey();
55                  if (pkgImport.getValue() != null && pkgImport.getValue().size() > 0)
56                  {
57                      Map<String,String> props = pkgImport.getValue();
58                      String version = props.get("version");
59                      foundPackages.put(pkg, guessVersion( pkg));
60                      if (version == null || version.length() == 0)
61                      {
62                          validationFailed = true;
63                      }
64                  }
65                  else
66                  {
67                      validationFailed = true;
68                      foundPackages.put(pkg, guessVersion(pkg));
69                  }
70              }
71  
72              if (validationFailed)
73              {
74                  StringBuilder sb = new StringBuilder();
75                  sb.append("The manifest should contain versions for all imports to prevent ambiguity at install time ");
76                  sb.append("due to multiple versions of a package.  Here are some suggestions for the ");
77                  sb.append("maven-").append(productName).append("-plugin configuration generated for this project ");
78                  sb.append("to start from:\n ");
79                  sb.append("  <configuration>\n");
80                  sb.append("    <instructions>\n");
81                  sb.append("      <Import-Package>\n");
82                  for (Map.Entry<String,String> entry : compressPackages(foundPackages).entrySet())
83                  {
84                      sb.append("        ").append(entry.getKey()).append(";version=\"").append(entry.getValue()).append("\",\n");
85                  }
86                  sb.delete(sb.length() - 2, sb.length());
87                  sb.append("\n");
88                  sb.append("      </Import-Package>\n");
89                  sb.append("    </instructions>\n");
90                  sb.append("  </configuration>\n");
91                  sb.append("You may notice many packages you weren't expecting.  This is usually because of a bundled jar ");
92                  sb.append("that references packages that don't apply.  You can usually remove these or if necessary, ");
93                  sb.append("mark them as optional by adding ';resolution:=optional' to the package import.  Packages ");
94                  sb.append("that are detected as version '0.0.0' usually mean either they are JDK packages or ones that ");
95                  sb.append("aren't referenced in your project, and therefore, likely candidates for removal entirely.");
96                  log.warn(sb.toString());
97              }
98          }
99      }
100 
101     /**
102      * Compress packages into sets of wildcard expressions, where applicable.
103      *
104      * @param allPackages The raw set of packages and versions
105      * @return A map of import package pattern and version
106      */
107     static Map<String,String> compressPackages(Map<String, String> allPackages)
108     {
109         Map<String,String> pkgs = new HashMap<String,String>();
110         Set<String> unmatchedPackages = new TreeSet<String>(allPackages.keySet());
111 
112         // Iterate through all packages to compress
113         for (String pkg : new TreeSet<String>(allPackages.keySet()))
114         {
115             // only process unmatched packages
116             if (!unmatchedPackages.contains(pkg))
117             {
118                 continue;
119             }
120             String version = allPackages.get(pkg);
121 
122             // Create set of all other unmatched patches
123             Set<String> others = new TreeSet<String>(unmatchedPackages);
124             others.remove(pkg);
125 
126             // Build list of packages with the same version
127             for (Iterator<String> i = others.iterator(); i.hasNext(); )
128             {
129                 String otherPkg = i.next();
130                 if (!allPackages.get(otherPkg).equals(version))
131                 {
132                     i.remove();
133                 }
134             }
135 
136 
137             // Iterate through characters in the current package, looking for packages with matching characters and a
138             // minimum of 3 packages
139             int numberOfPackages = 1;
140             for (int curpos = 0; curpos<pkg.length(); curpos++)
141             {
142                 char curchar = pkg.charAt(curpos);
143                 if (curchar == '.')
144                 {
145                     numberOfPackages++;
146                 }
147 
148                 for (Iterator<String> i = others.iterator(); i.hasNext(); )
149                 {
150                     String otherPkg = i.next();
151 
152                     // Remove other package if the character at the same index is different
153                     if (otherNotMatchesNextChar(curpos, curchar, otherPkg))
154                     {
155                         i.remove();
156                     }
157                 }
158 
159                 if (numberOfPackages == MIN_PACKAGES_FOR_WILDCARD || curpos == pkg.length() - 1)
160                 {
161                     if (others.size() > 0 && numberOfPackages == MIN_PACKAGES_FOR_WILDCARD)
162                     {
163                         String pattern = greedlyBuildPattern(pkg, others, curpos).toString();
164                         pkgs.put(pattern + "*", version);
165                         unmatchedPackages.removeAll(others);
166                     }
167                     else
168                     {
169                         // No wildcard possible
170                         pkgs.put(pkg, version);
171                     }
172                     unmatchedPackages.remove(pkg);
173                     break;
174                 }
175             }
176         }
177         return pkgs;
178     }
179 
180     private static boolean otherNotMatchesNextChar(int curpos, char curchar, String other)
181     {
182         return other.length() <= curpos || curchar != other.charAt(curpos);
183     }
184 
185     /**
186      * Tries to consume characters to build the longest possible match string
187      * @param pkg The original package
188      * @param others The other matching packages
189      * @param curpos The minimal position for a match
190      * @return A pattern containing the maximum amount of characters to still match the pattern
191      */
192     private static StringBuilder greedlyBuildPattern(String pkg, Set<String> others, int curpos)
193     {
194         StringBuilder pattern = new StringBuilder(pkg.substring(0, curpos + 1));
195         boolean canConsumeAnotherChar = true;
196         for (int greedyPos = curpos + 1; greedyPos < pkg.length() && canConsumeAnotherChar; greedyPos++)
197         {
198             for (String greedyOther : others)
199             {
200                 canConsumeAnotherChar = true;
201                 if (otherNotMatchesNextChar(greedyPos, pkg.charAt(greedyPos), greedyOther))
202                 {
203                     canConsumeAnotherChar = false;
204                     break;
205                 }
206             }
207             if (canConsumeAnotherChar)
208             {
209                 pattern.append(pkg.charAt(greedyPos));
210             }
211         }
212         return pattern;
213     }
214 
215     /**
216      * Guesses the version for the package by scanning the Maven configuration.
217      *
218      * @param pkg The package to guess
219      * @return The guessed version, or [0.0.0,) if unknown
220      */
221     private String guessVersion(String pkg)
222     {
223         for (Artifact artifact : new HashSet<Artifact>(project.getArtifacts()))
224         {
225 
226             File file = artifact.getFile();
227             if (file.exists() && file.getName().endsWith(".jar"))
228             {
229                 Set<String> contents = jarPackageCache.get(file.getAbsolutePath());
230                 if (contents == null)
231                 {
232                     contents = new HashSet<String>();
233                     jarPackageCache.put(file.getAbsolutePath(), contents);
234                     ZipInputStream in = null;
235                     try
236                     {
237                         in = new ZipInputStream(new FileInputStream(file));
238                         ZipEntry entry;
239                         while ((entry = in.getNextEntry()) != null)
240                         {
241                             contents.add(entry.getName());
242                         }
243                     }
244                     catch (IOException e)
245                     {
246                         // ignore
247                     }
248                     finally
249                     {
250                         IOUtils.closeQuietly(in);
251                     }
252                 }
253 
254                 if (contents.contains(pkg.replace('.','/') + "/"))
255                 {
256                     return artifact.getVersion();
257                 }
258             }
259         }
260         return "0.0.0";
261     }
262 }