diff --git a/Ghidra/Features/Base/src/main/java/ghidra/GhidraRun.java b/Ghidra/Features/Base/src/main/java/ghidra/GhidraRun.java index 33d25f5194..b5cc4f2c28 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/GhidraRun.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/GhidraRun.java @@ -31,11 +31,11 @@ import ghidra.framework.data.DomainObjectAdapter; import ghidra.framework.main.FrontEndTool; import ghidra.framework.model.*; import ghidra.framework.project.DefaultProjectManager; -import ghidra.framework.project.extensions.ExtensionUtils; import ghidra.framework.store.LockException; import ghidra.program.database.ProgramDB; import ghidra.util.*; import ghidra.util.exception.UsrException; +import ghidra.util.extensions.ExtensionUtils; import ghidra.util.task.TaskLauncher; /** diff --git a/Ghidra/Features/Base/src/main/java/ghidra/test/processors/support/ProcessorEmulatorTestAdapter.java b/Ghidra/Features/Base/src/main/java/ghidra/test/processors/support/ProcessorEmulatorTestAdapter.java index f3ed945cdb..bc47bcd39c 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/test/processors/support/ProcessorEmulatorTestAdapter.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/test/processors/support/ProcessorEmulatorTestAdapter.java @@ -257,9 +257,9 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E // By default, create test output within a directory at the same level as the // development repositories outputRoot = Application.getApplicationRootDirectory() - .getParentFile() - .getParentFile() - .getCanonicalPath(); + .getParentFile() + .getParentFile() + .getCanonicalPath(); } catch (IOException e) { throw new RuntimeException(e); @@ -938,7 +938,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E applicationRootDirectories = Application.getApplicationRootDirectories(); ResourceFile myModuleRootDirectory = - Application.getModuleContainingClass(getClass().getName()); + Application.getModuleContainingClass(getClass()); if (myModuleRootDirectory != null) { File myModuleRoot = myModuleRootDirectory.getFile(false); if (myModuleRoot != null) { @@ -1672,7 +1672,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E RegisterValue thumbMode = new RegisterValue(tReg, BigInteger.ONE); try { program.getProgramContext() - .setRegisterValue(functionAddr, functionAddr, thumbMode); + .setRegisterValue(functionAddr, functionAddr, thumbMode); } catch (ContextChangeException e) { throw new AssertException(e); @@ -1686,7 +1686,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E RegisterValue thumbMode = new RegisterValue(isaModeReg, BigInteger.ONE); try { program.getProgramContext() - .setRegisterValue(functionAddr, functionAddr, thumbMode); + .setRegisterValue(functionAddr, functionAddr, thumbMode); } catch (ContextChangeException e) { throw new AssertException(e); @@ -1911,7 +1911,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E if (absoluteGzfFilePath.exists()) { program = getGzfProgram(outputDir, gzfCachePath); if (program != null && !MD5Utilities.getMD5Hash(testFile.file) - .equals(program.getExecutableMD5())) { + .equals(program.getExecutableMD5())) { // remove obsolete GZF cache file env.release(program); program = null; @@ -1936,7 +1936,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E } else { program = env.getGhidraProject() - .importProgram(testFile.file, language, compilerSpec); + .importProgram(testFile.file, language, compilerSpec); } program.addConsumer(this); env.getGhidraProject().close(program); @@ -1957,8 +1957,8 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E if (!program.getLanguageID().equals(language.getLanguageID()) || !program.getCompilerSpec() - .getCompilerSpecID() - .equals(compilerSpec.getCompilerSpecID())) { + .getCompilerSpecID() + .equals(compilerSpec.getCompilerSpecID())) { throw new IOException((usingCachedGZF ? "Cached " : "") + "Program has incorrect language/compiler spec (" + program.getLanguageID() + "/" + program.getCompilerSpec().getCompilerSpecID() + "): " + @@ -2123,7 +2123,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E testFileDigest.append(nameAndAddr); testFileDigest.append(" (GroupInfo @ "); testFileDigest - .append(testGroup.controlBlock.getInfoStructureAddress().toString(true)); + .append(testGroup.controlBlock.getInfoStructureAddress().toString(true)); testFileDigest.append(")"); if (duplicateTests.contains(testGroup.testGroupName)) { testFileDigest.append(" *DUPLICATE*"); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/util/GhidraJarBuilder.java b/Ghidra/Features/Base/src/main/java/ghidra/util/GhidraJarBuilder.java index 197e098a5f..4a8c2b0389 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/util/GhidraJarBuilder.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/util/GhidraJarBuilder.java @@ -26,11 +26,11 @@ import generic.jar.*; import ghidra.GhidraApplicationLayout; import ghidra.GhidraLaunchable; import ghidra.framework.*; -import ghidra.framework.project.extensions.ExtensionUtils; import ghidra.util.classfinder.ClassFinder; import ghidra.util.classfinder.ClassSearcher; import ghidra.util.exception.AssertException; import ghidra.util.exception.CancelledException; +import ghidra.util.extensions.ExtensionUtils; import ghidra.util.task.TaskMonitor; import utilities.util.FileUtilities; import utility.application.ApplicationLayout; @@ -717,9 +717,6 @@ public class GhidraJarBuilder implements GhidraLaunchable { jarOut.close(); } - /** - * Outputs an individual file to the jar. - */ public void addFile(String jarPath, File file, ApplicationModule module) throws IOException, CancelledException { if (!file.exists()) { @@ -834,7 +831,7 @@ public class GhidraJarBuilder implements GhidraLaunchable { zipOut.close(); } - /** + /* * Outputs an individual file to the jar. */ public void addFile(String zipPath, File file) throws IOException, CancelledException { @@ -930,7 +927,7 @@ public class GhidraJarBuilder implements GhidraLaunchable { System.exit(0); } - /** + /* * Entry point for 'gradle buildGhidraJar'. */ public static void main(String[] args) throws IOException { diff --git a/Ghidra/Framework/Generic/Module.manifest b/Ghidra/Framework/Generic/Module.manifest index a10b5fa3d0..13d3d5348b 100644 --- a/Ghidra/Framework/Generic/Module.manifest +++ b/Ghidra/Framework/Generic/Module.manifest @@ -1,3 +1,4 @@ +MODULE FILE LICENSE: lib/commons-compress-1.21.jar Apache License 2.0 MODULE FILE LICENSE: lib/guava-31.1-jre.jar Apache License 2.0 MODULE FILE LICENSE: lib/failureaccess-1.0.1.jar Apache License 2.0 MODULE FILE LICENSE: lib/jdom-legacy-1.1.3.jar JDOM License diff --git a/Ghidra/Framework/Generic/build.gradle b/Ghidra/Framework/Generic/build.gradle index 2b2022a9a2..803549e9a9 100644 --- a/Ghidra/Framework/Generic/build.gradle +++ b/Ghidra/Framework/Generic/build.gradle @@ -32,6 +32,7 @@ dependencies { api "org.apache.logging.log4j:log4j-api:2.17.1" api "org.apache.logging.log4j:log4j-core:2.17.1" api "org.apache.commons:commons-collections4:4.1" + api "org.apache.commons:commons-compress:1.21" api "org.apache.commons:commons-lang3:3.12.0" api "org.apache.commons:commons-text:1.10.0" api "commons-io:commons-io:2.11.0" diff --git a/Ghidra/Framework/Generic/src/main/java/generic/application/GenericApplicationLayout.java b/Ghidra/Framework/Generic/src/main/java/generic/application/GenericApplicationLayout.java index 2583e6844a..83abd5803f 100644 --- a/Ghidra/Framework/Generic/src/main/java/generic/application/GenericApplicationLayout.java +++ b/Ghidra/Framework/Generic/src/main/java/generic/application/GenericApplicationLayout.java @@ -120,6 +120,8 @@ public class GenericApplicationLayout extends ApplicationLayout { userTempDir = ApplicationUtilities.getDefaultUserTempDir(applicationProperties); userSettingsDir = ApplicationUtilities.getDefaultUserSettingsDir(applicationProperties, applicationInstallationDir); + + extensionInstallationDirs = Collections.emptyList(); } protected Collection getAdditionalApplicationRootDirs( diff --git a/Ghidra/Framework/Generic/src/main/java/generic/json/Json.java b/Ghidra/Framework/Generic/src/main/java/generic/json/Json.java index fd6c844e96..eadfecf2c9 100644 --- a/Ghidra/Framework/Generic/src/main/java/generic/json/Json.java +++ b/Ghidra/Framework/Generic/src/main/java/generic/json/Json.java @@ -58,10 +58,43 @@ public class Json extends ToStringStyle { } } + /** + * A {@link ToStringStyle} inspired by {@link ToStringStyle#JSON_STYLE} that places + * object fields all on one line, with Json style formatting. + */ + public static class JsonWithFlatToStringStyle extends ToStringStyle { + + private JsonWithFlatToStringStyle() { + this.setUseClassName(false); + this.setUseIdentityHashCode(false); + + this.setContentStart("{ "); + this.setContentEnd(" }"); + + this.setArrayStart("["); + this.setArrayEnd("]"); + + this.setFieldSeparator(", "); + this.setFieldNameValueSeparator(": "); + + this.setNullText("null"); + + this.setSummaryObjectStartText("\"<"); + this.setSummaryObjectEndText(">\""); + + this.setSizeStartText("\"\""); + } + } + /** * Creates a Json string representation of the given object and all of its fields. To exclude * some fields, call {@link #toStringExclude(Object, String...)}. To only include particular * fields, call {@link #appendToString(StringBuffer, String)}. + *

+ * The returned string is formatted for pretty printing using whitespace, such as tabs and + * newlines. + * * @param o the object * @return the string */ @@ -69,6 +102,18 @@ public class Json extends ToStringStyle { return ToStringBuilder.reflectionToString(o, Json.WITH_NEWLINES); } + /** + * Creates a Json string representation of the given object and all of its fields. + *

+ * The returned string is formatted without newlines for better use in logging. + * + * @param o the object + * @return the string + */ + public static String toStringFlat(Object o) { + return ToStringBuilder.reflectionToString(o, new JsonWithFlatToStringStyle()); + } + /** * Creates a Json string representation of the given object and the given fields * @param o the object diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/framework/Application.java b/Ghidra/Framework/Generic/src/main/java/ghidra/framework/Application.java index d4f06e2b0d..79a5c7c0d7 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/framework/Application.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/framework/Application.java @@ -215,6 +215,10 @@ public class Application { return app.getModuleForClass(className); } + public static ResourceFile getModuleContainingClass(Class c) { + return app.getModuleForClass(c); + } + private void findJavaSourceDirectories(List list, ResourceFile moduleRootDirectory) { ResourceFile srcDir = new ResourceFile(moduleRootDirectory, "src"); @@ -254,6 +258,18 @@ public class Application { } private ResourceFile getModuleForClass(String className) { + try { + Class callersClass = Class.forName(className); + return getModuleForClass(callersClass); + } + catch (ClassNotFoundException e) { + // This can happen when we are being called from a script, which is not in the + // classpath. This file will not have a module anyway. + return null; + } + } + + private String toPath(String className) { // get rid of nested class name(s) if present int dollar = className.indexOf('$'); if (dollar != -1) { @@ -261,27 +277,22 @@ public class Application { } String path = className.replace('.', '/'); - String sourcePath = path + ".java"; - String classFilePath = path + ".class"; + return path + ".class"; + } + + private ResourceFile getModuleForClass(Class clazz) { if (inSingleJarMode()) { + String classFilePath = toPath(clazz.getName()); GModule gModule = getModuleFromTreeMap(classFilePath); return gModule == null ? null : gModule.getModuleRoot(); } // we're running from a binary installation...so get our jar and go up one - Class callersClass; - try { - callersClass = Class.forName(className); - } - catch (ClassNotFoundException e) { - // This can happen when we are being called from a script, which is not in the - // classpath. This file will not have a module anyway - return null; - } - - File sourceLocationForClass = SystemUtilities.getSourceLocationForClass(callersClass); + File sourceLocationForClass = SystemUtilities.getSourceLocationForClass(clazz); if (sourceLocationForClass.isDirectory()) { + String classFilePath = toPath(clazz.getName()); + String sourcePath = classFilePath.replace(".class", ".java"); return findModuleForJavaSource(sourcePath); } diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassFinder.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassFinder.java index 47fef9d878..7466ae2cf8 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassFinder.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassFinder.java @@ -22,9 +22,12 @@ import java.util.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import generic.json.Json; +import ghidra.GhidraClassLoader; import ghidra.util.Msg; import ghidra.util.SystemUtilities; import ghidra.util.exception.CancelledException; +import ghidra.util.extensions.*; import ghidra.util.task.TaskMonitor; import utility.module.ModuleUtilities; @@ -34,6 +37,9 @@ import utility.module.ModuleUtilities; public class ClassFinder { static final Logger log = LogManager.getLogger(ClassFinder.class); + private static final boolean IS_USING_RESTRICTED_EXTENSIONS = + Boolean.getBoolean(GhidraClassLoader.ENABLE_RESTRICTED_EXTENSIONS_PROPERTY); + private static List> FILTER_CLASSES = Collections.unmodifiableList(Arrays.asList(ExtensionPoint.class)); @@ -47,8 +53,10 @@ public class ClassFinder { private void initialize(List searchPaths, TaskMonitor monitor) throws CancelledException { - Set pathSet = new LinkedHashSet<>(searchPaths); + Msg.trace(this, + "Using restricted extension class loader? " + IS_USING_RESTRICTED_EXTENSIONS); + Set pathSet = new LinkedHashSet<>(searchPaths); Iterator pathIterator = pathSet.iterator(); while (pathIterator.hasNext()) { monitor.checkCancelled(); @@ -110,26 +118,58 @@ public class ClassFinder { return classList; } - /*package*/ static Class loadExtensionPoint(String path, String fullName) { + /** + * If the given class name matches the known extension name patterns, then this method will try + * to load that class using the provided path. Extensions may be loaded using their own + * class loader, depending on the system property + * {@link GhidraClassLoader#ENABLE_RESTRICTED_EXTENSIONS_PROPERTY}. + *

+ * Examples: + *

+	 * /foo/bar/baz/file.jar fully.qualified.ClassName
+	 * /foo/bar/baz/bin fully.qualified.ClassName
+	 * 
+ * + * @param path the jar or dir path + * @param className the fully qualified class name + * @return the class if it is an extension point + */ + /*package*/ static Class loadExtensionPoint(String path, String className) { - if (!ClassSearcher.isExtensionPointName(fullName)) { + if (!ClassSearcher.isExtensionPointName(className)) { return null; } - ClassLoader classLoader = ClassSearcher.class.getClassLoader(); + ClassLoader classLoader = getClassLoader(path); + try { - Class c = Class.forName(fullName, true, classLoader); + Class c = Class.forName(className, true, classLoader); if (isClassOfInterest(c)) { return c; } } catch (Throwable t) { - processClassLoadError(path, fullName, t); + processClassLoadError(path, className, t); } return null; } + private static ClassLoader getClassLoader(String path) { + ClassLoader classLoader = ClassSearcher.class.getClassLoader(); + if (!IS_USING_RESTRICTED_EXTENSIONS) { + return classLoader; // custom extension class loader is disabled + } + + ExtensionDetails extension = ExtensionUtils.getExtension(path); + if (extension != null) { + Msg.trace(ClassFinder.class, + "Installing custom extension class loader for: " + Json.toStringFlat(extension)); + classLoader = new ExtensionModuleClassLoader(extension); + } + return classLoader; + } + private static void processClassLoadError(String path, String name, Throwable t) { if (t instanceof LinkageError) { diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassSearcher.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassSearcher.java index 649d217fa4..5f2c12a343 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassSearcher.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/classfinder/ClassSearcher.java @@ -26,10 +26,12 @@ import java.util.stream.Collectors; import javax.swing.event.ChangeListener; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import generic.jar.ResourceFile; +import ghidra.GhidraClassLoader; import ghidra.framework.Application; import ghidra.util.Msg; import ghidra.util.SystemUtilities; @@ -277,15 +279,30 @@ public class ClassSearcher { } private static List gatherSearchPaths() { - String cp = System.getProperty("java.class.path"); - StringTokenizer st = new StringTokenizer(cp, File.pathSeparator); + + // + // By default all classes are found on the standard classpath. In the default mode, there + // are no values associated with the GhidraClassLoader.CP_EXT property. Alternatively, + // users can enable Extension classpath restriction. In this mode, any Extension module's + // jar files will *not* be on the standard classpath, but instead will be on CP_EXT. + // List rawPaths = new ArrayList<>(); - while (st.hasMoreTokens()) { - rawPaths.add(st.nextToken()); + getPropertyPaths(GhidraClassLoader.CP, rawPaths); + getPropertyPaths(GhidraClassLoader.CP_EXT, rawPaths); + return canonicalizePaths(rawPaths); + } + + private static void getPropertyPaths(String property, List results) { + String paths = System.getProperty(property); + Msg.trace(ClassSearcher.class, "Paths in " + property + ": " + paths); + if (StringUtils.isBlank(paths)) { + return; } - List canonicalPaths = canonicalizePaths(rawPaths); - return canonicalPaths; + StringTokenizer st = new StringTokenizer(paths, File.pathSeparator); + while (st.hasMoreTokens()) { + results.add(st.nextToken()); + } } private static List canonicalizePaths(Collection paths) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionDetails.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionDetails.java similarity index 90% rename from Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionDetails.java rename to Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionDetails.java index c5033ba93c..3042a7be4c 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionDetails.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionDetails.java @@ -13,10 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package ghidra.framework.project.extensions; +package ghidra.util.extensions; import java.io.File; -import java.util.List; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.*; import generic.jar.ResourceFile; import generic.json.Json; @@ -191,16 +193,41 @@ public class ExtensionDetails implements Comparable { this.version = version; } + /** + * Returns URLs for all jar files living in the {extension dir}/lib directory for an installed + * extension. + * + * @return the URLs + */ + public Set getLibraries() { + if (!isInstalled()) { + return Collections.emptySet(); + } + + Set jarFiles = new HashSet<>(); + findJarFiles(new File(installDir, "lib"), jarFiles); + Set paths = new HashSet<>(); + for (File jar : jarFiles) { + try { + URL jarUrl = jar.toURI().toURL(); + paths.add(jarUrl); + } + catch (MalformedURLException e) { + continue; + } + } + return paths; + } + /** * An extension is known to be installed if it has a valid installation path AND that path * contains a Module.manifest file. Extensions that are {@link #isPendingUninstall()} are * still on the filesystem, may be in use by the tool, but will be removed upon restart. *

* Note: The module manifest file is a marker that indicates several things; one of which is - * the installation status of an extension. When a user marks an extension to be uninstalled (by - * checking the appropriate checkbox in the {@link ExtensionTableModel}), the only thing - * that is done is to remove this manifest file, which tells the {@link ExtensionTableProvider} - * to remove the entire extension directory on the next launch. + * the installation status of an extension. When a user marks an extension to be uninstalled via + * the UI, the only thing that is done is to remove this manifest file, which tells the tool to + * remove the entire extension directory on the next launch. * * @return true if the extension is installed. */ @@ -329,7 +356,7 @@ public class ExtensionDetails implements Comparable { public boolean clearMarkForUninstall() { if (installDir == null) { - Msg.error(ExtensionUtils.class, + Msg.error(this, "Cannot restore extension; extension installation dir is missing for: " + name); return false; // already marked as uninstalled } @@ -373,4 +400,16 @@ public class ExtensionDetails implements Comparable { public String toString() { return Json.toString(this); } + + private void findJarFiles(File dir, Set jarFiles) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File f : files) { + if (f.isFile() && f.getName().endsWith(".jar")) { + jarFiles.add(f); + } + } + } } diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionModuleClassLoader.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionModuleClassLoader.java new file mode 100644 index 0000000000..def46ca7ad --- /dev/null +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionModuleClassLoader.java @@ -0,0 +1,45 @@ +/* ### + * IP: GHIDRA + * + * 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 ghidra.util.extensions; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Set; + +/** + * A class loader used with Ghidra extensions. + */ +public class ExtensionModuleClassLoader extends URLClassLoader { + + private ExtensionDetails extensionDir; + + public ExtensionModuleClassLoader(ExtensionDetails extensionDir) { + // It is important that this class use the default GhidraClassLoader as its parent. This + // allows resolution of Ghidra classes from extensions. + super(getURLs(extensionDir), ExtensionModuleClassLoader.class.getClassLoader()); + this.extensionDir = extensionDir; + } + + private static URL[] getURLs(ExtensionDetails extensionDir) { + Set jars = extensionDir.getLibraries(); + return jars.toArray(URL[]::new); + } + + @Override + public String toString() { + return "Extension ClassLoader for " + extensionDir.getName(); + } +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionUtils.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionUtils.java similarity index 56% rename from Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionUtils.java rename to Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionUtils.java index e7d4c1e8e4..cefae7610e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionUtils.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionUtils.java @@ -13,15 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package ghidra.framework.project.extensions; +package ghidra.util.extensions; import java.io.*; import java.nio.file.Files; import java.nio.file.attribute.PosixFilePermission; import java.util.*; -import java.util.Map.Entry; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; @@ -29,40 +26,25 @@ import org.apache.commons.compress.utils.IOUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import docking.widgets.OkDialog; -import docking.widgets.OptionDialog; import generic.jar.ResourceFile; import ghidra.framework.Application; import ghidra.util.Msg; import ghidra.util.exception.CancelledException; -import ghidra.util.task.TaskLauncher; import ghidra.util.task.TaskMonitor; import utilities.util.FileUtilities; import utility.application.ApplicationLayout; /** - * Utility class for managing Ghidra Extensions. + * Utilities for finding extensions. *

- * Extensions are defined as any archive or folder that contains an extension.properties - * file. This properties file can contain the following attributes: - *

    - *
  • name (required)
  • - *
  • description
  • - *
  • author
  • - *
  • createdOn (format: MM/dd/yyyy)
  • - *
  • version
  • - *
- * - *

- * Extensions may be installed/uninstalled by users at runtime, using the - * {@link ExtensionTableProvider}. Installation consists of unzipping the extension archive to an - * installation folder, currently {ghidra user settings dir}/Extensions. To uninstall, - * the unpacked folder is simply removed. + * Extension searching is cached. Use {@link #reload()} to update the cache. */ public class ExtensionUtils { - /** Magic number that identifies the first bytes of a ZIP archive. This is used to verify - that a file is a zip rather than just checking the extension. */ + /** + * Magic number that identifies the first bytes of a ZIP archive. This is used to verify that a + * file is a zip rather than just checking the extension. + */ private static final int ZIPFILE = 0x504b0304; public static String PROPERTIES_FILE_NAME = "extension.properties"; @@ -70,13 +52,15 @@ public class ExtensionUtils { private static final Logger log = LogManager.getLogger(ExtensionUtils.class); + private static Extensions extensions; + /** * Performs extension maintenance. This should be called at startup, before any plugins or * extension points are loaded. */ public static void initializeExtensions() { - Extensions extensions = getAllInstalledExtensions(); + extensions = getAllInstalledExtensions(); // delete any extensions marked for removal extensions.cleanupExtensionsMarkedForRemoval(); @@ -85,14 +69,52 @@ public class ExtensionUtils { extensions.reportDuplicateExtensions(); } + public static ExtensionDetails getExtension(String path) { + + File pathDir = new File(path); + Set installedExtensions = getActiveInstalledExtensions(); + for (ExtensionDetails ext : installedExtensions) { + File installDir = ext.getInstallDir(); + if (FileUtilities.isPathContainedWithin(installDir, pathDir)) { + return ext; + } + } + + return null; + } + /** - * Gets all known extensions that have not been marked for removal. + * Returns true if the given file or directory is a valid ghidra extension. + *

+ * Note: This means that the zip or directory contains an extension.properties file. * - * @return set of installed extensions + * @param file the zip or directory to inspect + * @return true if the given file represents a valid extension */ + public static boolean isExtension(File file) { + return ExtensionUtils.getExtension(file, true) != null; + } + + public static boolean install(ExtensionDetails extension, File file, TaskMonitor monitor) { + try { + if (file.isFile()) { + return unzipToInstallationFolder(extension, file, monitor); + } + + return copyToInstallationFolder(file, monitor); + } + catch (CancelledException e) { + log.info("Extension installation cancelled by user"); + } + catch (IOException e) { + Msg.showError(ExtensionUtils.class, null, "Error Installing Extension", + "Unexpected error installing extension", e); + } + return false; + } + public static Set getActiveInstalledExtensions() { - Extensions extensions = getAllInstalledExtensions(); - return extensions.getActiveExtensions(); + return getAllInstalledExtensions().getActiveExtensions(); } /** @@ -102,15 +124,18 @@ public class ExtensionUtils { * @return set of installed extensions */ public static Set getInstalledExtensions() { - Extensions extensions = getAllInstalledExtensions(); - return extensions.get(); + return getAllInstalledExtensions().get(); } - private static Extensions getAllInstalledExtensions() { + public static Extensions getAllInstalledExtensions() { + + if (extensions != null) { + return extensions; + } log.trace("Finding all installed extensions..."); - Extensions extensions = new Extensions(); + extensions = new Extensions(log); // Find all extension.properties or extension.properties.uninstalled files in // the install directory and create a ExtensionDetails object for each. @@ -146,6 +171,36 @@ public class ExtensionUtils { return extensions; } + public static ExtensionDetails getExtension(File file, boolean quiet) { + + if (file == null) { + log.error("Cannot get an extension; null file"); + return null; + } + + try { + return tryToGetExtension(file); + } + catch (IOException e) { + if (quiet) { + log.trace("Exception trying to read an extension from " + file, e); + } + else { + log.error("Exception trying to read an extension from " + file, e); + } + } + return null; + } + + /** + * Clears any cached extensions and searches for extensions. + */ + public static void reload() { + log.trace("Clearing extensions cache"); + extensions = null; + getAllInstalledExtensions(); + } + /** * Returns all archive extensions. These are all the extensions found in * {@link ApplicationLayout#getExtensionArchiveDir}. This are added to an installation as @@ -172,36 +227,68 @@ public class ExtensionUtils { return Collections.emptySet(); // no files or dirs inside of the archive directory } - Set extensions = new HashSet<>(); - findExtensionsInZips(archiveFiles, extensions); - findExtensionsInFolder(archiveDir.getFile(false), extensions); + Set results = new HashSet<>(); + findExtensionsInZips(archiveFiles, results); + findExtensionsInFolder(archiveDir.getFile(false), results); - return extensions; + return results; + } + + public static ExtensionDetails createExtensionFromProperties(File file) { + try { + return tryToLoadExtensionFromProperties(file); + } + catch (IOException e) { + log.error("Error loading extension properties from " + file.getAbsolutePath(), e); + return null; + } + } + + public static ExtensionDetails createExtensionDetailsFromArchive(ResourceFile resourceFile) { + + File file = resourceFile.getFile(false); + if (!isZip(file)) { + return null; + } + + try (ZipFile zipFile = new ZipFile(file)) { + Properties props = getProperties(zipFile); + if (props != null) { + ExtensionDetails extension = createExtensionDetails(props); + extension.setArchivePath(file.getAbsolutePath()); + return extension; + } + } + catch (IOException e) { + log.error( + "Unable to read zip file to get extension properties: " + file, e); + } + return null; } private static void findExtensionsInZips(ResourceFile[] archiveFiles, - Set extensions) { + Set results) { for (ResourceFile file : archiveFiles) { - ExtensionDetails extension = createExtensionDetailsFromArchive(file); + ExtensionDetails extension = ExtensionUtils.createExtensionDetailsFromArchive(file); if (extension == null) { log.trace("Skipping archive file; not an extension: " + file); continue; } - if (extensions.contains(extension)) { + if (results.contains(extension)) { log.error( "Skipping extension \"" + extension.getName() + "\" found at " + extension.getInstallPath() + ".\nArchived extension by that name already found."); } - extensions.add(extension); + results.add(extension); } } - private static void findExtensionsInFolder(File dir, Set extensions) { + private static void findExtensionsInFolder(File dir, Set results) { List propFiles = findExtensionPropertyFiles(dir); for (File propFile : propFiles) { - ExtensionDetails extension = createExtensionFromProperties(propFile); + ExtensionDetails extension = ExtensionUtils.createExtensionFromProperties(propFile); if (extension == null) { continue; } @@ -211,300 +298,15 @@ public class ExtensionUtils { File extDir = propFile.getParentFile(); extension.setArchivePath(extDir.getAbsolutePath()); - if (extensions.contains(extension)) { + if (results.contains(extension)) { log.error( "Skipping duplicate extension \"" + extension.getName() + "\" found at " + extension.getInstallPath()); } - extensions.add(extension); + results.add(extension); } } - /** - * Installs the given extension file. This can be either an archive (zip) or a directory that - * contains an extension.properties file. - * - * @param file the extension to install - * @return true if the extension was successfully installed - */ - public static boolean install(File file) { - - log.trace("Installing extension file " + file); - - if (file == null) { - log.error("Install file cannot be null"); - return false; - } - - ExtensionDetails extension = getExtension(file, false); - if (extension == null) { - Msg.showError(ExtensionUtils.class, null, "Error Installing Extension", - file.getAbsolutePath() + " does not point to a valid ghidra extension"); - return false; - } - - Extensions extensions = getAllInstalledExtensions(); - if (checkForConflictWithDevelopmentExtension(extension, extensions)) { - return false; - } - - if (checkForDuplicateExtensions(extension, extensions)) { - return false; - } - - // Verify that the version of the extension is valid for this version of Ghidra. If not, - // just exit without installing. - if (!validateExtensionVersion(extension)) { - return false; - } - - AtomicBoolean installed = new AtomicBoolean(false); - TaskLauncher.launchModal("Installing Extension", (monitor) -> { - installed.set(doRunInstallTask(extension, file, monitor)); - }); - - boolean success = installed.get(); - if (success) { - log.trace("Finished installing " + file); - } - else { - log.trace("Failed to install " + file); - } - - return success; - } - - private static boolean doRunInstallTask(ExtensionDetails extension, File file, - TaskMonitor monitor) { - try { - if (file.isFile()) { - return unzipToInstallationFolder(extension, file, monitor); - } - - return copyToInstallationFolder(file, monitor); - } - catch (CancelledException e) { - log.info("Extension installation cancelled by user"); - } - catch (IOException e) { - Msg.showError(ExtensionUtils.class, null, "Error Installing Extension", - "Unexpected error installing extension", e); - } - return false; - } - - /** - * Installs the given extension from its declared archive path - * @param extension the extension - * @return true if successful - */ - public static boolean installExtensionFromArchive(ExtensionDetails extension) { - if (extension == null) { - log.error("Extension to install cannot be null"); - return false; - } - - String archivePath = extension.getArchivePath(); - if (archivePath == null) { - log.error( - "Cannot install from archive; extension is missing archive path"); - return false; - } - - ApplicationLayout layout = Application.getApplicationLayout(); - ResourceFile extInstallDir = layout.getExtensionInstallationDirs().get(0); - String extName = extension.getName(); - File extDestinationDir = new ResourceFile(extInstallDir, extName).getFile(false); - File archiveFile = new File(archivePath); - if (install(archiveFile)) { - extension.setInstallDir(new File(extDestinationDir, extName)); - return true; - } - - return false; - } - - /** - * Compares the given extension version to the current Ghidra version. If they are different, - * then the user will be prompted to confirm the installation. This method will return true - * if the versions match or the user has chosen to install anyway. - * - * @param extension the extension - * @return true if the versions match or the user has chosen to install anyway - */ - private static boolean validateExtensionVersion(ExtensionDetails extension) { - String extVersion = extension.getVersion(); - if (extVersion == null) { - extVersion = ""; - } - - String appVersion = Application.getApplicationVersion(); - if (extVersion.equals(appVersion)) { - return true; - } - - String message = "Extension version mismatch.\nName: " + extension.getName() + - "Extension version: " + extVersion + ".\nGhidra version: " + appVersion + "."; - int choice = OptionDialog.showOptionDialogWithCancelAsDefaultButton(null, - "Extension Version Mismatch", - message, - "Install Anyway"); - if (choice != OptionDialog.OPTION_ONE) { - log.info(removeNewlines(message + " Did not install")); - return false; - } - return true; - } - - private static String removeNewlines(String s) { - return s.replaceAll("\n", " "); - } - - private static boolean checkForDuplicateExtensions(ExtensionDetails newExtension, - Extensions extensions) { - - String name = newExtension.getName(); - log.trace("Checking for duplicate extensions for '" + name + "'"); - - List matches = extensions.getMatchingExtensions(newExtension); - if (matches.isEmpty()) { - log.trace("No matching extensions installed"); - return false; - } - - log.trace("Duplicate extensions found by name '" + name + "'"); - - if (matches.size() > 1) { - reportMultipleDuplicateExtensionsWhenInstalling(newExtension, matches); - return true; - } - - ExtensionDetails installedExtension = matches.get(0); - String message = - "Attempting to install an extension matching the name of an existing extension.\n" + - "New extension version: " + newExtension.getVersion() + ".\n" + - "Installed extension version: " + installedExtension.getVersion() + ".\n\n" + - "To install, click 'Remove Existing', restart Ghidra, then install again."; - int choice = OptionDialog.showOptionDialogWithCancelAsDefaultButton(null, - "Duplicate Extension", - message, - "Remove Existing"); - - String installPath = installedExtension.getInstallPath(); - if (choice != OptionDialog.OPTION_ONE) { - log.info( - removeNewlines( - message + " Skipping installation. Original extension still installed: " + - installPath)); - return true; - } - - // - // At this point the user would like to replace the existing extension. We cannot delete - // the existing extension, as it may be in use; mark it for removal. - // - log.info( - removeNewlines( - message + " Installing new extension. Existing extension will be removed after " + - "restart: " + installPath)); - installedExtension.markForUninstall(); - return true; - } - - private static void reportMultipleDuplicateExtensionsWhenInstalling(ExtensionDetails extension, - List matches) { - - StringBuilder buffy = new StringBuilder(); - buffy.append("Found multiple duplicate extensions while trying to install '") - .append(extension.getName()) - .append("'\n"); - for (ExtensionDetails otherExtension : matches) { - buffy.append("Duplicate: " + otherExtension.getInstallPath()).append('\n'); - } - buffy.append("Please close Ghidra and manually remove from these extensions from the " + - "filesystem."); - - Msg.showInfo(ExtensionUtils.class, null, "Duplicate Extensions Found", buffy.toString()); - } - - private static void reportDuplicateExtensionsWhenLoading(String name, - List extensions) { - - ExtensionDetails loadedExtension = extensions.get(0); - File loadedInstallDir = loadedExtension.getInstallDir(); - - for (int i = 1; i < extensions.size(); i++) { - ExtensionDetails duplicate = extensions.get(i); - log.info("Duplicate extension found '" + name + "'. Keeping extension from " + - loadedInstallDir + ". Skipping extension found at " + - duplicate.getInstallDir()); - } - } - - private static boolean checkForConflictWithDevelopmentExtension(ExtensionDetails newExtension, - Extensions extensions) { - - String name = newExtension.getName(); - log.trace("Checking for duplicate dev mode extensions for '" + name + "'"); - - List matches = extensions.getMatchingExtensions(newExtension); - if (matches.isEmpty()) { - log.trace("No matching extensions installed"); - return false; - } - - for (ExtensionDetails extension : matches) { - - if (extension.isInstalledInInstallationFolder()) { - - String message = "Attempting to install an extension that conflicts with an " + - "extension located in the Ghidra installation folder.\nYou must manually " + - "remove the existing extension to install the new extension.\nExisting " + - "extension: " + extension.getInstallDir(); - - log.trace(removeNewlines(message)); - - OkDialog.showError("Duplicate Extensions Found", message); - return true; - } - } - - return false; - } - - /** - * Returns true if the given file or directory is a valid ghidra extension. - *

- * Note: This means that the zip or directory contains an extension.properties file. - * - * @param file the zip or directory to inspect - * @return true if the given file represents a valid extension - */ - public static boolean isExtension(File file) { - return getExtension(file, true) != null; - } - - private static ExtensionDetails getExtension(File file, boolean quiet) { - - if (file == null) { - log.error("Cannot get an extension; null file"); - return null; - } - - try { - return tryToGetExtension(file); - } - catch (IOException e) { - if (quiet) { - log.trace("Exception trying to read an extension from " + file, e); - } - else { - log.error("Exception trying to read an extension from " + file, e); - } - } - return null; - } - private static ExtensionDetails tryToGetExtension(File file) throws IOException { if (file == null) { @@ -537,36 +339,62 @@ public class ExtensionUtils { return null; } - /** - * Returns true if the given file is a valid .zip archive. - * - * @param file the file to test - * @return true if file is a valid zip - */ - private static boolean isZip(File file) { + private static ExtensionDetails tryToLoadExtensionFromProperties(File file) throws IOException { - if (file == null) { - log.error("Cannot check for extension zip; null file"); - return false; + Properties props = new Properties(); + try (InputStream in = new FileInputStream(file.getAbsolutePath())) { + props.load(in); + return createExtensionDetails(props); + } + } + + private static ExtensionDetails createExtensionDetails(Properties props) { + + String name = props.getProperty("name"); + String desc = props.getProperty("description"); + String author = props.getProperty("author"); + String date = props.getProperty("createdOn"); + String version = props.getProperty("version"); + + return new ExtensionDetails(name, desc, author, date, version); + } + + private static Properties getProperties(ZipFile zipFile) throws IOException { + + Properties props = null; + Enumeration zipEntries = zipFile.getEntries(); + while (zipEntries.hasMoreElements()) { + ZipArchiveEntry entry = zipEntries.nextElement(); + Properties nextProperties = getProperties(zipFile, entry); + if (nextProperties != null) { + if (props != null) { + throw new IOException( + "Zip file contains multiple extension properties files"); + } + props = nextProperties; + } + + } + return props; + } + + private static Properties getProperties(ZipFile zipFile, ZipArchiveEntry entry) + throws IOException { + // We only search for the property file at the top level + String path = entry.getName(); + List parts = FileUtilities.pathToParts(path); + if (parts.size() != 2) { // require 2 parts: dir name / props file + return null; } - if (file.isDirectory()) { - return false; + if (!entry.getName().endsWith(PROPERTIES_FILE_NAME)) { + return null; } - if (file.length() < 4) { - return false; - } - - try (DataInputStream in = - new DataInputStream(new BufferedInputStream(new FileInputStream(file)))) { - int test = in.readInt(); - return test == ZIPFILE; - } - catch (IOException e) { - log.trace("Unable to check if file is a zip file: " + file + ". " + e.getMessage()); - return false; - } + InputStream propFile = zipFile.getInputStream(entry); + Properties prop = new Properties(); + prop.load(propFile); + return prop; } /** @@ -618,64 +446,36 @@ public class ExtensionUtils { return null; } - private static ExtensionDetails createExtensionDetailsFromArchive(ResourceFile resourceFile) { + /** + * Returns true if the given file is a valid .zip archive. + * + * @param file the file to test + * @return true if file is a valid zip + */ + private static boolean isZip(File file) { - File file = resourceFile.getFile(false); - if (!isZip(file)) { - return null; + if (file == null) { + log.error("Cannot check for extension zip; null file"); + return false; } - try (ZipFile zipFile = new ZipFile(file)) { - Properties props = getProperties(zipFile); - if (props != null) { - ExtensionDetails extension = createExtensionDetails(props); - extension.setArchivePath(file.getAbsolutePath()); - return extension; - } + if (file.isDirectory()) { + return false; + } + + if (file.length() < 4) { + return false; + } + + try (DataInputStream in = + new DataInputStream(new BufferedInputStream(new FileInputStream(file)))) { + int test = in.readInt(); + return test == ZIPFILE; } catch (IOException e) { - log.error( - "Unable to read zip file to get extension properties: " + file, e); + log.trace("Unable to check if file is a zip file: " + file + ". " + e.getMessage()); + return false; } - return null; - } - - private static Properties getProperties(ZipFile zipFile) throws IOException { - - Properties props = null; - Enumeration zipEntries = zipFile.getEntries(); - while (zipEntries.hasMoreElements()) { - ZipArchiveEntry entry = zipEntries.nextElement(); - Properties nextProperties = getProperties(zipFile, entry); - if (nextProperties != null) { - if (props != null) { - throw new IOException( - "Zip file contains multiple extension properties files"); - } - props = nextProperties; - } - - } - return props; - } - - private static Properties getProperties(ZipFile zipFile, ZipArchiveEntry entry) - throws IOException { - // We only search for the property file at the top level - String path = entry.getName(); - List parts = FileUtilities.pathToParts(path); - if (parts.size() != 2) { // require 2 parts: dir name / props file - return null; - } - - if (!entry.getName().endsWith(PROPERTIES_FILE_NAME)) { - return null; - } - - InputStream propFile = zipFile.getInputStream(entry); - Properties prop = new Properties(); - prop.load(propFile); - return prop; } /** @@ -708,19 +508,6 @@ public class ExtensionUtils { return true; } - private static boolean hasExistingExtension(File extensionFolder, TaskMonitor monitor) { - - if (extensionFolder.exists()) { - Msg.showWarn(ExtensionUtils.class, null, "Duplicate Extension Folder", - "Attempting to install a new extension over an existing directory.\n" + - "Either remove the extension for that directory from the UI\n" + - "or close Ghidra and delete the directory and try installing again.\n\n" + - "Directory: " + extensionFolder); - return true; - } - return false; - } - /** * Unpacks a given zip file to {@link ApplicationLayout#getExtensionInstallationDirs}. The * file permissions in the original zip will be retained. @@ -770,6 +557,19 @@ public class ExtensionUtils { return true; } + private static boolean hasExistingExtension(File extensionFolder, TaskMonitor monitor) { + + if (extensionFolder.exists()) { + Msg.showWarn(ExtensionUtils.class, null, "Duplicate Extension Folder", + "Attempting to install a new extension over an existing directory.\n" + + "Either remove the extension for that directory from the UI\n" + + "or close Ghidra and delete the directory and try installing again.\n\n" + + "Directory: " + extensionFolder); + return true; + } + return false; + } + private static void writeZipEntryToFile(ZipFile zFile, ZipArchiveEntry entry, File destination) throws IOException { try (OutputStream outputStream = @@ -798,63 +598,6 @@ public class ExtensionUtils { } } - private static ExtensionDetails tryToLoadExtensionFromProperties(File file) throws IOException { - - Properties props = new Properties(); - try (InputStream in = new FileInputStream(file.getAbsolutePath())) { - props.load(in); - return createExtensionDetails(props); - } - } - - private static ExtensionDetails createExtensionFromProperties(File file) { - try { - return tryToLoadExtensionFromProperties(file); - } - catch (IOException e) { - log.error("Error loading extension properties from " + file.getAbsolutePath(), e); - return null; - } - } - - private static ExtensionDetails createExtensionDetails(Properties props) { - - String name = props.getProperty("name"); - String desc = props.getProperty("description"); - String author = props.getProperty("author"); - String date = props.getProperty("createdOn"); - String version = props.getProperty("version"); - - return new ExtensionDetails(name, desc, author, date, version); - } - - /** - * Uninstalls a given extension. - * - * @param extension the extension to uninstall - * @return true if successfully uninstalled - */ - private static boolean removeExtension(ExtensionDetails extension) { - - if (extension == null) { - log.error("Extension to uninstall cannot be null"); - return false; - } - - File installDir = extension.getInstallDir(); - if (installDir == null) { - log.error("Extension installation path is not set; unable to delete files"); - return false; - } - - if (FileUtilities.deleteDir(installDir)) { - extension.setInstallDir(null); - return true; - } - - return false; - } - /** * Converts Unix permissions to a set of {@link PosixFilePermission}s. * @@ -895,107 +638,4 @@ public class ExtensionUtils { return permissions; } - - /** - * A collection of all extensions found. This class provides methods processing duplicates and - * managing extensions marked for removal. - */ - private static class Extensions { - - private Map> extensionsByName = new HashMap<>(); - - void add(ExtensionDetails e) { - extensionsByName.computeIfAbsent(e.getName(), n -> new ArrayList<>()).add(e); - } - - Set getActiveExtensions() { - return extensionsByName.values() - .stream() - .map(list -> list.get(0)) - .filter(ext -> !ext.isPendingUninstall()) - .collect(Collectors.toSet()); - } - - List getMatchingExtensions(ExtensionDetails extension) { - return extensionsByName.computeIfAbsent(extension.getName(), name -> List.of()); - } - - void cleanupExtensionsMarkedForRemoval() { - - Set names = new HashSet<>(extensionsByName.keySet()); - for (String name : names) { - List extensions = extensionsByName.get(name); - Iterator it = extensions.iterator(); - while (it.hasNext()) { - ExtensionDetails extension = it.next(); - if (!extension.isPendingUninstall()) { - continue; - } - if (!removeExtension(extension)) { - log.error("Error removing extension: " + extension.getInstallPath()); - } - - it.remove(); - } - - if (extensions.isEmpty()) { - extensionsByName.remove(name); - } - } - } - - void reportDuplicateExtensions() { - - Set>> entries = extensionsByName.entrySet(); - for (Entry> entry : entries) { - List list = entry.getValue(); - if (list.size() == 1) { - continue; - } - - reportDuplicateExtensionsWhenLoading(entry.getKey(), list); - } - } - - /** - * Returns all unique (no duplicates) extensions that the application is aware of - * @return the extensions - */ - Set get() { - return extensionsByName.values() - .stream() - .map(list -> list.get(0)) - .collect(Collectors.toSet()); - } - - String getAsString() { - StringBuilder buffy = new StringBuilder(); - - Set>> entries = extensionsByName.entrySet(); - for (Entry> entry : entries) { - String name = entry.getKey(); - buffy.append("Name: ").append(name); - - List extensions = entry.getValue(); - if (extensions.size() == 1) { - buffy.append(" - ").append(extensions.get(0).getInstallDir()).append('\n'); - } - else { - for (ExtensionDetails e : extensions) { - buffy.append("\t").append(e.getInstallDir()).append('\n'); - } - } - } - - if (buffy.isEmpty()) { - return ""; - } - - if (!buffy.isEmpty()) { - // remove trailing newline to keep logging consistent - buffy.deleteCharAt(buffy.length() - 1); - } - return buffy.toString(); - } - } } diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/Extensions.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/Extensions.java new file mode 100644 index 0000000000..0d553b6a51 --- /dev/null +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/Extensions.java @@ -0,0 +1,195 @@ +/* ### + * IP: GHIDRA + * + * 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 ghidra.util.extensions; + +import java.io.File; +import java.util.*; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.Logger; + +import utilities.util.FileUtilities; + +/** + * A collection of all extensions found. This class provides methods processing duplicates and + * managing extensions marked for removal. + */ +public class Extensions { + + private Logger log; + private Map> extensionsByName = new HashMap<>(); + + Extensions(Logger log) { + this.log = log; + } + + /** + * Returns all extensions matching the given details + * @param e the extension details to match + * @return all matching extensions + */ + public List getMatchingExtensions(ExtensionDetails e) { + return extensionsByName.computeIfAbsent(e.getName(), name -> List.of()); + } + + /** + * Adds an extension to this collection of extensions + * @param e the extension + */ + void add(ExtensionDetails e) { + extensionsByName.computeIfAbsent(e.getName(), n -> new ArrayList<>()).add(e); + } + + /** + * Returns all installed extensions that are not marked for uninstall + * @return all installed extensions that are not marked for uninstall + */ + Set getActiveExtensions() { + return extensionsByName.values() + .stream() + .filter(list -> !list.isEmpty()) + .map(list -> list.get(0)) + .filter(ext -> !ext.isPendingUninstall()) + .collect(Collectors.toSet()); + } + + /** + * Removes any extensions that have already been marked for removal. This should be called + * before any class loading has occurred. + */ + void cleanupExtensionsMarkedForRemoval() { + + Set names = new HashSet<>(extensionsByName.keySet()); + for (String name : names) { + List extensions = extensionsByName.get(name); + Iterator it = extensions.iterator(); + while (it.hasNext()) { + ExtensionDetails extension = it.next(); + if (!extension.isPendingUninstall()) { + continue; + } + if (!removeExtension(extension)) { + log.error("Error removing extension: " + extension.getInstallPath()); + } + + it.remove(); + } + + if (extensions.isEmpty()) { + extensionsByName.remove(name); + } + } + } + + private boolean removeExtension(ExtensionDetails extension) { + + if (extension == null) { + log.error("Extension to uninstall cannot be null"); + return false; + } + + File installDir = extension.getInstallDir(); + if (installDir == null) { + log.error("Extension installation path is not set; unable to delete files"); + return false; + } + + if (FileUtilities.deleteDir(installDir)) { + extension.setInstallDir(null); + return true; + } + + return false; + } + + /** + * Returns all unique extensions (no duplicates) that the application is aware of + * @return the extensions + */ + Set get() { + return extensionsByName.values() + .stream() + .filter(list -> !list.isEmpty()) + .map(list -> list.get(0)) + .collect(Collectors.toSet()); + } + + /** + * Returns a string representation of this collection of extensions + * @return a string representation of this collection of extensions + */ + String getAsString() { + StringBuilder buffy = new StringBuilder(); + + Set>> entries = extensionsByName.entrySet(); + for (Entry> entry : entries) { + String name = entry.getKey(); + buffy.append("Name: ").append(name); + + List extensions = entry.getValue(); + if (extensions.size() == 1) { + buffy.append(" - ").append(extensions.get(0).getInstallDir()).append('\n'); + } + else { + for (ExtensionDetails e : extensions) { + buffy.append("\t").append(e.getInstallDir()).append('\n'); + } + } + } + + if (buffy.isEmpty()) { + return ""; + } + + if (!buffy.isEmpty()) { + // remove trailing newline to keep logging consistent + buffy.deleteCharAt(buffy.length() - 1); + } + return buffy.toString(); + } + + /** + * Logs any duplicate extensions + */ + void reportDuplicateExtensions() { + + Set>> entries = extensionsByName.entrySet(); + for (Entry> entry : entries) { + List list = entry.getValue(); + if (list.size() == 1) { + continue; + } + + reportDuplicateExtensionsWhenLoading(entry.getKey(), list); + } + } + + private void reportDuplicateExtensionsWhenLoading(String name, + List extensions) { + + ExtensionDetails loadedExtension = extensions.get(0); + File loadedInstallDir = loadedExtension.getInstallDir(); + + for (int i = 1; i < extensions.size(); i++) { + ExtensionDetails duplicate = extensions.get(i); + log.info("Duplicate extension found '" + name + "'. Keeping extension from " + + loadedInstallDir + ". Skipping extension found at " + + duplicate.getInstallDir()); + } + } + +} diff --git a/Ghidra/Framework/Project/Module.manifest b/Ghidra/Framework/Project/Module.manifest index b0a176db86..dcb42d3eba 100644 --- a/Ghidra/Framework/Project/Module.manifest +++ b/Ghidra/Framework/Project/Module.manifest @@ -1,2 +1 @@ -MODULE FILE LICENSE: lib/commons-compress-1.21.jar Apache License 2.0 MODULE FILE LICENSE: lib/xz-1.9.jar Public Domain diff --git a/Ghidra/Framework/Project/build.gradle b/Ghidra/Framework/Project/build.gradle index eae2d0ba55..065c2757c0 100644 --- a/Ghidra/Framework/Project/build.gradle +++ b/Ghidra/Framework/Project/build.gradle @@ -27,7 +27,6 @@ dependencies { api project(':FileSystem') testImplementation project(path: ':Generic', configuration: 'testArtifacts') - api "org.apache.commons:commons-compress:1.21" api "org.tukaani:xz:1.9" } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginDescription.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginDescription.java index d1862e3786..025dd5ef52 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginDescription.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginDescription.java @@ -153,7 +153,7 @@ public class PluginDescription implements Comparable { */ public String getModuleName() { if (moduleName == null) { - ResourceFile moduleDir = Application.getModuleContainingClass(pluginClass.getName()); + ResourceFile moduleDir = Application.getModuleContainingClass(pluginClass); moduleName = (moduleDir == null) ? "" : moduleDir.getName(); } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginUtils.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginUtils.java index 637fed66ba..d364a0740b 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginUtils.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/util/PluginUtils.java @@ -16,6 +16,7 @@ package ghidra.framework.plugintool.util; import java.lang.reflect.*; +import java.util.List; import ghidra.framework.plugintool.*; import ghidra.util.Msg; @@ -76,6 +77,14 @@ public class PluginUtils { */ public static Class forName(String pluginClassName) throws PluginException { try { + + List> classes = ClassSearcher.getClasses(Plugin.class); + for (Class plug : classes) { + if (plug.getName().equals(pluginClassName)) { + return plug; + } + } + Class tmpClass = Class.forName(pluginClassName); if (!Plugin.class.isAssignableFrom(tmpClass)) { throw new PluginException( @@ -84,7 +93,7 @@ public class PluginUtils { return tmpClass.asSubclass(Plugin.class); } catch (ClassNotFoundException e) { - throw new PluginException("Plugin class not found"); + throw new PluginException("Plugin class not found: " + pluginClassName); } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionDetailsPanel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionDetailsPanel.java index b55e39a55f..4bfa1af546 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionDetailsPanel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionDetailsPanel.java @@ -23,6 +23,7 @@ import javax.swing.text.SimpleAttributeSet; import docking.widgets.table.threaded.ThreadedTableModelListener; import generic.theme.GColor; import ghidra.framework.plugintool.dialog.AbstractDetailsPanel; +import ghidra.util.extensions.ExtensionDetails; /** * Panel that shows information about the selected extension in the {@link ExtensionTablePanel}. This diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionInstaller.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionInstaller.java new file mode 100644 index 0000000000..7490ebe015 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionInstaller.java @@ -0,0 +1,276 @@ +/* ### + * IP: GHIDRA + * + * 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 ghidra.framework.project.extensions; + +import java.io.File; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import docking.widgets.OkDialog; +import docking.widgets.OptionDialog; +import generic.jar.ResourceFile; +import ghidra.framework.Application; +import ghidra.util.Msg; +import ghidra.util.extensions.*; +import ghidra.util.task.TaskLauncher; +import utility.application.ApplicationLayout; + +/** + * Utility class for managing Ghidra Extensions. + *

+ * Extensions are defined as any archive or folder that contains an extension.properties + * file. This properties file can contain the following attributes: + *

    + *
  • name (required)
  • + *
  • description
  • + *
  • author
  • + *
  • createdOn (format: MM/dd/yyyy)
  • + *
  • version
  • + *
+ * + *

+ * Extensions may be installed/uninstalled by users at runtime, using the + * {@link ExtensionTableProvider}. Installation consists of unzipping the extension archive to an + * installation folder, currently {ghidra user settings dir}/Extensions. To uninstall, + * the unpacked folder is simply removed. + */ +public class ExtensionInstaller { + + private static final Logger log = LogManager.getLogger(ExtensionInstaller.class); + + /** + * Installs the given extension file. This can be either an archive (zip) or a directory that + * contains an extension.properties file. + * + * @param file the extension to install + * @return true if the extension was successfully installed + */ + public static boolean install(File file) { + + log.trace("Installing extension file " + file); + + if (file == null) { + log.error("Install file cannot be null"); + return false; + } + + ExtensionDetails extension = ExtensionUtils.getExtension(file, false); + if (extension == null) { + Msg.showError(ExtensionInstaller.class, null, "Error Installing Extension", + file.getAbsolutePath() + " does not point to a valid ghidra extension"); + return false; + } + + Extensions extensions = ExtensionUtils.getAllInstalledExtensions(); + if (checkForConflictWithDevelopmentExtension(extension, extensions)) { + return false; + } + + if (checkForDuplicateExtensions(extension, extensions)) { + return false; + } + + // Verify that the version of the extension is valid for this version of Ghidra. If not, + // just exit without installing. + if (!validateExtensionVersion(extension)) { + return false; + } + + AtomicBoolean installed = new AtomicBoolean(false); + TaskLauncher.launchModal("Installing Extension", (monitor) -> { + installed.set(ExtensionUtils.install(extension, file, monitor)); + }); + + boolean success = installed.get(); + if (success) { + log.trace("Finished installing " + file); + } + else { + log.trace("Failed to install " + file); + } + + return success; + } + + /** + * Installs the given extension from its declared archive path + * @param extension the extension + * @return true if successful + */ + public static boolean installExtensionFromArchive(ExtensionDetails extension) { + if (extension == null) { + log.error("Extension to install cannot be null"); + return false; + } + + String archivePath = extension.getArchivePath(); + if (archivePath == null) { + log.error( + "Cannot install from archive; extension is missing archive path"); + return false; + } + + ApplicationLayout layout = Application.getApplicationLayout(); + ResourceFile extInstallDir = layout.getExtensionInstallationDirs().get(0); + String extName = extension.getName(); + File extDestinationDir = new ResourceFile(extInstallDir, extName).getFile(false); + File archiveFile = new File(archivePath); + if (install(archiveFile)) { + extension.setInstallDir(new File(extDestinationDir, extName)); + return true; + } + + return false; + } + + /** + * Compares the given extension version to the current Ghidra version. If they are different, + * then the user will be prompted to confirm the installation. This method will return true + * if the versions match or the user has chosen to install anyway. + * + * @param extension the extension + * @return true if the versions match or the user has chosen to install anyway + */ + private static boolean validateExtensionVersion(ExtensionDetails extension) { + String extVersion = extension.getVersion(); + if (extVersion == null) { + extVersion = ""; + } + + String appVersion = Application.getApplicationVersion(); + if (extVersion.equals(appVersion)) { + return true; + } + + String message = "Extension version mismatch.\nName: " + extension.getName() + + "Extension version: " + extVersion + ".\nGhidra version: " + appVersion + "."; + int choice = OptionDialog.showOptionDialogWithCancelAsDefaultButton(null, + "Extension Version Mismatch", + message, + "Install Anyway"); + if (choice != OptionDialog.OPTION_ONE) { + log.info(removeNewlines(message + " Did not install")); + return false; + } + return true; + } + + private static String removeNewlines(String s) { + return s.replaceAll("\n", " "); + } + + private static boolean checkForDuplicateExtensions(ExtensionDetails newExtension, + Extensions extensions) { + + String name = newExtension.getName(); + log.trace("Checking for duplicate extensions for '" + name + "'"); + + List matches = extensions.getMatchingExtensions(newExtension); + if (matches.isEmpty()) { + log.trace("No matching extensions installed"); + return false; + } + + log.trace("Duplicate extensions found by name '" + name + "'"); + + if (matches.size() > 1) { + reportMultipleDuplicateExtensionsWhenInstalling(newExtension, matches); + return true; + } + + ExtensionDetails installedExtension = matches.get(0); + String message = + "Attempting to install an extension matching the name of an existing extension.\n" + + "New extension version: " + newExtension.getVersion() + ".\n" + + "Installed extension version: " + installedExtension.getVersion() + ".\n\n" + + "To install, click 'Remove Existing', restart Ghidra, then install again."; + int choice = OptionDialog.showOptionDialogWithCancelAsDefaultButton(null, + "Duplicate Extension", + message, + "Remove Existing"); + + String installPath = installedExtension.getInstallPath(); + if (choice != OptionDialog.OPTION_ONE) { + log.info( + removeNewlines( + message + " Skipping installation. Original extension still installed: " + + installPath)); + return true; + } + + // + // At this point the user would like to replace the existing extension. We cannot delete + // the existing extension, as it may be in use; mark it for removal. + // + log.info( + removeNewlines( + message + " Installing new extension. Existing extension will be removed after " + + "restart: " + installPath)); + installedExtension.markForUninstall(); + return true; + } + + private static void reportMultipleDuplicateExtensionsWhenInstalling(ExtensionDetails extension, + List matches) { + + StringBuilder buffy = new StringBuilder(); + buffy.append("Found multiple duplicate extensions while trying to install '") + .append(extension.getName()) + .append("'\n"); + for (ExtensionDetails otherExtension : matches) { + buffy.append("Duplicate: " + otherExtension.getInstallPath()).append('\n'); + } + buffy.append("Please close Ghidra and manually remove from these extensions from the " + + "filesystem."); + + Msg.showInfo(ExtensionInstaller.class, null, "Duplicate Extensions Found", + buffy.toString()); + } + + private static boolean checkForConflictWithDevelopmentExtension(ExtensionDetails newExtension, + Extensions extensions) { + + String name = newExtension.getName(); + log.trace("Checking for duplicate dev mode extensions for '" + name + "'"); + + List matches = extensions.getMatchingExtensions(newExtension); + if (matches.isEmpty()) { + log.trace("No matching extensions installed"); + return false; + } + + for (ExtensionDetails extension : matches) { + + if (extension.isInstalledInInstallationFolder()) { + + String message = "Attempting to install an extension that conflicts with an " + + "extension located in the Ghidra installation folder.\nYou must manually " + + "remove the existing extension to install the new extension.\nExisting " + + "extension: " + extension.getInstallDir(); + + log.trace(removeNewlines(message)); + + OkDialog.showError("Duplicate Extensions Found", message); + return true; + } + } + + return false; + } +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableModel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableModel.java index 2ff7de22c9..7b5cb2eda5 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableModel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableModel.java @@ -27,6 +27,8 @@ import ghidra.framework.plugintool.ServiceProvider; import ghidra.util.Msg; import ghidra.util.datastruct.Accumulator; import ghidra.util.exception.CancelledException; +import ghidra.util.extensions.ExtensionDetails; +import ghidra.util.extensions.ExtensionUtils; import ghidra.util.table.column.AbstractGColumnRenderer; import ghidra.util.table.column.GColumnRenderer; import ghidra.util.task.TaskMonitor; @@ -155,7 +157,7 @@ class ExtensionTableModel extends ThreadedTableModel { // into this state is by clicking an extension that was discovered in the 'extension // archives folder' if (extension.isFromArchive()) { - if (ExtensionUtils.installExtensionFromArchive(extension)) { + if (ExtensionInstaller.installExtensionFromArchive(extension)) { refreshTable(); } return; @@ -192,6 +194,7 @@ class ExtensionTableModel extends ThreadedTableModel { return; } + ExtensionUtils.reload(); Set archived = ExtensionUtils.getArchiveExtensions(); Set installed = ExtensionUtils.getInstalledExtensions(); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTablePanel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTablePanel.java index 5afd16e6a5..68ca08ab90 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTablePanel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTablePanel.java @@ -27,6 +27,7 @@ import docking.widgets.table.*; import ghidra.app.util.GenericHelpTopics; import ghidra.framework.plugintool.PluginTool; import ghidra.util.HelpLocation; +import ghidra.util.extensions.ExtensionDetails; import help.Help; import help.HelpService; diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableProvider.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableProvider.java index 5d9be3a9dc..3c3356d31b 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableProvider.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/extensions/ExtensionTableProvider.java @@ -33,6 +33,7 @@ import ghidra.framework.Application; import ghidra.framework.plugintool.PluginTool; import ghidra.util.HelpLocation; import ghidra.util.Msg; +import ghidra.util.extensions.ExtensionUtils; import ghidra.util.filechooser.GhidraFileChooserModel; import ghidra.util.filechooser.GhidraFileFilter; import resources.Icons; @@ -105,7 +106,7 @@ public class ExtensionTableProvider extends DialogComponentProvider { super.dialogClosed(); if (extensionTablePanel.getTableModel().hasModelChanged() || requireRestart) { - Msg.showInfo(this, getComponent(), "Extensions Changed!", + Msg.showInfo(this, null, "Extensions Changed!", "Please restart Ghidra for extension changes to take effect."); } } @@ -176,7 +177,7 @@ public class ExtensionTableProvider extends DialogComponentProvider { continue; } - boolean success = ExtensionUtils.install(file); + boolean success = ExtensionInstaller.install(file); didInstall |= success; } return didInstall; diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ExtensionManager.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ExtensionManager.java index 9bb794b892..9af5810262 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ExtensionManager.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ExtensionManager.java @@ -16,7 +16,6 @@ package ghidra.framework.project.tool; import java.io.File; -import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.*; @@ -29,10 +28,10 @@ import generic.json.Json; import ghidra.framework.plugintool.*; import ghidra.framework.plugintool.dialog.PluginInstallerDialog; import ghidra.framework.plugintool.util.PluginDescription; -import ghidra.framework.project.extensions.ExtensionDetails; -import ghidra.framework.project.extensions.ExtensionUtils; import ghidra.util.NumericUtilities; import ghidra.util.classfinder.ClassSearcher; +import ghidra.util.extensions.ExtensionDetails; +import ghidra.util.extensions.ExtensionUtils; import ghidra.util.xml.XmlUtilities; import utilities.util.FileUtilities; @@ -191,12 +190,7 @@ class ExtensionManager { Set pluginPaths = getPluginPaths(); Set> extensionPlugins = new HashSet<>(); for (ExtensionDetails extension : extensions) { - File installDir = extension.getInstallDir(); - if (installDir == null) { - continue; - } - - Set> classes = findPluginsLoadedFromExtension(installDir, pluginPaths); + Set> classes = findPluginsLoadedFromExtension(extension, pluginPaths); extensionPlugins.addAll(classes); } @@ -219,28 +213,31 @@ class ExtensionManager { * classpath. For each class, the original resource file is compared against the * given extension folder and the jar files for that extension. * - * @param dir the directory to search, or a jar file + * @param extension the extension from which to find plugins * @param pluginPaths all loaded plugin paths * @return list of {@link Plugin} classes, or empty list if none found */ - private static Set> findPluginsLoadedFromExtension(File dir, + private static Set> findPluginsLoadedFromExtension(ExtensionDetails extension, Set pluginPaths) { - Set> result = new HashSet<>(); + if (!extension.isInstalled()) { + return Collections.emptySet(); + } // Find any jar files in the directory provided - Set jarPaths = getJarPaths(dir); + Set jarPaths = extension.getLibraries(); // Now get all Plugin.class file paths and see if any of them were loaded from one of the // extension the given extension directory + Set> result = new HashSet<>(); for (PluginPath pluginPath : pluginPaths) { - if (pluginPath.isFrom(dir)) { + if (pluginPath.isFrom(extension.getInstallDir())) { result.add(pluginPath.getPluginClass()); continue; } - for (String jarPath : jarPaths) { - if (pluginPath.isFrom(jarPath)) { + for (URL jarUrl : jarPaths) { + if (pluginPath.isFrom(jarUrl)) { result.add(pluginPath.getPluginClass()); } } @@ -248,45 +245,6 @@ class ExtensionManager { return result; } - private static Set getJarPaths(File dir) { - Set jarFiles = new HashSet<>(); - findJarFiles(dir, jarFiles); - Set paths = new HashSet<>(); - for (File jar : jarFiles) { - try { - URL jarUrl = jar.toURI().toURL(); - paths.add(jarUrl.getPath()); - } - catch (MalformedURLException e) { - continue; - } - } - return paths; - } - - /** - * Populates the given list with all discovered jar files found in the given directory and - * its subdirectories. - * - * @param dir the directory to search - * @param jarFiles list of found jar files - */ - private static void findJarFiles(File dir, Set jarFiles) { - File[] files = dir.listFiles(); - if (files == null) { - return; - } - for (File f : files) { - if (f.isDirectory()) { - findJarFiles(f, jarFiles); - } - - if (f.isFile() && f.getName().endsWith(".jar")) { - jarFiles.add(f); - } - } - } - private static class PluginPath { private Class pluginClass; private String pluginLocation; @@ -304,7 +262,8 @@ class ExtensionManager { return FileUtilities.isPathContainedWithin(dir, pluginFile); } - boolean isFrom(String jarPath) { + boolean isFrom(URL jarUrl) { + String jarPath = jarUrl.getPath(); return pluginLocation.contains(jarPath); } diff --git a/Ghidra/Framework/Project/src/test/java/ghidra/framework/project/extensions/ExtensionUtilsTest.java b/Ghidra/Framework/Project/src/test/java/ghidra/framework/project/extensions/ExtensionInstallerTest.java similarity index 93% rename from Ghidra/Framework/Project/src/test/java/ghidra/framework/project/extensions/ExtensionUtilsTest.java rename to Ghidra/Framework/Project/src/test/java/ghidra/framework/project/extensions/ExtensionInstallerTest.java index f70e997010..47a2858dd1 100644 --- a/Ghidra/Framework/Project/src/test/java/ghidra/framework/project/extensions/ExtensionUtilsTest.java +++ b/Ghidra/Framework/Project/src/test/java/ghidra/framework/project/extensions/ExtensionInstallerTest.java @@ -31,15 +31,17 @@ import docking.DialogComponentProvider; import docking.test.AbstractDockingTest; import generic.jar.ResourceFile; import ghidra.framework.Application; +import ghidra.util.extensions.ExtensionDetails; +import ghidra.util.extensions.ExtensionUtils; import utilities.util.FileUtilities; import utility.application.ApplicationLayout; import utility.function.ExceptionalCallback; import utility.module.ModuleUtilities; /** - * Tests for the {@link ExtensionUtils} class. + * Tests for the {@link ExtensionInstaller} class. */ -public class ExtensionUtilsTest extends AbstractDockingTest { +public class ExtensionInstallerTest extends AbstractDockingTest { private static final String BUILD_FOLDER_NAME = "TestExtensionParentDir"; private static final String TEST_EXT_NAME = "test"; @@ -87,7 +89,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { // Create an extension and install it. File file = createExtensionZip(TEST_EXT_NAME); - ExtensionUtils.install(file); + ExtensionInstaller.install(file); // Verify there is something in the installation directory and it has the correct name checkExtensionInstalledInFilesystem(TEST_EXT_NAME); @@ -101,7 +103,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { // Create an extension and install it. File file = createExtensionFolderInArchiveDir(); - ExtensionUtils.install(file); + ExtensionInstaller.install(file); // Verify the extension is in the install folder and has the correct name checkExtensionInstalledInFilesystem(TEST_EXT_NAME); @@ -142,10 +144,9 @@ public class ExtensionUtilsTest extends AbstractDockingTest { @Test public void testBadInputs() throws Exception { errorsExpected(() -> { - assertFalse(ExtensionUtils.isExtension(null)); - assertFalse(ExtensionUtils.install(new File("this/file/does/not/exist"))); - assertFalse(ExtensionUtils.install(null)); - assertFalse(ExtensionUtils.installExtensionFromArchive(null)); + assertFalse(ExtensionInstaller.install(new File("this/file/does/not/exist"))); + assertFalse(ExtensionInstaller.install(null)); + assertFalse(ExtensionInstaller.installExtensionFromArchive(null)); }); } @@ -156,7 +157,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { extension.setArchivePath(zipFile.getAbsolutePath()); String ghidraVersion = Application.getApplicationVersion(); extension.setVersion(ghidraVersion); - assertTrue(ExtensionUtils.installExtensionFromArchive(extension)); + assertTrue(ExtensionInstaller.installExtensionFromArchive(extension)); } @Test @@ -168,7 +169,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { AtomicBoolean didInstall = new AtomicBoolean(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.installExtensionFromArchive(extension)); + didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension)); }); DialogComponentProvider confirmDialog = @@ -187,7 +188,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { AtomicBoolean didInstall = new AtomicBoolean(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.installExtensionFromArchive(extension)); + didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension)); }); DialogComponentProvider confirmDialog = @@ -207,7 +208,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { AtomicBoolean didInstall = new AtomicBoolean(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.installExtensionFromArchive(extension)); + didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension)); }); DialogComponentProvider confirmDialog = @@ -221,7 +222,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { public void testMarkForUninstall_ClearMark() throws Exception { File externalFolder = createExternalExtensionInFolder(); - assertTrue(ExtensionUtils.install(externalFolder)); + assertTrue(ExtensionInstaller.install(externalFolder)); ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME); @@ -238,7 +239,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { public void testCleanupUninstalledExtions_WithExtensionMarkedForUninstall() throws Exception { File externalFolder = createExternalExtensionInFolder(); - assertTrue(ExtensionUtils.install(externalFolder)); + assertTrue(ExtensionInstaller.install(externalFolder)); ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME); @@ -255,8 +256,8 @@ public class ExtensionUtilsTest extends AbstractDockingTest { public void testCleanupUninstalledExtions_SomeExtensionMarkedForUninstall() throws Exception { List extensionFolders = createTwoExternalExtensionsInFolder(); - assertTrue(ExtensionUtils.install(extensionFolders.get(0))); - assertTrue(ExtensionUtils.install(extensionFolders.get(1))); + assertTrue(ExtensionInstaller.install(extensionFolders.get(0))); + assertTrue(ExtensionInstaller.install(extensionFolders.get(1))); Set extensions = ExtensionUtils.getInstalledExtensions(); assertEquals(extensions.size(), 2); @@ -279,7 +280,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { public void testCleanupUninstalledExtions_NoExtensionsMarkedForUninstall() throws Exception { File externalFolder = createExternalExtensionInFolder(); - assertTrue(ExtensionUtils.install(externalFolder)); + assertTrue(ExtensionInstaller.install(externalFolder)); assertExtensionInstalled(TEST_EXT_NAME); // This should not uninstall any extensions @@ -299,7 +300,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { String appVersion = Application.getApplicationVersion(); File extensionFolder = doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion); - assertTrue(ExtensionUtils.install(extensionFolder)); + assertTrue(ExtensionInstaller.install(extensionFolder)); Set extensions = ExtensionUtils.getInstalledExtensions(); assertEquals(extensions.size(), 1); @@ -313,7 +314,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { AtomicBoolean didInstall = new AtomicBoolean(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.install(extensionFolder2)); + didInstall.set(ExtensionInstaller.install(extensionFolder2)); }); DialogComponentProvider confirmDialog = @@ -329,7 +330,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { checkCleanInstall(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.install(extensionFolder2)); + didInstall.set(ExtensionInstaller.install(extensionFolder2)); }); // no longer an installed extension conflict; now we have a version mismatch @@ -349,7 +350,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { String appVersion = Application.getApplicationVersion(); File extensionFolder = doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion); - assertTrue(ExtensionUtils.install(extensionFolder)); + assertTrue(ExtensionInstaller.install(extensionFolder)); // create another extension Foo v2 File buildFolder2 = createTempDirectory("TestExtensionParentDir2"); @@ -359,7 +360,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { AtomicBoolean didInstall = new AtomicBoolean(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.install(extensionFolder2)); + didInstall.set(ExtensionInstaller.install(extensionFolder2)); }); DialogComponentProvider confirmDialog = @@ -379,7 +380,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { String appVersion = Application.getApplicationVersion(); File extensionFolder = doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion); - assertTrue(ExtensionUtils.install(extensionFolder)); + assertTrue(ExtensionInstaller.install(extensionFolder)); Set extensions = ExtensionUtils.getInstalledExtensions(); assertEquals(extensions.size(), 1); @@ -393,7 +394,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { AtomicBoolean didInstall = new AtomicBoolean(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.install(extensionFolder2)); + didInstall.set(ExtensionInstaller.install(extensionFolder2)); }); DialogComponentProvider confirmDialog = @@ -409,7 +410,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { checkCleanInstall(); runSwingLater(() -> { - didInstall.set(ExtensionUtils.install(extensionFolder2)); + didInstall.set(ExtensionInstaller.install(extensionFolder2)); }); waitFor(didInstall); @@ -437,7 +438,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { errorsExpected(() -> { File zipFile = createZipWithMultipleExtensions(); - assertFalse(ExtensionUtils.install(zipFile)); + assertFalse(ExtensionInstaller.install(zipFile)); }); } @@ -452,7 +453,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest { String nameProperty = "ExtensionNamedFoo"; File externalFolder = createExtensionWithMismatchingNamePropertyString(nameProperty); - assertTrue(ExtensionUtils.install(externalFolder)); + assertTrue(ExtensionInstaller.install(externalFolder)); ExtensionDetails extension = assertExtensionInstalled(nameProperty); diff --git a/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraClassLoader.java b/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraClassLoader.java index 4cad280222..ff9f777cc4 100644 --- a/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraClassLoader.java +++ b/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraClassLoader.java @@ -31,8 +31,23 @@ import ghidra.util.Msg; * */ public class GhidraClassLoader extends URLClassLoader { - - private static final String CP = "java.class.path"; + + /** + * When 'true', this property will trigger the system to put each Extension module's lib jar + * files into the {@link #CP_EXT} property. + */ + public static final String ENABLE_RESTRICTED_EXTENSIONS_PROPERTY = + "ghidra.extensions.classpath.restricted"; + + /** + * The classpath system property: {@code java.class.path} + */ + public static final String CP = "java.class.path"; + + /** + * The extensions classpath system property: {@code java.class.path.ext} + */ + public static final String CP_EXT = "java.class.path.ext"; /** * This one-argument constructor is required for the JVM to successfully use this class loader @@ -45,7 +60,7 @@ public class GhidraClassLoader extends URLClassLoader { } @Override - public void addURL(URL url) { + public void addURL(URL url) { super.addURL(url); try { System.setProperty(CP, diff --git a/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraLauncher.java b/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraLauncher.java index 728348a976..e6096766ac 100644 --- a/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraLauncher.java +++ b/Ghidra/Framework/Utility/src/main/java/ghidra/GhidraLauncher.java @@ -145,17 +145,36 @@ public class GhidraLauncher { // First add Eclipse's module "bin" paths. If we didn't find any, assume Ghidra was // compiled with Gradle, and add the module jars Gradle built. addModuleBinPaths(classpathList, modules); - if (classpathList.isEmpty()) { + boolean gradleDevMode = classpathList.isEmpty(); + if (gradleDevMode) { + // Add the module jars Gradle built. + // Note: this finds Extensions' jar files so there is no need to to call + // addExtensionJarPaths() addModuleJarPaths(classpathList, modules); } + else { /* Eclipse dev mode */ + // Support loading pre-built, jar-based, non-repo extensions in Eclipse dev mode + addExtensionJarPaths(classpathList, modules, layout); + } - addExtensionJarPaths(classpathList, modules, layout); + // In development mode, jars do not live in module directories. Instead, each jar lives + // in an external, non-repo location, which is listed in build/libraryDependencies.txt. addExternalJarPaths(classpathList, layout.getApplicationRootDirs()); } else { addPatchPaths(classpathList, layout.getPatchDir()); addModuleJarPaths(classpathList, modules); } + + // + // The framework may choose to handle extension class loading separately from all other + // class loading. In that case, we will separate the extension jar files from standard + // module jar files. + // + // (If the custom extension class loading is disabled, then the extensions will be put onto + // the standard classpath.) + setExtensionJarPaths(modules, layout, classpathList); + classpathList = orderClasspath(classpathList, modules); return classpathList; } @@ -202,6 +221,30 @@ public class GhidraLauncher { dirs.forEach(d -> pathList.addAll(findJarsInDir(d))); } + /** + * Initializes the Extension classpath system property, unless disabled. + * @param modules the known modules + * @param layout the application layout + * @param classpathList the standard classpath elements + */ + private static void setExtensionJarPaths(Map modules, + GhidraApplicationLayout layout, List classpathList) { + + if (!Boolean.getBoolean(GhidraClassLoader.ENABLE_RESTRICTED_EXTENSIONS_PROPERTY)) { + // custom extension class loader is disabled; use normal classpath + return; + } + + List extClasspathList = new ArrayList<>(); + addExtensionJarPaths(extClasspathList, modules, layout); + + // Remove the extensions that were added before this method was called + classpathList.removeAll(extClasspathList); + + String extCp = String.join(File.pathSeparator, extClasspathList); + System.setProperty(GhidraClassLoader.CP_EXT, extCp); + } + /** * Add extension module lib jars to the given path list. (This only needed in dev mode to find * any pre-built extensions that have been installed, since we already find extension module diff --git a/Ghidra/Framework/Utility/src/main/java/utility/application/DummyApplicationLayout.java b/Ghidra/Framework/Utility/src/main/java/utility/application/DummyApplicationLayout.java index f691869cae..a364ae587e 100644 --- a/Ghidra/Framework/Utility/src/main/java/utility/application/DummyApplicationLayout.java +++ b/Ghidra/Framework/Utility/src/main/java/utility/application/DummyApplicationLayout.java @@ -17,6 +17,7 @@ package utility.application; import java.io.FileNotFoundException; import java.util.ArrayList; +import java.util.Collections; import generic.jar.ResourceFile; import ghidra.framework.ApplicationProperties; @@ -30,7 +31,7 @@ public class DummyApplicationLayout extends ApplicationLayout { /** * Constructs a new dummy application layout object. - * + * @param name the application name * @throws FileNotFoundException if there was a problem getting a user directory. */ public DummyApplicationLayout(String name) throws FileNotFoundException { @@ -48,5 +49,7 @@ public class DummyApplicationLayout extends ApplicationLayout { // User directories userTempDir = ApplicationUtilities.getDefaultUserTempDir(applicationProperties); + + extensionInstallationDirs = Collections.emptyList(); } } diff --git a/Ghidra/RuntimeScripts/Common/support/launch.properties b/Ghidra/RuntimeScripts/Common/support/launch.properties index 727af65fd7..6849b53927 100644 --- a/Ghidra/RuntimeScripts/Common/support/launch.properties +++ b/Ghidra/RuntimeScripts/Common/support/launch.properties @@ -102,6 +102,10 @@ VMARGS=-Xshare:off # Limit on XML parsing. See https://docs.oracle.com/javase/tutorial/jaxp/limits/limits.html #VMARGS=-Djdk.xml.totalEntitySizeLimit=50000000 +# Restrict extensions to their own 'lib' directory for loading non-Ghidra jars. This may be used +# to fix class resolution if multiple extensions include different versions of the same named class. +#VMARGS=-Dghidra.extensions.classpath.restricted=true + # Enables PDB debug logging during import and analysis to .ghidra/.ghidra_ver/pdb.analyzer.log #VMARGS=-Dghidra.pdb.logging=true diff --git a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java index cb90856d3e..9de997b95b 100644 --- a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java +++ b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java @@ -35,13 +35,13 @@ import docking.wizard.WizardManager; import docking.wizard.WizardPanel; import generic.theme.GThemeDefaults.Colors; import ghidra.app.plugin.core.archive.RestoreDialog; -import ghidra.framework.data.GhidraFileData; import ghidra.framework.data.DefaultProjectData; +import ghidra.framework.data.GhidraFileData; import ghidra.framework.main.*; import ghidra.framework.model.*; -import ghidra.framework.plugintool.dialog.*; import ghidra.framework.preferences.Preferences; -import ghidra.framework.project.extensions.*; +import ghidra.framework.project.extensions.ExtensionTablePanel; +import ghidra.framework.project.extensions.ExtensionTableProvider; import ghidra.framework.remote.User; import ghidra.framework.store.LockException; import ghidra.program.database.ProgramContentHandler; @@ -50,6 +50,7 @@ import ghidra.test.ProjectTestUtils; import ghidra.util.InvalidNameException; import ghidra.util.Msg; import ghidra.util.exception.CancelledException; +import ghidra.util.extensions.ExtensionDetails; import ghidra.util.task.TaskMonitor; import resources.MultiIcon; @@ -703,7 +704,7 @@ public class FrontEndPluginScreenShots extends GhidraScreenShotGenerator { Language language = getZ80_LANGUAGE(); DomainFile otherFile = ProjectTestUtils.createProgramFile(otherProject, "Program1", language, - language.getDefaultCompilerSpec(), null); + language.getDefaultCompilerSpec(), null); ProjectTestUtils.createProgramFile(otherProject, "Program2", language, language.getDefaultCompilerSpec(), null);