View Javadoc
1   package com.atlassian.plugin.osgi.container.felix;
2   
3   import com.atlassian.plugin.osgi.container.PackageScannerConfiguration;
4   import com.atlassian.plugin.osgi.hostcomponents.HostComponentRegistration;
5   import com.atlassian.plugin.osgi.util.OsgiHeaderUtil;
6   import com.atlassian.plugin.util.PluginFrameworkUtils;
7   import com.google.common.annotations.VisibleForTesting;
8   import com.google.common.collect.ImmutableList;
9   import io.github.lukehutch.fastclasspathscanner.FastClasspathScanner;
10  import org.apache.commons.io.FileUtils;
11  import org.dom4j.Document;
12  import org.dom4j.DocumentException;
13  import org.dom4j.Element;
14  import org.dom4j.io.SAXReader;
15  import org.slf4j.Logger;
16  import org.slf4j.LoggerFactory;
17  import org.twdata.pkgscanner.DefaultOsgiVersionConverter;
18  import org.twdata.pkgscanner.ExportPackage;
19  import org.twdata.pkgscanner.PackageScanner;
20  
21  import javax.servlet.ServletContext;
22  import java.io.File;
23  import java.io.IOException;
24  import java.net.MalformedURLException;
25  import java.net.URL;
26  import java.util.ArrayList;
27  import java.util.Collection;
28  import java.util.Collections;
29  import java.util.Deque;
30  import java.util.HashMap;
31  import java.util.LinkedList;
32  import java.util.List;
33  import java.util.Map;
34  import java.util.StringTokenizer;
35  import java.util.jar.Attributes;
36  import java.util.jar.JarFile;
37  import java.util.jar.Manifest;
38  import java.util.regex.Matcher;
39  import java.util.regex.Pattern;
40  
41  import static com.atlassian.plugin.osgi.container.felix.ExportBuilderUtils.copyUnlessExist;
42  import static com.atlassian.plugin.osgi.container.felix.ExportBuilderUtils.parseExportFile;
43  import static com.google.common.collect.Lists.newArrayList;
44  import static org.twdata.pkgscanner.PackageScanner.exclude;
45  import static org.twdata.pkgscanner.PackageScanner.include;
46  import static org.twdata.pkgscanner.PackageScanner.jars;
47  import static org.twdata.pkgscanner.PackageScanner.packages;
48  
49  /**
50   * Builds the OSGi package exports string. Uses a file to cache the scanned results, keyed by the application version.
51   */
52  class ExportsBuilder {
53  
54      static final String JDK8_PACKAGES_PATH = "jdk8-packages.txt";
55      static final String JDK9_PACKAGES_PATH = "jdk9-packages.txt";
56      static final String JDK11_PACKAGES_PATH = "jdk11-packages.txt";
57  
58      private static final List<String> FRAMEWORK_PACKAGES = ImmutableList.of(
59              "com.atlassian.plugin.remotable",
60              // webfragments and webresources moved out in PLUG-942 and PLUG-943
61              "com.atlassian.plugin.cache.filecache",
62              "com.atlassian.plugin.webresource",
63              "com.atlassian.plugin.web"
64      );
65      private static final String OSGI_PACKAGES_PATH = "osgi-packages.txt";
66      /**
67       * A pattern for {@code jar:file:} URLs which extracts the file path. {@code jar:file:} URLs are common in
68       * Spring Boot's {@code URLClassLoader}s.
69       *
70       * @see #maybeUnwrapJarFileUrl(URL)
71       */
72      private static final Pattern PATTERN_JAR_FILE_URL = Pattern.compile("jar:(file:.+\\.jar)!/");
73      private static final Logger log = LoggerFactory.getLogger(ExportsBuilder.class);
74  
75  
76      static String getLegacyScanModeProperty() {
77          return "com.atlassian.plugin.export.legacy.scan.mode";
78      }
79  
80      private static String exportStringCache;
81  
82      public interface CachedExportPackageLoader {
83          Collection<ExportPackage> load();
84      }
85  
86      private final CachedExportPackageLoader cachedExportPackageLoader;
87  
88      ExportsBuilder() {
89          this(new PackageScannerExportsFileLoader("package-scanner-exports.xml"));
90      }
91  
92      ExportsBuilder(final CachedExportPackageLoader loader) {
93          this.cachedExportPackageLoader = loader;
94      }
95  
96      /**
97       * Detects URLs of the form {@code jar:file:some/path/to.jar!/} and unwraps them to
98       * {@code file:some/path/to.jar}. While both denote paths to jar files on disk, the
99       * {@code PackageScanner} code only accepts the latter. {@code jar:file:} URLs are
100      * used extensively by Spring Boot, and this unwrapping allows the jars to be scanned.
101      *
102      * @param url the URL to unwrap
103      * @return the unwrapped URL, if it matched the required pattern, or the provided URL
104      *         unchanged if it did not match the pattern or the unwrapped URL was invalid
105      */
106     @VisibleForTesting
107     static URL maybeUnwrapJarFileUrl(final URL url) {
108         final Matcher matcher = PATTERN_JAR_FILE_URL.matcher(url.toString());
109         if (matcher.matches()) {
110             final String fileUrl = matcher.group(1);
111             log.debug("Unwrapped Spring Boot URL: {} -> {}", url, fileUrl);
112 
113             try {
114                 return new URL(fileUrl);
115             } catch (final MalformedURLException e) {
116                 log.warn("Could not create URL from apparent Spring Boot jar:file: {}->{}", url, fileUrl);
117             }
118         }
119 
120         return url;
121     }
122 
123     @VisibleForTesting
124     static boolean isPluginFrameworkPackage(String pkg) {
125         return pkg.startsWith("com.atlassian.plugin.") &&
126                 FRAMEWORK_PACKAGES.stream().noneMatch(
127                         frameworkPackage -> pkg.equals(frameworkPackage) || pkg.startsWith(frameworkPackage + "."));
128     }
129 
130     /**
131      * Gets the framework exports taking into account host components and package scanner configuration.
132      * <p>
133      * Often, this information will not change without a system restart, so we determine this once and then cache the value.
134      * The cache is only useful if the plugin system is thrown away and re-initialised. This is done thousands of times
135      * during JIRA functional testing, and the cache was added to speed this up.
136      *
137      * If needed, call {@link #clearExportCache()} to clear the cache.
138      *
139      * @param regs                 The list of host component registrations
140      * @param packageScannerConfig The configuration for the package scanning
141      * @return A list of exports, in a format compatible with OSGi headers
142      */
143     String getExports(final List<HostComponentRegistration> regs, final PackageScannerConfiguration packageScannerConfig) {
144         if (exportStringCache == null) {
145             exportStringCache = determineExports(regs, packageScannerConfig);
146         }
147         return exportStringCache;
148     }
149 
150     /**
151      * Clears the export string cache. This results in {@link #getExports(List, PackageScannerConfiguration)}
152      * having to recalculate the export string next time which can significantly slow down the start up time of plugin framework.
153      *
154      * @since 2.9.0
155      */
156     void clearExportCache() {
157         exportStringCache = null;
158     }
159 
160     /**
161      * Determines framework exports taking into account host components and package scanner configuration.
162      *
163      * @param regs                 The list of host component registrations
164      * @param packageScannerConfig The configuration for the package scanning
165      * @return A list of exports, in a format compatible with OSGi headers
166      */
167     String determineExports(final List<HostComponentRegistration> regs, final PackageScannerConfiguration packageScannerConfig) {
168         final Map<String, String> exportPackages = new HashMap<>();
169 
170         // The first part is osgi related packages.
171         copyUnlessExist(exportPackages, parseExportFile(OSGI_PACKAGES_PATH));
172 
173         // The second part is JDK packages.
174         copyUnlessExist(exportPackages, parseExportFile(getJdkPackagesPath()));
175 
176         // Third part by scanning packages available via classloader. The versions are determined by jar names.
177         final Collection<ExportPackage> scannedPackages = generateExports(packageScannerConfig);
178         copyUnlessExist(exportPackages, ExportBuilderUtils.toMap(scannedPackages));
179 
180         // Fourth part by scanning host components since all the classes referred to by them must be available to consumers.
181         try {
182             final Map<String, String> referredPackages = OsgiHeaderUtil.findReferredPackageVersions(
183                     regs, packageScannerConfig.getPackageVersions());
184             copyUnlessExist(exportPackages, referredPackages);
185         } catch (final IOException ex) {
186             log.error("Unable to calculate necessary exports based on host components", ex);
187         }
188 
189         // All the packages under plugin framework namespace must be exported as the plugin framework's version.
190         enforceFrameworkVersion(exportPackages);
191 
192         // Generate the actual export string in OSGi spec.
193         final String exports = OsgiHeaderUtil.generatePackageVersionString(exportPackages);
194 
195         if (log.isDebugEnabled()) {
196             log.debug("Exports:\n" + exports.replaceAll(",", "\r\n"));
197         }
198 
199         return exports;
200     }
201 
202     private void enforceFrameworkVersion(final Map<String, String> exportPackages) {
203         final String frameworkVersion = PluginFrameworkUtils.getPluginFrameworkVersion();
204 
205         // convert the version to OSGi format.
206         final DefaultOsgiVersionConverter converter = new DefaultOsgiVersionConverter();
207         final String frameworkVersionOsgi = converter.getVersion(frameworkVersion);
208 
209         exportPackages.keySet().stream()
210                 .filter(ExportsBuilder::isPluginFrameworkPackage)
211                 .forEach(pkg -> exportPackages.put(pkg, frameworkVersionOsgi));
212     }
213 
214     Collection<ExportPackage> generateExports(final PackageScannerConfiguration packageScannerConfig) {
215         final String[] arrType = new String[0];
216 
217         final Map<String, String> pkgVersions = new HashMap<>(packageScannerConfig.getPackageVersions());
218         // If the product isn't trying to override servlet, set the version to that reported by the ServletContext
219         final String javaxServletPattern = "javax.servlet*";
220         final ServletContext servletContext = packageScannerConfig.getServletContext();
221         if ((null == pkgVersions.get(javaxServletPattern)) && (null != servletContext)) {
222             final String servletVersion = servletContext.getMajorVersion() + "." + servletContext.getMinorVersion();
223             pkgVersions.put(javaxServletPattern, servletVersion);
224         }
225 
226         final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
227         final PackageScanner scanner = new PackageScanner()
228                 .useClassLoader(contextClassLoader)
229                 .select(
230                         jars(
231                                 include(packageScannerConfig.getJarIncludes().toArray(arrType)),
232                                 exclude(packageScannerConfig.getJarExcludes().toArray(arrType))),
233                         packages(
234                                 include(packageScannerConfig.getPackageIncludes().toArray(arrType)),
235                                 exclude(packageScannerConfig.getPackageExcludes().toArray(arrType)))
236                 )
237                 .withMappings(pkgVersions);
238 
239         if (log.isDebugEnabled()) {
240             scanner.enableDebug();
241         }
242 
243         Collection<ExportPackage> exports = cachedExportPackageLoader.load();
244         if (exports == null) {
245             final boolean legacyMode = Boolean.getBoolean(getLegacyScanModeProperty());
246             if (legacyMode) {
247                 // Legacy mode is still in used on windows bamboo agents - see https://extranet.atlassian.com/jira/browse/BDEV-8619
248                 // The issue is that the path walking code org.twdata.pkgscanner.InternalScanner#loadImplementationsInDirectory
249                 // assumes that (File.isDirectory() == true) => (File.listFiles[] != null), which is not true for windows Junctions.
250                 exports = scanner.scan();
251             } else {
252                 final URL[] urls = getClassPathUrls(contextClassLoader);
253                 exports = scanner.scan(urls);
254             }
255         }
256         log.info("Package scan completed. Found " + exports.size() + " packages to export.");
257 
258         if (packageScanFailed(exports) && servletContext != null) {
259             log.warn("Unable to find expected packages via classloader scanning. Trying ServletContext scanning...");
260             try {
261                 exports = scanner.scan(servletContext.getResource("/WEB-INF/lib"), servletContext.getResource("/WEB-INF/classes"));
262             } catch (final MalformedURLException e) {
263                 log.warn("Unable to scan webapp for packages", e);
264             }
265         }
266 
267         if (packageScanFailed(exports)) {
268             throw new IllegalStateException("Unable to find required packages via classloader or servlet context"
269                     + " scanning, most likely due to an application server bug.");
270         }
271         return exports;
272     }
273 
274     private URL[] getClassPathUrls(ClassLoader contextClassLoader) {
275         // The stack of urls we have yet to expand. We initialize the deque with the URLs in the order the class loader
276         // searches them, since the ArrayDeque implementation pops from the front.
277         final Deque<URL> loaderUrls = new LinkedList<>();
278 
279         new FastClasspathScanner("")
280                 .addClassLoader(contextClassLoader)
281                 .disableRecursiveScanning()
282                 .getUniqueClasspathElementURLs()
283                 .forEach(loaderUrls::push);
284 
285         // Expand the list of loaderUrls to obtain all jar and directory entries accessible from them.
286         // A best effort is made to return the results in the order they are loaded by class loading. We deal only in
287         // file (jar and directory) entries, because PackageScanner only works on these anyway. For reasons
288         // inexplicable, intermittently in some environments urls such as "http://felix.extensions:9/" appear in the
289         // classpath.
290 
291         final List<URL> allUrls = new ArrayList<>();
292         while (!loaderUrls.isEmpty()) {
293             final URL url = maybeUnwrapJarFileUrl(loaderUrls.pop());
294             try {
295                 final File file = FileUtils.toFile(url);
296                 if (null == file) {
297                     log.warn("Cannot deep scan non file '{}'", url);
298                 } else if (!file.exists()) {
299                     // This is only debug worthy - jars sometimes speculatively reference optional components
300                     log.debug("Cannot deep scan missing file '{}'", url);
301                 } else if (file.isDirectory()) {
302                     // A directory class path entry is fine, so collect it, but we can't scan any deeper into it
303                     allUrls.add(url);
304                 } else if (file.isFile() && file.getName().endsWith(".jar")) {
305                     // It's a jar file, so collect it ...
306                     allUrls.add(url);
307                     // ... and look for a Class-Path to expand
308                     final JarFile jar = new JarFile(file);
309                     collectClassPath(loaderUrls, url, jar);
310                 } else {
311                     // This is reasonable, for example there's JNI stuff in the class path we can't deep scan,
312                     // but a log message seems reasonable just so everything can be tracked when debugging.
313                     log.debug("Skipping deep scan of non jar-file ");
314                 }
315             } catch (final Exception exception) {
316                 // The likely reasons for hitting this, based on inspection at the time of writing, are
317                 // 1. An unparseable URL in the Class-Path in a manifest, and
318                 // 2. An IOException opening a JAR
319                 // Neither of these are expected during normal usage, nor have happened during testing, so the current
320                 // plan is to ignore them and push on. Things not definitely critically broken at this point, the likely
321                 // outcome is just that some jars didn't get scanned. It may be that we need to fallback to the mode
322                 // used when getLegacyScanModeProperty is set, but it's not yet clear what failure modes are extreme
323                 // enough to warrant this, since both the above cases have the same expected outcome in legacy mode.
324                 log.warn("Failed to deep scan '{}'", url, exception);
325             }
326         }
327         return allUrls.toArray(new URL[0]);
328     }
329 
330     /**
331      * Collect URLs obtained by parsing the Manifest "Class-Path:" of the given jar.
332      * <p>
333      * We collect the results using a Deque so we can push jars found in Manifest Class-Path: entries back on the front and
334      * search them before following jars.
335      * <p>
336      * The expansion of the Manifest Class-Path: entries adds code complexity and may impact performance, but since surefire
337      * uses the Manifest Class-Path: to run tests in the correct class loaders, we need to handle it. See PLUGDEV-67 for
338      * more details.
339      *
340      * @param loaderUrls the stack of urls to push discovered entries on to.
341      * @param url        the url of the jar used to resolve relative entries.
342      * @param jar        the actual jar file whose manifest is parsed.
343      */
344     private void collectClassPath(final Deque<URL> loaderUrls, final URL url, final JarFile jar) throws IOException {
345         final Manifest manifest = jar.getManifest();
346         if (null == manifest) {
347             log.debug("Missing manifest prevents deep scan of '{}'", url);
348             return;
349         }
350 
351         final String classPath = manifest.getMainAttributes().getValue(Attributes.Name.CLASS_PATH);
352         if (null != classPath) {
353             final StringTokenizer tokenizer = new StringTokenizer(classPath);
354             while (tokenizer.hasMoreTokens()) {
355                 final String classPathEntry = tokenizer.nextToken();
356                 try {
357                     loaderUrls.push(new URL(url, classPathEntry));
358                     log.debug("Deep scan found url '{}'", loaderUrls.peekFirst());
359                 } catch (final MalformedURLException emu) {
360                     // Classloaders silently ignore bad URLs in Class-Path, so we non-silently ignore them
361                     log.warn("Cannot deep scan unparseable Class-Path entry '{}' in '{}'", url, classPath);
362                 }
363             }
364         }
365         // else no Class-Path: entry which is fine, there's no need to scan anything else
366     }
367 
368     private String getJdkPackagesPath() {
369         String versionString = System.getProperty("java.specification.version");
370         if (versionString == null) {
371             versionString = System.getProperty("java.version", "11");
372         }
373         if (versionString.startsWith("1.")) {
374             versionString = versionString.substring(2);
375         }
376         int version = 0;
377         for (char c : versionString.toCharArray()) {
378             if (Character.isDigit(c)) {
379                 version = 10 * version + Character.digit(c, 10);
380             }
381         }
382 
383         if (version >= 11) {
384             return JDK11_PACKAGES_PATH;
385         }
386         if (version >= 9) {
387             return JDK9_PACKAGES_PATH;
388         }
389         return JDK8_PACKAGES_PATH;
390     }
391 
392     /**
393      * Tests to see if a scan of packages to export was successful, using the presence of slf4j as the criteria.
394      *
395      * @param exports The exports found so far
396      * @return True if slf4j is present, false otherwise
397      */
398     private static boolean packageScanFailed(final Collection<ExportPackage> exports) {
399         return exports.stream().noneMatch(export -> export.getPackageName().equals("org.slf4j"));
400     }
401 
402     static class PackageScannerExportsFileLoader implements CachedExportPackageLoader {
403         private final String path;
404 
405         PackageScannerExportsFileLoader(final String path) {
406             this.path = path;
407         }
408 
409         @Override
410         public Collection<ExportPackage> load() {
411             final URL exportsUrl = getClass().getClassLoader().getResource(path);
412             if (exportsUrl != null) {
413                 log.debug("Precalculated exports found, loading...");
414                 final List<ExportPackage> result = newArrayList();
415                 try {
416                     final Document doc = new SAXReader().read(exportsUrl);
417                     // Our version of dom4j does not have a generic safe API
418                     //noinspection unchecked
419                     for (final Element export : ((List<Element>) doc.getRootElement().elements())) {
420                         final String packageName = export.attributeValue("package");
421                         final String version = export.attributeValue("version");
422                         final String location = export.attributeValue("location");
423 
424                         if (packageName == null || location == null) {
425                             log.warn("Invalid configuration: package({}) and location({}) are required, " +
426                                             "aborting precalculated exports and reverting to normal scanning",
427                                     packageName, location);
428                             return Collections.emptyList();
429                         }
430                         result.add(new ExportPackage(packageName, version, new File(location)));
431                     }
432                     log.debug("Loaded {} precalculated exports", result.size());
433 
434                     return result;
435                 } catch (final DocumentException e) {
436                     log.warn("Unable to load exports from " + path + " due to malformed XML", e);
437                 }
438             }
439             log.debug("No precalculated exports found");
440             return null;
441         }
442     }
443 }