View Javadoc
1   package com.atlassian.plugin.osgi.factory.transform.stage;
2   
3   import aQute.bnd.osgi.Analyzer;
4   import aQute.bnd.osgi.Builder;
5   import aQute.bnd.osgi.Jar;
6   import com.atlassian.plugin.PluginInformation;
7   import com.atlassian.plugin.PluginParseException;
8   import com.atlassian.plugin.osgi.factory.OsgiPlugin;
9   import com.atlassian.plugin.osgi.factory.transform.PluginTransformationException;
10  import com.atlassian.plugin.osgi.factory.transform.TransformContext;
11  import com.atlassian.plugin.osgi.factory.transform.TransformStage;
12  import com.atlassian.plugin.osgi.factory.transform.model.SystemExports;
13  import com.atlassian.plugin.osgi.util.OsgiHeaderUtil;
14  import com.atlassian.plugin.parsers.XmlDescriptorParser;
15  import com.atlassian.plugin.util.PluginUtils;
16  import com.google.common.base.Joiner;
17  import com.google.common.collect.ImmutableMap;
18  import com.google.common.collect.ImmutableSet;
19  import org.apache.commons.lang3.StringUtils;
20  import org.osgi.framework.Constants;
21  import org.osgi.framework.Version;
22  import org.slf4j.Logger;
23  import org.slf4j.LoggerFactory;
24  
25  import java.io.ByteArrayOutputStream;
26  import java.io.IOException;
27  import java.util.ArrayList;
28  import java.util.Collection;
29  import java.util.Collections;
30  import java.util.LinkedHashMap;
31  import java.util.List;
32  import java.util.Map;
33  import java.util.Map.Entry;
34  import java.util.Properties;
35  import java.util.Set;
36  import java.util.StringTokenizer;
37  import java.util.jar.Attributes;
38  import java.util.jar.Manifest;
39  
40  import static com.atlassian.plugin.util.PluginUtils.isAtlassianDevMode;
41  
42  /**
43   * Generates an OSGi manifest if not already defined. Should be the last stage.
44   *
45   * @since 2.2.0
46   */
47  public class GenerateManifestStage implements TransformStage {
48      private static final Logger log = LoggerFactory.getLogger(GenerateManifestStage.class);
49  
50      private final int SPRING_TIMEOUT = PluginUtils.getDefaultEnablingWaitPeriod();
51      private final String SPRING_CONTEXT_DEFAULT = "*;" + SPRING_CONTEXT_TIMEOUT + SPRING_TIMEOUT;
52      public static final String SPRING_CONTEXT = "Spring-Context";
53      private static final String SPRING_CONTEXT_TIMEOUT = "timeout:=";
54      private static final String SPRING_CONTEXT_DELIM = ";";
55      private static final String RESOLUTION_DIRECTIVE = "resolution:";
56      private static final String EXCLUDE_PLUGIN_XML = "!atlassian-plugin.xml";
57      private static final String OPTIONAL_CATCHALL_KEY = "*";
58      private static final Map<String, String> OPTIONAL_CATCHALL_VALUE = ImmutableMap.of("resolution:", "optional");
59  
60      public void execute(final TransformContext context) throws PluginTransformationException {
61          final Joiner joiner = Joiner.on(",").skipNulls();
62  
63          try (Builder builder = new Builder()) {
64              builder.setJar(context.getPluginFile());
65  
66              // We don't care about the modules, so we pass null
67              final XmlDescriptorParser parser = new XmlDescriptorParser(context.getDescriptorDocument(), ImmutableSet.of());
68  
69              Manifest mf;
70              final Manifest contextManifest = context.getManifest();
71  
72              // OSGi instructions, such as imports/exports have already been resolved at build time
73              if (isOsgiBundle(contextManifest)) {
74                  if (context.getExtraImports().isEmpty()) {
75                      boolean modified = false;
76                      mf = builder.getJar().getManifest();
77                      for (final Entry<String, String> entry : getRequiredOsgiHeaders(context, parser.getKey()).entrySet()) {
78                          if (manifestDoesntHaveRequiredOsgiHeader(mf, entry)) {
79                              mf.getMainAttributes().putValue(entry.getKey(), entry.getValue());
80                              modified = true;
81                          }
82                      }
83                      validateOsgiVersionIsValid(mf);
84                      if (modified) {
85                          writeManifestOverride(context, mf);
86                      }
87                      // skip any manifest manipulation by bnd
88                      return;
89                  } else {
90                      // Possibly necessary due to Spring XML creation
91  
92                      assertSpringAvailableIfRequired(context);
93                      mf = builder.getJar().getManifest();
94  
95                      // combine the extra imports from atlassian-plugin.xml with the user supplied imports in the MF
96                      Map<String, Map<String, String>> importsByPackage = addExtraImports(
97                              builder.getJar().getManifest().getMainAttributes().getValue(Constants.IMPORT_PACKAGE),
98                              context.getExtraImports());
99  
100                     // strip any duplicates, including any duplicates that the user may have supplied
101                     importsByPackage = OsgiHeaderUtil.stripDuplicatePackages(importsByPackage, parser.getKey(), "import");
102 
103                     // convert imports to a single string and put in the MF
104                     final String imports = OsgiHeaderUtil.buildHeader(importsByPackage);
105                     mf.getMainAttributes().putValue(Constants.IMPORT_PACKAGE, imports);
106 
107 
108                     for (final Entry<String, String> entry : getRequiredOsgiHeaders(context, parser.getKey()).entrySet()) {
109                         mf.getMainAttributes().putValue(entry.getKey(), entry.getValue());
110                     }
111                 }
112             }
113             // there are no OSGi instructions, we need to create them from scratch
114             else {
115                 final PluginInformation info = parser.getPluginInformation();
116 
117                 final Properties properties = new Properties();
118 
119                 // Setup defaults
120                 for (final Entry<String, String> entry : getRequiredOsgiHeaders(context, parser.getKey()).entrySet()) {
121                     properties.put(entry.getKey(), entry.getValue());
122                 }
123                 //check for extra xml scan folders
124                 final Set<String> scanFolders = info.getModuleScanFolders();
125                 if (!scanFolders.isEmpty()) {
126                     properties.put(OsgiPlugin.ATLASSIAN_SCAN_FOLDERS, StringUtils.join(scanFolders, ","));
127                 }
128 
129                 properties.put(Analyzer.BUNDLE_SYMBOLICNAME, parser.getKey());
130                 properties.put(Analyzer.BUNDLE_VERSION, info.getVersion());
131                 // don't include a Require-Capability header for "osgi.ee" in the manifest; without NOEE,
132                 // module-info classes, for JPMS-ready libraries, can result in transformed plugins that
133                 // require Java 9+, even if the library itself would work correctly on Java 8
134                 // an example of a JPMS-ready library is javax.xml.bind:jaxb-api, which is compiled for
135                 // Java 7 but has a module-info.class that, by nature, must be compiled targeting Java 9
136                 properties.put(Analyzer.NOEE, "true");
137                 // remove the verbose Include-Resource entry from generated manifest
138                 properties.put(Analyzer.REMOVEHEADERS, Analyzer.INCLUDE_RESOURCE);
139 
140                 header(properties, Analyzer.BUNDLE_DESCRIPTION, info.getDescription());
141                 header(properties, Analyzer.BUNDLE_NAME, parser.getKey());
142                 header(properties, Analyzer.BUNDLE_VENDOR, info.getVendorName());
143                 header(properties, Analyzer.BUNDLE_DOCURL, info.getVendorUrl());
144 
145                 final List<String> bundleClassPaths = new ArrayList<>();
146 
147                 // the jar root.
148                 bundleClassPaths.add(".");
149 
150                 // inner jars. make the order deterministic here.
151                 final List<String> innerClassPaths = new ArrayList<>(context.getBundleClassPathJars());
152                 Collections.sort(innerClassPaths);
153                 bundleClassPaths.addAll(innerClassPaths);
154 
155                 // generate bundle classpath.
156                 header(properties, Analyzer.BUNDLE_CLASSPATH, StringUtils.join(bundleClassPaths, ','));
157 
158                 // Process any bundle instructions in atlassian-plugin.xml
159                 properties.putAll(context.getBndInstructions());
160 
161                 // Add extra imports to the imports list
162                 Map<String, Map<String, String>> importsByPackage = addExtraImports(
163                         properties.getProperty(Analyzer.IMPORT_PACKAGE),
164                         context.getExtraImports());
165 
166                 // shuffle any package imports containing a '*' to the end, in their current order
167                 importsByPackage = OsgiHeaderUtil.moveStarPackageToEnd(importsByPackage, parser.getKey());
168 
169                 // if there is not already an absolute '*' package import, put '*;resolution:="optional"' at the end of the imports
170                 if (!importsByPackage.containsKey(OPTIONAL_CATCHALL_KEY)) {
171                     importsByPackage.put(OPTIONAL_CATCHALL_KEY, OPTIONAL_CATCHALL_VALUE);
172                 }
173 
174                 // strip any duplicates, including any duplicates that the user may have supplied
175                 // note that this is not strictly necessary, however it's a good safety net in case any duplicates were found during extra import analysis
176                 importsByPackage = OsgiHeaderUtil.stripDuplicatePackages(importsByPackage, parser.getKey(), "import");
177 
178                 // convert imports to a single string and put in the properties
179                 final String imports = OsgiHeaderUtil.buildHeader(importsByPackage);
180                 properties.put(Analyzer.IMPORT_PACKAGE, imports);
181 
182                 // Add extra exports to the exports list
183                 if (!properties.containsKey(Analyzer.EXPORT_PACKAGE)) {
184                     properties.put(Analyzer.EXPORT_PACKAGE, StringUtils.join(context.getExtraExports(), ','));
185                 }
186 
187                 properties.put(Analyzer.EXPORT_PACKAGE, joiner.join(EXCLUDE_PLUGIN_XML,
188                         properties.getProperty(Analyzer.EXPORT_PACKAGE)));
189 
190                 builder.setProperties(properties);
191                 builder.calcManifest();
192                 try (Jar jar = builder.build()) {
193                     mf = jar.getManifest();
194                 }
195 
196                 // We want to preserve any extra headers from the context manifest, that is, the original untransformed manifest.
197                 // However, we don't want to clobber anything which was set by bnd, we want to warn about them being skipped.
198                 final Attributes attributes = mf.getMainAttributes();
199                 for (final Entry<Object, Object> entry : contextManifest.getMainAttributes().entrySet()) {
200                     final Object name = entry.getKey();
201                     if (attributes.containsKey(name)) {
202                         log.debug("Ignoring manifest header {} from {} due to transformer override",
203                                 name, context.getPluginArtifact());
204                     } else {
205                         attributes.put(name, entry.getValue());
206                     }
207                 }
208             }
209 
210             enforceHostVersionsForUnknownImports(mf, context.getSystemExports());
211             validateOsgiVersionIsValid(mf);
212 
213             writeManifestOverride(context, mf);
214         } catch (final Exception t) {
215             throw new PluginParseException("Unable to process plugin to generate OSGi manifest", t);
216         }
217     }
218 
219     private Map<String, String> getRequiredOsgiHeaders(final TransformContext context, final String pluginKey) {
220         final Map<String, String> props = new LinkedHashMap<>();
221         props.put(OsgiPlugin.ATLASSIAN_PLUGIN_KEY, pluginKey);
222         final String springHeader = getDesiredSpringContextValue(context);
223         if (springHeader != null) {
224             props.put(SPRING_CONTEXT, springHeader);
225         }
226         return props;
227     }
228 
229     private String getDesiredSpringContextValue(final TransformContext context) {
230         // Check for the explicit context value
231         final String header = context.getManifest().getMainAttributes().getValue(SPRING_CONTEXT);
232         if (header != null) {
233             return ensureDefaultTimeout(header);
234         }
235 
236         // Check for the spring files, as the default header value looks here
237         // TODO: This is probly not correct since if there is no META-INF/spring/*.xml, it's still not spring-powered.
238         if (context.getPluginArtifact().doesResourceExist("META-INF/spring/") ||
239                 context.shouldRequireSpring() ||
240                 context.getDescriptorDocument() != null) {
241             return SPRING_CONTEXT_DEFAULT;
242         }
243         return null;
244     }
245 
246     private String ensureDefaultTimeout(final String header) {
247         final boolean noTimeOutSpecified = StringUtils.isEmpty(System.getProperty(PluginUtils.ATLASSIAN_PLUGINS_ENABLE_WAIT));
248 
249         if (noTimeOutSpecified) {
250             return header;
251         }
252         final StringBuilder headerBuf;
253         //Override existing timeout
254         if (header.contains(SPRING_CONTEXT_TIMEOUT)) {
255             final StringTokenizer tokenizer = new StringTokenizer(header, SPRING_CONTEXT_DELIM);
256             headerBuf = new StringBuilder();
257             while (tokenizer.hasMoreElements()) {
258                 String directive = (String) tokenizer.nextElement();
259                 if (directive.startsWith(SPRING_CONTEXT_TIMEOUT)) {
260                     if (!directive.equals(SPRING_CONTEXT_TIMEOUT + PluginUtils.DEFAULT_ATLASSIAN_PLUGINS_ENABLE_WAIT_SECONDS)) {
261                         log.debug("Overriding configured timeout {} seconds", directive.substring(SPRING_CONTEXT_TIMEOUT.length()));
262                     }
263                     directive = SPRING_CONTEXT_TIMEOUT + SPRING_TIMEOUT;
264                 }
265                 headerBuf.append(directive);
266                 if (tokenizer.hasMoreElements()) {
267                     headerBuf.append(SPRING_CONTEXT_DELIM);
268                 }
269             }
270         } else {
271             //Append new timeout
272             headerBuf = new StringBuilder(header);
273             headerBuf.append(SPRING_CONTEXT_DELIM + SPRING_CONTEXT_TIMEOUT);
274             headerBuf.append(SPRING_TIMEOUT);
275         }
276         return headerBuf.toString();
277     }
278 
279     private void validateOsgiVersionIsValid(final Manifest mf) {
280         final String version = mf.getMainAttributes().getValue(Constants.BUNDLE_VERSION);
281         try {
282             if (Version.parseVersion(version) == Version.emptyVersion) {
283                 // we still consider an empty version to be bad
284                 throw new IllegalArgumentException();
285             }
286         } catch (final IllegalArgumentException ex) {
287             throw new IllegalArgumentException("Plugin version '" + version + "' is required and must be able to be " +
288                     "parsed as an OSGi version - MAJOR.MINOR.MICRO.QUALIFIER");
289         }
290     }
291 
292     private void writeManifestOverride(final TransformContext context, final Manifest mf)
293             throws IOException {
294         // Removing this field makes the generated manifest deterministic
295         final Attributes.Name lastModifiedKey = new Attributes.Name("Bnd-LastModified");
296         mf.getMainAttributes().remove(lastModifiedKey);
297 
298         final ByteArrayOutputStream bout = new ByteArrayOutputStream();
299         mf.write(bout);
300         context.getFileOverrides().put("META-INF/MANIFEST.MF", bout.toByteArray());
301     }
302 
303     /**
304      * Scans for any imports with no version specified and locks them into the specific version exported by the host
305      * container
306      *
307      * @param manifest The manifest to read and manipulate
308      * @param exports  The list of host exports
309      */
310     private void enforceHostVersionsForUnknownImports(final Manifest manifest, final SystemExports exports) {
311         final String origImports = manifest.getMainAttributes().getValue(Constants.IMPORT_PACKAGE);
312         if (origImports != null) {
313             final StringBuilder imports = new StringBuilder();
314             final Map<String, Map<String, String>> header = OsgiHeaderUtil.parseHeader(origImports);
315             for (final Map.Entry<String, Map<String, String>> pkgImport : header.entrySet()) {
316                 String imp = null;
317                 if (pkgImport.getValue().isEmpty()) {
318                     final String export = exports.getFullExport(pkgImport.getKey());
319                     if (!export.equals(imp)) {
320                         imp = export;
321                     }
322 
323                 }
324                 if (imp == null) {
325                     imp = OsgiHeaderUtil.buildHeader(pkgImport.getKey(), pkgImport.getValue());
326                 }
327                 imports.append(imp);
328                 imports.append(",");
329             }
330             if (imports.length() > 0) {
331                 imports.deleteCharAt(imports.length() - 1);
332             }
333 
334             manifest.getMainAttributes().putValue(Constants.IMPORT_PACKAGE, imports.toString());
335         }
336     }
337 
338     private boolean isOsgiBundle(final Manifest manifest) {
339         // OSGi core spec 4.2 section 3.5.2: The Bundle-SymbolicName manifest header is a mandatory header.
340         return manifest.getMainAttributes().getValue(Constants.BUNDLE_SYMBOLICNAME) != null;
341     }
342 
343     private Map<String, Map<String, String>> addExtraImports(final String importsLine, final List<String> extraImports) {
344         final Map<String, Map<String, String>> imports = OsgiHeaderUtil.parseHeader(importsLine);
345         for (final String exImport : extraImports) {
346             if (!exImport.startsWith("java.")) {
347                 // the extraImportPackage here can be in the form 'package;version=blah'. We only use the package component to check if it's already required.
348                 final String extraImportPackage = StringUtils.split(exImport, ';')[0];
349 
350                 final Map attrs = imports.get(extraImportPackage);
351                 // if the package is already required by the import directive supplied by plugin developer, we use the supplied one.
352                 if (attrs != null) {
353                     final Object resolution = attrs.get(RESOLUTION_DIRECTIVE);
354                     if (Constants.RESOLUTION_OPTIONAL.equals(resolution)) {
355                         attrs.put(RESOLUTION_DIRECTIVE, Constants.RESOLUTION_MANDATORY);
356                     }
357                 }
358                 // otherwise, it is system determined.
359                 else {
360                     imports.put(exImport, Collections.emptyMap());
361                 }
362             }
363         }
364         return imports;
365     }
366 
367     private boolean manifestDoesntHaveRequiredOsgiHeader(final Manifest mf, final Entry<String, String> entry) {
368         if (mf.getMainAttributes().containsKey(new Attributes.Name(entry.getKey()))) {
369             return !entry.getValue().equals(mf.getMainAttributes().getValue(entry.getKey()));
370         }
371         return true;
372     }
373 
374     private static void header(final Properties properties, final String key, final Object value) {
375         if (value == null) {
376             return;
377         }
378 
379         if (value instanceof Collection && ((Collection) value).isEmpty()) {
380             return;
381         }
382 
383         properties.put(key, value.toString().replaceAll("[\r\n]", ""));
384     }
385 
386     private void assertSpringAvailableIfRequired(final TransformContext context) {
387         if (isAtlassianDevMode() && context.shouldRequireSpring()) {
388             final String header = context.getManifest().getMainAttributes().getValue(SPRING_CONTEXT);
389             if (header == null) {
390                 log.debug("Manifest has no 'Spring-Context:' header. Prefer the header 'Spring-Context: *' in the jar '{}'.",
391                         context.getPluginArtifact());
392             } else if (header.contains(";timeout:=")) {
393                 log.warn("Manifest contains a 'Spring-Context:' header with a timeout, namely '{}'. This can cause problems as the "
394                                 + "timeout is server specific. Use the header 'Spring-Context: *' in the jar '{}'.",
395                         header, context.getPluginArtifact());
396             }
397             // else there's a Spring-Context with no timeout:=, which is fine.
398         }
399     }
400 
401 
402 }