package gov.nih.ncgc.bard.tools; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.fge.jsonschema.exceptions.ProcessingException; import com.github.fge.jsonschema.main.JsonSchemaFactory; import com.github.fge.jsonschema.report.ProcessingMessage; import com.github.fge.jsonschema.report.ProcessingReport; import com.github.fge.jsonschema.util.JsonLoader; import gov.nih.ncgc.bard.plugin.IPlugin; import org.apache.http.HttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.DefaultHttpClient; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.nio.SelectChannelConnector; import org.eclipse.jetty.util.log.Logger; import org.eclipse.jetty.webapp.WebAppContext; import org.eclipse.jetty.xml.XmlConfiguration; import org.xml.sax.SAXException; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.Produces; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Enumeration; import java.util.List; import java.util.jar.JarInputStream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; /** * A tool to validate BARD plugins. * <p/> * If used in your own code, you should ensure that the plugin manifest schema * is located at <code>/manifest.json</code> in your CLASSPATH. When run from the * command line, the schema is bundled with the final JAR file. * * @author Rajarshi Guha */ public class PluginValidator { private static Server server = null; private static final String version = "1.1"; private static Integer JETTY_PORT = 8989; private String[] packagesToIgnore = {"javax.servlet"}; private String currentClassName = ""; private ErrorList errors; public static String getVersion() { return version; } public class NoLogging implements Logger { @Override public String getName() { return "no"; } @Override public void warn(String msg, Object... args) { } @Override public void warn(Throwable thrown) { } @Override public void warn(String msg, Throwable thrown) { } @Override public void info(String msg, Object... args) { } @Override public void info(Throwable thrown) { } @Override public void info(String msg, Throwable thrown) { } @Override public boolean isDebugEnabled() { return false; } @Override public void setDebugEnabled(boolean enabled) { } @Override public void debug(String msg, Object... args) { } @Override public void debug(Throwable thrown) { } @Override public void debug(String msg, Throwable thrown) { } @Override public Logger getLogger(String name) { return this; } @Override public void ignore(Throwable ignored) { } } public PluginValidator() { errors = new ErrorList(); org.eclipse.jetty.util.log.Log.setLog(new NoLogging()); } public List<String> getErrors() { return errors; } class ErrorList extends ArrayList<String> { public void info(String s) { add("INFO:\t" + s); } public void error(String s) { add("ERROR:\t" + s); } } class ByteArrayClassLoader extends ClassLoader { byte[] bytes; public ByteArrayClassLoader(byte[] bytes) { this.bytes = bytes; } public Class findClass(String name) { Class klass = null; try { if (name.startsWith("java")) { System.out.println("trying to load via super"); klass = super.findClass(name); System.out.println(" got " + name + " from super"); } else klass = defineClass(name, bytes, 0, bytes.length); resolveClass(klass); } catch (IllegalAccessError e) { errors.info("Got an IllegalAccessError when loading " + name); return null; } catch (NoClassDefFoundError e) { errors.info("Got an NoClassDefFoundError when loading " + name); return null; } catch (ClassNotFoundException e) { errors.info("Got an ClassNotFound when loading " + name); return null; } return klass; } } // from http://stackoverflow.com/a/9505409 void extractFolder(String zipFile, String todir) throws IOException { int BUFFER = 2048; File file = new File(zipFile); ZipFile zip = new ZipFile(file); String newPath = todir; if (newPath == null) newPath = zipFile.substring(0, zipFile.length() - 4); Enumeration zipFileEntries = zip.entries(); // Process each entry while (zipFileEntries.hasMoreElements()) { // grab a zip file entry ZipEntry entry = (ZipEntry) zipFileEntries.nextElement(); String currentEntry = entry.getName(); File destFile = new File(newPath, currentEntry); //destFile = new File(newPath, destFile.getName()); File destinationParent = destFile.getParentFile(); // create the parent directory structure if needed destinationParent.mkdirs(); if (!entry.isDirectory()) { BufferedInputStream is = new BufferedInputStream(zip .getInputStream(entry)); int currentByte; // establish buffer for writing file byte data[] = new byte[BUFFER]; // write the current file to disk FileOutputStream fos = new FileOutputStream(destFile); BufferedOutputStream dest = new BufferedOutputStream(fos, BUFFER); // read and write until last byte is encountered while ((currentByte = is.read(data, 0, BUFFER)) != -1) { dest.write(data, 0, currentByte); } dest.flush(); dest.close(); is.close(); } else { destFile.mkdirs(); } if (currentEntry.endsWith(".zip")) { // found a zip file, try to open extractFolder(destFile.getAbsolutePath(), null); } } zip.close(); } void loadJarFile(String filePath) throws IOException { URLClassLoader sysLoader; URL u; Class sysclass; try { u = new URL("file://" + filePath); sysLoader = (URLClassLoader) ClassLoader.getSystemClassLoader(); sysclass = URLClassLoader.class; Method method = sysclass.getDeclaredMethod("addURL", new Class[]{URL.class}); method.setAccessible(true); method.invoke(sysLoader, new Object[]{u}); } catch (Throwable t) { t.printStackTrace(System.err); } } private boolean ignoreClass(String className) { for (String pkg : packagesToIgnore) { if (className.contains(pkg)) return true; } return false; } protected Object[] readFromUrl(String url) throws IOException { StringBuffer result = new StringBuffer(); DefaultHttpClient httpclient = new DefaultHttpClient(); HttpGet get = new HttpGet(url); HttpResponse response = httpclient.execute(get); Integer statusCode = response.getStatusLine().getStatusCode(); BufferedReader rd = new BufferedReader(new InputStreamReader(response.getEntity().getContent())); String line; while ((line = rd.readLine()) != null) { result.append(line); } return new Object[]{statusCode, result.toString()}; } public boolean validateServlet(String filename) throws Exception, IOException, SAXException { URL configResource = this.getClass().getResource("/jetty.xml"); XmlConfiguration configuration = new XmlConfiguration(configResource); server = (Server) configuration.configure(); Connector connector = new SelectChannelConnector(); connector.setPort(JETTY_PORT); connector.setHost("127.0.0.1"); server.addConnector(connector); WebAppContext wac = new WebAppContext(); wac.setWar(filename); wac.setContextPath("/"); wac.setParentLoaderPriority(true); server.setHandler(wac); server.setStopAtShutdown(true); server.start(); String res = null; filename = new File(filename).getName(); String[] toks = filename.split("\\."); if (toks.length == 2) { res = toks[0].replace("bardplugin_", ""); } else { errors.add("Invalid name format for war file"); } String baseUrl = "http://localhost:" + JETTY_PORT; // check that we can access the servlet String url = baseUrl + "/" + res; Object[] ret = readFromUrl(url); if ((Integer) ret[0] == 404) errors.add("/" + res + " resource not found"); // check we can get the /_info subresource url = baseUrl + "/" + res + "/_info"; ret = readFromUrl(url); if ((Integer) ret[0] != 200) errors.add("/" + res + "/_info resource not found"); // check we can get the /_manifest subresource url = baseUrl + "/" + res + "/_manifest"; ret = readFromUrl(url); if ((Integer) ret[0] != 200) errors.add("/" + res + "/_manifest resource not found"); boolean manifestIsValid = false; try { if (ret[1] != null && !ret[1].equals("")) { ObjectMapper mapper = new ObjectMapper(); JsonNode manifestNode = mapper.readTree((String) ret[1]); JsonSchemaFactory factory = JsonSchemaFactory.byDefault(); JsonNode schemaNode = JsonLoader.fromResource("/manifest.json"); com.github.fge.jsonschema.main.JsonSchema schema = factory.getJsonSchema(schemaNode); ProcessingReport report = schema.validate(manifestNode); manifestIsValid = report.isSuccess(); if (!manifestIsValid) { for (ProcessingMessage msg : report) errors.error(msg.getMessage()); } } } catch (IOException e) { } catch (ProcessingException e) { } if (!manifestIsValid) errors.error("Manifest did not validate"); server.setGracefulShutdown(0); return errors.size() == 0; } public boolean validate(String filename) throws IOException, InstantiationException, IllegalAccessException, ClassNotFoundException { String basename = (new File(filename)).getName(); boolean atLeastOnePlugin = false; boolean status = false; // extract the war to a temp dir int nJar = 0; String tempDir = System.getProperty("java.io.tmpdir") + File.separator + "tmp" + System.nanoTime(); File tempDirFile = new File(tempDir); if (!tempDirFile.exists()) tempDirFile.mkdir(); extractFolder(filename, tempDir); // load JARs and classes we just extracted File[] jars = (new File(tempDir + File.separator + "WEB-INF/lib")).listFiles(); for (File jar : jars) loadJarFile(jar.getAbsolutePath()); System.out.println("Added " + jars.length + " jars from WEB-INF/lib to the current CLASSPATH"); loadJarFile(tempDir + File.separator + "WEB-INF/classes/"); System.out.println("Added class from WEB-INF/classes to current CLASSPATH"); ZipFile zf = new ZipFile(filename); Enumeration entries = zf.entries(); ByteArrayClassLoader loader; while (entries.hasMoreElements()) { ZipEntry ze = (ZipEntry) entries.nextElement(); String entryName = ze.getName(); if (entryName.endsWith(".class")) { BufferedInputStream bis = new BufferedInputStream(zf.getInputStream(ze)); ByteArrayOutputStream baos = new ByteArrayOutputStream(); int c; while ((c = bis.read()) != -1) { baos.write(c); } bis.close(); baos.close(); byte[] bytes = baos.toByteArray(); String className = entryName.split("\\.")[0].replace("WEB-INF/classes/", "").replace("/", "."); if (ignoreClass(className)) continue; loader = new ByteArrayClassLoader(bytes); Class klass = loader.findClass(className); if (klass != null && implementsPluginInterface(klass)) { status = validate(klass, basename); atLeastOnePlugin = true; } } else if (entryName.endsWith(".jar")) { // look for classes in the jar file JarInputStream jis = new JarInputStream(zf.getInputStream(ze)); ZipEntry entry; while ((entry = jis.getNextEntry()) != null) { if (!entry.getName().contains(".class")) continue; String className = entry.getName().replace(".class", "").replace("/", "."); if (ignoreClass(className)) continue; if (entry.getSize() <= 0) continue; ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] bytes = new byte[1024]; long nbyte = 0; while (true) { int n = jis.read(bytes); if (n == -1) break; baos.write(bytes, 0, n); nbyte += n; } bytes = baos.toByteArray(); loader = new ByteArrayClassLoader(bytes); Class klass = loader.findClass(className); if (klass != null && implementsPluginInterface(klass)) { status = validate(klass, basename); atLeastOnePlugin = true; } } jis.close(); } } zf.close(); tempDirFile.delete(); if (!atLeastOnePlugin) { errors.add("This does not seem to be a BARD plugin as there were no classes implementing the IPlugin interface"); return false; } else return status; } private boolean implementsPluginInterface(Class klass) { boolean implementsInterface = false; Class pluginInterface = IPlugin.class; Class[] interfaces = klass.getInterfaces(); for (Class iface : interfaces) { if (iface.equals(pluginInterface)) { implementsInterface = true; break; } } return implementsInterface; } /** * Validate a class that will expose a BARD plugin service. * * @param klass The class in question. * @param warName If validating via a WAR file, this should be the war file name. Otherwise, null * @return true if a valid plugin class, otherwise false. * @throws IllegalAccessException * @throws InstantiationException */ public boolean validate(Class klass, String warName) throws IllegalAccessException, InstantiationException { // check appropriate interface boolean implementsInterface = implementsPluginInterface(klass); if (!implementsInterface) { errors.add("Does not implement IPlugin"); } // check that the class has a class level @Path annotation // if @Path is present, ensure it matches war file name (if we got one) boolean collidesWithRegistry = false; if (klass.isAnnotationPresent(Path.class)) { Path annot = (Path) klass.getAnnotation(Path.class); String value = annot.value(); if (value != null && value.indexOf("/plugins/registry") == 0) { collidesWithRegistry = true; } } if (collidesWithRegistry) errors.error("Class level @Path annotation cannot start with '/plugins/registry'"); Method[] methods = klass.getMethods(); // check that we have at least one (public) method that is annotated // with a GET or a POST and has a non null @Path annotation // and a @Produces annotations // // Note that this check excludes the method annotated with the _info // resource path boolean resourcePresent = false; for (Method method : methods) { if (method.isAnnotationPresent(Path.class)) { Path annot = method.getAnnotation(Path.class); if (annot.value().equals("/_info")) continue; // check for a @GET/@POST/@PUT if (method.isAnnotationPresent(GET.class) || method.isAnnotationPresent(POST.class) || method.isAnnotationPresent(PUT.class)) { // check for a @Produces if (method.isAnnotationPresent(Produces.class)) { resourcePresent = true; break; } } } } if (!resourcePresent) errors.error("At least one public method must have a @Path annotation (in addition to the _info & _manifest resources"); boolean hasEmptyCtor = false; Constructor[] ctors = klass.getConstructors(); for (Constructor ctor : ctors) { if (ctor.getParameterTypes().length == 0) { hasEmptyCtor = true; break; } } if (!hasEmptyCtor) { errors.error("Cannot instantiate plugin because it does not have an empty constructor"); return false; } return errors.size() == 0; } public static void main(String[] args) throws Exception { boolean printInfo = false; boolean printWarn = false; if (args.length < 1) { System.out.println("\nBARD Plugin validator v" + version); System.out.println("\nUsage: java -jar validator.jar bardplugin_FOO.war [-i|-w|-p PORT]"); System.out.println("\n-i\tPrint INFO messages"); System.out.println("-w\tPrint WARN messages"); System.out.println("-p PORT\tSet Jetty port. Default is 8989"); System.out.println("\nBy default only ERROR messages are reported"); System.exit(-1); } int i = 0; for (String arg : args) { if (arg.contains("-i")) printInfo = true; if (arg.contains("-w")) printWarn = true; if (arg.contains("-i")) JETTY_PORT = Integer.parseInt(args[i + 1]); i++; } PluginValidator v = new PluginValidator(); boolean status = v.validateServlet(args[0]) && v.validate(args[0]); // boolean status = v.validate(args[0]); // boolean status = v.validate("/Users/guhar/Downloads/bardplugin_badapple.war"); // boolean status = v.validate("/Users/guhar/Downloads/bardplugin_hellofromunm.war"); System.out.println("PLUGINVALIDATOR: status = " + status); for (String s : v.getErrors()) { if (s.startsWith("INFO") && printInfo) System.out.println("PLUGINVALIDATOR:" + s); else if (s.startsWith("WARN") && printWarn) System.out.println("PLUGINVALIDATOR:" + s); else if (s.startsWith("ERROR")) System.out.println("PLUGINVALIDATOR:" + s); } if (server != null) server.stop(); } }