org.pepsoft.util.plugins.PluginManager Maven / Gradle / Ivy
/*
* To change this template, choose Tools | Templates
* and open the template in the editor.
*/
package org.pepsoft.util.plugins;
import org.jetbrains.annotations.NotNull;
import org.pepsoft.util.StreamUtils;
import org.pepsoft.util.Version;
import org.pepsoft.util.mdc.MDCCapturingRuntimeException;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static java.util.Collections.unmodifiableList;
import static org.pepsoft.util.FileUtils.stripDirectory;
import static org.pepsoft.util.FileUtils.stripExtension;
import static org.pepsoft.util.ObjectMapperHolder.OBJECT_MAPPER;
/**
* A general purpose plugin manager.
*
* @author pepijn
*/
public final class PluginManager {
private PluginManager() {
// Prevent instantiation
}
/**
* Load plugin jars from a directory, which are signed with a particular private key. Optionally check for updates
* and replace versions which have updates with their newer versions.
*
* This method should be invoked only once. Any discovered and properly signed plugin jars will be available to
* be returned by later invocations of the {@link #findPlugins(Class, String, ClassLoader)} method.
*
* @param pluginDir The directory from which to load the plugins.
* @param publicKey The public key corresponding to the private key with which the plugins must have been signed.
* @param descriptorPath The resource path of the file containing the plugin descriptor.
* @param hostVersion The version of the host, for the update checking process and for checking the minimum required
* WorldPainter version of the plugins.
*/
public static void loadPlugins(File pluginDir, PublicKey publicKey, String descriptorPath, Version hostVersion, boolean updatePlugins) {
if (logger.isDebugEnabled()) {
logger.debug("Loading plugins");
}
File[] pluginFiles = pluginDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".jar"));
if (pluginFiles != null) {
for (File pluginFile: pluginFiles) {
try {
JarFile jarFile = new JarFile(pluginFile);
if (! isSigned(jarFile, publicKey)) {
String message = stripDirectory(jarFile.getName()) + " is not official or has been tampered with";
errors.add(message);
logger.error(message + "; not loading it");
continue;
}
if (updatePlugins) {
checkForUpdates(jarFile, publicKey, descriptorPath, hostVersion);
}
final Descriptor descriptor = loadDescriptor(jarFile, descriptorPath);
if ((descriptor.minimumHostVersion != null) && (! hostVersion.isAtLeast(descriptor.minimumHostVersion))) {
String message = "Plugin " + descriptor.name + " requires at least version " + descriptor.minimumHostVersion + " of WorldPainter";
errors.add(message);
logger.error(message + "; not loading it");
continue;
}
ClassLoader pluginClassLoader = new URLClassLoader(new URL[] {pluginFile.toURI().toURL()});
jarClassLoaders.put(jarFile, pluginClassLoader);
} catch (IOException e) {
errors.add(pluginFile.getName() + " could not be loaded due to an I/O error");
logger.error("{} while loading plugin from file {} (message: {}); not loading it", e.getClass().getSimpleName(), pluginFile.getName(), e.getMessage(), e);
}
}
}
}
/**
* Obtain a list of instances of all plugins available through a particular classloader, or from plugin jars
* discovered by a previous invocation of {@link #loadPlugins(File, PublicKey, String, Version, boolean)}, which
* implement a particular type.
*
* @param type The type of plugin to return.
* @param descriptorPath The resource path of the file containing the plugin descriptor.
* @param classLoader The classloader from which to discover plugins.
* @param The type of plugin to return.
* @return A list of newly instantiated plugin objects of the specified type available from the specified
* classloader and/or any earlier discovered plugin jars.
*/
public static List findPlugins(Class type, String descriptorPath, ClassLoader classLoader) {
try {
List plugins = new ArrayList<>();
findPlugins(type, descriptorPath, classLoader, plugins);
for (JarFile pluginJar: jarClassLoaders.keySet()) {
try {
findPlugins(type, descriptorPath, pluginJar, plugins);
} catch (ClassNotFoundException | IllegalAccessException | InstantiationException | NoClassDefFoundError e) {
errors.add(stripDirectory(pluginJar.getName()) + " could not be loaded; perhaps it is not compatible with this version of WorldPainter");
logger.error("{} while instantiating plugin {} (message: {}); skipping plugin", e.getClass().getSimpleName(), pluginJar.getName(), e.getMessage(), e);
}
}
return plugins;
} catch (IOException e) {
throw new MDCCapturingRuntimeException("I/O error while loading plugins", e);
}
}
/**
* Get a classloader which gives access to the classes of all the plugins.
*
* @return A classloader which gives access to the classes of all the
* plugins.
*/
public static ClassLoader getPluginClassLoader() {
return classLoader;
}
/**
* Get the list of informational messages, if any, that were generated while
* loading the plugins.
*
* @return The list of informational messages, if any, that were generated
* while loading the plugins. May be empty, but is never {@code null}.
*/
public static @NotNull List getMessages() {
return unmodifiableList(messages);
}
/**
* Get the list of errors, if any, that occurred while loading the plugins.
*
* @return The list of errors, if any, that occurred while loading the
* plugins. May be empty, but is never {@code null}.
*/
public static @NotNull List getErrors() {
return unmodifiableList(errors);
}
@SuppressWarnings({"StatementWithEmptyBody", "BooleanMethodIsAlwaysInverted"})
private static boolean isSigned(JarFile jarFile, PublicKey publicKey) throws IOException {
for (Enumeration e = jarFile.entries(); e.hasMoreElements(); ) {
// Iterator over all the entries in the jar except directories and
// signature files
JarEntry jarEntry = e.nextElement();
String entryName = jarEntry.getName().toUpperCase();
if (jarEntry.isDirectory() || entryName.endsWith(".SF") || entryName.endsWith(".DSA") || entryName.endsWith(".EC") || entryName.endsWith(".RSA")) {
continue;
}
// Read the entry fully, otherwise the certificates won't be available
byte[] buffer = new byte[BUFFER_SIZE];
try (InputStream in = jarFile.getInputStream(jarEntry)) {
while (in.read(buffer) != -1) ;
}
// Get the signing certificate chain and check if one of them is the
// WorldPainter plugin signing certificate
Certificate[] certificates = jarEntry.getCertificates();
boolean signed = false;
if (certificates != null) {
for (Certificate certificate: certificates) {
if (certificate.getPublicKey().equals(publicKey)) {
signed = true;
break;
}
}
}
if (! signed) {
return false;
}
}
return true;
}
@SuppressWarnings("unchecked") // Guaranteed by isAssignableFrom
private static void findPlugins(Class type, String descriptorPath, JarFile jarFile, List plugins) throws IOException, ClassNotFoundException, IllegalAccessException, InstantiationException {
Descriptor descriptor = loadDescriptor(jarFile, descriptorPath);
for (String class_: descriptor.classes) {
Class> pluginType = classLoader.loadClass(class_);
if (type.isAssignableFrom(pluginType)) {
plugins.add(((Class) pluginType).newInstance());
}
}
}
@SuppressWarnings("unchecked") // Guaranteed by isAssignableFrom
private static void findPlugins(Class type, String descriptorPath, ClassLoader classLoader, List plugins) throws IOException {
Enumeration resources = classLoader.getResources(descriptorPath);
while (resources.hasMoreElements()) {
try (InputStream in = resources.nextElement().openStream()) {
Descriptor descriptor = loadDescriptor(in, null);
for (String class_: descriptor.classes) {
Class> pluginType = classLoader.loadClass(class_);
if (type.isAssignableFrom(pluginType)) {
plugins.add(((Class) pluginType).newInstance());
}
}
} catch (IOException | ClassNotFoundException | IllegalAccessException | InstantiationException e) {
errors.add("Could not load or initialise plugin from class path; internal WorldPainter error");
logger.error("{} while instantiating plugin (message: {}); skipping plugin", e.getClass().getSimpleName(), e.getMessage(), e);
}
}
}
/**
* Checks whether the specified plugin jar specifies an update URL, and if
* so checks whether there is a newer version and downloads it if so,
* replacing the original file.
*
* @param jarFile The plugin jar for which to check for updates.
* @param publicKey The public key corresponding to the private key with
* which the update must have been signed.
* @param descriptorPath The resource path of the file containing the plugin
* descriptor.
* @param hostVersion The version of the host, for comparison with the
* {@code minimumHostVersion} property, if present.
*/
@SuppressWarnings("ResultOfMethodCallIgnored") // Best effort
private static void checkForUpdates(JarFile jarFile, PublicKey publicKey, String descriptorPath, Version hostVersion) {
File originalFile = new File(jarFile.getName());
// Find, read and parse the descriptor
try {
Descriptor descriptor = loadDescriptor(jarFile, descriptorPath);
if (descriptor.version == null) {
logger.warn("Local descriptor for plugin {} does not provide enough information to check for updates (missing version)", descriptor.name);
return;
} else if (descriptor.descriptorUrl == null) {
logger.warn("Local descriptor for plugin {} does not provide enough information to check for updates (missing descriptorUrl)", descriptor.name);
return;
}
// Download the descriptor
URL descriptorUrl = new URL(descriptor.descriptorUrl);
HttpURLConnection connection = (HttpURLConnection) descriptorUrl.openConnection();
connection.setAllowUserInteraction(false);
connection.setUseCaches(false);
connection.setConnectTimeout(UPDATE_TIMEOUT);
connection.setReadTimeout(UPDATE_TIMEOUT);
connection.connect();
if (connection.getResponseCode() != HTTP_OK) {
throw new IOException("Server responded with status code " + connection.getResponseCode() + " (message: \"" + connection.getResponseMessage() + "\") after requesting " + descriptor.descriptorUrl);
}
Descriptor newDescriptor = loadDescriptor(connection.getInputStream(), descriptor.name);
if (newDescriptor.version == null) {
logger.warn("Remote descriptor for plugin {} does not provide enough information to check for updates (missing version)", descriptor.name);
return;
} else if (newDescriptor.pluginUrl == null) {
logger.warn("Remote descriptor for plugin {} does not provide enough information to check for updates (missing pluginUrl)", descriptor.name);
return;
}
// Check whether we should actually update
if (newDescriptor.version.compareTo(descriptor.version) <= 0) {
logger.debug("Plugin {} not updated (our version: {}, remote version: {})", descriptor.name, descriptor.version, newDescriptor.version);
return;
} else if ((newDescriptor.minimumHostVersion != null) && (newDescriptor.minimumHostVersion.compareTo(hostVersion) > 0)) {
logger.info("Plugin {} not updated because it requires a newer host version ({})", descriptor.name, descriptor.minimumHostVersion);
return;
}
// We should update; download the new version of the plugin
logger.info("Update found for plugin {}; downloading version {}", descriptor.name, newDescriptor.version);
URL pluginUrl = new URL(descriptorUrl, newDescriptor.pluginUrl);
connection = (HttpURLConnection) pluginUrl.openConnection();
connection.setAllowUserInteraction(false);
connection.setUseCaches(false);
connection.setConnectTimeout(UPDATE_TIMEOUT);
connection.setReadTimeout(UPDATE_TIMEOUT);
connection.connect();
if (connection.getResponseCode() != HTTP_OK) {
throw new IOException("Server responded with status code " + connection.getResponseCode() + " (message: \"" + connection.getResponseMessage() + "\") after requesting " + newDescriptor.pluginUrl);
}
File tempFile = File.createTempFile("wpdownloadedplugin", null);
try {
try (InputStream in2 = connection.getInputStream(); FileOutputStream out = new FileOutputStream(tempFile)) {
StreamUtils.copy(in2, out, MAXIMUM_PLUGIN_UPDATE_SIZE);
}
// Check that the update is signed, and if so copy it
// over the original plugin file (creating a backup of
// the plugin file just in case)
if (!isSigned(new JarFile(tempFile), publicKey)) {
logger.error("Update for {} downloaded, but is not official or has been tampered with; not updating plugin", descriptor.name);
return;
}
Files.move(originalFile.toPath(),
new File(originalFile.getParentFile(), originalFile.getName() + ".bak").toPath(),
REPLACE_EXISTING);
Files.move(tempFile.toPath(), originalFile.toPath());
// Finally, after all went well, record the update
messages.add("Plugin " + descriptor.name + " was updated from version " + descriptor.version + " to version " + newDescriptor.version);
} finally {
// Make sure the temporary file is deleted in all
// circumstances
tempFile.delete();
}
} catch (IOException | ClassCastException e) {
logger.error("{} while checking for updates for plugin {} (message: {})", e.getClass().getSimpleName(), originalFile, e.getMessage(), e);
}
}
/**
* Load a plugin descriptor from a plugin jar. Both the old format and the
* new JSON-based format are supported.
*
* @param jarFile The plugin jar from which to load the descriptor.
* @param descriptorPath The resource path of the file containing the plugin
* descriptor.
* @return The plugin descriptor read from the plugin jar.
*/
private static Descriptor loadDescriptor(JarFile jarFile, String descriptorPath) throws IOException {
try (InputStream in = jarFile.getInputStream(new ZipEntry(descriptorPath))) {
return loadDescriptor(in, stripExtension(stripDirectory(jarFile.getName())));
}
}
/**
* Load a plugin descriptor from an input stream. Both the old format and
* the new JSON-based format are supported.
*
* @param in The input stream from which to read the descriptor. Will be
* closed.
* @param name A name associated with the input stream, to be used as a fall
* back plugin name. May be {@code null} if not available.
* @return The plugin descriptor read from the stream.
*/
@SuppressWarnings("unchecked") // Responsibility of plugin author
public static Descriptor loadDescriptor(InputStream in, String name) throws IOException {
try (BufferedInputStream in2 = new BufferedInputStream(in)) {
// If the first character is not a { we just silently assume this is
// an old style non-JSON descriptor
in2.mark(1);
boolean json = in2.read() == '{';
in2.reset();
if ((! json) && logger.isDebugEnabled()) {
logger.debug("Plugin descriptor does not start with {; assuming it is not JSON");
}
if (json) {
Map descriptor = OBJECT_MAPPER.readValue(in2, Map.class);
String myName = (String) descriptor.get("name");
Version version = Version.parse((String) descriptor.get("version"));
String minimumHostVersionStr = (String) descriptor.get("minimumHostVersion");
List classes = (List) descriptor.get("classes");
String descriptorUrl = (String) descriptor.get("descriptorUrl");
String pluginUrl = (String) descriptor.get("pluginUrl");
return new Descriptor((myName != null) ? myName : name, classes, descriptorUrl, pluginUrl, version,
(minimumHostVersionStr != null) ? Version.parse(minimumHostVersionStr) : null);
} else {
List classes = new ArrayList<>();
try (BufferedReader lineReader = new BufferedReader(new InputStreamReader(in2, UTF_8))) {
String line;
while ((line = lineReader.readLine()) != null) {
classes.add(line.trim());
}
}
return new Descriptor(name, classes, null, null, null, null);
}
}
}
private static final Map jarClassLoaders = new HashMap<>();
private static final int BUFFER_SIZE = 32768;
private static final int UPDATE_TIMEOUT = 200; // milliseconds
private static final int MAXIMUM_PLUGIN_UPDATE_SIZE = 2 * 1024 * 1024; // 2 MB TODO: this is too large; find out how to have the shader minimize the jar, preferably from the command line
private static final ClassLoader classLoader = new ClassLoader(ClassLoader.getSystemClassLoader()) {
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
for (Map.Entry entry: jarClassLoaders.entrySet()) {
Class> _class;
try {
_class = entry.getValue().loadClass(name);
} catch (ClassNotFoundException e) {
continue;
}
logger.debug("Loading {} from {}", name, entry.getKey().getName());
return _class;
}
throw new ClassNotFoundException("Class " + name + " not found in plugin class loaders");
}
};
private static final List errors = new ArrayList<>(), messages = new ArrayList<>();
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(PluginManager.class);
}