/* * Syncany, www.syncany.org * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.syncany.operations.plugin; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.jar.JarInputStream; import java.util.jar.Manifest; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.simpleframework.xml.core.Persister; import org.syncany.Client; import org.syncany.config.Config; import org.syncany.config.LocalEventBus; import org.syncany.config.UserConfig; import org.syncany.crypto.CipherUtil; import org.syncany.operations.Operation; import org.syncany.operations.daemon.messages.ConnectToHostExternalEvent; import org.syncany.operations.daemon.messages.PluginInstallExternalEvent; import org.syncany.operations.plugin.PluginOperationOptions.PluginListMode; import org.syncany.operations.plugin.PluginOperationResult.PluginResultCode; import org.syncany.plugins.Plugin; import org.syncany.plugins.Plugins; import org.syncany.util.EnvironmentUtil; import org.syncany.util.FileUtil; import org.syncany.util.StringUtil; import com.github.zafarkhaja.semver.Version; import com.google.common.base.Function; import com.google.common.base.Predicate; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; /** * The plugin operation installs, removes and lists storage {@link Plugin}s. * * <p>The plugin implements these three functionalities as different * {@link PluginOperationAction}: * * <ul> * <li><tt>INSTALL</tt>: Installation means copying a file to the user plugin directory * as specified by {@link Client#getUserPluginLibDir()}. A plugin can be installed * from a local JAR file, a URL (the operation downloads a JAR file), or the * API host (the operation find the plugin using the 'list' action and downloads * the JAR file).</li> * <li><tt>REMOVE</tt>: Removal means deleting a JAR file from the user plugin * directoryThis action. This action simply finds the responsible plugin JAR * file and deletes it. Only JAR files inside the user plugin direcory can be * deleted.</li> * <li><tt>LIST</tt>: Listing refers to a local and a remote list. The locally installed * plugins can be queried by {@link Plugins#list()}. These plugins' JAR files must be * in the application's class path. Remotely available plugins are queried through the * API.</li> * </ul> * * @author Philipp C. Heckel <philipp.heckel@gmail.com> */ public class PluginOperation extends Operation { private static final Logger logger = Logger.getLogger(PluginOperation.class.getSimpleName()); private static final String API_DEFAULT_ENDPOINT_URL = "https://api.syncany.org/v3"; private static final String API_PLUGIN_LIST_REQUEST_FORMAT = "%s/plugins/list?appVersion=%s&snapshots=%s&pluginId=%s&os=%s&arch=%s"; private static final String PURGEFILE_FILENAME = "purgefile"; private static final String UPDATE_FILENAME = "updatefile"; private PluginOperationOptions options; private PluginOperationResult result; private LocalEventBus eventBus; public PluginOperation(Config config, PluginOperationOptions options) { super(config); this.options = options; this.result = new PluginOperationResult(); this.eventBus = LocalEventBus.getInstance(); } @Override public PluginOperationResult execute() throws Exception { result.setAction(options.getAction()); switch (options.getAction()) { case LIST: return executeList(); case INSTALL: return executeInstall(); case REMOVE: return executeRemove(); case UPDATE: return executeUpdate(); default: throw new Exception("Unknown action: " + options.getAction()); } } private PluginOperationResult executeUpdate() throws Exception { List<String> updateablePlugins = findUpdateCandidates(); List<String> erroneousPlugins = Lists.newArrayList(); List<String> delayedPlugins = Lists.newArrayList(); // update only a specific plugin if it is updatable and provided String forcePluginId = options.getPluginId(); logger.log(Level.FINE, "Force plugin is " + forcePluginId); if (forcePluginId != null) { if (updateablePlugins.contains(forcePluginId)) { updateablePlugins = Lists.newArrayList(forcePluginId); } else { logger.log(Level.WARNING, "User requested to update a non-updatable plugin: " + forcePluginId); erroneousPlugins.add(forcePluginId); updateablePlugins = Lists.newArrayList(); // empty list } } logger.log(Level.INFO, "The following plugins can be automatically updated: " + StringUtil.join(updateablePlugins, ", ")); for (String pluginId : updateablePlugins) { // first remove PluginOperationResult removeResult = executeRemove(pluginId); if (removeResult.getResultCode() == PluginResultCode.NOK) { logger.log(Level.SEVERE, "Unable to remove " + pluginId + " during the update process"); erroneousPlugins.add(pluginId); continue; } // ... and install again if (EnvironmentUtil.isWindows()) { logger.log(Level.FINE, "Appending jar to updatefile"); File updatefilePath = new File(UserConfig.getUserConfigDir(), UPDATE_FILENAME); try (PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(updatefilePath, true)))) { out.println(pluginId + (options.isSnapshots() ? " --snapshot" : "")); delayedPlugins.add(pluginId); } catch (IOException e) { logger.log(Level.SEVERE, "Unable to append to updatefile " + updatefilePath, e); erroneousPlugins.add(pluginId); } } else { PluginOperationResult installResult = executeInstallFromApiHost(pluginId); if (installResult.getResultCode() == PluginResultCode.NOK) { logger.log(Level.SEVERE, "Unable to install " + pluginId + " during the update process"); erroneousPlugins.add(pluginId); } } } if (erroneousPlugins.size() > 0 && erroneousPlugins.size() == updateablePlugins.size()) { result.setResultCode(PluginResultCode.NOK); } else { result.setResultCode(PluginResultCode.OK); } result.setUpdatedPluginIds(updateablePlugins); result.setErroneousPluginIds(erroneousPlugins); result.setDelayedPluginIds(delayedPlugins); return result; } private List<String> findUpdateCandidates() throws Exception { List<ExtendedPluginInfo> updateCandidates = executeList().getPluginList(); Iterables.removeIf(updateCandidates, new Predicate<ExtendedPluginInfo>() { @Override public boolean apply(ExtendedPluginInfo pluginInfo) { return !pluginInfo.isInstalled() || !pluginInfo.canUninstall() || !pluginInfo.isOutdated(); } }); return Lists.transform(updateCandidates, new Function<ExtendedPluginInfo, String>() { @Override public String apply(ExtendedPluginInfo pluginInfo) { return pluginInfo.getLocalPluginInfo().getPluginId(); } }); } private PluginOperationResult executeRemove() throws Exception { return executeRemove(options.getPluginId()); } private PluginOperationResult executeRemove(String pluginId) throws Exception { Plugin plugin = Plugins.get(pluginId); if (plugin == null) { throw new Exception("Plugin not installed."); } File pluginJarFile = getJarFile(plugin); boolean canUninstall = canUninstall(pluginJarFile); if (canUninstall) { PluginInfo pluginInfo = readPluginInfoFromJar(pluginJarFile); logger.log(Level.INFO, "Uninstalling plugin from file " + pluginJarFile); boolean deleted = pluginJarFile.delete(); // JAR files are locked on Windows, adding JAR filename to a list for delayed deletion (by batch file) if (EnvironmentUtil.isWindows() || !deleted) { logger.log(Level.FINE, "Appending jar to purgefile (" + EnvironmentUtil.isWindows() + ", "+ deleted +")"); File purgefilePath = new File(UserConfig.getUserConfigDir(), PURGEFILE_FILENAME); try (PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(purgefilePath, true)))) { out.println(pluginJarFile.getAbsolutePath()); } catch (IOException e) { logger.log(Level.SEVERE, "Unable to append to purgefile " + purgefilePath, e); } } // refresh plugin list Plugins.refresh(); result.setSourcePluginPath(pluginJarFile.getAbsolutePath()); result.setAffectedPluginInfo(pluginInfo); result.setResultCode(PluginResultCode.OK); } else { logger.log(Level.INFO, "Plugin can NOT be uninstalled because class location not in " + UserConfig.getUserPluginLibDir()); result.setResultCode(PluginResultCode.NOK); } return result; } private boolean canUninstall(File pluginJarFile) { File globalUserPluginDir = UserConfig.getUserPluginLibDir(); return pluginJarFile != null && pluginJarFile.getAbsolutePath().startsWith(globalUserPluginDir.getAbsolutePath()); } private File getJarFile(Plugin plugin) { Class<? extends Plugin> pluginClass = plugin.getClass(); URL pluginClassLocation = pluginClass.getResource('/' + pluginClass.getName().replace('.', '/') + ".class"); String pluginClassLocationStr = pluginClassLocation.toString(); logger.log(Level.INFO, "Plugin class is at " + pluginClassLocationStr); if (pluginClassLocationStr.startsWith("jar:file:")) { int indexStartAfterSchema = "jar:file:".length(); int indexEndAtExclamationPoint = pluginClassLocationStr.indexOf("!"); File pluginJarFile = new File(pluginClassLocationStr.substring(indexStartAfterSchema, indexEndAtExclamationPoint)); logger.log(Level.INFO, "Plugin is in JAR at " + pluginJarFile); return pluginJarFile; } else { logger.log(Level.INFO, "Plugin is not in a JAR file. Probably in test environment."); return null; } } private PluginOperationResult executeInstall() throws Exception { String pluginId = options.getPluginId(); File potentialLocalPluginJarFile = new File(pluginId); if (pluginId.matches("^https?://.+")) { return executeInstallFromUrl(pluginId); } else if (potentialLocalPluginJarFile.exists()) { return executeInstallFromLocalFile(potentialLocalPluginJarFile); } else { return executeInstallFromApiHost(pluginId); } } private PluginOperationResult executeInstallFromApiHost(String pluginId) throws Exception { checkPluginNotInstalled(pluginId); PluginInfo pluginInfo = getRemotePluginInfo(pluginId); if (pluginInfo == null) { throw new Exception("Plugin with ID '" + pluginId + "' not found"); } checkPluginCompatibility(pluginInfo); eventBus.post(new PluginInstallExternalEvent(pluginInfo.getDownloadUrl())); File tempPluginJarFile = downloadPluginJar(pluginInfo.getDownloadUrl()); String expectedChecksum = pluginInfo.getSha256sum(); String actualChecksum = calculateChecksum(tempPluginJarFile); if (expectedChecksum == null || !expectedChecksum.equals(actualChecksum)) { throw new Exception("Checksum mismatch. Expected: " + expectedChecksum + ", but was: " + actualChecksum); } logger.log(Level.INFO, "Plugin JAR checksum verified: " + actualChecksum); File targetPluginJarFile = installPlugin(tempPluginJarFile, pluginInfo); result.setSourcePluginPath(pluginInfo.getDownloadUrl()); result.setTargetPluginPath(targetPluginJarFile.getAbsolutePath()); result.setAffectedPluginInfo(pluginInfo); result.setResultCode(PluginResultCode.OK); return result; } private void checkPluginCompatibility(PluginInfo pluginInfo) throws Exception { Version applicationVersion = Version.valueOf(Client.getApplicationVersion()); Version pluginAppMinVersion = Version.valueOf(pluginInfo.getPluginAppMinVersion()); logger.log(Level.INFO, "Checking plugin compatibility:"); logger.log(Level.INFO, "- Application version: " + Client.getApplicationVersion() + "(" + applicationVersion + ")"); logger.log(Level.INFO, "- Plugin min. application version: " + pluginInfo.getPluginAppMinVersion() + "(" + pluginAppMinVersion + ")"); if (applicationVersion.lessThan(pluginAppMinVersion)) { throw new Exception("Plugin is incompatible to this application version. Plugin min. application version is " + pluginInfo.getPluginAppMinVersion() + ", current application version is " + Client.getApplicationVersion()); } // Verify if any conflicting plugins are installed logger.log(Level.INFO, "Checking for conflicting plugins."); List<String> conflictingIds = pluginInfo.getConflictingPluginIds(); List<String> conflictingInstalledIds = new ArrayList<String>(); if (conflictingIds != null) { for (String pluginId : conflictingIds) { Plugin plugin = Plugins.get(pluginId); if (plugin != null) { logger.log(Level.INFO, "- Conflicting plugin " + pluginId + " found."); conflictingInstalledIds.add(pluginId); } logger.log(Level.FINE, "- Conflicting plugin " + pluginId + " not installed"); } } result.setConflictingPlugins(conflictingInstalledIds); } private String calculateChecksum(File tempPluginJarFile) throws Exception { CipherUtil.enableUnlimitedStrength(); byte[] actualChecksum = FileUtil.createChecksum(tempPluginJarFile, "SHA256"); return StringUtil.toHex(actualChecksum); } private PluginOperationResult executeInstallFromLocalFile(File pluginJarFile) throws Exception { eventBus.post(new PluginInstallExternalEvent(pluginJarFile.getAbsolutePath())); PluginInfo pluginInfo = readPluginInfoFromJar(pluginJarFile); checkPluginNotInstalled(pluginInfo.getPluginId()); checkPluginCompatibility(pluginInfo); File targetPluginJarFile = installPlugin(pluginJarFile, pluginInfo); result.setSourcePluginPath(pluginJarFile.getPath()); result.setTargetPluginPath(targetPluginJarFile.getPath()); result.setAffectedPluginInfo(pluginInfo); result.setResultCode(PluginResultCode.OK); return result; } private PluginOperationResult executeInstallFromUrl(String downloadJarUrl) throws Exception { eventBus.post(new PluginInstallExternalEvent(downloadJarUrl)); File tempPluginJarFile = downloadPluginJar(downloadJarUrl); PluginInfo pluginInfo = readPluginInfoFromJar(tempPluginJarFile); checkPluginNotInstalled(pluginInfo.getPluginId()); checkPluginCompatibility(pluginInfo); File targetPluginJarFile = installPlugin(tempPluginJarFile, pluginInfo); result.setSourcePluginPath(downloadJarUrl); result.setTargetPluginPath(targetPluginJarFile.getPath()); result.setAffectedPluginInfo(pluginInfo); result.setResultCode(PluginResultCode.OK); return result; } private void checkPluginNotInstalled(String pluginId) throws Exception { Plugin locallyInstalledPlugin = Plugins.get(pluginId); if (locallyInstalledPlugin != null) { throw new Exception("Plugin '" + pluginId + "' already installed. Use 'sy plugin remove " + pluginId + "' to uninstall it first."); } logger.log(Level.INFO, "Plugin '" + pluginId + "' not installed. Okay!"); } private PluginInfo readPluginInfoFromJar(File pluginJarFile) throws Exception { try (JarInputStream jarStream = new JarInputStream(new FileInputStream(pluginJarFile))) { Manifest jarManifest = jarStream.getManifest(); if (jarManifest == null) { throw new Exception("Given file is not a valid Syncany plugin file (not a JAR file, or no manifest)."); } String pluginId = jarManifest.getMainAttributes().getValue("Plugin-Id"); if (pluginId == null) { throw new Exception("Given file is not a valid Syncany plugin file (no plugin ID in manifest)."); } PluginInfo pluginInfo = new PluginInfo(); pluginInfo.setPluginId(pluginId); pluginInfo.setPluginName(jarManifest.getMainAttributes().getValue("Plugin-Name")); pluginInfo.setPluginVersion(jarManifest.getMainAttributes().getValue("Plugin-Version")); pluginInfo.setPluginDate(jarManifest.getMainAttributes().getValue("Plugin-Date")); pluginInfo.setPluginAppMinVersion(jarManifest.getMainAttributes().getValue("Plugin-App-Min-Version")); pluginInfo.setPluginRelease(Boolean.parseBoolean(jarManifest.getMainAttributes().getValue("Plugin-Release"))); if (jarManifest.getMainAttributes().getValue("Plugin-Conflicts-With") != null) { pluginInfo.setConflictingPluginIds(Arrays.asList(jarManifest.getMainAttributes().getValue("Plugin-Conflicts-With"))); } return pluginInfo; } } private File installPlugin(File pluginJarFile, PluginInfo pluginInfo) throws IOException { File globalUserPluginDir = UserConfig.getUserPluginLibDir(); globalUserPluginDir.mkdirs(); File targetPluginJarFile = new File(globalUserPluginDir, String.format("syncany-plugin-%s-%s.jar", pluginInfo.getPluginId(), pluginInfo.getPluginVersion())); logger.log(Level.INFO, "Installing plugin from " + pluginJarFile + " to " + targetPluginJarFile + " ..."); FileUtils.copyFile(pluginJarFile, targetPluginJarFile); return targetPluginJarFile; } /** * Downloads the plugin JAR from the given URL to a temporary * local location. */ private File downloadPluginJar(String pluginJarUrl) throws Exception { URL pluginJarFile = new URL(pluginJarUrl); logger.log(Level.INFO, "Querying " + pluginJarFile + " ..."); URLConnection urlConnection = pluginJarFile.openConnection(); urlConnection.setConnectTimeout(2000); urlConnection.setReadTimeout(2000); File tempPluginFile = File.createTempFile("syncany-plugin", "tmp"); tempPluginFile.deleteOnExit(); logger.log(Level.INFO, "Downloading to " + tempPluginFile + " ..."); FileOutputStream tempPluginFileOutputStream = new FileOutputStream(tempPluginFile); InputStream remoteJarFileInputStream = urlConnection.getInputStream(); IOUtils.copy(remoteJarFileInputStream, tempPluginFileOutputStream); remoteJarFileInputStream.close(); tempPluginFileOutputStream.close(); if (!tempPluginFile.exists() || tempPluginFile.length() == 0) { throw new Exception("Downloading plugin file failed, URL was " + pluginJarUrl); } return tempPluginFile; } private PluginOperationResult executeList() throws Exception { final Version applicationVersion = Version.valueOf(Client.getApplicationVersion()); Map<String, ExtendedPluginInfo> pluginInfos = new TreeMap<String, ExtendedPluginInfo>(); // First, list local plugins if (options.getListMode() == PluginListMode.ALL || options.getListMode() == PluginListMode.LOCAL) { for (PluginInfo localPluginInfo : getLocalList()) { if (options.getPluginId() != null && !localPluginInfo.getPluginId().equals(options.getPluginId())) { continue; } // Determine standard plugin information ExtendedPluginInfo extendedPluginInfo = new ExtendedPluginInfo(); extendedPluginInfo.setLocalPluginInfo(localPluginInfo); extendedPluginInfo.setInstalled(true); // Test if plugin can be uninstalled Plugin plugin = Plugins.get(localPluginInfo.getPluginId()); File pluginJarFile = getJarFile(plugin); boolean canUninstall = canUninstall(pluginJarFile); extendedPluginInfo.setCanUninstall(canUninstall); // Add to list pluginInfos.put(localPluginInfo.getPluginId(), extendedPluginInfo); } } // Then, list remote plugins if (options.getListMode() == PluginListMode.ALL || options.getListMode() == PluginListMode.REMOTE) { for (PluginInfo remotePluginInfo : getRemotePluginInfoList()) { if (options.getPluginId() != null && !remotePluginInfo.getPluginId().equals(options.getPluginId())) { continue; } ExtendedPluginInfo extendedPluginInfo = pluginInfos.get(remotePluginInfo.getPluginId()); boolean localPluginInstalled = extendedPluginInfo != null; if (!localPluginInstalled) { // Locally not installed extendedPluginInfo = new ExtendedPluginInfo(); extendedPluginInfo.setInstalled(false); extendedPluginInfo.setRemoteAvailable(true); } else { // Locally also installed extendedPluginInfo.setRemoteAvailable(true); Version localVersion = Version.valueOf(extendedPluginInfo.getLocalPluginInfo().getPluginVersion()); Version remoteVersion = Version.valueOf(remotePluginInfo.getPluginVersion()); Version remoteMinAppVersion = Version.valueOf(remotePluginInfo.getPluginAppMinVersion()); boolean localVersionOutdated = localVersion.lessThan(remoteVersion); boolean applicationVersionCompatible = applicationVersion.greaterThanOrEqualTo(remoteMinAppVersion); boolean pluginIsOutdated = localVersionOutdated && applicationVersionCompatible; extendedPluginInfo.setOutdated(pluginIsOutdated); } extendedPluginInfo.setRemotePluginInfo(remotePluginInfo); pluginInfos.put(remotePluginInfo.getPluginId(), extendedPluginInfo); } } result.setPluginList(new ArrayList<ExtendedPluginInfo>(pluginInfos.values())); result.setResultCode(PluginResultCode.OK); return result; } private List<PluginInfo> getLocalList() { List<PluginInfo> localPluginInfos = new ArrayList<PluginInfo>(); for (Plugin plugin : Plugins.list()) { PluginInfo pluginInfo = new PluginInfo(); pluginInfo.setPluginId(plugin.getId()); pluginInfo.setPluginName(plugin.getName()); pluginInfo.setPluginVersion(plugin.getVersion()); localPluginInfos.add(pluginInfo); } return localPluginInfos; } private List<PluginInfo> getRemotePluginInfoList() throws Exception { String remoteListStr = getRemoteListStr(null); PluginListResponse pluginListResponse = new Persister().read(PluginListResponse.class, remoteListStr); return pluginListResponse.getPlugins(); } private PluginInfo getRemotePluginInfo(String pluginId) throws Exception { String remoteListStr = getRemoteListStr(pluginId); PluginListResponse pluginListResponse = new Persister().read(PluginListResponse.class, remoteListStr); if (pluginListResponse.getPlugins().size() > 0) { return pluginListResponse.getPlugins().get(0); } else { return null; } } private String getRemoteListStr(String pluginId) throws Exception { String appVersion = Client.getApplicationVersion(); String snapshotsEnabled = (options.isSnapshots()) ? "true" : "false"; String pluginIdQueryStr = (pluginId != null) ? pluginId : ""; String osStr = EnvironmentUtil.getOperatingSystemDescription(); String archStr = EnvironmentUtil.getArchDescription(); String apiEndpointUrl = (options.getApiEndpoint() != null) ? options.getApiEndpoint() : API_DEFAULT_ENDPOINT_URL; URL pluginListUrl = new URL(String.format(API_PLUGIN_LIST_REQUEST_FORMAT, apiEndpointUrl, appVersion, snapshotsEnabled, pluginIdQueryStr, osStr, archStr)); logger.log(Level.INFO, "Querying " + pluginListUrl + " ..."); eventBus.post(new ConnectToHostExternalEvent(pluginListUrl.getHost())); URLConnection urlConnection = pluginListUrl.openConnection(); urlConnection.setConnectTimeout(2000); urlConnection.setReadTimeout(2000); BufferedReader urlStreamReader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream())); StringBuilder responseStringBuilder = new StringBuilder(); String line; while ((line = urlStreamReader.readLine()) != null) { responseStringBuilder.append(line); } String responseStr = responseStringBuilder.toString(); logger.log(Level.INFO, "Response from api.syncany.org: " + responseStr); return responseStr; } }