Decouple plugins from their "jar" external form

Until now all the server-side plugins have been associated
to a single jar file in the /plugin directory.

As first step to allow different forms of plugins
(e.g. script files, directories or anything else that
can provide classes and resources) we need to de-couple
the underlying Jar file from the server side plugin.

We introduce the concept of "plugin-scanner" as the interface
to scan the external form to discover:
- plugin classes
- plugin resources
- plugin meta-data (i.e. Manifest)

Change-Id: I769595a030545a5f272f453c3cf435b74719e1e7
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 45b55c1..215736e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.PluginsCollection;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
+import com.google.gerrit.server.plugins.ServerPlugin;
 import com.google.gerrit.server.plugins.StartPluginListener;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtexpui.server.CacheHeaders;
@@ -268,7 +269,7 @@
     }
 
     if (file.startsWith(holder.staticPrefix)) {
-      JarFile jar = holder.plugin.getJarFile();
+      JarFile jar = jarFileOf(holder.plugin);
       if (jar != null) {
         JarEntry entry = jar.getJarEntry(file);
         if (exists(entry)) {
@@ -286,7 +287,7 @@
     } else if (file.startsWith(holder.docPrefix) && file.endsWith("/")) {
       res.sendRedirect(uri + "index.html");
     } else if (file.startsWith(holder.docPrefix)) {
-      JarFile jar = holder.plugin.getJarFile();
+      JarFile jar = jarFileOf(holder.plugin);
       JarEntry entry = jar.getJarEntry(file);
       if (!exists(entry)) {
         entry = findSource(jar, file);
@@ -632,6 +633,14 @@
     return data;
   }
 
+  private static JarFile jarFileOf(Plugin plugin) {
+    if(plugin instanceof ServerPlugin) {
+      return ((ServerPlugin) plugin).getJarFile();
+    } else {
+      return null;
+    }
+  }
+
   private static class PluginHolder {
     final Plugin plugin;
     final GuiceFilter filter;
@@ -648,7 +657,7 @@
     }
 
     private static String getPrefix(Plugin plugin, String attr, String def) {
-      JarFile jarFile = plugin.getJarFile();
+      JarFile jarFile = jarFileOf(plugin);
       if (jarFile == null) {
         return def;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
index bd8a524..6f1204b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.annotations.Listen;
-import com.google.gerrit.server.plugins.JarScanner.ExtensionMetaData;
+import com.google.gerrit.server.plugins.PluginContentScanner.ExtensionMetaData;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
@@ -34,12 +34,11 @@
 import java.util.Arrays;
 import java.util.Map;
 import java.util.Set;
-import java.util.jar.JarFile;
 
 class AutoRegisterModules {
   private final String pluginName;
   private final PluginGuiceEnvironment env;
-  private final JarFile jarFile;
+  private final PluginContentScanner scanner;
   private final ClassLoader classLoader;
   private final ModuleGenerator sshGen;
   private final ModuleGenerator httpGen;
@@ -53,11 +52,11 @@
 
   AutoRegisterModules(String pluginName,
       PluginGuiceEnvironment env,
-      JarFile jarFile,
+      PluginContentScanner scanner,
       ClassLoader classLoader) {
     this.pluginName = pluginName;
     this.env = env;
-    this.jarFile = jarFile;
+    this.scanner = scanner;
     this.classLoader = classLoader;
     this.sshGen = env.hasSshModule() ? env.newSshModuleGenerator() : null;
     this.httpGen = env.hasHttpModule() ? env.newHttpModuleGenerator() : null;
@@ -111,7 +110,7 @@
 
   private void scan() throws InvalidPluginException {
     Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> extensions =
-        JarScanner.scan(jarFile, pluginName, Arrays.asList(Export.class, Listen.class));
+        scanner.scan(pluginName, Arrays.asList(Export.class, Listen.class));
     for (ExtensionMetaData export : extensions.get(Export.class)) {
       export(export);
     }
@@ -123,18 +122,18 @@
   private void export(ExtensionMetaData def) throws InvalidPluginException {
     Class<?> clazz;
     try {
-      clazz = Class.forName(def.getClassName(), false, classLoader);
+      clazz = Class.forName(def.className, false, classLoader);
     } catch (ClassNotFoundException err) {
       throw new InvalidPluginException(String.format(
           "Cannot load %s with @Export(\"%s\")",
-          def.getClassName(), def.getAnnotationValue()), err);
+          def.className, def.annotationValue), err);
     }
 
     Export export = clazz.getAnnotation(Export.class);
     if (export == null) {
       PluginLoader.log.warn(String.format(
           "In plugin %s asm incorrectly parsed %s with @Export(\"%s\")",
-          pluginName, clazz.getName(), def.getAnnotationValue()));
+          pluginName, clazz.getName(), def.annotationValue));
       return;
     }
 
@@ -162,11 +161,11 @@
   private void listen(ExtensionMetaData def) throws InvalidPluginException {
     Class<?> clazz;
     try {
-      clazz = Class.forName(def.getClassName(), false, classLoader);
+      clazz = Class.forName(def.className, false, classLoader);
     } catch (ClassNotFoundException err) {
       throw new InvalidPluginException(String.format(
           "Cannot load %s with @Listen",
-          def.getClassName()), err);
+          def.className), err);
     }
 
     Listen listen = clazz.getAnnotation(Listen.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
index c4fe1ec..6ce3464 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
@@ -38,6 +39,7 @@
 import org.objectweb.asm.Opcodes;
 import org.objectweb.asm.Type;
 
+import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
@@ -46,10 +48,12 @@
 import java.util.Enumeration;
 import java.util.Map;
 import java.util.Set;
+import java.util.jar.Attributes;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
+import java.util.jar.Manifest;
 
-public class JarScanner {
+public class JarScanner implements PluginContentScanner {
   private static final int SKIP_ALL = ClassReader.SKIP_CODE
       | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
   private static final Function<ClassData, ExtensionMetaData> CLASS_DATA_TO_EXTENSION_META_DATA =
@@ -61,27 +65,19 @@
         }
       };
 
-  public static class ExtensionMetaData {
-    private final String className;
-    private final String annotationValue;
+  private final JarFile jarFile;
 
-    private ExtensionMetaData(String className, String annotationValue) {
-      this.className = className;
-      this.annotationValue = annotationValue;
-    }
-
-    public String getAnnotationValue() {
-      return annotationValue;
-    }
-
-    public String getClassName() {
-      return className;
+  public JarScanner(File srcFile) throws InvalidPluginException {
+    try {
+      this.jarFile = new JarFile(srcFile);
+    } catch (IOException e) {
+      throw new InvalidPluginException("Cannot scan plugin file " + srcFile, e);
     }
   }
 
-  public static Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
-      JarFile jarFile, String pluginName,
-      Iterable<Class<? extends Annotation>> annotations)
+  @Override
+  public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
+      String pluginName, Iterable<Class<? extends Annotation>> annotations)
       throws InvalidPluginException {
     Set<String> descriptors = Sets.newHashSet();
     Multimap<String, JarScanner.ClassData> rawMap = ArrayListMultimap.create();
@@ -262,4 +258,62 @@
     public void visitEnd() {
     }
   }
+
+  @Override
+  public Optional<PluginEntry> getEntry(String resourcePath) throws IOException {
+    JarEntry jarEntry = jarFile.getJarEntry(resourcePath);
+    if (jarEntry == null || jarEntry.getSize() == 0) {
+      return Optional.absent();
+    }
+
+    return Optional.of(resourceOf(jarEntry));
+  }
+
+  @Override
+  public Enumeration<PluginEntry> entries() {
+    return Collections.enumeration(Lists.transform(
+        Collections.list(jarFile.entries()),
+        new Function<JarEntry, PluginEntry>() {
+          public PluginEntry apply(JarEntry jarEntry) {
+            try {
+              return resourceOf(jarEntry);
+            } catch (IOException e) {
+              throw new IllegalArgumentException("Cannot convert jar entry "
+                  + jarEntry + " to a resource", e);
+            }
+          }
+        }));
+  }
+
+  @Override
+  public InputStream getInputStream(PluginEntry entry)
+      throws IOException {
+    return jarFile.getInputStream(jarFile
+        .getEntry(entry.getName()));
+  }
+
+  @Override
+  public Manifest getManifest() throws IOException {
+    return jarFile.getManifest();
+  }
+
+  private PluginEntry resourceOf(JarEntry jarEntry) throws IOException {
+    return new PluginEntry(jarEntry.getName(), jarEntry.getTime(),
+        jarEntry.getSize(), attributesOf(jarEntry));
+  }
+
+  private Map<Object, String> attributesOf(JarEntry jarEntry)
+      throws IOException {
+    Attributes attributes = jarEntry.getAttributes();
+    if (attributes == null) {
+      return Collections.emptyMap();
+    }
+    return Maps.transformEntries(attributes,
+        new Maps.EntryTransformer<Object, Object, String>() {
+          @Override
+          public String transformEntry(Object key, Object value) {
+            return (String) value;
+          }
+        });
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
index ff72576..e21bb75 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
@@ -67,11 +67,6 @@
   }
 
   @Override
-  public JarFile getJarFile() {
-    return null;
-  }
-
-  @Override
   public Injector getSysInjector() {
     return null;
   }
@@ -109,4 +104,9 @@
           new JavaScriptPlugin(fileName));
     }
   }
+
+  @Override
+  public PluginContentScanner getContentScanner() {
+    return PluginContentScanner.EMPTY;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
index 577ee0c..c5611dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -137,7 +137,7 @@
       version = p.getVersion();
       disabled = p.isDisabled() ? true : null;
 
-      if (p.getJarFile() != null) {
+      if (p.getSrcFile() != null) {
         indexUrl = String.format("plugins/%s/", p.getName());
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
index 988669a..aeafc5be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
@@ -124,7 +124,7 @@
 
   abstract void stop(PluginGuiceEnvironment env);
 
-  public abstract JarFile getJarFile();
+  public abstract PluginContentScanner getContentScanner();
 
   public abstract Injector getSysInjector();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
new file mode 100644
index 0000000..0228509
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// 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.google.gerrit.server.plugins;
+
+import com.google.common.base.Optional;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Map;
+import java.util.jar.Manifest;
+
+/**
+ * Scans the plugin returning classes and resources.
+ *
+ * Gerrit uses the scanner to automatically discover the classes
+ * and resources exported by the plugin for auto discovery
+ * of exported SSH commands, Servlets and listeners.
+ */
+public interface PluginContentScanner {
+
+  /**
+   * Scanner without resources.
+   */
+  PluginContentScanner EMPTY = new PluginContentScanner() {
+    @Override
+    public Manifest getManifest() throws IOException {
+      return new Manifest();
+    }
+
+    @Override
+    public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
+        String pluginName, Iterable<Class<? extends Annotation>> annotations)
+        throws InvalidPluginException {
+     return Collections.emptyMap();
+    }
+
+    @Override
+    public Optional<PluginEntry> getEntry(String resourcePath)
+        throws IOException {
+      return Optional.absent();
+    }
+
+    @Override
+    public InputStream getInputStream(PluginEntry entry) throws IOException {
+      throw new FileNotFoundException("Empty plugin");
+    }
+
+    @Override
+    public Enumeration<PluginEntry> entries() {
+      return Collections.emptyEnumeration();
+    }
+  };
+
+  /**
+   * Plugin class extension meta-data
+   *
+   * Class name and annotation value of the class
+   * provided by a plugin to extend an existing
+   * extension point in Gerrit.
+   */
+  public static class ExtensionMetaData {
+    public final String className;
+    public final String annotationValue;
+
+    public ExtensionMetaData(String className, String annotationValue) {
+      this.className = className;
+      this.annotationValue = annotationValue;
+    }
+  }
+
+  /**
+   * Return the plugin meta-data manifest
+   *
+   * @return Manifest of the plugin or null if plugin has no meta-data
+   * @throws IOException if an I/O problem occurred whilst accessing the Manifest
+   */
+  Manifest getManifest() throws IOException;
+
+  /**
+   * Scans the plugin for declared public annotated classes
+   *
+   * @param pluginName the plugin name
+   * @param annotations annotations declared by the plugin classes
+   * @return map of annotations and associated plugin classes found
+   * @throws InvalidPluginException if the plugin is not valid or corrupted
+   */
+  Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
+      String pluginName, Iterable<Class<? extends Annotation>> annotations)
+      throws InvalidPluginException;
+
+  /**
+   * Return the plugin resource associated to a path
+   *
+   * @param resourcePath full path of the resource inside the plugin package
+   * @return the resource object or Optional.absent() if the resource was not found
+   * @throws IOException if there was a problem retrieving the resource
+   */
+  Optional<PluginEntry> getEntry(String resourcePath) throws IOException;
+
+  /**
+   * Return the InputStream of the resource entry
+   *
+   * @param entry resource entry inside the plugin package
+   * @return the resource input stream
+   * @throws IOException if there was an I/O problem accessing the resource
+   */
+  InputStream getInputStream(PluginEntry entry) throws IOException;
+
+  /**
+   * Return all the resources inside a plugin
+   *
+   * @return the enumeration of all resources found
+   */
+  Enumeration<PluginEntry> entries();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
new file mode 100644
index 0000000..6022f02
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// 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.google.gerrit.server.plugins;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * Plugin static resource entry
+ *
+ * Bean representing a static resource inside a plugin.
+ * All static resources are available at <plugin web url>/static
+ * and served by the HttpPluginServlet.
+ */
+public class PluginEntry {
+  public static final String ATTR_CHARACTER_ENCODING = "Character-Encoding";
+  public static final String ATTR_CONTENT_TYPE = "Content-Type";
+
+  private static final Map<Object,String> EMPTY_ATTRS = Collections.emptyMap();
+
+  private final String name;
+  private final long time;
+  private final long size;
+  private final Map<Object, String> attrs;
+
+  public PluginEntry(String name, long time, long size,
+      Map<Object, String> attrs) {
+    this.name = name;
+    this.time = time;
+    this.size = size;
+    this.attrs = attrs;
+  }
+
+  public PluginEntry(String name, long time, long size) {
+    this(name, time, size, EMPTY_ATTRS);
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public long getTime() {
+    return time;
+  }
+
+  public long getSize() {
+    return size;
+  }
+
+  public Map<Object, String> getAttrs() {
+    return attrs;
+  }
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index c2c8d03..b56a10c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -589,8 +589,8 @@
 
       Plugin plugin = new ServerPlugin(name, url,
           pluginUserFactory.create(name),
-          srcJar, snapshot,
-          jarFile, manifest,
+          srcJar, snapshot, new JarFile(srcJar),
+          new JarScanner(srcJar),
           new File(dataDir, name), type, pluginLoader,
           sysModule, sshModule, httpModule);
       cleanupHandles.put(plugin, new CleanupHandle(tmp, jarFile));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 8dae5ae..052cdc7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -35,12 +35,13 @@
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
 import java.io.File;
+import java.io.IOException;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
 
-class ServerPlugin extends Plugin {
+public class ServerPlugin extends Plugin {
 
   /** Unique key that changes whenever a plugin reloads. */
   public static final class CacheKey {
@@ -59,6 +60,7 @@
 
   private final JarFile jarFile;
   private final Manifest manifest;
+  private final PluginContentScanner scanner;
   private final File dataDir;
   private final String pluginCanonicalWebUrl;
   private final ClassLoader classLoader;
@@ -78,28 +80,39 @@
       File srcJar,
       FileSnapshot snapshot,
       JarFile jarFile,
-      Manifest manifest,
+      PluginContentScanner scanner,
       File dataDir,
       ApiType apiType,
       ClassLoader classLoader,
       @Nullable Class<? extends Module> sysModule,
       @Nullable Class<? extends Module> sshModule,
-      @Nullable Class<? extends Module> httpModule) {
+      @Nullable Class<? extends Module> httpModule)
+      throws InvalidPluginException {
     super(name, srcJar, pluginUser, snapshot, apiType);
     this.pluginCanonicalWebUrl = pluginCanonicalWebUrl;
     this.jarFile = jarFile;
-    this.manifest = manifest;
+    this.scanner = scanner;
     this.dataDir = dataDir;
     this.classLoader = classLoader;
     this.sysModule = sysModule;
     this.sshModule = sshModule;
     this.httpModule = httpModule;
+    this.manifest = getPluginManifest(scanner);
   }
 
   File getSrcJar() {
     return getSrcFile();
   }
 
+  private Manifest getPluginManifest(PluginContentScanner scanner)
+      throws InvalidPluginException {
+    try {
+       return scanner.getManifest();
+    } catch (IOException e) {
+      throw new InvalidPluginException("Cannot get plugin manifest", e);
+    }
+  }
+
   @Nullable
   public String getVersion() {
     Attributes main = manifest.getMainAttributes();
@@ -136,7 +149,7 @@
 
     AutoRegisterModules auto = null;
     if (sysModule == null && sshModule == null && httpModule == null) {
-      auto = new AutoRegisterModules(getName(), env, jarFile, classLoader);
+      auto = new AutoRegisterModules(getName(), env, scanner, classLoader);
       auto.discover();
     }
 
@@ -270,4 +283,9 @@
       manager.add(handle);
     }
   }
+
+  @Override
+  public PluginContentScanner getContentScanner() {
+    return scanner;
+  }
 }